diff --git a/CLAUDE.md b/CLAUDE.md index ff531ecb..d0a039de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. -**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so relaunch appends `--resume ` (codex `resume `, gemini `--resume`) and continues the conversation from the bind-mounted transcript. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` (seeded when `resumeOnStart`, default true); `-A` makes the flag self-selecting (inert on reattach, active only when tmux was re-created). **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe: convenient default bind-mounts host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/{gcloud,opencode}` RW (bind mounts are physically excluded from `docker commit`, so exports stay secret-free), API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY via `scripts/build-agent-image.mjs` from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab, the one-click checkbox + Template picker, and a Manage-tab Export button. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. +**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. **Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). @@ -218,6 +218,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. +**File Viewer button** (header, 1.4.1) is likewise **hidden by default**: enable under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, also in the per-device `displayKeys` set). Purely client-side like the response viewer: the template ships `btn-file-viewer--hidden` and `applyHeaderVisibilitySettings()` toggles the marker class after settings load. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). + **Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected). **Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`. @@ -248,7 +250,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### SSE Event Registry -~136 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend), incl. `docker:exportComplete`/`exportFailed`/`importComplete`. Both must be kept in sync. +~138 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend), incl. `docker:exportComplete`/`exportFailed`/`importComplete` and `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`. Both must be kept in sync. ### API Routes diff --git a/README.md b/README.md index 5387bca1..b1e48dfd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Mission control for AI coding agents

- Claude Code • OpenCode • Codex • Terminal - One Dashboard • Any Device + Claude Code • OpenCode • Codex • Gemini • Terminal - One Dashboard • Any Device

@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. -You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). 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), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). After install: ```bash codeman web @@ -106,7 +106,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | 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), or [Codex](https://developers.openai.com/codex/cli)). 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), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser. @@ -365,13 +365,14 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt ## More Features - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → 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) -- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) +- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-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) - **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 - **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`) - **Image input** — paste or drag-and-drop images straight into a session - **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display - **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam +- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Display → Header Displays - **CJK / IME input** — full composition support for Chinese / Japanese / Korean - **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:` so multi-host setups stay unambiguous @@ -384,11 +385,12 @@ Run a case inside its own hardened Docker container instead of directly on your - **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in. - **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap. - **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. Your existing `~/.claude` login is bind-mounted (credentials stay on the host, never captured in exports); a **sealed** profile (no host mounts, network off) is one toggle away. +- **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 / Gemini / OpenCode 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: Docker (or Podman) and the base image — build it once with `node scripts/build-agent-image.mjs`. Full guide: [`docs/docker-cases.md`](docs/docker-cases.md). +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). --- @@ -555,7 +557,7 @@ These run for **every** request — before auth, even on the default no-password ### Input, files & headers -- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive +- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env-prefix allowlist gates which settings each CLI can receive - **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes - **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1` @@ -791,7 +793,7 @@ flowchart TB end subgraph External["External"] - CLI["AI CLI
Claude Code / OpenCode / Codex"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Gemini"] BG["Background Agents
(Task tool)"] end end diff --git a/README.zh-CN.md b/README.zh-CN.md index 5af905d2..120b7499 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@

AI 编程智能体的任务控制中心

- Claude Code • OpenCode • Codex —— 统一仪表盘 • 任意设备 + Claude Code • OpenCode • Codex • Gemini • 终端 —— 统一仪表盘 • 任意设备

@@ -14,7 +14,7 @@

License: MIT - Node.js 18+ + Node.js 22+ TypeScript 5.9 Fastify Tests @@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b 该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。 -你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后: +你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装完成后: ```bash codeman web @@ -47,6 +47,7 @@ codeman web

作为后台服务运行 **Linux(systemd):** + ```bash mkdir -p ~/.config/systemd/user cat > ~/.config/systemd/user/codeman-web.service << EOF @@ -69,6 +70,7 @@ loginctl enable-linger $USER ``` **macOS(launchd):** + ```bash mkdir -p ~/Library/LaunchAgents cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF @@ -96,6 +98,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF EOF launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist ``` +
@@ -105,11 +108,79 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | 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))。安装完成后,即可从 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) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。 +
--- +## 使用 Codeman —— 人类操作指南 + +从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。 + +### 1. 启动服务器 + +```bash +codeman web # localhost:3000(仅环回 —— 安全默认值) +codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT) +codeman web --https # 自签名 TLS(仅远程访问时需要) +codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」) +``` + +打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。 + +### 2. 创建你的第一个会话 + +点击 **+ New Session**(或 **Quick Start**)。一个会话就是一个运行在自己 tmux 终端里的 AI CLI。你可以选择: + +| 字段 | 作用 | +| ---------------------- | ------------------------------------------------------------------------------------------- | +| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 | +| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Gemini` 或 `Terminal`(普通 shell)。 | +| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 | +| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 | + +点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。 + +### 3. 读懂仪表盘 + +- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序。 +- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。 +- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。 + +### 4. 与智能体对话 + +- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。 +- **粘贴或拖放图片**,直接进入会话。 +- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,自动静音停止)。 +- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF。 + +### 5. 让它自主运行 + +| 模式 | 用途 | 位置 | +| ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- | +| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 | +| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 | +| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 | +| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮 | +| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) | + +### 6. 随时随地访问 + +- **手机/平板** —— UI 完全触控优化;扫描桌面上的**二维码**即可免密码登录。 +- **网络之外** —— `./scripts/tunnel.sh start` 打开一条 Cloudflare 隧道(先设置 `CODEMAN_PASSWORD`)。 +- **SSH** —— `sc` 选择器可从终端附着任意会话(`sc` 交互式,`sc 2` 快速附着,`sc -l` 列表)。 + +### 7. 运维与维护 + +- **App Settings** —— 模型、effort、主题/皮肤、通知、显示开关、各 CLI 的专属选项。 +- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。 +- **部署你自己的改动** —— 见[开发](#开发)。 + +> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。 + +--- + ## 移动端优化的 Web UI 在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。 @@ -216,7 +287,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE → ``` - **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性 -- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用 +- **用量限额自动恢复**(_可选,默认关闭_)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用 - **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误) - **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复 - **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min) @@ -262,10 +333,10 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈 ### 智能 Token 管理 -| 阈值 | 动作 | 结果 | -|-----------|--------|--------| +| 阈值 | 动作 | 结果 | +| --------------- | --------------- | ---------------------- | | **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 | -| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 | +| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 | ### 通知 @@ -296,17 +367,35 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端 ## 更多特性 - **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新) -- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) +- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) +- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md) - **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置 - **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`) - **图像输入** —— 直接把图片粘贴或拖放进会话 -- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用 -- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝 +- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用 +- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝 +- **文件查看器按钮** _(可选)_ —— 头部新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Display → Header Displays 中启用 - **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入 - **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:` 为前缀,使多主机配置不再含糊 --- +## 隔离的 Docker 会话 + +让案例(case)运行在专属的加固 Docker 容器里,而不是直接跑在主机上:获得安全隔离、可复现的工具链和一键可移植性。 + +- **一键启动** —— 在 **New Case → Create New** 中勾选 **🐳 Run in an isolated Docker container**。Codeman 会创建案例文件夹、用默认设置启动容器,并在容器内启动智能体。无需填写任何主机/镜像/网络字段。 +- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。 +- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。 +- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。 +- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Gemini / OpenCode 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。 +- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。 +- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。 + +前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。 + +--- + ## 远程访问 —— Cloudflare 隧道 使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。 @@ -374,14 +463,14 @@ loginctl enable-linger $USER 该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对: -| USENIX 缺陷 | 缓解措施 | -|-------------|------------| -| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 | -| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 | -| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 | -| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 | -| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 | -| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie | +| USENIX 缺陷 | 缓解措施 | +| ---------------------------- | --------------------------------------------------------------------------------------------- | +| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 | +| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 | +| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 | +| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 | +| **缺陷 5**:缺少状态通知 | 桌面提示:_「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」_ —— 实时 QRLjacking 检测 | +| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie | #### 时序安全的查找 @@ -406,23 +495,23 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符) #### 威胁覆盖 -| 威胁 | 为何无效 | -|--------|-------------------| -| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 | -| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 | +| 威胁 | 为何无效 | +| ----------------------- | ------------------------------------------------------------------------------------ | +| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 | +| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 | | **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 | -| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 | -| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 | -| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 | -| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 | +| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 | +| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 | +| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 | +| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 | #### 横向对比 -| 平台 | 模型 | 对比 | -|----------|-------|------------| -| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 | -| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) | -| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 | +| 平台 | 模型 | 对比 | +| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 | +| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) | +| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 | > 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md) @@ -430,7 +519,7 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符) ## 安全 -Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。 +Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。 ### 网络与访问 @@ -450,7 +539,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设 ### 输入、文件与响应头 -- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置 +- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置 - **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行 - **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS @@ -481,73 +570,177 @@ sc -l # 列出会话 > Ctrl 绑定在 macOS 上也接受 Cmd。 -| 快捷键 | 动作 | -|----------|--------| -| `Ctrl/Cmd+W` | 杀掉当前会话 | -| `Ctrl/Cmd+Tab` | 下一个会话 | -| `Alt+1`–`Alt+9` | 切换到第 N 个标签 | -| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 | -| `Ctrl/Cmd+L` | 清屏 | -| `Ctrl+Shift+R` | 恢复终端尺寸 | -| `Ctrl+Shift+V` | 切换语音输入 | -| `Ctrl/Cmd +` / `-` | 字体大小 | -| `Ctrl/Cmd+?` | 键盘帮助 | -| `Shift+Enter` | 插入换行(发送到终端) | -| `Escape` | 关闭面板与模态框 | +| 快捷键 | 动作 | +| ------------------------------- | -------------------------------------------------------- | +| `Ctrl/Cmd+W` | 杀掉当前会话 | +| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 | +| `Ctrl/Cmd+Tab` | 下一个会话 | +| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 | +| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) | +| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 | +| `Ctrl/Cmd+L` | 清屏 | +| `Ctrl+Shift+R` | 恢复终端尺寸 | +| `Ctrl+Shift+V` | 切换语音输入 | +| `Ctrl/Cmd +` / `-` | 字体大小 | +| `Ctrl/Cmd+?` | 键盘帮助 | +| `Shift+Enter` | 插入换行(发送到终端) | +| `Escape` | 关闭面板与模态框 | + +--- + +## 从智能体驱动 Codeman —— 编程指南 + +面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。 + +### 检测自己身处 Codeman 内部 + +当 CLI 运行在 Codeman 受管会话中时,以下环境变量会被设置 —— 读取它们,别硬编码任何东西: + +| 变量 | 含义 | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `CODEMAN_MUX=1` | 你在一个受管 tmux 会话里。**绝不要** `tmux kill-session` / `pkill claude` / `pkill tmux` —— 你会杀掉自己或兄弟会话。 | +| `CODEMAN_API_URL` | API 的基础 URL(例如 `https://127.0.0.1:3000`)。下面每个调用都用它。 | +| `CODEMAN_SESSION_ID` | *你自己的*会话 id。用它避免对自己下手。 | +| `CODEMAN_HOOK_SECRET_FILE` | hook 密钥文件的路径(受管隧道开启时调用 `/api/hook-event` 必需)。 | + +### 行路规则(POST 之前先读) + +1. **只发单行输入。** 编程输入会作为字面文本 **+ Enter** 一次性发送。多行字符串会破坏智能体 TUI(Ink)—— 发送一行,或拆成多次调用。 +2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。 +3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。 +4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。 +5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。 + +### 常用配方 + +```bash +API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" +# (若设置了密码,给每个调用加上 -u admin:"$CODEMAN_PASSWORD") + +# 1. 看看有什么在运行 +curl -s "$API/api/sessions" | jq '.data // .' + +# 2. 拉起一个工作会话(「case」= 命名工作目录) +curl -s -X POST "$API/api/quick-start" \ + -H 'Content-Type: application/json' \ + -d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq + +# 3. 向会话发送提示(精确一次:clientId + seq) +curl -s -X POST "$API/api/sessions/$SID/input" \ + -H 'Content-Type: application/json' \ + -d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}' + +# 4. 读回终端内容 +curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .' + +# 5. 流式接收实时事件(会话输出、智能体活动、状态) +curl -sN "$API/api/events" # Server-Sent Events + +# 6. 调度周期性工作(cron 风格任务) +curl -s -X POST "$API/api/cron/jobs" \ + -H 'Content-Type: application/json' \ + -d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj", + "promptMode":"inline_text","promptText":"Update dependencies and open a PR", + "inputMode":"typed","scheduleType":"daily","dailyTime":"03:00", + "enabled":true,"concurrencyPolicy":"warn_only"}' | jq + +# 7. 查看后台子智能体及其活动记录 +curl -s "$API/api/subagents" | jq '.data // .' +curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .' + +# 8. 全系统快照(会话、设置、重生、统计) +curl -s "$API/api/status" | jq +``` + +### 或使用内置 CLI + +同样的操作也有命令形式(`codeman `,括号内为别名)—— 在会话内的 shell 工具里很顺手: + +```bash +codeman session start -d /path/to/repo # (s) 启动会话 +codeman session list # 列出会话 +codeman session logs # 查看输出 +codeman task add "fix the failing test" # (t) 排入任务 +codeman ralph start --min-hours 8 # (r) 启动自主循环 +codeman attach # 附着 Claude hook 上下文 +``` + +### Hook(事件*回流*到 Codeman) + +Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。 + +> 完整端点列表与请求/响应形状见下文。 --- ## API -基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集: +基于 Fastify 的 REST —— **18 个路由模块中约 160 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集: ### 会话(Sessions) -| 方法 | 端点 | 说明 | -|--------|----------|-------------| -| `GET` | `/api/sessions` | 列出全部 | -| `POST` | `/api/quick-start` | 创建 case 并启动会话 | -| `DELETE` | `/api/sessions/:id` | 删除会话 | -| `POST` | `/api/sessions/:id/input` | 发送输入 | + +| 方法 | 端点 | 说明 | +| -------- | -------------------------- | ------------------------------------------------------------------------------ | +| `GET` | `/api/sessions` | 列出全部 | +| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) | +| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) | +| `GET` | `/api/sessions/:id/output` | 读取终端输出 | +| `DELETE` | `/api/sessions/:id` | 删除会话 | ### 重生(Respawn) -| 方法 | 端点 | 说明 | -|--------|----------|-------------| + +| 方法 | 端点 | 说明 | +| ------ | ---------------------------------- | -------------------- | | `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 | -| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 | -| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 | +| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 | +| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 | ### Ralph / Todo -| 方法 | 端点 | 说明 | -|--------|----------|-------------| -| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos | -| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 | + +| 方法 | 端点 | 说明 | +| ------ | -------------------------------- | -------------------- | +| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos | +| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 | ### 编排器(Orchestrator) -| 方法 | 端点 | 说明 | -|--------|----------|-------------| -| `POST` | `/api/orchestrator/start` | 从目标启动编排 | -| `POST` | `/api/orchestrator/approve` | 批准生成的计划 | -| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 | -| `POST` | `/api/orchestrator/stop` | 停止并清理 | + +| 方法 | 端点 | 说明 | +| ------ | --------------------------- | --------------- | +| `POST` | `/api/orchestrator/start` | 从目标启动编排 | +| `POST` | `/api/orchestrator/approve` | 批准生成的计划 | +| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 | +| `POST` | `/api/orchestrator/stop` | 停止并清理 | + +### Cron(定时任务) + +| 方法 | 端点 | 说明 | +| ---------------- | ---------------------------- | --------------------- | +| `GET` / `POST` | `/api/cron/jobs` | 列出 / 创建 cron 任务 | +| `PUT` / `DELETE` | `/api/cron/jobs/:id` | 更新 / 删除任务 | +| `PUT` | `/api/cron/jobs/:id/enabled` | 启用 / 禁用 | +| `POST` | `/api/cron/jobs/:id/run` | 立即运行 | +| `GET` | `/api/cron/jobs/:id/runs` | 运行历史 | ### 子智能体(Subagents) -| 方法 | 端点 | 说明 | -|--------|----------|-------------| -| `GET` | `/api/subagents` | 列出所有后台智能体 | -| `GET` | `/api/subagents/:id` | 智能体信息与状态 | -| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 | -| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 | + +| 方法 | 端点 | 说明 | +| -------- | ------------------------------- | ------------------ | +| `GET` | `/api/subagents` | 列出所有后台智能体 | +| `GET` | `/api/subagents/:id` | 智能体信息与状态 | +| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 | +| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 | ### 系统(System) -| 方法 | 端点 | 说明 | -|--------|----------|-------------| -| `GET` | `/api/events` | SSE 流 | -| `GET` | `/api/status` | 完整应用状态 | -| `POST` | `/api/hook-event` | Hook 回调 | -| `GET` | `/api/system/update/check` | 检查新发行版 | -| `POST` | `/api/system/update` | 自更新(git-clone 安装) | -| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) | -| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 | + +| 方法 | 端点 | 说明 | +| ------ | ------------------------------- | ---------------------------------------- | +| `GET` | `/api/events` | SSE 流 | +| `GET` | `/api/status` | 完整应用状态 | +| `POST` | `/api/hook-event` | Hook 回调 | +| `GET` | `/api/system/update/check` | 检查新发行版 | +| `POST` | `/api/system/update` | 自更新(git-clone 安装) | +| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) | +| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 | --- @@ -582,7 +775,7 @@ flowchart TB end subgraph External["外部"] - CLI["AI CLI
Claude Code / OpenCode / Codex"] + CLI["AI CLI
Claude Code / OpenCode / Codex / Gemini"] BG["后台智能体
(Task 工具)"] end end @@ -625,14 +818,14 @@ npm test # 运行测试 本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构: -| 阶段 | 改了什么 | 影响 | -|-------|-------------|--------| -| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms | -| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) | -| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 | -| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** | -| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 | -| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 | +| 阶段 | 改了什么 | 影响 | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms | +| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) | +| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 | +| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** | +| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 | +| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 | 完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md) @@ -654,6 +847,10 @@ npm install xterm-zerolag-input --- +## 版本策略 + +Codeman 遵循 [SemVer](https://semver.org/)。版本号真正承诺的内容,以及哪些算内部实现(HTTP/SSE API、磁盘上的状态、实验性特性),都写在 [`docs/versioning-policy.md`](docs/versioning-policy.md) 中。如果你的脚本依赖 HTTP API,请锁定到确切版本。 + ## 许可证 MIT —— 见 [LICENSE](LICENSE) diff --git a/docs/docker-cases.md b/docs/docker-cases.md index ee53feb9..53a990fe 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -52,8 +52,9 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl ## Lifecycle - **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives). -- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript. +- **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id `, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off. - **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions. +- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive. - **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone. ## Isolation & security diff --git a/src/docker-export.ts b/src/docker-export.ts index bb5549c0..e1e259e3 100644 --- a/src/docker-export.ts +++ b/src/docker-export.ts @@ -105,6 +105,45 @@ export function parseLoadedImageRef(loadOutput: string): string | null { return null; } +/** + * Validate an imported bundle's manifest BEFORE any of its fields are trusted. + * A bundle is cross-machine input (potentially authored by someone else), and its + * fields flow into stored host/case config that the schema layer never sees: + * `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir` + * reach the shellescaped launch string, `network` is a create arg. Mirror the + * DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is + * not a web-layer module). Exported for unit tests. + */ +export function validateImportManifest(manifest: DockerExportManifest): void { + const fail = (msg: string): never => { + throw new Error(`invalid bundle manifest: ${msg}`); + }; + if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { + fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`); + } + if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`); + if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`); + if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName'); + if ( + typeof manifest.image !== 'string' || + manifest.image.length > 512 || + !/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image) + ) { + fail('bad image reference'); + } + if ( + typeof manifest.containerWorkdir !== 'string' || + manifest.containerWorkdir.length > 2000 || + !manifest.containerWorkdir.startsWith('/') || + // comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it + /[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir) + ) { + fail('bad containerWorkdir'); + } + if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`); + if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums'); +} + // ========== IO helpers ========== function run( @@ -338,25 +377,34 @@ export async function importDockerBundle(params: { destWorkspace: string; engine: DockerEngine; timestamp: number; + /** Schema-validated destination case name; the quarantine tag derives from THIS, + * never from the (attacker-authored) manifest.caseName. */ + newCaseName: string; }): Promise { - const { bundlePath, destWorkspace, engine, timestamp } = params; + const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params; const argv: string[] = [engine === 'podman' ? 'podman' : 'docker']; if (IS_TEST_MODE) { const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}'); - return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace }; + const manifest = JSON.parse(raw) as DockerExportManifest; + validateImportManifest(manifest); + return { manifest, workspacePath: destWorkspace }; } const stageDir = `${destWorkspace}.import-stage-${timestamp}`; mkdirSync(stageDir, { recursive: true }); try { - await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); + // Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse + // `..`/absolute members by default, but the bundle is cross-machine input). + const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 }); + for (const member of bundleMembers.split('\n').filter(Boolean)) { + if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`); + } + await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8'); const manifest = JSON.parse(manifestRaw) as DockerExportManifest; - if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { - throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`); - } + validateImportManifest(manifest); // Integrity: verify checksums before trusting any member. const workspaceTar = join(stageDir, 'workspace.tar'); @@ -385,8 +433,9 @@ export async function importDockerBundle(params: { const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 }); const loadedRef = parseLoadedImageRef(stdout); if (!loadedRef) throw new Error('could not determine loaded image ref'); - // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag. - importedImage = importedImageTag(manifest.caseName, timestamp); + // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original + // tag; the tag name derives from the caller's schema-validated newCaseName. + importedImage = importedImageTag(newCaseName, timestamp); await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); } diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index bbbae840..328757b4 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -109,6 +109,24 @@ export async function writeDockerCases(configDir: string, cases: DockerCase[]): await writeJsonArray(configDir, dockerCasesPath(configDir), cases); } +/** + * Persist the case's last Claude conversation id (the `--resume` seed for the + * container-recreated relaunch, docs/docker-cases-plan.md two-layer durability). + * Keyed by container name so callers that only hold a SessionDocker can update it. + * No-op when the id is unchanged or the case is gone. + */ +export async function persistDockerCaseClaudeSessionId( + configDir: string, + containerName: string, + claudeSessionId: string +): Promise { + const cases = await readDockerCases(configDir); + const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName); + if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return; + cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId }; + await writeDockerCases(configDir, cases); +} + // ========== Naming / display / defaults ========== /** Per-case container name. Mirrors how remote derives a stable name from the case. */ @@ -624,6 +642,73 @@ export function resolveDockerCredentialArtifacts(home: string = homedir()): Dock // ========== Daemon probes (IO; no-op under VITEST) ========== +/** + * UNESCAPED argv prefix for execFile-based probes. The shellescaped + * buildDockerBaseArgs variant is for interpolation into the `bash -c` launch + * string; argv arrays must NOT carry literal quotes (mirror of docker-export's + * dockerArgv). + */ +function dockerEngineArgv(docker: Pick): string[] { + const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) argv.push('--context', docker.context); + if (docker.daemonHost) argv.push('-H', docker.daemonHost); + return argv; +} + +export interface DockerDriftStatus { + /** Container exists (daemon reachable AND a container with this name is present). */ + exists: boolean; + running: boolean; + /** The desired configHash no longer matches the container's codeman.confighash label. */ + drifted: boolean; + currentHash?: string; +} + +/** + * Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash + * against the existing container's `codeman.confighash` label so docker-host + * config edits actually take effect instead of being silently ignored by the + * idempotent inspect-or-create launch chain. `exists:false` (no container / + * daemon down) means there is nothing to drift. No-op under VITEST. + */ +export async function checkDockerConfigDrift( + docker: Pick +): Promise { + if (IS_TEST_MODE) return { exists: false, running: false, drifted: false }; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [ + ...argv.slice(1), + 'inspect', + '-f', + '{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}', + docker.containerName, + ], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const [running = '', hash = ''] = stdout.trim().split('\t'); + return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash }; + } catch { + return { exists: false, running: false, drifted: false }; + } +} + +/** + * `docker rm -f` the case container (the recreate-on-drift confirm action; the + * launch chain recreates it with the new config on next start). Workspace + + * transcripts ride bind mounts and survive; the conversation resumes via the + * case's lastClaudeSessionId. No-op under VITEST. + */ +export async function removeDockerContainer( + docker: Pick +): Promise { + if (IS_TEST_MODE) return; + const argv = dockerEngineArgv(docker); + await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 }); +} + export interface DockerAvailability { ok: boolean; engine: DockerEngine; @@ -702,11 +787,16 @@ export async function checkDockerAvailable(engine?: DockerEngine): Promise { +/** Is the base image present on the host's daemon? (never triggers an auto-pull). + * Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */ +export async function checkDockerImagePresent( + docker: Pick, + image: string +): Promise { if (IS_TEST_MODE) return true; + const argv = dockerEngineArgv(docker); try { - await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], { + await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], { timeout: DOCKER_PROBE_TIMEOUT_MS, }); return true; @@ -748,12 +838,12 @@ function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | * lines for SSE surfacing. */ export async function ensureAgentBaseImage( - engine: DockerEngine, + docker: Pick, image: string, opts: { onProgress?: (line: string) => void; noCache?: boolean } = {} ): Promise { if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true }; - if (await checkDockerImagePresent(engine, image)) { + if (await checkDockerImagePresent(docker, image)) { return { ok: true, built: false, alreadyPresent: true }; } if (image !== DEFAULT_AGENT_IMAGE) { @@ -764,16 +854,16 @@ export async function ensureAgentBaseImage( error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`, }; } - const key = `${engine}:${image}`; + const key = `${docker.engine}:${image}`; const existing = inFlightImageBuilds.get(key); if (existing) return existing; - const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key)); + const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key)); inFlightImageBuilds.set(key, build); return build; } function buildAgentImage( - engine: DockerEngine, + docker: Pick, image: string, opts: { onProgress?: (line: string) => void; noCache?: boolean } ): Promise { @@ -786,10 +876,14 @@ function buildAgentImage( error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`, }); } - const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache); + const argv = dockerEngineArgv(docker); + const args = [ + ...argv.slice(1), + ...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache), + ]; return new Promise((resolve) => { // async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop. - const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] }); const forward = (buf: Buffer) => { for (const line of buf.toString('utf-8').split('\n')) { const trimmed = line.trimEnd(); @@ -803,12 +897,12 @@ function buildAgentImage( ok: false, built: false, alreadyPresent: false, - error: `could not spawn ${engine} build: ${err.message}`, + error: `could not spawn ${argv[0]} build: ${err.message}`, }); }); child.on('exit', (code) => { if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false }); - else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` }); + else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` }); }); }); } @@ -827,21 +921,21 @@ export interface DockerTmuxCheckResult { * (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable. */ export async function checkDockerTmuxAvailable( - docker: Pick + docker: Pick ): Promise { if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' }; - const engine = docker.engine; - if (!(await checkDockerImagePresent(engine, docker.image))) { + if (!(await checkDockerImagePresent(docker, docker.image))) { return { ok: false, imageMissing: true, error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`, }; } + const argv = dockerEngineArgv(docker); try { const { stdout } = await execFileAsync( - engine, - ['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], + argv[0], + [...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], { timeout: DOCKER_PROBE_TIMEOUT_MS } ); const tmuxPath = stdout.trim(); @@ -938,16 +1032,21 @@ export async function reapOrphanedDockerContainers( * Returns undefined on any failure. No-op under VITEST. */ export async function probeDockerCliVersion( - docker: Pick, + docker: Pick, mode: SessionMode ): Promise { if (IS_TEST_MODE) return undefined; const bin = mode === 'shell' ? null : mode; if (!bin) return undefined; + const argv = dockerEngineArgv(docker); try { - const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], { - timeout: DOCKER_PROBE_TIMEOUT_MS, - }); + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, bin, '--version'], + { + timeout: DOCKER_PROBE_TIMEOUT_MS, + } + ); const match = stdout.trim().match(/\d+\.\d+\.\d+/); return match ? match[0] : stdout.trim() || undefined; } catch { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 76ec2f05..657ccb67 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -873,15 +873,15 @@ export function dockerTmuxSessionName(sessionId: string): string { const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; /** - * Append the CLI-specific resume flag to a pane command. Only fires when the - * in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a - * live reattach), i.e. exactly when the previous live agent was lost and we want - * to resume the conversation from the bind-mounted transcript. + * Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires + * when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert + * on a live reattach), i.e. exactly when the previous live agent was lost and we + * want to resume the conversation from the bind-mounted transcript. Claude mode + * uses claudeDockerPaneCommand instead. */ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; switch (mode) { - case 'claude': case 'gemini': return `${modeCommand} --resume ${resumeId}`; case 'codex': @@ -891,6 +891,30 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri } } +/** + * Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog + * of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes + * `--session-id `, so the in-container conversation id is knowable + * host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on + * hook reachability. When the in-container tmux was re-created after a container + * stop/reboot, the same command re-runs against the surviving transcript: + * `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that + * conversation (verified CLI behavior). An explicit resumeId gets the local + * builder's shape — resume first, session-id fallback — so a stale id never + * dead-panes. The leading `exec ` is stripped: an exec'd first branch could never + * fall back. + */ +function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string { + if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids + const cmd = modeCommand.replace(/^exec\s+/, ''); + const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined; + if (rid && rid !== sessionId) { + return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`; + } + const cid = rid ?? sessionId; + return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`; +} + /** Fully-resolved inputs for buildDockerLaunchCommand (pure). */ export interface DockerLaunchOptions { mode: SessionMode; @@ -930,7 +954,11 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const sid = sessionId.slice(0, 8); let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); - if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId); + if (mode === 'claude') { + modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId); + } else if (resumeSessionId) { + modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId); + } // Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the // pane PID the agent itself. const paneCommand = `cd ${workdir} && ${modeCommand}`; diff --git a/src/types/session.ts b/src/types/session.ts index 3e50941d..7edc73bb 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -9,7 +9,7 @@ * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend) - * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools') + * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId) diff --git a/src/web/public/app.js b/src/web/public/app.js index f36238e1..b14bf5c9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1452,6 +1452,26 @@ class CodemanApp { console.error('[SSE] docker export failed:', err); } }); + // Import + drift-recreate completions: refresh case lists in EVERY open tab + // (the initiating tab already refreshes via its own fetch response). + addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker bundle imported as case "${d.name}"`, 'success'); + this.loadQuickStartCases?.(); + this.refreshDockerExports?.(); + } catch (err) { + console.error('[SSE] docker import complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info'); + } catch (err) { + console.error('[SSE] docker container recreated:', err); + } + }); // Base image auto-build on first Docker case (build-on-first-use). A single // multi-minute event; surface start/finish so the Run spinner is explained. addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => { diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 435fe040..dc598890 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -484,6 +484,7 @@ const SSE_EVENTS = { // Multi-user (admin-only / targeted) ADMIN_USERS_CHANGED: 'admin:usersChanged', AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired', + DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index 28db93d3..2f792231 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1453,7 +1453,7 @@ - How Claude CLI is started in screen sessions + How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)
- Runs this case in a hardened, isolated container. The base image is built automatically on first use. + Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.
Container settings (optional, sensible defaults) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 39ab745b..db2b4961 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel { height: 12px; } - /* Hide header settings gear, lifecycle log, away digest, and session manager on - mobile - settings moved to toolbar; away digest and the session manager are - secondary controls that don't belong on the cramped phone header (the session - manager stays reachable via the Ctrl+K palette's "Browse all sessions" item). + /* Hide header settings gear, lifecycle log, away digest, session manager, and + file viewer on mobile - settings moved to toolbar; the others are secondary / + desktop-oriented controls that don't belong on the cramped phone header (the + session manager stays reachable via the Ctrl+K palette's "Browse all sessions" + item; the file viewer button is opt-in but its panel is desktop-sized). (The attachments button is opt-in / default-hidden everywhere via its own --hidden marker, so it needs no mobile-specific rule here.) */ .btn-icon-header.btn-settings, .btn-icon-header.btn-lifecycle-log, .btn-icon-header.btn-away-digest, - .btn-icon-header.btn-session-manager { + .btn-icon-header.btn-session-manager, + .btn-icon-header.btn-file-viewer { display: none !important; } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 5a8b915d..a5f0f041 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -551,14 +551,50 @@ Object.assign(CodemanApp.prototype, { // Name remote/docker tabs with the same w- convention as local // sessions (quick-start would otherwise auto-generate codeman-). const startNumber = this._nextCaseSessionStartNumber(caseName); + // Docker (NOT remote): the App Settings Claude Model choice applies — the + // workspace is a real host dir, so quick-start writes it to the case's + // .claude/settings.local.json and the in-container claude reads it. + // Remote quick-starts REJECT modelOverride (the file would land on the + // wrong machine), so never send it there. + let dockerModelOverride; + if (caseData.location === 'docker') { + const dockerGlobalSettings = this.loadAppSettingsFromStorage(); + const dockerCaseSettings = this.getCaseSettings(caseName); + const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled; + dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : ''); + } const remoteIds = []; + let driftHandled = false; for (let i = 0; i < tabCount; i++) { - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` }) + const quickStartBody = JSON.stringify({ + caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`, + ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {}) }); - const data = await res.json(); + const doQuickStart = async () => { + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: quickStartBody + }); + return res.json(); + }; + let data = await doQuickStart(); + // Docker config drift: the host config changed since the container was + // created (CONFLICT from quick-start). Confirm once, recreate, retry. + if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) { + driftHandled = true; + const recreate = confirm( + `Container config for "${caseName}" changed since its container was created.\n\n` + + 'Recreate the container to apply the new config? Workspace files and the ' + + 'conversation survive (the conversation resumes on launch).' + ); + if (recreate) { + const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' }); + const recData = await recRes.json(); + if (!recData.success) throw new Error(recData.error || 'Failed to recreate container'); + data = await doQuickStart(); + } + } if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); remoteIds.push(data.data.sessionId); } @@ -1958,7 +1994,8 @@ Object.assign(CodemanApp.prototype, { listEl.innerHTML = exports .map(e => { const mb = (e.sizeBytes / 1e6).toFixed(1); - const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name; + // escapeHtml is the free function from constants.js (never a method on `this`) + const nm = escapeHtml(e.name); return `
${nm} (${mb} MB) diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 1cc82165..20c1b3a7 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -43,7 +43,7 @@ import { import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; -import type { EventPort, ConfigPort } from '../ports/index.js'; +import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js'; import type { FastifyRequest } from 'fastify'; import { dataPath, getDataDir } from '../../config/instance.js'; import { @@ -56,6 +56,7 @@ import { dockerDisplayPath, readDockerCases, readDockerHosts, + removeDockerContainer, toSessionDocker, writeDockerCases, writeDockerHosts, @@ -118,7 +119,7 @@ async function ensureCaseImage( sessionDocker: SessionDocker, name: string ): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> { - if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) { + if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) { const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' }; return { ok: true, imageBuilding: false }; @@ -130,7 +131,7 @@ async function ensureCaseImage( }; } broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image }); - void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, { + void ensureAgentBaseImage(sessionDocker, sessionDocker.image, { onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }), }) .then((r) => @@ -146,7 +147,7 @@ async function ensureCaseImage( return { ok: true, imageBuilding: true }; } -export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { +export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void { // ═══════════════════════════════════════════════════════════════ // Case CRUD (list, create, link, detail, fix-plan) // ═══════════════════════════════════════════════════════════════ @@ -727,29 +728,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const timestamp = Date.now(); let result; try { - result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp }); + result = await importDockerBundle({ + bundlePath, + destWorkspace: destWorkspacePath, + engine: 'docker', + timestamp, + newCaseName, + }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`); } - // Create a dedicated docker host pointing at the quarantined imported image - // (full mode) or the manifest's base image (workspace-only). + // Create (or REFRESH) the dedicated docker host pointing at the quarantined + // imported image (full mode) or the manifest's base image (workspace-only). + // Refresh matters: after a case-delete + re-import of the same name, a stale + // `imported-` host would silently pin the PREVIOUS import's image tag. const hostId = `imported-${newCaseName}`; const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); - if (!hosts.some((h) => h.id === hostId)) { - await writeDockerHosts(CODEMAN_CONFIG_DIR, [ - ...hosts, - { - id: hostId, - label: `Imported: ${newCaseName}`, - engine: result.manifest.engine, - image: result.importedImage ?? result.manifest.image, - network: (['bridge', 'none', 'custom'].includes(result.manifest.network) - ? result.manifest.network - : 'bridge') as 'bridge' | 'none' | 'custom', - }, - ]); - } + const importedHost = { + id: hostId, + label: `Imported: ${newCaseName}`, + engine: result.manifest.engine, + image: result.importedImage ?? result.manifest.image, + network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as + | 'bridge' + | 'none' + | 'custom', + }; + await writeDockerHosts( + CODEMAN_CONFIG_DIR, + hosts.some((h) => h.id === hostId) + ? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h)) + : [...hosts, importedHost] + ); const newCase = { name: newCaseName, type: 'docker' as const, @@ -763,6 +774,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { case: newCase } }; }); + // Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case + // container so the next launch recreates it with the CURRENT host config. The + // workspace + transcripts ride bind mounts and survive; the conversation resumes + // via the case's lastClaudeSessionId. Refused while sessions of the case are live + // (removal would yank the container out from under their panes). + app.post( + '/api/docker-cases/:name/recreate', + async (req): Promise> => { + const { name } = req.params as { name: string }; + const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name); + if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found'); + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const sessionDocker = toSessionDocker(host, dockerCase); + + for (const session of ctx.sessions.values()) { + if (session.docker?.containerName === sessionDocker.containerName && session.pid) { + return createErrorResponse( + ApiErrorCode.CONFLICT, + `Sessions of case "${name}" are still running — stop them first, then recreate the container.` + ); + } + } + + try { + await removeDockerContainer(sessionDocker); + } catch (err) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to remove container: ${getErrorMessage(err)}` + ); + } + // Drop the per-container claude-config seed; it is regenerated at next launch. + await fs + .rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true }) + .catch(() => {}); + ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName }); + return { success: true, data: { name, container: sessionDocker.containerName } }; + } + ); + // Link an existing folder as a case app.post('/api/cases/link', async (req, reply): Promise> => { // Linking writes an arbitrary absolute path into the shared ownerless registry: diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index ff83e9d2..0762c329 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; import { sanitizeHookData, parseBody } from '../route-helpers.js'; +import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js'; +import { getDataDir } from '../../config/instance.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; export function registerHookEventRoutes( @@ -48,7 +50,18 @@ export function registerHookEventRoutes( // the user ran `/clear` (which spins up a new conversation jsonl). if (data && typeof data.session_id === 'string' && data.session_id) { const session = ctx.sessions.get(sessionId); + const prevClaudeSessionId = session?.claudeSessionId; session?.adoptClaudeSessionId(data.session_id); + // Docker sessions: keep the case's resume seed following the LIVE + // conversation (post-/clear id switches), so a container stop/reboot + // relaunch resumes the right transcript. + if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) { + void persistDockerCaseClaudeSessionId( + getDataDir(), + session.docker.containerName, + session.claudeSessionId + ).catch(() => {}); + } } // Sanitize forwarded data: only include known safe fields, limit size diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2029dc66..eb98c634 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -86,9 +86,11 @@ import { dataPath, getDataDir } from '../../config/instance.js'; import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js'; import { checkDockerAvailable, + checkDockerConfigDrift, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, + persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, @@ -999,6 +1001,12 @@ export function registerSessionRoutes( const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir); if (activeId && activeId !== session.claudeSessionId) { session.adoptClaudeSessionId(activeId); + // Docker sessions: keep the case's resume seed following the live conversation. + if (session.docker) { + void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch( + () => {} + ); + } } // The Claude conversation ID (used as JSONL filename) @@ -1753,6 +1761,7 @@ export function registerSessionRoutes( caseName = 'testcase', sessionName, mode = 'claude', + modelOverride, openCodeConfig, codexConfig, geminiConfig, @@ -1798,13 +1807,14 @@ export function registerSessionRoutes( if ( (envOverrides && Object.keys(envOverrides).length > 0) || effort || + modelOverride !== undefined || codexConfig || geminiConfig || openCodeConfig ) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, - 'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.' + 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.' ); } @@ -1849,7 +1859,7 @@ export function registerSessionRoutes( // Ensure the base image exists, auto-building the default image on first use so // it is never a blocker. Dedup'd with any build kicked off at case-create, so // this awaits the SAME in-flight build rather than starting a second one. - const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, { + const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), }); if (!ensured.ok) { @@ -1868,6 +1878,19 @@ export function registerSessionRoutes( } } + // Config drift (docs/docker-cases-plan.md §4): the desired create-config no + // longer matches the existing container's codeman.confighash label. Refuse to + // silently launch into the stale container — the frontend confirms a recreate + // (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind + // mounts and the conversation resumes), or the user reverts the host edit. + const drift = await checkDockerConfigDrift(sessionDocker); + if (drift.exists && drift.drifted) { + return createErrorResponse( + ApiErrorCode.CONFLICT, + `Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.` + ); + } + 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 @@ -1992,6 +2015,13 @@ 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) { + 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 ( @@ -2096,6 +2126,19 @@ export function registerSessionRoutes( } ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); + // Docker + claude: the pane command pins the conversation id (--session-id / + // --resume, claudeDockerPaneCommand), so persist it as the case's resume seed + // NOW — a later container stop/reboot relaunch resumes this conversation even + // if no in-container hook ever reaches the host (loopback bind, no bridge + // listener). Hook/last-response adoption updates it again after /clear. + if (docker && mode === 'claude') { + void persistDockerCaseClaudeSessionId( + CODEMAN_CONFIG_DIR, + docker.containerName, + session.claudeSessionId || session.id + ).catch(() => {}); + } + // Save lastUsedCase to settings for TUI/web sync try { const settingsFilePath = SETTINGS_PATH; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 9fd229d3..b2dd6c5f 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -449,17 +449,22 @@ export const DockerHostSchema = z.object({ export const DockerCaseLinkSchema = z.object({ name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + // No commas: the path is embedded in a `--mount type=bind,src=,dst=` + // CSV spec, and docker's --mount parser splits fields on commas (shell escaping + // cannot protect it). Spaces are fine. hostWorkspacePath: z .string() .min(1) .max(2000) .regex(/^\//, 'Workspace path must be absolute') + .regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in workspace path'), containerWorkdir: z .string() .min(1) .max(2000) .regex(/^\//, 'Container workdir must be absolute') + .regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in container workdir') .optional(), container: z @@ -487,6 +492,7 @@ export const DockerImportSchema = z.object({ .min(1) .max(2000) .regex(/^\//, 'Destination path must be absolute') + .regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)') .regex(NO_SHELL_META, 'Invalid characters in destination path'), }); @@ -539,6 +545,11 @@ export const QuickStartSchema = z.object({ /** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable * mux/container names derive from the session id, not this. Defaults server-side. */ sessionName: z.string().max(128).optional(), + /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). + * Empty string clears. Applied for local AND docker cases (the docker workspace is + * 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']).optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 69b566e7..56de16e2 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -385,6 +385,8 @@ export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const; export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const; /** The agent base image build failed. */ export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const; +/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */ +export const DockerContainerRecreated = 'docker:containerRecreated' as const; // ─── Multi-user (admin-only / targeted) ────────────────────────────────────── @@ -585,4 +587,5 @@ export const SseEvent = { DockerImageBuildFailed, AdminUsersChanged, AuthPasswordChangeRequired, + DockerContainerRecreated, } as const; diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts index 6e23ee31..e89fc0ac 100644 --- a/test/docker-exec-options.test.ts +++ b/test/docker-exec-options.test.ts @@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain("sh -lc '"); }); - it('injects the resume flag ONLY when a resume id is passed', () => { + it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => { + const cmd = buildDockerLaunchCommand(launchOpts()); + // --session-id first (fresh start), || --resume so a container stop/reboot + // relaunch of the SAME session resumes instead of dead-paning on + // "Session ID already in use". + expect(cmd).toContain( + 'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' + + 'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f' + ); + // exec is stripped from the claude pane command — an exec'd first branch could never fall back. + expect(cmd).not.toContain('exec claude'); + }); + + it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => { const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' })); - expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def'); - const without = buildDockerLaunchCommand(launchOpts()); - expect(without).not.toContain('--resume'); + expect(withResume).toContain( + 'claude --dangerously-skip-permissions --resume abc-123-def || ' + + 'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f' + ); }); it('uses codex resume syntax and drops an unsafe resume id', () => { @@ -101,8 +115,10 @@ describe('buildDockerLaunchCommand', () => { ); expect(codex).toContain('exec codex resume 01H-codex-id'); const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' })); - expect(unsafe).not.toContain('--resume'); + expect(unsafe).not.toContain('x; rm'); // unsafe id dropped entirely expect(unsafe).not.toContain('rm -rf'); + // falls back to the deterministic fresh-launch chain on the session's own id + expect(unsafe).toContain('--session-id 1a2b3c4d5e6f'); }); it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => { @@ -128,10 +144,13 @@ describe('buildDockerLaunchCommand', () => { expect(cmd).toContain('/home/arkon/my cases/proj'); }); - it('honors a per-host command override', () => { + it('honors a per-host command override (exec stripped for the session-id chain)', () => { const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } }; const cmd = buildDockerLaunchCommand(launchOpts({ docker })); - expect(cmd).toContain('exec claude --model opus'); + expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f'); + const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } }; + const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride })); + expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec }); it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => { diff --git a/test/docker-export.test.ts b/test/docker-export.test.ts index 9c03edad..46521c0d 100644 --- a/test/docker-export.test.ts +++ b/test/docker-export.test.ts @@ -12,7 +12,9 @@ import { isSafeTarMember, parseLoadedImageRef, exportDockerCase, + validateImportManifest, DOCKER_EXPORT_SCHEMA, + type DockerExportManifest, } from '../src/docker-export.js'; import { toSessionDocker } from '../src/docker-hosts.js'; import type { DockerCase, DockerHost } from '../src/types.js'; @@ -63,6 +65,43 @@ describe('isSafeTarMember (import traversal guard)', () => { }); }); +describe('validateImportManifest (untrusted cross-machine input)', () => { + const good = (): DockerExportManifest => ({ + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName: 'myproj', + mode: 'full', + engine: 'docker', + image: 'codeman/agent:base', + containerWorkdir: '/home/arkon/cases/myproj', + network: 'bridge', + createdAt: 1, + codemanVersion: '1.4.1', + mountCredentials: true, + secretFree: true, + checksums: {}, + }); + + it('accepts a well-formed manifest', () => { + expect(() => validateImportManifest(good())).not.toThrow(); + }); + + it('rejects a hostile engine (would select the probe/launch binary)', () => { + expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/); + }); + + it('rejects shell metacharacters in containerWorkdir', () => { + expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/); + expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/); + }); + + it('rejects bad image refs, case names, networks, and schema versions', () => { + expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/); + expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/); + expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/); + expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/); + }); +}); + describe('parseLoadedImageRef', () => { it('parses "Loaded image ID: sha256:..."', () => { expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def'); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 7cc29fe5..3d848433 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -25,6 +25,7 @@ import { defaultDockerCommandForMode, ensureAgentBaseImage, hostGatewayAlias, + persistDockerCaseClaudeSessionId, probeDockerCliVersion, readDockerCases, readDockerHosts, @@ -67,6 +68,17 @@ describe('docker-hosts storage', () => { expect(await readDockerHosts(dir)).toEqual([]); expect(await readDockerCases(dir)).toEqual([]); }); + + it('persists the last Claude conversation id keyed by container name', async () => { + await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]); + await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1'); + await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2'); + await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op + const cases = await readDockerCases(dir); + expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1'); + expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2'); + expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false); + }); }); describe('naming / display / defaults', () => { @@ -427,7 +439,7 @@ describe('agentImageBuildArgs', () => { describe('ensureAgentBaseImage (no-op under VITEST)', () => { it('reports the image as already present without spawning a build', async () => { - const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE); + const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE); expect(r).toEqual({ ok: true, built: false, alreadyPresent: true }); }); }); @@ -442,7 +454,7 @@ describe('daemon probes (no-op under VITEST)', () => { it('checkDockerTmuxAvailable + image present are canned-true', async () => { expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true); - expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true); + expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true); }); it('probeDockerCliVersion is undefined under test', async () => { diff --git a/test/mobile-header-buttons-policy.test.ts b/test/mobile-header-buttons-policy.test.ts index ba0fd32f..36497334 100644 --- a/test/mobile-header-buttons-policy.test.ts +++ b/test/mobile-header-buttons-policy.test.ts @@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set([]); // that removes a hide rule fails loudly (not silently). The attachments button is // NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so // it's excluded from the default-visible enumeration rather than mobile-hidden. -const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager']; +const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer']; function attrOf(openTag: string, name: string): string { const m = openTag.match(new RegExp(`${name}="([^"]*)"`));