From 1157aa9ecde5a2c1a51b3b395a0c4a4c10f8501d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 13:46:31 +0100 Subject: [PATCH] fix(install): say why an added agent failed on a Mac with no other agent After "Skip for now" there is no other agent, so a failed add hit install_offered_cli's gate first, and the app showed its first-install text ("re-run the installer") instead of naming the agent and its command. That is the main path of adding an agent. run_offered_cli now runs a trusted command and looks again; the gate stays in install_offered_cli for the install, and `agent` judges its own run. The one line that executes a trusted command is unchanged. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- install.sh | 22 +++++++++++++++------- packages/desktop/README.md | 5 +++++ test/install-sh-invariants.test.ts | 11 ++++++++++- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db59f6ca..70819ec4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,6 +384,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer ## Scripts & Tunnel -**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli |none`, `install.sh preflight`, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) +**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli |none`, `install.sh preflight`, `install.sh agent ` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3917aaca..259919df 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1303,7 +1303,7 @@ On this route Node.js comes from nodejs.org (`latest-v.x/SHASUMS256.txt` ⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. -**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent ` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli` is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). +**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent ` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). ⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. diff --git a/install.sh b/install.sh index 772cb1ba..e656b2fb 100755 --- a/install.sh +++ b/install.sh @@ -860,9 +860,9 @@ cli_offerable() { return 0 } -# install_offered_cli : run the trusted command of one entry and prove -# it left a CLI behind. A chosen install that leaves nothing is fatal. -install_offered_cli() { +# run_offered_cli : run the trusted command of one entry and look again +# at what is installed. What that means is the caller's to judge. +run_offered_cli() { local idx="$1" info "Installing ${CLI_LABELS[$idx]}..." # : the same during an install, where a chosen CLI +# that leaves no CLI at all behind is fatal. +install_offered_cli() { + run_offered_cli "$1" if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi @@ -884,9 +891,10 @@ install_offered_cli() { # Install one AI CLI and nothing else (`install.sh agent `): how the desktop # app adds an agent once Codeman is here, also over a running server, which this -# never touches. install_offered_cli alone cannot end this run: it is fatal only -# when NO CLI is left, and here another one usually is, so a failed install would -# report success. A CLI that is here already is a success, and installs nothing. +# never touches. It judges the run itself, because install_offered_cli cannot: its +# gate is fatal only when NO CLI is left, and here another one usually is, so a +# failed install would report success, and its message is about re-running the +# installer. A CLI that is here already is a success, and installs nothing. agent_subcommand() { if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then die "Name the AI CLI to install: install.sh agent (one of: $(cli_catalog_ids))." @@ -912,7 +920,7 @@ agent_subcommand() { die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" fi - install_offered_cli "$idx" + run_offered_cli "$idx" if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" fi diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 3b9cd3f7..0491ad7e 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -228,6 +228,11 @@ and the Dock icon bounces. agents is part of the page. - **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS install is Homebrew, so it is not offered, also on a Mac that has Homebrew. +- **Agents that install with npm use whichever Node.js the login shell finds.** + With the Node.js the installer downloaded, or Homebrew's, that folder is yours. + The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those + agents would be expected to fail there, with npm's permission error shown, while + the ones with their own installer still work. This has not been tried. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a self-signed certificate, and WKWebView rejects it (wry has no trust override). Use `http://127.0.0.1:3000` for a server on this Mac. For another diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index fa00011b..746dbfa9 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -600,6 +600,15 @@ describe('install.sh agent, one AI CLI and nothing else', () => { expect(run.stdout).not.toContain('REACHED THE END'); }); + it('says the same with no other agent here, the way a Mac is after Skip for now', () => { + const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex']); + expect(run.ran).toEqual(['codex']); + expect(run.status).toBe(1); + expect(run.stderr).toContain('@@codeman error Codex did not install.'); + expect(run.stderr).not.toContain('re-run the installer'); + expect(run.stderr).not.toContain('@@codeman phase done'); + }); + it('installs nothing for an agent that is here already, and that is a success', () => { const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'codex' }); expect(run.status, run.stderr).toBe(0); @@ -653,7 +662,7 @@ describe('install.sh agent, one AI CLI and nothing else', () => { for (const word of ['launchctl', 'systemctl', 'setup_launchd', 'install_or_update_repo', 'npm run', 'sudo']) { expect(body, word).not.toContain(word); } - expect(body).toContain('install_offered_cli "$idx"'); + expect(body).toContain('run_offered_cli "$idx"'); }); });