From a21400614a72a6dd3383c8e53722021c1ec2d1dd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 20 Jul 2026 02:36:02 +0200 Subject: [PATCH] chore: version packages Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 22 ++++ CLAUDE.md | 4 +- docs/docker-cases.md | 3 +- package-lock.json | 4 +- package.json | 2 +- src/docker-export.ts | 65 ++++++++-- src/docker-hosts.ts | 141 ++++++++++++++++++---- src/session-cli-builder.ts | 2 + src/session.ts | 2 +- src/tmux-manager.ts | 44 +++++-- src/types/session.ts | 6 +- src/web/public/app.js | 20 +++ src/web/public/constants.js | 1 + src/web/public/index.html | 5 +- src/web/public/mobile.css | 12 +- src/web/public/session-ui.js | 49 +++++++- src/web/routes/case-routes.ts | 94 +++++++++++---- src/web/routes/hook-event-routes.ts | 13 ++ src/web/routes/session-routes.ts | 47 +++++++- src/web/schemas.ts | 11 ++ src/web/server.ts | 7 +- src/web/sse-events.ts | 3 + test/claude-permission-mode.test.ts | 83 +++++++++++++ test/docker-exec-options.test.ts | 33 +++-- test/docker-export.test.ts | 39 ++++++ test/docker-hosts.test.ts | 16 ++- test/mobile-header-buttons-policy.test.ts | 2 +- 27 files changed, 638 insertions(+), 92 deletions(-) create mode 100644 test/claude-permission-mode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbeac2da..4377d3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # aicodeman +## 1.4.2 + +### Patch Changes + +- Docker session-mode deep-review fixes (all e2e-verified against a real daemon), a new Auto permission mode, and docs sync. + + **Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written anywhere, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()` in tmux-manager.ts): fresh launches run `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot; verified CLI behavior), explicit resumes run `--resume || --session-id ` so a stale id never dead-panes; the leading `exec ` is stripped so the fallback can run. The id is persisted via `persistDockerCaseClaudeSessionId()` at quick-start launch and again on hook / last-response conversation-id adoption (post-`/clear` switches track). Verified end-to-end: a conversation survives `docker stop` + relaunch AND a full container recreate. + + **Config drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI shows a confirm and calls the new `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), then relaunches — verified e2e that an edited `--memory` cap is applied after recreate and the conversation resumes. New SSE event `docker:containerRecreated` (both registries). + + **Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted, applied via `updateCaseModel` for local and docker quick-starts, sent by the frontend docker run path, and explicitly rejected for remote quick-starts (the settings file would land on the wrong machine). + + **Import hardening.** `importDockerBundle` now validates the (cross-machine, untrusted) manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated `newCaseName` instead of the manifest's; re-importing a case name now refreshes the auto-created `imported-` host instead of leaving it pinned to the previous import's image tag. + + **Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`); previously they always probed the local daemon. + + **Smaller fixes:** commas are now rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=...` CSV spec, which shell escaping cannot protect); `refreshDockerExports` used a never-defined `this.escapeHtml` (dead escaping, now the real `escapeHtml`); `docker:importComplete` and `docker:containerRecreated` now have frontend SSE listeners so other open tabs refresh; the opt-in File Viewer header button is hidden on phone headers like its siblings; the Run-in-Docker hint notes that Docker/Podman must be installed; `docs/docker-cases.md` documents the resume + drift-recreate lifecycle. + + **New: Auto permission mode.** App Settings → Startup Mode gains `auto` (`claude --permission-mode auto`, Claude Code 2.1.207+): no routine prompts, with the background safety classifier guarding destructive actions. Threaded through both spawn paths (direct PTY and tmux) with tests; the default stays `--dangerously-skip-permissions`. + + **Docs:** multi-user mode design plan added; CLAUDE.md + READMEs (incl. zh-CN full re-translation) synced with the 1.4.1 feature set. + ## 1.4.1 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index c7dd5d88..7acb8c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.4.1 (must match `package.json`) +**Version**: 1.4.2 (must match `package.json`) ## Project Overview @@ -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 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` (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`. +**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). 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/package-lock.json b/package-lock.json index 9bab1b55..a44a6d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.4.1", + "version": "1.4.2", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 382c7113..d55a65e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.4.1", + "version": "1.4.2", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", 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/session-cli-builder.ts b/src/session-cli-builder.ts index 95f9f0da..546ce557 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str switch (claudeMode) { case 'dangerously-skip-permissions': return ['--dangerously-skip-permissions']; + case 'auto': + return ['--permission-mode', 'auto']; case 'allowedTools': if (allowedTools) { return ['--allowedTools', allowedTools]; diff --git a/src/session.ts b/src/session.ts index 02ce3e1b..dd589f8d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1523,7 +1523,7 @@ export class Session extends EventEmitter { // === Auto-accept workspace trust dialog === // Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory. - // Codeman sessions always use --dangerously-skip-permissions, so auto-accept. + // Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept. if (!this._trustDialogAccepted && data.includes('trust this folder')) { this._trustDialogAccepted = true; console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 13200fe8..297190ee 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -563,6 +563,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri switch (mode) { case 'dangerously-skip-permissions': return ' --dangerously-skip-permissions'; + case 'auto': + return ' --permission-mode auto'; case 'allowedTools': if (allowedTools) { // Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated @@ -674,7 +676,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string { return flag && value ? ` ${flag} '${value}'` : ''; } -function buildSpawnCommand(options: { +export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; model?: string; @@ -858,15 +860,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': @@ -876,6 +878,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; @@ -915,7 +941,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 0744beab..09a11fef 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) @@ -35,10 +35,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; /** * Claude CLI startup permission mode. * - `'dangerously-skip-permissions'`: Bypass all permission prompts (default) + * - `'auto'`: `--permission-mode auto`: no routine prompts, background safety + * classifier blocks destructive actions (Claude Code 2.1.207+, recent models) * - `'normal'`: Standard mode with permission prompts * - `'allowedTools'`: Only allow specific tools (requires allowedTools list) */ -export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; +export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools'; /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; 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 fb81deaf..f6f7cca5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -481,6 +481,7 @@ const SSE_EVENTS = { DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress', DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete', DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed', + DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index a70f360e..670354d3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1449,10 +1449,11 @@ - 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 ee899add..7917fd08 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -30,7 +30,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.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 { dataPath, getDataDir } from '../../config/instance.js'; import { checkDockerAvailable, @@ -42,6 +42,7 @@ import { dockerDisplayPath, readDockerCases, readDockerHosts, + removeDockerContainer, toSessionDocker, writeDockerCases, writeDockerHosts, @@ -99,7 +100,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 }; @@ -111,7 +112,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) => @@ -127,7 +128,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) // ═══════════════════════════════════════════════════════════════ @@ -652,29 +653,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, @@ -687,6 +698,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): Promise> => { const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); 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 af8abe51..32aef601 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -76,9 +76,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, @@ -936,6 +938,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) @@ -1683,6 +1691,7 @@ export function registerSessionRoutes( caseName = 'testcase', sessionName, mode = 'claude', + modelOverride, openCodeConfig, codexConfig, geminiConfig, @@ -1713,13 +1722,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.' ); } @@ -1764,7 +1774,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) { @@ -1783,6 +1793,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 @@ -1894,6 +1917,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 ( @@ -1990,6 +2020,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/server.ts b/src/web/server.ts index f3a2187b..649929eb 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1514,7 +1514,12 @@ export class WebServer extends EventEmitter { const claudeMode = settings.claudeMode as string | undefined; const allowedTools = settings.allowedTools as string | undefined; // Only return valid modes - if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') { + if ( + claudeMode === 'dangerously-skip-permissions' || + claudeMode === 'auto' || + claudeMode === 'normal' || + claudeMode === 'allowedTools' + ) { return { claudeMode, allowedTools }; } return {}; diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index e9945ed4..0ed68c1c 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; // ─── Namespace Re-export ───────────────────────────────────────────────────── @@ -576,4 +578,5 @@ export const SseEvent = { DockerImageBuildProgress, DockerImageBuildComplete, DockerImageBuildFailed, + DockerContainerRecreated, } as const; diff --git a/test/claude-permission-mode.test.ts b/test/claude-permission-mode.test.ts new file mode 100644 index 00000000..a10a1caa --- /dev/null +++ b/test/claude-permission-mode.test.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Tests for Claude CLI startup permission modes, focused on the + * 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode) + * added alongside the default `--dangerously-skip-permissions`. + * + * Covers BOTH spawn paths, which build the permission flags independently: + * - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback) + * - tmux-manager.buildSpawnCommand (tmux pane command string) + * The default must stay 'dangerously-skip-permissions' when the setting is unset. + */ + +import { describe, it, expect } from 'vitest'; +import { buildInteractiveArgs } from '../src/session-cli-builder.js'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; + +describe('buildInteractiveArgs permission modes (direct PTY path)', () => { + it('keeps --dangerously-skip-permissions as the skip-mode flag', () => { + const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions'); + expect(args).toContain('--dangerously-skip-permissions'); + expect(args).not.toContain('--permission-mode'); + }); + + it('auto mode emits --permission-mode auto and never the skip flag', () => { + const args = buildInteractiveArgs('sid-1', 'auto'); + const idx = args.indexOf('--permission-mode'); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe('auto'); + expect(args).not.toContain('--dangerously-skip-permissions'); + }); + + it('normal mode emits no permission flag at all', () => { + const args = buildInteractiveArgs('sid-1', 'normal'); + expect(args).not.toContain('--dangerously-skip-permissions'); + expect(args).not.toContain('--permission-mode'); + }); + + it('allowedTools mode is unchanged by the auto addition', () => { + const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep'); + expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep'])); + expect(args).not.toContain('--permission-mode'); + }); + + it('auto mode composes with model and effort flags', () => { + const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high'); + expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high'])); + }); +}); + +describe('buildSpawnCommand permission modes (tmux path)', () => { + it('unset claudeMode defaults to --dangerously-skip-permissions', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' }); + expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"'); + expect(cmd).not.toContain('--permission-mode'); + }); + + it('auto mode emits --permission-mode auto and never the skip flag', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' }); + expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"'); + expect(cmd).not.toContain('--dangerously-skip-permissions'); + }); + + it('auto mode carries into BOTH legs of the resume fallback command', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + claudeMode: 'auto', + resumeSessionId: 'abc-123', + }); + const [resumeLeg, fallbackLeg] = cmd.split('||'); + expect(resumeLeg).toContain('--permission-mode auto'); + expect(resumeLeg).toContain('--resume "abc-123"'); + expect(fallbackLeg).toContain('--permission-mode auto'); + expect(fallbackLeg).toContain('--session-id "sid-1"'); + expect(cmd).not.toContain('--dangerously-skip-permissions'); + }); + + it('normal mode emits no permission flag', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' }); + expect(cmd).toContain('claude --session-id "sid-1"'); + expect(cmd).not.toContain('--permission-mode'); + expect(cmd).not.toContain('--dangerously-skip-permissions'); + }); +}); 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}="([^"]*)"`));