Files
Codeman/.github/workflows/ci.yml
T
Codeman maintainer ae28534406 feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.

install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:

- Node.js: the official tarball from nodejs.org, checked against the
  SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
  against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
  and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
  archive, the install is marked with .install-source, and it updates by
  re-running the installer: the new tree is built beside the old one and
  swapped in only after the build succeeded. A later re-run on a machine
  that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
  and the route is never decided by `sudo -v`.

What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).

Along the way:

- check_git no longer trusts `command -v git`. On a Mac without the Xcode
  Command Line Tools /usr/bin/git is a stub that opens Apple's install
  dialog; it is judged by `xcode-select -p` and never executed. Any other
  git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
  the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
  install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
  main() used to set it, so `install.sh cloudflared` died on `set -u` with
  the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
  is no Homebrew.

Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 18:50:58 +01:00

287 lines
14 KiB
YAML

name: CI
on:
push:
branches: [master, main]
pull_request:
jobs:
ci:
name: Typecheck & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check package-lock.json version sync
run: npm run check:lockfile
- name: Type check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
# Asks `vitest list` what CI would actually collect, rather than matching
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
# (config/test-suites.ts) passes locally and dies in the test job with
# "browserType.launch: Executable doesn't exist".
- name: Browser-test exclusion check
run: npm run check:browser-excludes
- name: Format check
run: npm run format:check
# install.sh reaches users through `curl | bash` with nothing between it and
# them, and until now nothing in this repo checked it at all: no shellcheck,
# no bats, and the vitest gate is Node-only.
- name: install.sh syntax
run: bash -n install.sh
# macOS ships bash 3.2 and this runner has bash 5, so the constructs that
# actually break a Mac install are invisible here without a container. This
# step is what catches them — in particular expanding an EMPTY array under
# `set -u`, which bash 3.2 treats as an unbound variable and `bash -n`
# cannot see because it is a runtime error, not a syntax one.
- name: install.sh runs on bash 3.2 (macOS's version)
run: |
set -euo pipefail
docker run --rm -v "$PWD":/w -w /w bash:3.2 bash -n /w/install.sh
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c '
set -euo pipefail
. /w/install.sh
detect_all_clis
# `shell` declares no binaries, so its offset/length window is length 0.
# Iterating it is the empty-array case; reaching here means it did not abort.
echo "bash $BASH_VERSION: ${#CLI_IDS[@]} CLIs, $CLI_FOUND_COUNT found"
cli_catalog_names >/dev/null
cli_catalog_print_install_hints >/dev/null
# The install menu with nothing installed and the user answering "s":
# skipping must warn and continue, never trip the "failed to install"
# gate (it did once, aborting the install before the clone).
has_tty() { return 0; }
headless_guard() { return 0; }
read_reply() { eval "$1=s"; }
NONINTERACTIVE=0
k=0; while [[ $k -lt ${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="no-such-cli-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt ${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/$k"; k=$((k + 1)); done
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
'
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
# array, and on stock macOS there is no `timeout`, so the array is empty and the
# expansion aborts the whole installer under `set -u`. The step above cannot
# reach that branch: this image HAS `timeout`, and with no `dsh` on PATH the
# probe is never called at all. So hide `timeout` and call it directly.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 bash:3.2 bash -c '
set -euo pipefail
. /w/install.sh
printf "#!/bin/sh\necho \"DeepSeek Harness 0.1\"\n" > /tmp/dsh
printf "#!/bin/sh\necho \"dancer shell (Debian dsh)\"\n" > /tmp/not-dsh
chmod 755 /tmp/dsh /tmp/not-dsh
# A PATH the probe can still work on, minus the binary under test.
mkdir -p /tmp/nobin
for b in grep sh; do ln -sf "$(command -v $b)" "/tmp/nobin/$b"; done
export PATH=/tmp/nobin
if command -v timeout >/dev/null 2>&1; then
echo "timeout is still on PATH, so this is NOT exercising the empty-array branch" >&2
exit 1
fi
dsh_banner_probe /tmp/dsh
if dsh_banner_probe /tmp/not-dsh; then
echo "identity probe accepted a foreign dsh" >&2
exit 1
fi
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
'
# Installer v2: the question phase runs before the build, and every decision it
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
# default, the occupied-:443 menu and the rename question with canned answers,
# so a bash-4 construct or a flipped default in any of them fails here, not on a
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h
. /w/install.sh
parse_flags --tailscale --service --name Build-Box --port 4000
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
has_tty() { return 0; }
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
systemctl() { return 0; }
LAUNCH_PRESET=""; NONINTERACTIVE=0
choose_launch_mode linux >/dev/null 2>&1
[[ "$LAUNCH_CHOICE" == "2" ]]
check_tailscale() { return 0; }
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
ts_backend_state() { printf Running; }
ts_dns_name() { printf box.tail.ts.net; }
ts_serve_443_target_port() { printf 8080; }
ts_serve_find_port_mapping() { :; }
ts_serve_port_used() { return 1; }
detect_tailscale_serve_url() { :; }
tailscale_choose_mapping >/dev/null 2>&1
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
[[ -z "$RENAMED" ]]
# A flag re-run keeps the password the unit already carries (and so
# never writes the unauthenticated ack), and the hand-start line the
# done screen prints carries every non-default value.
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
choose_network_binding >/dev/null 2>&1
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
# No-admin route: the helpers that decide what gets downloaded and whether it is
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
# stop-before-download check, and an archive install plus its replacement, all
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
# runs for real: a machine the route cannot serve must stop BEFORE a download.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h /tmp/src/Codeman-1/src
. /w/install.sh
sha=$(printf "%064d" 7)
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
fi
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
done
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
verify_sha256 /tmp/f "$good"
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
echo "accepted a wrong or empty checksum" >&2; exit 1
fi
is_musl_libc
NO_ADMIN=1; NONINTERACTIVE=1
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
check_build_tools() { return 0; }
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
echo "did not stop on a musl system" >&2; exit 1
fi
case "$out" in
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
*musl*) ;;
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
esac
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
grep -q 1.0.0 "$INSTALL_DIR/package.json"
touch "$INSTALL_DIR/from-the-old-build"
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
finish_source_swap
grep -q 2.0.0 "$INSTALL_DIR/package.json"
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
- name: Server boot smoke test
run: |
set -u
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
for i in $(seq 1 30); do
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
echo "Server booted in ${i}s"
exit 0
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server exited before becoming ready. Logs:"
cat /tmp/boot.log
exit 1
fi
sleep 1
done
echo "Server did not respond on /api/status within 30s. Logs:"
cat /tmp/boot.log
exit 1
test:
name: Unit & integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install tmux
run: |
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
- name: Run unit & integration tests
# Excludes the suites that need chromium, per-machine PNG baselines or a
# quiet machine — see config/test-suites.ts for the list and the reason
# behind each entry. Identical to what `npm test` runs locally.
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
run: npm run test:ci
- name: Run xterm-zerolag-input package tests
# Layers 1-3 of the predictive-echo suites (unit laws, fixture replay,
# seeded fuzz): deterministic, no browser, no live server. Depends on
# the ROOT `npm ci` above — workspaces hoist the package's vitest into
# the root node_modules; do not add a separate install here.
run: npx vitest run
working-directory: packages/xterm-zerolag-input
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
# that job is informational and never gates a push or a PR.
# Note: three suites are excluded from CI, each with its own local runner:
# npm run test:browser Playwright + chromium (+ a live server, and a real
# codex binary for codex-predictive-echo)
# npm run test:mobile the above plus environment-specific PNG baselines
# npm run test:perf wall-clock benchmarks; need an otherwise idle machine
# config/test-suites.ts holds the globs; the configs derive from it so the
# exclusions here and those runners cannot drift apart. Everything else runs in
# the `test` job above, which is the same thing `npm test` runs.