From c5c015d6489ef02ccbefa337aa6aad6f595706c7 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:02:54 +0800 Subject: [PATCH] docs(cli-registry): document the catalogue's consumers and the trust boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Consumers outside the server" section covering the two generated artifacts, why each exists (neither install.sh nor a .mjs can import TypeScript), what is deliberately NOT exported and why, the three-rule install command trust boundary, and the bash 3.2 constraint with the offset/length window shape it forces. The adding-a-CLI checklist gains the regenerate step, since forgetting it is how the installer would keep detecting the old set while the server offers the new one — the drift this change removes, one level out. docs/docker-cases.md gains how CLI_NPM_PACKAGES is derived, why it reads the stock catalogue and not the merged registry, and a table of the four documented Dockerfile special cases with their reasons. CLAUDE.md gains a command row and names the generated block, the bash 3.2 rule and the trust boundary in its install.sh paragraph. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 --- .changeset/cli-catalog-consumers.md | 49 +++++++++++++++++++++++++++ CLAUDE.md | 3 +- docs/cli-registry.md | 52 +++++++++++++++++++++++++++-- docs/docker-cases.md | 33 ++++++++++++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 .changeset/cli-catalog-consumers.md diff --git a/.changeset/cli-catalog-consumers.md b/.changeset/cli-catalog-consumers.md new file mode 100644 index 00000000..46bb4a4b --- /dev/null +++ b/.changeset/cli-catalog-consumers.md @@ -0,0 +1,49 @@ +--- +'aicodeman': minor +--- + +`install.sh` and the Docker agent image now read the shipped CLI catalogue instead of +hand-maintaining their own lists. + +Adding a CLI to `src/config/cli-registry/stock.ts` and running +`npm run generate:cli-catalog` wires it into the installer's detection, its install menu and +its closing reminder, and into the agent image's npm layer. Previously each of those was a +separate hand-written list that had to be kept in step and was not: upstream `b6d0f1fa` is +"wire OMP into install.sh's CLI detection (it had none)", where a user with only `omp` +installed was told no AI CLI was found and offered Claude Code, and the section comment above +that code named six of the nine CLIs. + +The generator emits two committed artifacts, because neither consumer can import TypeScript: +`config/clis.stock.json` for the Docker build, and a marked block inside `install.sh` itself, +which runs via `curl | bash` before any checkout exists. The embedded copy is the FULL +catalogue: an earlier attempt fetched it and fell back to a hardcoded two-CLI list, degrading +silently on an empty response, and there is no degraded mode to fall into now. An optional, +opt-in refresh (`CODEMAN_CLI_CATALOGUE_URL` or `CODEMAN_REFRESH_CLI_CATALOGUE=1`) warns loudly +on all three failure shapes. + +**Trust model is unchanged and now mechanical.** The server still never executes an entry's +install command. `install.sh` executes only commands embedded in itself — same file, same TLS +fetch, same commit as the `curl | bash` line that fetched it — and nothing pulled from the +network at install time is ever run: the two live in separate arrays and a test asserts the +refresh cannot write the executable one. + +**The agent image respects `enabled`.** The generated catalogue carries that flag, so a CLI +shipping disabled is no longer baked into every image. It reads the stock catalogue rather than +the merged registry, so a user's `~/.codeman/clis.json` cannot change what is inside an image +tagged `codeman/agent:base`. + +User-visible changes, all in the installer: + +- The install menu is built from the catalogue, so it offers every enabled CLI that is not installed and ships an install command — five rather than the previous fixed two. Gemini had a command in the registry and appeared in no list in the script at all. +- Its entries use the registry's labels ("Claude" rather than "Claude Code"), the same trade already made for `codeman doctor` rows. A suffix map would just be the hand-maintained list again. +- On a `wget`-only host the menu prints the commands instead of running them. The registry's commands call `curl`, whereas the two literals they replace went through `download_to_stdout`; rewriting `curl` to `wget` inside a string about to be executed is the wrong instinct. +- `CODEMAN_NONINTERACTIVE=1` still defaults to Claude Code, unchanged. + +`install.sh` remains bash 3.2 compatible (macOS ships it): parallel indexed arrays with +offset/length windows instead of delimiters, no associative arrays, namerefs, `mapfile` or +here-strings. CI now runs `bash -n`, executes the script inside a real `bash:3.2` container — +which is what catches expanding an empty array under `set -u`, a runtime abort `bash -n` cannot +see — and checks the generated artifacts are in sync. + +`docker/server.Dockerfile` is deliberately untouched; its narrower CLI list is now asserted as +a declared omission list so the divergence is visible rather than accidental. diff --git a/CLAUDE.md b/CLAUDE.md index f0558d38..dc4b2fcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Test coverage | `npm run test:coverage` | | Dead-code sweep | `npm run knip` (config in `config/knip.json`, passed via `--config`) | | Rebuild gesture overlay | `npm run build:gesture` (esbuild `packages/gesture-control/src/codeman/entry.ts` → `src/web/public/gesture/gesture-codeman.js`; commit the result) | +| Regenerate the CLI catalogue | `npm run generate:cli-catalog` (`--check` to fail on drift). Rewrites `config/clis.stock.json` **and** the marked block in `install.sh` from `stock.ts`. ⚠ **Commit both.** They are what `install.sh` and the Docker agent image read, since neither can import TypeScript; `test/cli-catalog-sync.test.ts` and a CI `--check` step fail if either goes stale. See `docs/cli-registry.md` | | Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` | | Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) | | Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) | @@ -451,6 +452,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer ## Scripts & Tunnel -**`install.sh`** (repo root, 104KB) is the public entry point: `curl -fsSL | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: **Tailscale** (loopback bind + guided `tailscale serve --bg ` HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset` or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). `install.sh update`, `install.sh uninstall`, and `install.sh tailscale` (retrofit Tailscale access onto an existing install) also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation, `CODEMAN_TAILSCALE=1` presets the Tailscale choice (never installs Tailscale non-interactively). +**`install.sh`** (repo root, 104KB) is the public entry point: `curl -fsSL | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: **Tailscale** (loopback bind + guided `tailscale serve --bg ` HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset` or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). `install.sh update`, `install.sh uninstall`, and `install.sh tailscale` (retrofit Tailscale access onto an existing install) also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation, `CODEMAN_TAILSCALE=1` presets the Tailscale choice (never installs Tailscale non-interactively). Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers in the file), not a hand-written list: detection, the install menu and the closing reminder all read it, which is what stops the class of bug upstream `b6d0f1fa` fixed by hand (a user with only omp installed being told no AI CLI was found). ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`); nothing fetched by the optional catalogue refresh is ever run. 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/cli-registry.md b/docs/cli-registry.md index c84664ef..da9fa12a 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -118,6 +118,52 @@ Treat those values as **transcribed, not authoritative** — nothing enforces th Everything else in the interface is live, including `overlays.remote` / `overlays.docker`, which back `defaultRemoteCommandForMode()` and `defaultDockerCommandForMode()` directly. Those two used to be hardcoded `Record<…CommandMode, string>` tables duplicating the registry with nothing keeping the two in step; `test/location-overlay-commands.test.ts` pins every resulting command as a literal string. +## Consumers outside the server + +Two things need the catalogue but cannot import TypeScript, so `npm run generate:cli-catalog` +(`scripts/generate-cli-catalog.mts`) emits two artifacts from `stock.ts`. Both are committed, +and `test/cli-catalog-sync.test.ts` fails if either drifts from a fresh generation. + +| Artifact | Consumer | Why it exists | +| ------------------------------------ | ---------------------------------- | ---------------------------------------------------------------------------------- | +| `config/clis.stock.json` | `scripts/lib/cli-catalog.mjs` (Docker build args), tests | A `.mjs` cannot import the registry. | +| a marked block inside `install.sh` | the installer itself | It runs via `curl \| bash` before any checkout exists, so it can read neither. | + +Only `id`, `label`, `shortBadge`, `enabled`, `order`, `kind` and `discovery` are exported. +`launch`, `env`, `capabilities` and `overlays` are spawn-time concerns the server alone +interprets, and a test asserts they never leak into the artifact — a second reading of the +launch model in a consumer that cannot be tested against a real spawn is exactly what this +registry exists to prevent. + +The install.sh copy is **embedded, not fetched**, and is the FULL catalogue. An earlier design +fetched it and fell back to a hardcoded two-CLI list, which degraded silently on an empty +response; there is no degraded mode to fall into now. An optional, opt-in refresh +(`CODEMAN_CLI_CATALOGUE_URL`, or `CODEMAN_REFRESH_CLI_CATALOGUE=1`) exists for a stale local +copy, and warns on all three failure shapes — empty body, unparseable content, failed fetch. + +### The install-command trust boundary + +Three rules, and the middle one is why the embed matters: + +1. **The server never executes an entry's `install.command`.** Unchanged, and still enforced by nothing executing it: the field is display text (`CliDiscovery.install.command`). +2. **`install.sh` executes only commands embedded in itself.** Those arrive in the same file, over the same TLS fetch, in the same commit as the `curl \| bash` line that fetched the script — identical trust to the hardcoded vendor one-liners it replaces. +3. **Nothing fetched at install time is ever executed.** + +That is mechanical rather than a promise. `CLI_INSTALL_CMD_TRUSTED` is written only from the +generated block and is the only array the installer runs; `CLI_INSTALL_CMD_DISPLAY` is what the +refresh may rewrite. `test/install-sh-invariants.test.ts` asserts the split holds, that the +refresh never assigns into a `*_TRUSTED` array, and that it never `eval`s. + +### bash 3.2 + +macOS ships bash 3.2 and the documented install is `curl -fsSL | bash` under +`set -euo pipefail`, so a bash-4 construct is not a warning there — it kills the install. The +generated block therefore uses parallel indexed arrays with **offset/length windows** into one +flat array instead of delimiters (a `$HOME` containing a space needs no `IFS` handling, and an +entry with nothing to contribute gets length 0 and is never iterated). CI runs `bash -n` and +executes the script inside a real `bash:3.2` container, because the empty-window case is a +runtime `set -u` abort that `bash -n` cannot see. + ## Resolve at call time, never at import Anything reading the registry must resolve it when it is asked, not when its module is first imported. `sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()` and each resolver's `searchDirs` thunk all re-read the catalog per call. @@ -127,8 +173,10 @@ A module-level const freezes at first import, and the failure is asymmetric: a C ## Adding a CLI 1. Add a `CliEntry` to `stock.ts`. -2. Add a golden spawn-command pin to `test/cli-registry-spawn-golden.test.ts`, a row to `test/cli-capability-predicates.test.ts`, and its remote/docker commands to `test/location-overlay-commands.test.ts`. -3. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code. +2. Run `npm run generate:cli-catalog` and commit **both** artifacts (`config/clis.stock.json` and `install.sh`). The installer's detection, its install menu, its reminder text and the Docker agent image all follow from that one step — this is what makes upstream `b6d0f1fa` ("wire OMP into install.sh's CLI detection, it had none") impossible rather than merely fixed. +3. Add a golden spawn-command pin to `test/cli-registry-spawn-golden.test.ts`, a row to `test/cli-capability-predicates.test.ts`, its remote/docker commands to `test/location-overlay-commands.test.ts`, and its search paths to `test/install-sh-detection-parity.test.ts`. +4. Only if it cannot install with a plain `npm install -g `: give it a layer in `docker/agent.Dockerfile` and a reason in `AGENT_IMAGE_SPECIAL_CASES` (`scripts/lib/cli-catalog.mjs`). The coverage test requires both, so an exclusion cannot quietly become an omission. +5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code. ## See also diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 738d5a3d..760aa5bc 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -21,6 +21,39 @@ The image is **secret-free**: credentials are delivered at runtime (bind mounts node scripts/build-agent-image.mjs --no-cache ``` +### Which CLIs the image contains + +The npm-published CLIs come from `ARG CLI_NPM_PACKAGES`, which `scripts/build-agent-image.mjs` +fills from `config/clis.stock.json` (generated from `src/config/cli-registry/stock.ts`). Adding +a stock CLI that installs with a plain `npm install -g` needs no Dockerfile edit. The ARG +defaults to the same list in the same order, so a bare `docker build` produces a byte-identical +layer — a different order would be a different `RUN` string and so a needless cache miss. + +⚠️ It reads the **stock** catalogue, never the merged registry. A user's `~/.codeman/clis.json` +must not change what is inside an image tagged `codeman/agent:base`, or two machines holding +that tag hold different images and every cache decision downstream is a lie. Each entry's +`enabled` flag IS honoured, so a CLI that ships disabled is never baked in. + +Four CLIs keep hand-written layers, because the registry cannot express what makes them +special: + +| CLI | Why it is not in the shared npm layer | +| ------------- | ------------------------------------------------------------------------------------- | +| `pi` | Installs with `--ignore-scripts`, kept in its own layer so the flag cannot leak to the others. | +| `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. | +| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). | +| `grok`, `omp` | Not on npm — standalone vendor installers. | + +`test/docker-agent-image-coverage.test.ts` requires every special case to carry a written +reason AND still be present in the Dockerfile, so an exclusion cannot silently become an +omission — which is the same failure upstream `b6d0f1fa` hit in `install.sh`. + +Two things build this image: `scripts/build-agent-image.mjs` (a human) and +`ensureAgentBaseImage()` in `src/docker-hosts.ts` (the app, on the first Docker case). They +assemble the argv independently, because a `.mjs` cannot import TypeScript, so +`test/agent-image-build-args-parity.test.ts` pins them together. Without it, an image built by +hand and one built by the app could hold different CLIs under the same tag. + A zero exit code only proves the layers ran, not that the toolchain works. Verify by actually executing each CLI in the image, and check the build log for `Using cache` lines: ```bash