diff --git a/CHANGELOG.md b/CHANGELOG.md index e90c26c6..b39bf409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # aicodeman +## 1.4.0 + +### Minor Changes + +- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine. + - Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-`), so multiple sessions share it; killing one session never stops the shared container. + - New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME). + - Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in. + - Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`. + - Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`. + - Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based. + + Also wire session, away-digest, and cron header-button visibility toggles in App Settings. + ## 1.3.5 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index e39051e5..d21b91a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,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.3.5 (must match `package.json`) +**Version**: 1.4.0 (must match `package.json`) ## Project Overview @@ -171,6 +171,8 @@ 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** (branch `feat/docker-session-mode`, ⚠️ NOT yet on master; design + live status in `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` (`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 in-container hooks fire AND `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`). `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. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`. **Implemented (Phases 0-5, e2e-verified)**: types/storage/schemas, tmux builders, session threading + recovery, routes CRUD + quick-start, base image + host-guard. **REMAINING (Phases 6-8)**: export/import (`docker commit`+`save`+workspace tar → move to another machine, `~/.codeman/docker-exports/`), the Create Case **Docker** tab + run wiring in `session-ui.js`, drift-recreate route, `docker:*` SSE events, and the CLAUDE.md/COM finalize. + **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). **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile new file mode 100644 index 00000000..2e3e6d79 --- /dev/null +++ b/docker/agent.Dockerfile @@ -0,0 +1,54 @@ +# Codeman agent base image (built locally by scripts/build-agent-image.mjs). +# +# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO +# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.) +# or name-only `docker exec --env`, never baked in, so `docker save` exports stay +# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a +# reconnect durable), so it is installed here and probed before launch. +# +# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0, +# group-writable" convention: on Linux we run `--user :0`, so the agent +# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME +# writable even though the uid is not the baked 1000. +FROM node:22-bookworm-slim + +# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`), +# `procps` for `ps`, `tmux` for the durable in-container session. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + tmux \ + ripgrep \ + curl \ + ca-certificates \ + less \ + procps \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# The agent CLIs (all four backends Codeman supports). Pinning is left to the +# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2). +RUN npm install -g \ + @anthropic-ai/claude-code \ + @openai/codex \ + @google/gemini-cli \ + opencode-ai \ + && npm cache clean --force + +# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is +# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at +# runtime Codeman overrides with `--user :0` on Linux, so the baked uid +# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable +# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid. +ENV HOME=/home/agent +RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ + && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ + && chgrp -R 0 /home/agent \ + && chmod -R g=u /home/agent + +USER agent +WORKDIR /home/agent + +# Codeman overrides the command with `sleep infinity` at create time; this is the +# fallback so a hand-run container also idles rather than exiting. +CMD ["sleep", "infinity"] diff --git a/docs/docker-cases-plan.md b/docs/docker-cases-plan.md new file mode 100644 index 00000000..ef450bcc --- /dev/null +++ b/docs/docker-cases-plan.md @@ -0,0 +1,433 @@ + + +# Docker Session Mode, Implementation Plan + +## Decisions (locked 2026-07-19, by repo owner) + +1. **Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in. +2. **Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time). +3. **Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.) +4. **Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker. + +Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort. + +## Implementation status (branch `feat/docker-session-mode`) + +DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it): + +- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests. +- Phase 2: `tmux-manager` `buildDockerLaunchCommand` (image-check -> ensure -> start -> exec, resume-aware), `buildDockerKillCommand` (in-container tmux only, multi-session safe), stop/remove; wired into `createSession`/`respawnPane`/`killSession`. 14 unit tests. +- Phase 3: `Session` threading (`_docker`, toState, option builders, in-container cliVersion probe, `resolveMuxAttachCwd`), `server.ts` recovery round-trip. +- Phase 4: `case-routes` `/api/docker-hosts` CRUD + `/api/cases/docker-link` + listing + docker-unlink; `session-routes` `/api/quick-start` docker branch (rejects per-session config, probes availability + tmux, scaffolds hooks, seeds resume id). +- Phase 5 (partial): `docker/agent.Dockerfile` + `scripts/build-agent-image.mjs` (built + verified: node 22, tmux, claude/codex/gemini/opencode, arbitrary-uid HOME). Host-guard allowlists `host.docker.internal`/`host.containers.internal` for in-container hooks. +- Full CI green (3445 tests). + +REMAINING: + +- Phase 6: export / import (`docker commit` + `save | gzip` + workspace tar + manifest; `load` + quarantined re-tag), GC / boot reaper, disk-safety prechecks, drift-recreate route, SSE `docker:*` events. THE "move to a new machine" feature. +- Phase 7: frontend Create Case "Docker" tab + `linkDockerCase` + run wiring + case-picker labels + export/import UI. +- Phase 8: CLAUDE.md "Docker cases" Key Pattern + `docs/docker-cases.md` + COM. +- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic). + +## 1. Goal & user stories + +Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`. + +User stories: + +- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup. +- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4). +- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1). +- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click. +- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone. + +Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers. + +## 2. Chosen architecture and why + +The design grafts the strongest idea from each of the three proposals: + +- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing. +- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login. +- One-artifact export + in-app import route (from "Container-as-Cargo"): the genuinely new, high-value capability Codeman lacks. + +### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model) + +Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-` (Docker charset `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`; Codeman already slugs case names for tmux), so create-if-missing and boot recovery are idempotent. PID1 is `sleep infinity` under `--init` (tini reaps zombies and forwards `docker stop`'s SIGTERM); the CLI is NOT the container command. The CLI runs inside a DURABLE in-container tmux on a dedicated socket `-L codeman-docker`, session `codeman-dkr-`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-`. + +Two DIFFERENT failure surfaces need two DIFFERENT recovery layers, and conflating them is the central flaw the critic caught: + +1. Codeman-PROCESS restart while the container stays up: the in-container tmux is still alive, so `tmux new-session -A` (attach-or-create) reattaches the SAME live agent and the paneCommand is ignored. This is the remote-SSH durability idiom and it works unchanged. +2. CONTAINER stop / daemon restart / host reboot / OOM-kill: the in-container tmux is GONE (fresh PID1). `new-session -A` will now CREATE a fresh session and run the paneCommand, which would start a brand-new conversation. This is the case the raw plan silently lost. Because the transcript directory is bind-mounted from the host (Key decision 3), the fix is to launch with RESUME: the paneCommand becomes `exec claude --dangerously-skip-permissions --resume ` (codex uses `resume `, gemini `--resume `) whenever a captured `claudeSessionId` exists. The `-A` semantics make this self-selecting: the resume flag only ever executes when tmux is actually re-created, which is exactly when the live session was lost. When tmux is still alive (case 1), attach wins and the flag is inert. + +Capturing / persisting / reusing the resume id (the missing mechanism the critic flagged): Codeman already learns `Session.claudeSessionId` from transcript correlation (which works here because projHash matches, Key decision 3) and persists it in `SessionState`. We thread that value into `createSessionOptions` / `respawnPaneOptions` for docker so `buildDockerLaunchCommand` can inject the resume flag on any relaunch. To make a NEW Codeman session (new `id8`) re-launched against the same case resume its predecessor's conversation, we ALSO persist `lastClaudeSessionId` on the `DockerCase` record; the quick-start docker branch seeds the new `Session` with it when the `dockerResumeOnStart` setting is on. First-ever launch has no id, so it starts fresh. This is user-decision 7 (default resume behavior). + +Reconciling with stop-on-kill and with the `--restart` policy (the internal inconsistency the critic found): the container is created with `--restart no` uniformly (Codeman's idempotent create-if-missing plus boot recovery is the single recovery mechanism; a restart policy would not preserve the conversation anyway because a restarted container gets a fresh PID1/tmux). Boot recovery re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` (`docker inspect || docker create; docker start`, then exec with resume), so a host reboot or daemon restart recreates+starts the container and resumes the conversation instead of the session vanishing. `reconcileSessions` (tmux-manager.ts ~1800-1815) must NOT hard-delete a docker session merely because no LOCAL pane exists after the local `-L codeman` server died; docker (like remote) sessions are restored from `mux-sessions.json` and relaunched. This relaunch path is explicitly part of Phase 4/Phase 3 recovery work, not assumed. + +Why this over the alternatives: `docker exec` gets SIGHUP and dies when its client TTY closes, so a bare `docker exec claude` restarts the CLI on every reconnect/respawn. The inner tmux plus resume is what makes reconnect idempotent across BOTH failure surfaces. Because this durability is the single most important design point, tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent fallback to bare exec. Rejected alternatives: ephemeral-per-run or bare-exec containers (no reattach durability); a literal `'docker'` `SessionMode` (touches dozens of switch/enum sites and diverges from the remote overlay precedent, since Docker is a LOCATION orthogonal to the 5 CLI backends). + +### Key decision 2: CLI + auth delivery + +One prebuilt base image (built once, contains NO secrets): `node:22-bookworm-slim` + `git tmux ripgrep ca-certificates`, `npm i -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai`, an `agent` user, HOME dirs made writable by an arbitrary host uid via the OpenShift "gid 0, group-writable" convention (Key decision 6). Because the toolchain is baked, export is reproducible and needs no network at import time. The image name/namespace/registry and its refresh cadence are user-decision 2 (the `codeman/agent:base` placeholder implies a Docker Hub org the project may not own). + +Credentials are delivered ONLY at runtime, two commit-safe channels, default convenient: + +- OAuth/config-file CLIs (Claude Max/Pro, gcloud, opencode): bind-mount the host credential dirs read-write (`~/.claude`, `~/.codex`, `~/.gemini` + `~/.config/gcloud`, `~/.config/opencode`) so the common user "just works" with no in-container login. Because these are bind mounts, `docker commit` (which captures only the container's own writable layer, never bind mounts) physically cannot capture them, so exports stay secret-free. +- API-key CLIs (codex/gemini): exec-time NAME-ONLY `docker exec --env OPENAI_API_KEY --env GEMINI_API_KEY ...` (no `=value`), sourced from Codeman's own process env. Only the key NAME appears in argv (no `ps` leak), and per-exec env is never captured by `docker commit`. This is the technique Codeman already uses via `tmux setenv` for the local Codex/Gemini panes, so it composes with existing machinery. + +Per-host `DockerHost.mountCredentials` defaults `true` (convenient); setting it `false` yields a SEALED profile (no host cred mounts, in-container login only) for genuinely untrusted work. CRITICAL sealed-mode export rule (the leak the critic caught): in sealed mode the in-container login writes tokens into the container's OWN writable layer, which `docker commit` DOES capture, so a full-image export of a sealed container would ship credentials. Therefore full-image export is REFUSED for `mountCredentials:false` containers by default; the user may either take a workspace-only export (always safe) or opt into a pre-commit scrub that `docker exec`s `rm -rf ~/.claude ~/.codex ~/.gemini ~/.config/gcloud ~/.config/opencode` inside the container before commit (destructive to the in-container login, which is the point). This is enforced in the export route, not left to a manifest assertion. + +Per-session `envOverrides` / `effort` / `codexConfig` / `geminiConfig` / `openCodeConfig` are REJECTED at quick-start exactly like the remote branch (session-routes.ts ~1698-1710). `modelOverride` is the one deliberate difference from remote: because the docker workspace is a REAL bind-mounted host dir that Codeman scaffolds (Key decision 5 and Section 6), `updateCaseModel()` can write the `model` key into `/.claude/settings.local.json` and the in-container `claude` reads it, so the App Settings Claude Model picker works for docker cases. `effort` is a `--effort` CLI arg applied only by the local-spawn path we bypass, so it stays rejected (surfaced honestly in the UI, not silently inert). Per-mode command customization goes through `DockerHost.commands.` (`defaultDockerCommandForMode`, mirror of `defaultRemoteCommandForMode` at remote-hosts.ts:60). NEVER bake secrets into an image layer and NEVER pass a secret via create-time `-e` (both are committed). + +Rejected alternative: sealed-by-default. For a single-operator loopback tool where the agent already runs skip-permissions on the host, forcing an in-container OAuth re-login is a UX regression with little real gain. We keep sealed as an opt-in. Rejected alternative: baking a login into the image, which leaks the instant you `docker save`. + +### Key decision 3: workspace mount, container CWD, and transcript correlation + +Bind-mount the host workspace dir into the container at the SAME absolute path (`dst == src`, mirror the host path), and set both `Session.workingDir` and the container workdir to that host path. + +Two problems this solves that the raw proposals got wrong: + +- File features: `DockerCase.hostWorkspacePath` is a REAL host directory, so `Session.workingDir = hostWorkspacePath` keeps file-routes, attachments, image-watcher, and previews working on real host bytes (unlike remote, where the path is remote-only and those features no-op). All three proposals wired `casePath = `; we deliberately diverge and use the host path. +- Transcript correlation: Claude writes transcripts under `~/.claude/projects//`. By mirroring the host path as the container CWD, the projHash computed inside the container equals the host-side hash Codeman's transcript/subagent/workflow watchers expect, so correlation keeps working (and, in turn, feeds the resume-id capture in Key decision 1). A `/workspace`-style fixed dst would break it. Mirror-vs-fixed is user-decision 3. + +`resolveMuxAttachCwd` still returns `/tmp` for docker sessions (the LOCAL bash pane only runs `docker exec`; it never needs the workspace as its cwd), mirroring remote. + +### Key decision 4: network default and the engine-specific host gateway + +Default `bridge` (own netns, NAT egress, no inbound), per-case changeable to `none` (offline shell sandbox; warned because it breaks the API CLIs) or `custom` (a user-defined bridge `codeman-net-`, the chokepoint for a future egress allowlist). `host` networking and any `-p` inbound publish are structurally unrepresentable in the flag builder and schema. Rationale: every API-backed CLI (Claude, Codex, Gemini) plus npm/git needs egress, so `bridge` is the only sane functional default; `none` is reserved for `shell`. + +The host-callback gateway alias is ENGINE-SPECIFIC (the critic's podman finding): Docker uses `host.docker.internal`, Podman uses `host.containers.internal` (Docker's alias only exists on recent podman). A helper `hostGatewayAlias(engine)` returns the right name; Section 2.5, the create args, the `CODEMAN_API_URL` rewrite, and the host-guard allowlist all consume it, and BOTH aliases are added to the allowlist so a mixed fleet keeps working. + +### Key decision 5: hooks actually reach the host AND are actually installed + +Two independent things must both be true for a hook to fire, and the raw plan wired only the first: + +1. Network reachability. Claude Code hooks POST to `$CODEMAN_API_URL` (`curl -sk`). Inside a bridge container `localhost` is the container and prod binds `127.0.0.1`, so we set `--add-host :host-gateway` on create (skipped on Docker Desktop, where the alias is native), add the gateway alias to the host guard, and provide `CODEMAN_API_URL` and the hook secret (below). +2. Hook INSTALLATION. Hooks live in `/.claude/settings.local.json`, written by the quick-start scaffolding block (around session-routes.ts ~1776) that calls `writeHooksConfig()` / `updateCaseModel()`. The raw plan extended the `!remote` guard to `!remote && !docker`, which would SKIP that block and silently disable ALL hooks regardless of networking. For docker the workspace is a REAL bind-mounted host dir, so the scaffolding block MUST run. Precise fix: extend to `!remote && !docker` ONLY the LOCAL-CLI-availability and local-spawn guards (the ones that stat the local binary or build the local spawn command); leave the workspace-scaffolding guard at `!remote` so it runs for docker. This same decision is what makes `modelOverride` work (Key decision 2). Consequence, surfaced as user-decision 4: linking a docker case now WRITES `.claude/settings.local.json` (and the CLAUDE.md scaffold, matching local-case behavior) into the user's real host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." + +`CODEMAN_API_URL` derivation (the wrong-scheme bug the critic caught): prod is HTTPS-only on 3000, and `server.ts` (~2000) auto-sets `process.env.CODEMAN_API_URL = ${protocol}://${apiHost}:${port}`. Hardcoding `http://host.docker.internal:3000` fails every hook. Instead a pure helper `containerApiUrl(process.env.CODEMAN_API_URL, engine)` parses the running URL and substitutes ONLY the hostname with `hostGatewayAlias(engine)`, preserving scheme and port (`https://host.docker.internal:3000`). Unit-tested against http, https, non-default ports, and both engines. Passed as create-time `--env CODEMAN_API_URL=` (case-stable, non-secret). + +Hook secret and session attribution: +- `~/.codeman/hook-secret` is bind-mounted read-only to a container path; `--env CODEMAN_HOOK_SECRET_FILE=` is create-time (a path is non-secret; the bytes ride the bind mount and are never committed). +- `CODEMAN_SESSION_ID` (which the generated hooks reference at hooks-config.ts:78-80 to attribute events) plus `CODEMAN_MUX=1` are SESSION-scoped, so they are passed at EXEC time via `docker exec --env CODEMAN_SESSION_ID= --env CODEMAN_MUX=1` (non-secret, value inline is fine, and exec env is not committed). Because a `tmux` session started fresh only inherits the invoking env when it starts the SERVER, the launch chain ALSO runs `tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID ` (and `CODEMAN_MUX`) so reattaches and newly created panes see the same values. This mirrors how Codeman already injects per-session env into tmux for the external CLIs. + +Hooks-in-MVP-vs-deferred stays user-decision 4; if deferred, docker ships as explicitly hook-degraded and we lean on output-based idle detection through the docker-exec PTY. + +### Key decision 6: uid / HOME / rootless enforcement / macOS Docker Desktop + +The raw plan showed `--user 1000:1000` in one place and `--user "$(id -u):$(id -g)"` in another and never resolved HOME writability; this section fixes all of it. + +- Linux native (docker rootful or rootless): run `--user :0` (host uid, GID 0). The image follows the OpenShift arbitrary-uid convention: `HOME=/home/agent`, and `/home/agent` plus the tool cache dirs (`~/.npm`, `~/.cache`, `~/.config`) are owned `root:0` and group-writable (`chmod -R g+w`, `g+s` on dirs) so a process with GID 0 can write HOME even though its UID is not 1000. This keeps workspace files host-owned (the agent's UID is the host UID) AND keeps HOME writable, so the CLIs actually start. +- Podman rootless: use `--userns=keep-id` (maps the host uid to the image's `agent` uid inside the container) instead of `--user`, so `/home/agent` is owned by the running user and workspace files are host-owned. This is a real per-engine branch in `buildDockerCreateArgs`. +- macOS Docker Desktop: `--user ` (e.g. 501) does not own the image's `/home/agent`, so non-bind HOME writes fail EACCES and the CLIs may not start; Desktop also does its own bind-mount uid translation, provides `host.docker.internal` natively (no `--add-host`), and its VM memory ceiling can cap `--memory`. Detect Desktop via `docker info` (Server OS `linuxkit` / `OperatingString` contains "Docker Desktop") and take a dedicated path: do NOT pass `--user` (run as the image's baked `agent` uid and rely on Desktop's translation for workspace access), skip `--add-host`, and note in the UI that memory caps are subject to the VM ceiling. + +Rootless resource-cap enforcement (the silently-inert risk): rootless Docker without cgroup-v2 systemd delegation (`Delegate=yes`) silently IGNORES `--memory`/`--cpus`/`--pids-limit`. The probe checks `docker info` for `CgroupVersion=2` plus rootless plus delegation; if caps cannot be enforced, `checkDockerAvailable` returns `capsEnforced:false` and the link/probe surfaces "resource caps are advisory on this engine." Whether to REQUIRE delegation or ship-with-warning is user-decision 6. + +## 3. Data model + +New TypeScript types in `src/types/session.ts`, added right after the remote types (lines 46-99). SessionMode (line 44) is UNCHANGED. + +```ts +export type DockerCommandMode = Extract; +export type DockerEngine = 'docker' | 'podman'; +export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; // never 'host' + +export interface DockerResourceLimits { + memory?: string; // '4g' -> --memory 4g --memory-swap 4g (swap==memory: real OOM cap) + cpus?: string; // '2' + pidsLimit?: number; // 512 (fork-bomb guard) + nofile?: string; // '4096:8192' + shmSize?: string; // optional; only when a tool needs /dev/shm +} + +export interface DockerHost { + id: string; + label: string; + engine?: DockerEngine; // default resolved by probe (docker, else podman) + image: string; // default resolved image ref (see user-decision 2) + daemonHost?: string; // advanced: -H ssh://user@host / DOCKER_HOST + context?: string; // advanced: --context + network?: DockerNetworkMode; // default 'bridge' + networkName?: string; // when network === 'custom' + resources?: DockerResourceLimits; + mountCredentials?: boolean; // default true (false = sealed; blocks full-image export) + hooksEnabled?: boolean; // default true (host-gateway callback wiring) + resumeOnStart?: boolean; // default true (see Key decision 1 / user-decision 7) + commands?: Partial>; + extraCreateArgs?: string[]; // validated like extraSshOptions + extraExecArgs?: string[]; +} + +export interface DockerCase { + name: string; + type: 'docker'; + hostId: string; + hostWorkspacePath: string; // absolute HOST dir: bind src + Session.workingDir + containerWorkdir?: string; // container path; default = hostWorkspacePath (mirror -> projHash match) + container?: string; // default codeman-case- + lastClaudeSessionId?: string; // captured resume id (Key decision 1) +} + +export interface SessionDocker { // flattened, round-trips through mux/state (mirror SessionRemote at 91) + hostId: string; + label: string; + engine: DockerEngine; + image: string; + containerName: string; + hostWorkspacePath: string; + containerWorkdir: string; + network: DockerNetworkMode; + networkName?: string; + resources?: DockerResourceLimits; + mountCredentials: boolean; + hooksEnabled: boolean; + resumeOnStart: boolean; + daemonHost?: string; + context?: string; + commands?: Partial>; + extraCreateArgs?: string[]; + extraExecArgs?: string[]; + configHash?: string; // drift detection (Key decision, Section 4) +} +``` + +- `SessionState` gains `docker?: SessionDocker` immediately after `remote?` (line 219). It persists automatically because `SessionState` is structural and `state-store.ts` stores `toState()` verbatim. +- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (after line 38), `CreateSessionOptions` (after 81), `RespawnPaneOptions` (after 105). `MuxSession.docker` round-trips through `mux-sessions.json` automatically. +- `src/types/api.ts` `CaseInfo`: add `'docker'` to the `location` union and a `docker?: { hostId; container; image?; path; network }` display block. +- `src/services/unified-session-service.ts`: add a boolean `docker?` flag on `UnifiedSessionItem` and source rows, set from `MuxSession.docker` presence (mirror the `remote` flag at ~line 200 and the harvest at session-routes.ts:2313). + +New state files (all via `dataPath()`, mirroring `remote-hosts.json` / `remote-cases.json`): + +- `~/.codeman/docker-hosts.json` (reusable engine/image/network/resource profiles). +- `~/.codeman/docker-cases.json` (`name -> DockerCase`, including `lastClaudeSessionId`). +- `~/.codeman/docker-exports/` (dedicated dir for `.image.tar.gz` + `.workspace.tar.gz` + `manifest.json`; never inline in state.json; retention/pruning per Section 5). + +No new `state.json` / `mux-sessions.json` files: `SessionState.docker` and `MuxSession.docker` ride the existing serialization. + +## 4. Container lifecycle (exact command shapes) + +All builders are PURE string functions (directly unit-testable). Host values interpolated into the outer `bash -c "..."` layer (container name, image, workdir, host paths) are `shellescape()`'d and, for user-supplied fields, schema-rejected for `$`/backtick via `NO_SHELL_META`. The escaping chain here is DEEPER than remote's single `ssh ''`: the whole `docker inspect || docker create ` is interpolated into `bash -c "..."` then `JSON.stringify`'d into respawn-pane. This is a known place to get stuck, so it is covered by concrete escaping tests (Section 9), including host workspace paths containing spaces, not just a "we call shellescape" claim. + +New in `src/tmux-manager.ts`: + +```ts +const DOCKER_TMUX_SOCKET = 'codeman-docker'; +// 'dkr' letters deliberately FAIL SAFE_MUX_NAME_PATTERN (^codeman-[a-f0-9-]+$), +// so a Codeman running INSIDE the container never adopts/resizes/respawns our session. +export function dockerTmuxSessionName(id: string): string { return `codeman-dkr-${id.slice(0, 8)}`; } +``` + +`buildDockerBaseArgs(docker)` (pure, in `docker-hosts.ts`, mirror of `buildSshConnectionArgs`) emits the engine prefix tokens: `docker` (or `podman`) + optional `--context ` or `-H `. `buildDockerCreateArgs(docker, sessionId)` emits the `docker create` flag array (with the per-engine uid/userns branch from Key decision 6). + +IMAGE PRESENCE (before any create, the auto-pull footgun the critic caught): the launch chain runs `docker image inspect >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image not present: build with scripts/build-agent-image.mjs or pull it") rather than triggering a blocking multi-GB auto-pull inside the tmux pane. `docker create` carries `--pull=never`. The tmux-availability probe likewise uses `docker run --rm --pull=never sh -lc 'command -v tmux'` and reports the same build/pull hint if the image is absent, so the 15s-bounded probe never hangs on a pull. + +CREATE (the ensure step, embedded in the launch string): + +``` +docker create \ + --name codeman-case-myproj --hostname myproj \ + --label codeman.managed=1 --label codeman.instance= \ + --label codeman.case=myproj --label codeman.session= \ + --label codeman.confighash= \ + --pull=never --init --restart no \ + --user 1000:0 \ + --workdir '/home/arkon/cases/myproj' \ + --mount type=bind,src='/home/arkon/cases/myproj',dst='/home/arkon/cases/myproj' \ + --mount type=bind,src='/home/arkon/.claude',dst='/home/agent/.claude' \ + --mount type=bind,src='/home/arkon/.codeman/hook-secret',dst='/home/agent/.codeman/hook-secret',readonly \ + --add-host host.docker.internal:host-gateway \ + --memory 4g --memory-swap 4g --cpus 2 --pids-limit 512 --ulimit nofile=4096:8192 \ + --cap-drop ALL --security-opt no-new-privileges \ + --network bridge \ + --env HOME=/home/agent --env TERM=xterm-256color --env COLORTERM=truecolor \ + --env CODEMAN_API_URL=https://host.docker.internal:3000 \ + --env CODEMAN_HOOK_SECRET_FILE=/home/agent/.codeman/hook-secret \ + codeman/agent:base \ + sleep infinity +``` + +- `--user 1000:0` shown is the Linux-native form with GID 0 (Key decision 6); it is actually `--user :0`, or `--userns=keep-id` for podman rootless, or omitted on Docker Desktop. The literal is illustrative only. +- Create-time `--env` carries only NON-SESSION, non-secret, case-stable values (safe to be committed): the DERIVED `CODEMAN_API_URL` (https-preserving, Key decision 5) and the hook-secret FILE PATH. `CODEMAN_SESSION_ID`/`CODEMAN_MUX` and the codex/gemini key NAMES are exec-time only. +- `codeman.instance=` is REQUIRED on the label set so the boot reaper is instance-scoped (a beta/second instance must never reap prod's containers). +- `codeman.confighash` is a stable hash of the drift-relevant create args (image, resources, network, mounts, non-session env). Drift detection (user story 2, the config-never-takes-effect gap): on launch the ensure block compares the desired hash to the existing container's label; on mismatch the launch does NOT silently reuse the stale container. Instead the docker route returns a "container config changed, recreate?" action (SSE + UI confirm), and on confirm Codeman `docker rm`'s and recreates. rm destroys in-image (non-bind) state, but the workspace and transcripts survive on their bind mounts and the conversation is restored via `--resume`, so the recreate is safe. Auto-recreate-vs-prompt is a UI choice; the MVP prompts. +- `--restart no` (resolved consistently with Key decision 1; recovery is Codeman's idempotent create-if-missing, not an engine restart policy, which also matters for Podman which has no daemon). + +EXEC (`buildDockerLaunchCommand`, the docker analog of `buildRemoteLaunchCommand`, TTY-correct, resume-aware). The whole thing is ONE `bash -c` string that image-checks, ensures, starts, primes tmux env, then execs: + +``` +docker image inspect codeman/agent:base >/dev/null 2>&1 || { echo 'Codeman: base image codeman/agent:base not present (build or pull it)'; exit 1; } ; \ +docker inspect codeman-case-myproj >/dev/null 2>&1 || docker create ; \ +docker start codeman-case-myproj >/dev/null 2>&1 || { echo 'Codeman: container codeman-case-myproj failed to start (daemon down?)'; exit 1; } ; \ +exec docker exec -it \ + --workdir '/home/arkon/cases/myproj' \ + --env TERM=xterm-256color --env COLORTERM=truecolor \ + --env CODEMAN_SESSION_ID=1a2b3c4d --env CODEMAN_MUX=1 \ + --env OPENAI_API_KEY --env GEMINI_API_KEY \ + codeman-case-myproj \ + sh -lc 'tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID 1a2b3c4d \; setenv -g CODEMAN_MUX 1 \; new-session -A -s codeman-dkr-1a2b3c4d -c '\''/home/arkon/cases/myproj'\'' '\''cd /home/arkon/cases/myproj && exec claude --dangerously-skip-permissions --resume '\'' \; set -t codeman-dkr-1a2b3c4d status off \; set -t codeman-dkr-1a2b3c4d mouse off \; set -t codeman-dkr-1a2b3c4d prefix C-q \; set -s escape-time 0' +``` + +- `docker exec -it`: `-t` allocates a PTY and forwards SIGWINCH into the container so the Ink TUI re-lays-out on pane resize; `TERM`/`COLORTERM` prevent degraded rendering. `--env OPENAI_API_KEY` (name only) is present only for codex/gemini and is exec-time (never committed). `CODEMAN_SESSION_ID`/`CODEMAN_MUX` are exec-time values plus a `tmux setenv -g` prime so reattaches and new panes inherit them (Key decision 5). +- `--resume ` (codex `resume `, gemini `--resume `) is appended to `modeCommand` ONLY when a captured id exists; on first launch it is omitted. `new-session -A` makes the flag inert on a live-tmux reattach and effective only when tmux is re-created (Key decision 1). +- `modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode)` (`exec claude --dangerously-skip-permissions`, `exec bash -l`, etc.), with the resume suffix injected by the builder. +- Escaping survives every layer identically to remote in shape but deeper in nesting: `paneCommand` (`cd ... && exec ...`) is one shellescaped tmux arg, the whole `tmuxInvocation` is one shellescaped `sh -lc` arg, and the outer string is `JSON.stringify()`'d into `bash -c` by respawn-pane (tmux-manager.ts:1329). + +Wire-up (extend the two existing seams to 3-way): + +- createSession (tmux-manager.ts:1276): `const fullCmd = docker ? buildDockerLaunchCommand({ mode, docker, sessionId, resumeSessionId }) : remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;` +- launchCmd cd-skip (tmux-manager.ts:1327): `const launchCmd = (remote || docker) ? fullCmd : \`cd ${JSON.stringify(workingDir)} && ${fullCmd}\`;` +- respawnPane: same two edits at lines 1524 and 1542. + +START / reattach-after-reboot: the ensure block (image-check, `docker inspect || docker create`, `docker start`) is fully idempotent, so boot recovery just re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` with the persisted resume id. A rebooted host recreates the container and resumes the conversation. + +DOCKER-DOWN surfacing (the PTY-exit-breaker false-trip risk): if `docker start` or `docker exec` cannot attach (daemon down, container missing), the launch prints a docker-specific message and exits, which alone would still count toward `session-pty-exit-breaker` and show a generic "respawn breaker tripped" push. To avoid masking the cause, the docker reattach path runs a fast `checkDockerAvailable` pre-flight: if the daemon/container is unreachable, Codeman broadcasts a docker-specific error (SSE + push, "container is not running / daemon down") and SKIPS the auto-reattach that would trip the breaker, rather than fast-looping `docker exec`. + +STOP / KILL (`killSession` Strategy 3c, right after remote's Strategy 3b at tmux-manager.ts:1719, guarded by `IS_TEST_MODE`): + +```ts +if (session.docker) { + // best-effort, fire-and-forget, timeout-bounded so it never blocks the local kill + execAsync(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }).catch(() => {}); +} +``` + +`buildDockerKillCommand` emits: `docker exec codeman-case- tmux -L codeman-docker kill-session -t codeman-dkr- ; docker stop -t 10 codeman-case-`. Stopping frees CPU/RAM and, per Key decision 1, is safe for conversation continuity because the NEXT launch resumes from the bind-mounted transcript via `--resume`. Whether to stop at all (RAM vs instant live-agent reattach) is user-decision 6/1 (reframed honestly). The bind-mounted workspace and transcripts always survive on the host. + +REMOVE: only on explicit case delete (`docker rm -f codeman-case-`), gated behind an "export first?" UI prompt because rm destroys any in-image (non-bind) state. Instance-scoped boot reaper (fixing the racy/cross-instance reaper): after `docker-cases.json` is loaded AND after `restoreMuxSessions` has run, enumerate `docker ps -a --filter label=codeman.managed=1 --filter label=codeman.instance= --format '{{.Names}}\t{{index .Labels "codeman.case"}}'` and `docker rm -f` only containers whose case is gone from THIS instance's `docker-cases.json`. The instance filter is what stops a beta reaping prod's containers (the exact cross-instance hazard the project memory warns about). + +AVAILABILITY PROBE (`docker-hosts.ts`, timeout-bounded like `checkRemoteTmuxAvailable`'s 15s, `IS_TEST_MODE` no-op): + +``` +docker info --format '{{json .}}' # server up, CgroupVersion, rootless, OS (Desktop detect), cap-delegation +docker image inspect --format '{{.Id}}' # image PRESENT (no auto-pull) +docker run --rm --pull=never sh -lc 'command -v tmux' # tmux-in-image gate (hard prerequisite), only if image present +``` + +`checkDockerAvailable()` returns `{ ok, engine, rootless, isDesktop, cgroupV2, capsEnforced }` (parse `SecurityOptions` for `name=rootless`, `CgroupVersion`, delegation, and Server OS for Desktop). `checkDockerTmuxAvailable(host)` returns a structured result with a user-facing error and correct install hint (NOT `npm install -g`; the hint is "build/pull the base image" for a missing image and "install docker or podman" for a missing engine). + +IN-CONTAINER CLI VERSION (fixing the #154 wheel-forwarding regression): the raw plan skipped the LOCAL `cliVersion` probe for docker (correct, since it reports the HOST claude) but left `cliVersion` undefined, which disables trackpad wheel-forwarding. Instead, for docker sessions Codeman runs an IN-CONTAINER probe `docker exec claude --version` (bounded, `IS_TEST_MODE` no-op) and feeds THAT into `cliVersion`. This also means a stale baked CLI is visible; combined with the rebuild-cadence in user-decision 2, agents are not silently pinned to an old claude. + +## 5. Export / Import + +EXPORT is a concurrency-bounded job (reuse `runWithConversionLimit` from `document-conversion-limiter.ts` so N simultaneous exports cannot fork-bomb the host). Route `POST /api/docker-cases/:name/export`. + +Preconditions (the consistency and leak risks the critic caught): +- Sealed guard: if `mountCredentials:false`, full-image export is REFUSED unless the caller explicitly opts into the pre-commit scrub (Key decision 2). Workspace-only export is always allowed. +- Quiesce + free-space: require the session idle, then `docker pause` the container spanning BOTH the workspace tar AND the commit so the two artifacts are mutually consistent (the raw plan paused only the commit, leaving the bind-mount tar to run against a mid-write agent). Before any heavy step, precheck free space in the exports dir and in `/var/lib/docker`; if below `DOCKER_EXPORT_MIN_FREE_BYTES`, refuse with a clear error (a full `/var/lib/docker` wedges the daemon and breaks EVERY session on the host). + +Steps (all cleanup in try/finally so a mid-way failure never orphans an intermediate image or leaves the container paused): + +1. `docker commit -c 'LABEL codeman.exported=1' codeman-case- codeman/export-:` (unique tag per export defeats the stale-image trap). Optional pre-commit scrub in sealed mode as above; also blank instance-specific committed env (`-c 'ENV CODEMAN_API_URL='` etc.) so the image carries no stale host references. +2. `docker save codeman/export-: | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/-.image.tar.gz`. Uses `docker save` (layers + repo:tag + CMD), never `docker export` (flat rootfs), so restore is a trivial `docker load`. +3. `tar --numeric-owner -C -czf -.workspace.tar.gz .` while paused (the bind-mounted workspace is NOT in the image, so it travels separately and consistently). +4. Write `manifest.json`: schema version, caseName, image tag, engine, containerWorkdir, resource/network config, codeman version, base-image digest, createdAt, per-member sha256, `mountCredentials`, and `secretFree` (true only for convenient-mode or scrubbed-sealed exports). +5. `docker rmi codeman/export-:` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`. + +The three files are wrapped in one bundle `-.codeman-container.tgz` and offered as a downloadable artifact through the existing file-routes streaming + attachment-registry handoff. + +Retention / disk budget (user-decision 3): `docker-exports/` is capped at `DOCKER_EXPORT_KEEP` most-recent bundles with an auto-prune on each new export, plus the free-space precheck above. Workspace scrub: the WORKSPACE tar gets a scan/warn pass for agent-created `.env` / `.git/credentials` (a distinct leak channel from container creds). A lighter "workspace-only" export (just the workspace tar, no commit/save) is the fast default for 24h+ runs; full-image is the explicit heavier option (user-decision 7 in the original list, now decision on the default button below). + +What travels: the baked toolchain image plus any in-image writes, and the workspace tar. What does NOT travel: bind-mounted credentials (physically excluded from commit) and anything that lived only in a bind mount. Secret-free by construction in convenient mode, and enforced (refuse-or-scrub) in sealed mode. + +IMPORT `POST /api/docker-cases/import` (untrusted-bundle containment, the traversal/overwrite risk): stream the uploaded bundle, validate every manifest checksum BEFORE any extraction or load. Extract the workspace tar with `tar --no-absolute-names -C ` PLUS per-entry validation rejecting any member whose normalized path escapes the destination (leading `/` or `..` components). `gunzip | docker load` the image, then RE-TAG the loaded image id into a quarantined namespace `codeman/imported-:` and NEVER allow the load to overwrite `codeman/agent:base` or any pre-existing tag (capture the loaded id, ignore the bundle's repo:tag). Create a NEW `DockerCase` pointing at the quarantined image with THIS host's mounts/creds and the manifest's resource/network config, and recreate the container hardened (cap-drop ALL, no-new-privileges, non-root, `--pull=never`, CMD overridden to `sleep infinity`). The destination supplies its own login, so credentials never cross machines. Plus `GET /api/docker-exports` (list) and `DELETE /api/docker-exports/:filename`, all behind Codeman's existing auth / loopback-default / host-guard / Origin-CSRF stack. + +## 6. Codeman integration (file-by-file, mirroring the remote-SSH feature) + +- `src/types/session.ts`: add `DockerCommandMode`, `DockerEngine`, `DockerNetworkMode`, `DockerResourceLimits`, `DockerHost`, `DockerCase`, `SessionDocker` (Section 3). Add `docker?: SessionDocker` to `SessionState` after line 219. SessionMode (line 44) UNCHANGED. +- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (38), `CreateSessionOptions` (81), `RespawnPaneOptions` (105). +- `src/docker-hosts.ts` (NEW, direct mirror of `src/remote-hosts.ts`): `readDockerHosts`/`writeDockerHosts`/`readDockerCases`/`writeDockerCases` (via `dataPath`, including `lastClaudeSessionId` read/write), `defaultDockerCommandForMode` (mirror line 60), `dockerDisplayPath` (`container:/path`, mirror `remoteDisplayPath` at 205), `toSessionDocker(host, case)` (mirror `toSessionRemote` at 212), `buildDockerBaseArgs`/`buildDockerCreateArgs` (per-engine uid/userns branch), `hostGatewayAlias(engine)`, `containerApiUrl(processApiUrl, engine)` (scheme+port-preserving, unit-tested), `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` (15s-bounded, `IS_TEST_MODE` no-op), a config-hash helper for drift, its own POSIX `shellescape` copy (mirror line 83). `const IS_TEST_MODE = !!process.env.VITEST;` gates every real `docker` invocation. +- `src/tmux-manager.ts`: add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand` (Section 4). Extend the two `fullCmd` ternaries (1276, 1524) and the two `launchCmd` cd-skips (1327, 1542). Add `killSession` Strategy 3c after 1719. Ensure `reconcileSessions` (~1800-1815) does NOT hard-delete docker sessions on local-tmux death (recovery relaunch path). +- `src/session.ts`: add `_docker?: SessionDocker` field (mirror `_remote` at 403), constructor arg (477), assignment (550). Thread `docker: this._docker` and `resumeSessionId: this._claudeSessionId` into BOTH `createSessionOptions` and `respawnPaneOptions` in `startInteractive` (1352/1370) and the second path (1740/1750). Emit `docker: this._docker` in `toState()` (1010). Replace the LOCAL cliVersion probe at 1320 for docker with the IN-CONTAINER `probeDockerCliVersion` (do not merely skip it). Extend `resolveMuxAttachCwd(workingDir, remote, docker)` (215) to return `/tmp` when `docker` is set. On claudeSessionId capture, persist it to the owning `DockerCase.lastClaudeSessionId`. +- `src/web/server.ts`: in `restoreMuxSessions` (2160), add `docker: muxSession.docker ?? savedState?.docker` to the `new Session({...})` call (2195-2216), and skip docker in the same `isExternalCliMode`/Ralph recovery guards as remote. Register the instance-scoped boot reaper to run AFTER docker-cases load and AFTER `restoreMuxSessions`. Ensure `CODEMAN_API_URL` derivation reads the SAME `process.env.CODEMAN_API_URL` the server sets at ~2000. +- `src/web/schemas.ts`: add `DockerHostSchema` and `DockerCaseLinkSchema` (below). The three mode enums (177/373/705) and `QuickStartSchema` (368) UNCHANGED (docker resolves by `caseName` lookup like remote). +- `src/web/routes/session-routes.ts`: import the docker helpers from `../../docker-hosts.js`. Add a docker branch in `/api/quick-start` parallel to the remote branch (1686-1720): `readDockerCases` -> find by `caseName` -> `readDockerHosts` -> find by `hostId`; reject `envOverrides`/`effort`/`codexConfig`/`geminiConfig`/`openCodeConfig` (but ACCEPT `modelOverride`, which flows via scaffolded `settings.local.json`); run `checkDockerAvailable` + `checkDockerTmuxAvailable` (image-present, engine, caps-enforced); surface `capsEnforced:false` and Desktop notes; set `casePath = dockerCase.hostWorkspacePath` (REAL host dir), `docker = toSessionDocker(host, dockerCase)`, and seed `resumeSessionId` from `dockerCase.lastClaudeSessionId` when `resumeOnStart`. Extend the LOCAL-availability and local-spawn guards (around 1796/1810) to `!remote && !docker`, but DO NOT extend the workspace-scaffolding guard (~1776, `writeHooksConfig`/`updateCaseModel`), which MUST run for docker. Pass `docker` into `new Session` (1847); `autoConfigureRalph` (1853) gated on `!docker`. Add `docker: m.docker !== undefined ? true : undefined` to the unified harvest (2313). +- `src/web/routes/case-routes.ts`: import the docker read/write/check helpers + schemas. Add a docker listing loop in `GET /api/cases` (mirror 94-119, `location: 'docker'`, `docker: {...}` via `dockerDisplayPath`). Add `/api/docker-hosts` GET/POST/PUT/DELETE (mirror 168-204) and `POST /api/cases/docker-link` (mirror 206-232; run `checkDockerAvailable`/`checkDockerTmuxAvailable` at link time; broadcast `CaseLinked` with `type: 'docker'`). Add a docker-unlink branch to `DELETE /api/cases/:name` (mirror 288-296; `docker rm -f`; broadcast `CaseDeleted` `type: 'docker-unlinked'`). Add the docker branch to single-case `GET` (mirror 358-368). Add `POST /api/docker-cases/:name/export`, `/import`, `GET/DELETE /api/docker-exports`, and a `POST /api/docker-cases/:name/recreate` (drift confirm) per Sections 4 and 5. +- `src/web/sse-events.ts` + `src/web/public/constants.js`: reuse `CaseLinked`/`CaseDeleted` for CRUD. Add `docker:exportProgress`, `docker:exportComplete`, `docker:importComplete`, `docker:configDrift`, and `docker:containerError` to BOTH registries (kept in sync per CLAUDE.md). +- Frontend `src/web/public/index.html` (~1831): add a Docker `modal-tab-btn` next to Remote; add a `#case-docker` panel mirroring `#case-remote` with `dockerCaseName`, `dockerHostWorkspacePath`, `dockerContainer`, `dockerImage`, `dockerHostId`, and an Advanced `
` for network mode, resource caps, `mountCredentials`, `resumeOnStart`, and remote daemon. Surface a "scaffolds .claude into this host dir" note (user-decision 4) and a "resource caps advisory on this engine" warning when `capsEnforced:false`. +- Frontend `src/web/public/session-ui.js`: `formatCasePickerLabel` (48) + `buildCasePickerOptions` (71-73) handle `location === 'docker'` (`name @ container`, add container/image to the search haystack); `resetCaseModalFields` (~1514) add a `dockerFields` array; `switchCaseModalTab` (1573/1580/1597) handle `'case-docker'`; `submitCaseModal` add the docker branch; new `linkDockerCase()` (mirror `linkRemoteCase` at 1689) POSTing `/api/docker-hosts` then `/api/cases/docker-link`, sending omitted optionals as `undefined` (spread `...(x ? {x} : {})`, never `null`, per the Zod `.optional()`-rejects-null gotcha); `runClaude` (520) / `runShell` (702) extend the `location === 'remote'` routing to also match `'docker'`; `runOpenCode`/`runCodex`/`runGemini` (792/846/900) make the `isRemote` checks `isRemoteOrDocker` so local status probes are skipped. In the session-options Summary tab, note that `effort` is inert for docker (rejected) while `model` IS honored via `settings.local.json`. +- Frontend `src/web/public/panels-ui.js` (425-426): add `caseItem?.docker?.path`/`container` to the case-search fields. + +Schemas (`src/web/schemas.ts`), mirroring `RemoteHostSchema` (299) / `RemoteCaseLinkSchema` (351): + +```ts +export const DockerHostSchema = z.object({ + id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + label: z.string().min(1).max(100), + engine: z.enum(['docker', 'podman']).optional(), + image: z.string().min(1).max(512).regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image ref').regex(NO_SHELL_META), + daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(), + context: z.string().max(128).regex(/^[a-zA-Z0-9._-]+$/, 'Invalid context').optional(), + network: z.enum(['bridge', 'none', 'custom']).optional(), + networkName: z.string().max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/).optional(), + resources: z.object({ + memory: z.string().regex(/^\d+[bkmg]?$/i).optional(), + cpus: z.string().regex(/^\d+(\.\d+)?$/).optional(), + pidsLimit: z.number().int().positive().max(100000).optional(), + nofile: z.string().regex(/^\d+:\d+$/).optional(), + shmSize: z.string().regex(/^\d+[bkmg]?$/i).optional(), + }).strict().optional(), + mountCredentials: z.boolean().optional(), + hooksEnabled: z.boolean().optional(), + resumeOnStart: z.boolean().optional(), + commands: RemoteCommandOverridesSchema, // reuse the shared shape + extraCreateArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(), + extraExecArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(), +}); + +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'), + hostWorkspacePath: z.string().min(1).max(2000).regex(/^\//, 'Path must be absolute').regex(NO_SHELL_META, 'Invalid characters in workspace path'), + containerWorkdir: z.string().min(1).max(2000).regex(/^\//).regex(NO_SHELL_META).optional(), + container: z.string().min(2).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name').optional(), +}); +``` + +`NO_SHELL_META` (rejects `$`/backtick, schemas.ts:297) is REQUIRED on `image`, `hostWorkspacePath`, `containerWorkdir`, and `container`, because all four reach the outer `bash -c "..."` double-quote layer where `$(...)`/backtick re-expose, exactly the reason `remotePath`/`identityFile` use it. `--privileged` and any `-v /var/run/docker.sock` are structurally unrepresentable (never emitted by the builder, never accepted by the schema). + +## 7. Security model + +- Hardening flags on every create: `--cap-drop ALL`, `--security-opt no-new-privileges` (NOT auto-set by rootless Docker or Podman, so always explicit), the uid/userns branch of Key decision 6 (never container-root; workspace files stay host-owned and HOME stays writable via GID 0), `--pids-limit` (fork-bomb guard), `--memory` with `--memory-swap == --memory` (real OOM cap), `--ulimit nofile`, `--init`, `--pull=never`. NEVER `--privileged`, NEVER mount the docker socket into the agent container. `--storage-opt size=` is emitted ONLY after the probe confirms overlay2-on-xfs-pquota or btrfs (the AICE-class silently-ignored trap); otherwise it is omitted and the UI does not advertise a size cap. Resource caps are advertised as ENFORCED only when the probe reports `capsEnforced:true`; under non-delegated rootless they are labeled advisory (user-decision 6). +- Engine: prefer whichever the probe finds, Podman-rootless first for security (a container-root breakout lands as an unprivileged host user). Rootless bind-mount ownership uses `--userns=keep-id` (Podman) vs `--user :0` (Docker), so real per-engine branching lives in `buildDockerCreateArgs`. Docker Desktop takes its own uid path (Key decision 6). +- Blast radius (the combined-posture the critic asked to surface, user-decision 5): the default convenient profile mounts an arbitrary host workspace dir RW (host-owned, mirrored path) AND host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/gcloud`/`~/.config/opencode` RW into a NETWORK-ENABLED container. Container-run agent code can therefore read/modify those host trees and reach the network simultaneously. This is still a strict improvement over today's on-host skip-permissions execution, but the user must accept the combined posture explicitly; the sealed profile plus `network:none` is the mitigation for genuinely untrusted work. +- Secret handling: creds arrive ONLY as bind-mounted files (default) or exec-time NAME-ONLY `--env` (codex/gemini keys), NEVER as create-time `-e` and NEVER as an image layer. Sealed-mode export is refuse-or-scrub (Section 5), closing the sealed-leak inversion. +- CLAUDE.md "Multi-CLI prefix discipline": the exec-time name-only env is restricted to the CLI-specific keys per mode (Claude: none with OAuth mount; Codex: `OPENAI_API_KEY`/`CODEX_API_KEY`; Gemini: `GEMINI_API_KEY`/`GOOGLE_*`), never a blanket forward. `envOverrides` is rejected for docker, so the `ALLOWED_ENV_PREFIXES` allowlist is not widened. +- hook-secret: bind-mounted read-only, referenced via `CODEMAN_HOOK_SECRET_FILE` (a path, non-secret); the secret bytes never enter env or the image. Both `host.docker.internal` and `host.containers.internal` are added to the host-guard allowlist so the in-container hook curl's Host header passes on either engine. +- Host guard / instance isolation: the in-container tmux socket (`codeman-docker`) and name (`codeman-dkr-`) deliberately FAIL a container-internal Codeman's `SAFE_MUX_NAME_PATTERN`, so a nested Codeman never adopts our session (unit-asserted). The boot reaper is instance-scoped by the `codeman.instance` label so a beta never reaps prod. Any remote-daemon (`-H`/`--context`) mode is host-root-equivalent and stays strictly behind the existing auth/loopback/host-guard/Origin-CSRF stack. +- Import containment: untrusted bundles are checksum-validated, extracted with traversal guards, and loaded into a quarantined image namespace (never overwriting the base image), then run with the same hardening. + +## 8. Phased implementation (branch: `feat/docker-session-mode`) + +Each phase is independently testable; per CLAUDE.md, end-to-end test in the real env before COM. All new docker IO paths carry `const IS_TEST_MODE = !!process.env.VITEST;` and no-op under it; the pure command builders are tested directly. + +- Phase 0: base image + engine probe. Author `docker/agent.Dockerfile` (OpenShift arbitrary-uid HOME) and `scripts/build-agent-image.mjs` (build or pull the base image; digest recorded). Add `checkDockerAvailable`/`checkDockerTmuxAvailable`/`containerApiUrl`/`hostGatewayAlias` (IS_TEST_MODE no-op) and `GET /api/docker/status`. Test: probe stub returns available/caps/Desktop flags under VITEST; `containerApiUrl` preserves scheme+port and swaps host per engine; status route returns the envelope. +- Phase 1: types + storage + schemas. Add all types (Section 3), `src/docker-hosts.ts`, `DockerHostSchema`/`DockerCaseLinkSchema`. Test: `docker-hosts.test.ts` (round-trip incl. `lastClaudeSessionId`, display path, config-hash stability); `docker-exec-options.test.ts` (schema rejects `$`/backtick in image/workdir/container). +- Phase 2: tmux-manager builders. Add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand`; wire the two ternaries + two cd-skips + Strategy 3c; harden `reconcileSessions` against docker hard-delete. Test (pure strings): adopt-proof name fails `SAFE_MUX_NAME_PATTERN`; image-check precedes create; `new-session -A` idempotent; resume flag present only when a resume id is passed; `--pull=never` present; instance label present; escaping survives `bash -c` -> `docker exec` -> `sh -lc` -> tmux WITH a host workspace path containing spaces. +- Phase 3: session.ts + mux + recovery. Add `_docker` + `resumeSessionId` threading, in-container cliVersion probe, `resolveMuxAttachCwd`, mux-interface fields, `restoreMuxSessions` passthrough, instance-scoped reaper wiring, claudeSessionId -> `DockerCase.lastClaudeSessionId` persistence, unified flag. Test: `toState()` emits docker; a persisted docker session round-trips through mux/state; a relaunch injects the persisted resume id (mock mux); reaper only targets this instance's orphaned containers. +- Phase 4: routes + first real e2e. case-routes CRUD + listing + drift-recreate; session-routes quick-start branch (scaffolding RUNS, local-availability guards skip, model accepted, effort/config rejected). Manual e2e on a real docker host: docker-host create -> docker-link -> quick-start; confirm the pane runs `claude` in the container, files land host-owned, a Codeman restart reattaches the SAME live agent, and a `docker stop` followed by relaunch RESUMES the conversation. +- Phase 5: hooks connectivity + installation. host-gateway (per engine), derived `CODEMAN_API_URL`, hook-secret mount, `CODEMAN_SESSION_ID`/`CODEMAN_MUX` exec-env + tmux setenv, host-guard allowlist, and the scaffolding write into the real workspace. Manual e2e: trigger a permission prompt from inside the container and confirm it surfaces; verify hook payloads carry the right session id. If deferred, ship docker as explicitly hook-degraded and verify output-based idle detection through the docker-exec PTY. +- Phase 6: export/import + GC + disk safety. quiesce+pause span, free-space precheck, commit+save+gzip + workspace tar + manifest + streaming download; sealed-mode refuse-or-scrub; retention/auto-prune; import with checksum validation + traversal guard + quarantined re-tag; drift-recreate; boot reaper; `runWithConversionLimit` cap; `docker rmi` in finally. Manual e2e: export, `docker load` on a second machine (or fresh case), import, confirm toolchain + workspace restored and NO creds present; attempt a sealed full-image export and confirm it is refused-or-scrubbed; attempt a `../` bundle and confirm it is rejected. +- Phase 7: frontend. Docker tab, `linkDockerCase`, run wiring, case-picker labels, panels search, caps-advisory + scaffold-warning + effort-inert notes. Verify with Playwright (`waitUntil: 'domcontentloaded'`, 3-4s settle) that the Docker tab renders and a linked docker case appears in the picker. +- Phase 8: docs + COM. Update CLAUDE.md (a "Docker cases" Key Pattern paragraph mirroring remote-SSH, plus the new state files, routes counts, and the resume/durability model), `docs/docker-cases.md`, then COM per the standard flow. + +## 9. Test plan + +- Unit (pure, CI-safe, mirror `test/remote-hosts.test.ts` / `test/remote-ssh-options.test.ts`): + - `test/docker-hosts.test.ts`: storage round-trip (incl. `lastClaudeSessionId`), `dockerDisplayPath`, `defaultDockerCommandForMode`, `toSessionDocker`, `containerApiUrl` (http/https, custom port, docker vs podman gateway), config-hash stability/drift, `buildDockerCreateArgs` flag ordering (cap-drop/no-new-privileges/memory==memory-swap/instance-label/`--pull=never` present; host/privileged/socket absent; per-engine uid vs `--userns=keep-id`). + - `test/docker-exec-options.test.ts`: `buildDockerLaunchCommand`/`buildDockerKillCommand` string shape and escaping through `bash -c` -> `docker exec` -> `sh -lc` -> tmux, including a workspace path with spaces; resume flag present only with a resume id; image-presence check precedes create; `dockerTmuxSessionName` fails `SAFE_MUX_NAME_PATTERN`; schema rejects `$`/backtick in image/workdir/container/name; `linkDockerCase`-shaped bodies with omitted optionals validate (no `null` on the wire). + - Probe no-op: `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` return canned values under VITEST and never spawn. +- Integration (route tests via `app.inject()`, docker no-op'd): `/api/docker-hosts` CRUD; `/api/cases/docker-link` dup-check + broadcast; `GET /api/cases` includes the docker case with `location: 'docker'`; `/api/quick-start` docker branch rejects `envOverrides`/`effort`/config but ACCEPTS `modelOverride`, runs the workspace-scaffolding path, and constructs a session with `docker` set + seeded resume id; `DELETE /api/cases/:name` docker-unlink; export refuse-or-scrub for sealed; import traversal rejection; reaper instance-scoping (label filter). Pick a unique port only if a live-server test is added (search `const PORT =`; 3150+). +- Manual end-to-end (real docker daemon, the mandatory "always end-to-end test" gate): build the base image; link a docker case; quick-start `claude`; verify OAuth via the mounted `~/.claude`, transcript correlation (subagent/workflow watchers show the session), host-owned files, and a working permission-prompt hook; reattach after a Codeman PROCESS restart (SAME live agent); `docker stop` then relaunch and confirm conversation RESUME; reboot-equivalent (daemon restart) and confirm boot recovery recreates+resumes; change the host's memory/image and confirm the drift-recreate prompt fires; export (convenient) and confirm the tar `docker load`s with no creds; attempt a sealed full-image export and confirm refuse-or-scrub; import into a fresh case; delete the case and confirm `docker rm -f` plus instance-scoped reaper GC; confirm a docker-down state surfaces a docker-specific error and does NOT trip the generic PTY-exit breaker. + +## 10. Open decisions for the user + +1. Credential + blast-radius posture (combined). Convenient default bind-mounts host `~/.claude` etc. RW AND an arbitrary host workspace RW into a network-enabled container, so container-run agent code can read/modify those host trees and reach the network at the same time. Recommended: convenient default plus a per-host SEALED opt-in (`mountCredentials:false` + `network:none`) for untrusted work. Please confirm you accept the combined arbitrary-workspace-plus-egress-plus-host-creds posture for the default profile (it is still a net improvement over today's on-host skip-permissions execution). +2. Base image ownership, registry, and freshness. The `codeman/agent:base` placeholder implies a Docker Hub org the project may not own. Pick the real registry/namespace (GHCR under the repo is the natural fit), decide digest pinning, and set a REBUILD CADENCE so agents are not stuck on a stale baked `claude` (the in-container version probe surfaces staleness, but something must trigger rebuilds). Choose: pull a pinned published image, build locally on first use via `scripts/build-agent-image.mjs`, or both. +3. Container CWD strategy. Mirror the host workspace path inside the container (recommended: makes transcript projHash correlate, file features and resume capture work) vs a fixed `/workspace` (simpler mount, breaks watcher correlation). Please confirm the mirror approach. +4. Hooks in the MVP AND workspace scaffolding. Making docker hooks fire requires WRITING `.claude/settings.local.json` (and the CLAUDE.md scaffold) into the user's REAL linked host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." Choose: wire hooks + scaffolding now (Phase 5, recommended, and it also enables the model picker), or ship docker as explicitly hook-degraded (no permission prompts / hook-idle) for v1 and add later. Confirm you are OK with Codeman mutating the linked host workspace. +5. Session-kill teardown and RESUME (reframed honestly). `docker stop` on session kill is not merely "free RAM vs instant reattach": it destroys the in-container live agent, and the conversation survives ONLY because the next launch runs `--resume` from the bind-mounted transcript. Choose: keep the container running (costs RAM, preserves the exact live in-flight agent) vs stop and rely on `--resume` (frees RAM, may lose uncommitted in-flight tool state). Case-delete always `docker rm -f`. +6. Rootless enforcement posture. Under rootless without cgroup-v2 systemd delegation, `--memory`/`--cpus`/`--pids-limit` are SILENTLY ignored. Choose: REQUIRE delegation (refuse to link a host that cannot enforce caps) or ship-with-warning ("resource caps are advisory on your engine"). The probe reports `capsEnforced` either way. +7. Default resume behavior. Should a re-linked or re-run docker case default to resuming its last conversation (`resumeOnStart:true`, using `DockerCase.lastClaudeSessionId`) rather than starting clean? This is the crux of making the durability story real and is the recommended default, but it changes user-visible behavior (a new session in an existing case continues the prior conversation). +8. Export defaults and disk budget. Default export button: workspace-only (fast, small, files-only, recommended for 24h+ runs) vs full-image (reproducible env, multi-GB). Also set the retention cap (max retained exports), the auto-prune policy, and the free-space threshold below which export is refused (a full `/var/lib/docker` breaks EVERY session on the host, not just docker ones). +9. Remote docker daemon (`-H ssh://...` / `--context`). Support in the MVP (composes with remote hosts, adds host-root trust surface) or local-daemon-only first. +10. Podman parity depth. Full `--userns=keep-id` plus Quadlet boot-persistence, or Docker-first with Podman as best-effort and boot-persistence via Codeman's idempotent create-if-missing only. Note the podman host alias is `host.containers.internal`, already handled per engine. diff --git a/docs/docker-cases.md b/docs/docker-cases.md new file mode 100644 index 00000000..ee53feb9 --- /dev/null +++ b/docs/docker-cases.md @@ -0,0 +1,94 @@ +# Docker cases + +Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**. + +Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container. + +## One-time setup: build the base image + +The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once: + +```bash +node scripts/build-agent-image.mjs # builds codeman/agent:base +# options: --engine docker|podman --image --no-cache +``` + +The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them. + +## Quickest path: one-click "Run in Docker" + +On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in. + +Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker: + +| Template | Memory | CPUs | GPUs | +|----------|--------|------|------| +| Small | 2 GB | 1 | none | +| Medium (default) | 4 GB | 2 | none | +| Large | 8 GB | 4 | none | +| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) | + +**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`. + +## Create a docker case (full control) + +App → **New case → Docker** tab: + +- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes. +- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings. +- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge. +- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**. + +Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-`); subsequent sessions attach to the same one. + +Equivalent API: + +```bash +curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}' +curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}' +curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}' +``` + +## 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. +- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions. +- **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 + +Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user :0` so workspace files stay host-owned), `--pids-limit`, `--memory` == `--memory-swap`, `--init`. Never `--privileged`, never the docker socket. The default **convenient** profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by `docker commit`); the **sealed** profile (`mountCredentials:false` + `network:none`) is the opt-in for genuinely untrusted work. + +Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory. + +## Export / Import (move to another machine) + +**Export** (from the Docker tab, or `POST /api/docker-cases/:name/export`): choose + +- **Full image + workspace**: `docker commit` the container to an image, `docker save` it, tar the workspace, and a manifest, all into one portable `-.codeman-container.tgz` (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready. +- **Workspace only**: just the project files (fast, small). + +The container is paused across the capture so the image and workspace are consistent; a full `/var/lib/docker` is guarded against with a free-space precheck; the intermediate image is always cleaned up. + +**Import** (`POST /api/docker-cases/import`, or the Manage tab): copy the `.tgz` onto the new machine's `~/.codeman/docker-exports/`, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is `docker load`ed and **re-tagged into a quarantined namespace** (`codeman/imported-:`) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines. + +`GET /api/docker-exports` lists bundles; `GET /api/docker-exports/:filename` downloads one; `DELETE` removes one. + +## Hooks require the server to be reachable from the container + +In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**. + +- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway. +- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart. +- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too). + +The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers. + +## Notes & limits + +- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time). +- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. +- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling). + +Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md). diff --git a/package-lock.json b/package-lock.json index b0753b94..b7091bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.3.5", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.3.5", + "version": "1.4.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 17d61aa5..379482a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.3.5", + "version": "1.4.0", "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/scripts/build-agent-image.mjs b/scripts/build-agent-image.mjs new file mode 100644 index 00000000..00602d24 --- /dev/null +++ b/scripts/build-agent-image.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Build the Codeman agent base image locally (decision: "build locally on first + * use", see docs/docker-cases-plan.md). No registry account required. + * + * Usage: + * node scripts/build-agent-image.mjs [--engine docker|podman] [--image ] [--no-cache] + * + * Defaults: engine=docker (falls back to podman if docker is absent), + * image=codeman/agent:base + */ +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile'); +const DEFAULT_IMAGE = 'codeman/agent:base'; + +function parseArgs(argv) { + const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--image') args.image = argv[++i]; + else if (a === '--engine') args.engine = argv[++i]; + else if (a === '--no-cache') args.noCache = true; + else if (a === '-h' || a === '--help') args.help = true; + } + return args; +} + +function engineAvailable(engine) { + const r = spawnSync(engine, ['--version'], { stdio: 'ignore' }); + return r.status === 0; +} + +function resolveEngine(preferred) { + if (preferred) { + if (!engineAvailable(preferred)) { + console.error(`[build-agent-image] engine "${preferred}" not found on PATH`); + process.exit(1); + } + return preferred; + } + if (engineAvailable('docker')) return 'docker'; + if (engineAvailable('podman')) return 'podman'; + console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.'); + process.exit(1); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image ] [--no-cache]'); + process.exit(0); +} + +const engine = resolveEngine(args.engine); +const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image]; +if (args.noCache) buildArgs.push('--no-cache'); +buildArgs.push(REPO_ROOT); + +console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`); +const child = spawn(engine, buildArgs, { stdio: 'inherit' }); +child.on('exit', (code) => { + if (code === 0) { + console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`); + } else { + console.error(`\n[build-agent-image] build failed (exit ${code}).`); + } + process.exit(code ?? 1); +}); diff --git a/src/docker-export.ts b/src/docker-export.ts new file mode 100644 index 00000000..bb5549c0 --- /dev/null +++ b/src/docker-export.ts @@ -0,0 +1,416 @@ +/** + * @fileoverview Docker case export / import: move a container (toolchain + any + * in-image changes) PLUS its workspace to another machine as one portable + * `.codeman-container.tgz`, and restore it. + * + * A full-image export = `docker commit` the running container to an image -> + * `docker save` that image -> tar the bind-mounted workspace -> a manifest, all + * bundled into one gzip tarball. A workspace-only export skips the image (fast, + * files-only). Import validates the manifest + per-member checksums, extracts the + * workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it + * into a quarantined namespace (never overwriting a local tag), and hands the + * caller enough to recreate a hardened case on the destination. + * + * Safety (all from the design critic): pause the container spanning the workspace + * tar AND the commit so the two artifacts are mutually consistent; a free-space + * precheck (a full docker graph wedges EVERY session on the host); `docker rmi` + * the intermediate image in a finally; sealed containers refuse a full-image + * export (an in-container login would ride the committed layer); import rejects + * absolute / `..` tar members and checksum mismatches. Bounded by + * runWithConversionLimit so N exports cannot fork-bomb the host. + * + * @module docker-export + */ + +import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join, basename } from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; +import type { DockerEngine, SessionDocker } from './types.js'; +import { runWithConversionLimit } from './document-conversion-limiter.js'; + +const IS_TEST_MODE = !!process.env.VITEST; + +/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */ +export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB + +/** Manifest schema version (bump on any breaking field change). */ +export const DOCKER_EXPORT_SCHEMA = 1; + +export type DockerExportMode = 'full' | 'workspace'; + +export interface DockerExportManifest { + schemaVersion: number; + caseName: string; + mode: DockerExportMode; + engine: DockerEngine; + image: string; + containerWorkdir: string; + network: string; + createdAt: number; + codemanVersion: string; + mountCredentials: boolean; + /** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */ + secretFree: boolean; + /** sha256 of each bundle member that is present. */ + checksums: { image?: string; workspace?: string }; +} + +// ========== Pure helpers (unit-tested) ========== + +/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */ +export function dockerArgv(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; +} + +/** Portable bundle filename for a case export. */ +export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string { + const suffix = mode === 'workspace' ? 'workspace' : 'container'; + return `${caseName}-${timestamp}.codeman-${suffix}.tgz`; +} + +/** Quarantined image tag for an imported bundle (never overwrites a local tag). */ +export function importedImageTag(caseName: string, timestamp: number): string { + return `codeman/imported-${caseName}:${timestamp}`; +} + +/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */ +export function exportImageTag(caseName: string, timestamp: number): string { + return `codeman/export-${caseName}:${timestamp}`; +} + +/** + * Reject a tar member path that would escape the extraction root (absolute path + * or a `..` component). The import-side traversal guard. + */ +export function isSafeTarMember(member: string): boolean { + const trimmed = member.trim(); + if (!trimmed || trimmed === './') return true; + if (trimmed.startsWith('/')) return false; + // Normalize separators and check each component. + return !trimmed.split('/').some((part) => part === '..'); +} + +/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */ +export function parseLoadedImageRef(loadOutput: string): string | null { + const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i); + if (idMatch) return idMatch[1]; + const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i); + if (refMatch) return refMatch[1]; + return null; +} + +// ========== IO helpers ========== + +function run( + cmd: string, + args: string[], + opts: { timeout?: number } = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let timer: NodeJS.Timeout | undefined; + if (opts.timeout) { + timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`${cmd} timed out after ${opts.timeout}ms`)); + }, opts.timeout); + } + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('error', (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`)); + }); + }); +} + +/** + * Stream `docker save ` stdout to a raw tar file (no shell, no double-gzip). + * Uses stream `pipeline` so completion means the write stream is FULLY flushed to + * disk (a naive child 'close' resolves before the last chunks land, truncating the + * file — a real bug caught in end-to-end testing), AND waits for a clean exit code. + */ +async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise { + const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (d) => (stderr += d)); + const exited = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`)) + ); + }); + // pipeline resolves only after the destination has fully flushed. + await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]); +} + +async function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('data', (d) => hash.update(d)); + stream.on('error', reject); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +async function freeBytes(path: string): Promise { + try { + const stat = await fs.statfs(path); + return Number(stat.bavail) * Number(stat.bsize); + } catch { + return Number.POSITIVE_INFINITY; // statfs unsupported — don't block + } +} + +async function isContainerRunning(argv: string[], container: string): Promise { + try { + const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], { + timeout: 15_000, + }); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +export interface ExportResult { + bundlePath: string; + manifest: DockerExportManifest; + sizeBytes: number; +} + +/** + * Export a docker case to a portable bundle. Bounded by runWithConversionLimit. + * `full` mode commits + saves the image AND tars the workspace; `workspace` mode + * tars just the workspace. The container is paused across the artifact capture so + * image and workspace are mutually consistent. + */ +export async function exportDockerCase(params: { + docker: SessionDocker; + caseName: string; + timestamp: number; + exportsDir: string; + mode: DockerExportMode; + codemanVersion: string; +}): Promise { + const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params; + + if (mode === 'full' && !docker.mountCredentials) { + throw new Error( + 'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.' + ); + } + + if (IS_TEST_MODE) { + // No real docker/tar under vitest — return a deterministic stub. + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + secretFree: true, + checksums: {}, + }; + return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 }; + } + + return runWithConversionLimit(async () => { + if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true }); + + const free = await freeBytes(exportsDir); + if (free < DOCKER_EXPORT_MIN_FREE_BYTES) { + throw new Error( + `not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.` + ); + } + + const argv = dockerArgv(docker); + const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode)); + const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`); + mkdirSync(stageDir, { recursive: true }); + const wasRunning = await isContainerRunning(argv, docker.containerName); + let commitTag: string | undefined; + + try { + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + + const checksums: DockerExportManifest['checksums'] = {}; + + if (mode === 'full') { + commitTag = exportImageTag(caseName, timestamp); + // Blank instance-specific committed env so the image carries no stale host refs. + await run( + argv[0], + [ + ...argv.slice(1), + 'commit', + '-c', + 'ENV CODEMAN_API_URL=', + '-c', + 'ENV CODEMAN_HOOK_SECRET_FILE=', + docker.containerName, + commitTag, + ], + { timeout: 300_000 } + ); + const imageTar = join(stageDir, 'image.tar'); + await saveImageToTar(argv, commitTag, imageTar); + checksums.image = await sha256File(imageTar); + } + + const workspaceTar = join(stageDir, 'workspace.tar'); + await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 }); + checksums.workspace = await sha256File(workspaceTar); + + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + // Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free. + secretFree: docker.mountCredentials, + checksums, + }; + await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const members = + mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar']; + await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 }); + + const stat = await fs.stat(bundlePath); + return { bundlePath, manifest, sizeBytes: stat.size }; + } finally { + // Always remove the intermediate image + stage dir, and unpause. + if (commitTag) { + await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {}); + } + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + } + }); +} + +export interface ImportResult { + manifest: DockerExportManifest; + /** Quarantined image ref the destination case should use (full mode only). */ + importedImage?: string; + /** Directory the workspace was extracted into. */ + workspacePath: string; +} + +/** + * Import a bundle produced by exportDockerCase: validate the manifest + per-member + * checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in + * full mode, `docker load` the image and re-tag it into a quarantined namespace. + */ +export async function importDockerBundle(params: { + bundlePath: string; + destWorkspace: string; + engine: DockerEngine; + timestamp: number; +}): Promise { + const { bundlePath, destWorkspace, engine, timestamp } = 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 stageDir = `${destWorkspace}.import-stage-${timestamp}`; + mkdirSync(stageDir, { recursive: true }); + try { + await run('tar', ['-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})`); + } + + // Integrity: verify checksums before trusting any member. + const workspaceTar = join(stageDir, 'workspace.tar'); + if (manifest.checksums.workspace) { + const actual = await sha256File(workspaceTar); + if (actual !== manifest.checksums.workspace) + throw new Error('workspace checksum mismatch (corrupt or tampered bundle)'); + } + + // Traversal guard: reject absolute / `..` members before extraction. + const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 }); + for (const member of memberList.split('\n').filter(Boolean)) { + if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`); + } + mkdirSync(destWorkspace, { recursive: true }); + await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 }); + + let importedImage: string | undefined; + if (manifest.mode === 'full') { + const imageTar = join(stageDir, 'image.tar'); + if (manifest.checksums.image) { + const actual = await sha256File(imageTar); + if (actual !== manifest.checksums.image) + throw new Error('image checksum mismatch (corrupt or tampered bundle)'); + } + 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); + await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); + } + + return { manifest, importedImage, workspacePath: destWorkspace }; + } finally { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** List export bundles in the exports dir (newest first), with size + mtime. */ +export async function listDockerExports( + exportsDir: string +): Promise> { + if (!existsSync(exportsDir)) return []; + const entries = await fs.readdir(exportsDir).catch(() => [] as string[]); + const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = []; + for (const name of entries) { + if (!name.endsWith('.tgz')) continue; + try { + const stat = await fs.stat(join(exportsDir, name)); + out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); + } catch { + /* skip */ + } + } + return out.sort((a, b) => b.mtimeMs - a.mtimeMs); +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts new file mode 100644 index 00000000..10389bcc --- /dev/null +++ b/src/docker-hosts.ts @@ -0,0 +1,653 @@ +/** + * @fileoverview Docker cases: storage, pure command-arg builders, and daemon probes. + * + * Docker mode is a LOCATION OVERLAY on cases (not a 6th SessionMode), the direct + * analog of the remote-SSH feature in `remote-hosts.ts`. Instead of a local tmux + * pane running `ssh host` into a durable remote tmux server, a local tmux pane + * runs `docker exec -it` into a durable IN-CONTAINER tmux server. The container is + * scoped to the CASE (`codeman-case-`), so multiple sessions can `docker + * exec` into the same long-lived container. + * + * This module mirrors `remote-hosts.ts`: + * - JSON storage for hosts (`docker-hosts.json`) and cases (`docker-cases.json`) + * - `toSessionDocker()` (mirror of `toSessionRemote`) + * - `buildDockerBaseArgs()` / `buildDockerCreateArgs()` (mirror of `buildSshConnectionArgs`) + * - `checkDockerAvailable()` / `checkDockerTmuxAvailable()` (mirror of `checkRemoteTmuxAvailable`) + * + * The launch/kill command orchestration (`buildDockerLaunchCommand`, + * `buildDockerKillCommand`, `dockerTmuxSessionName`) lives in `tmux-manager.ts`, + * mirroring where `buildRemoteLaunchCommand` lives. + * + * @module docker-hosts + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { + DockerCase, + DockerCommandMode, + DockerEngine, + DockerHost, + DockerNetworkMode, + DockerResourceLimits, + SessionDocker, + SessionMode, +} from './types.js'; + +const execFileAsync = promisify(execFile); + +/** Under vitest, all real `docker` invocations no-op (mirror of tmux-manager's IS_TEST_MODE). */ +const IS_TEST_MODE = !!process.env.VITEST; + +const DOCKER_HOSTS_FILE = 'docker-hosts.json'; +const DOCKER_CASES_FILE = 'docker-cases.json'; + +/** Locally-built base image (see scripts/build-agent-image.mjs). */ +export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base'; + +/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */ +export const CONTAINER_HOME = '/home/agent'; + +/** Per-case container name prefix. The `case` letters deliberately do NOT matter to + * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are + * already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-` is always valid. */ +const CONTAINER_NAME_PREFIX = 'codeman-case-'; + +/** Sensible resource defaults (all overridable per host). */ +export const DEFAULT_DOCKER_RESOURCES: DockerResourceLimits = { + memory: '4g', + cpus: '2', + pidsLimit: 512, + nofile: '4096:8192', +}; + +// ========== Storage (mirror of remote-hosts.ts) ========== + +export function dockerHostsPath(configDir: string): string { + return join(configDir, DOCKER_HOSTS_FILE); +} + +export function dockerCasesPath(configDir: string): string { + return join(configDir, DOCKER_CASES_FILE); +} + +async function readJsonArray(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +async function writeJsonArray(configDir: string, path: string, value: T[]): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await fs.writeFile(path, JSON.stringify(value, null, 2)); +} + +export async function readDockerHosts(configDir: string): Promise { + return readJsonArray(dockerHostsPath(configDir)); +} + +export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise { + await writeJsonArray(configDir, dockerHostsPath(configDir), hosts); +} + +export async function readDockerCases(configDir: string): Promise { + return readJsonArray(dockerCasesPath(configDir)); +} + +export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise { + await writeJsonArray(configDir, dockerCasesPath(configDir), cases); +} + +// ========== Naming / display / defaults ========== + +/** Per-case container name. Mirrors how remote derives a stable name from the case. */ +export function dockerContainerName(caseName: string): string { + return `${CONTAINER_NAME_PREFIX}${caseName}`; +} + +/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ +export function defaultDockerCommandForMode(mode: SessionMode): string { + const commands: Record = { + shell: 'exec bash -l', + // Mirror the LOCAL claude default so the in-container agent runs non-interactively. + claude: 'exec claude --dangerously-skip-permissions', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + }; + return commands[mode as DockerCommandMode] || commands.shell; +} + +/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */ +export function dockerDisplayPath( + docker: Pick | { container: string; path: string } +): string { + if ('containerName' in docker) return `${docker.containerName}:${docker.containerWorkdir}`; + return `${docker.container}:${docker.path}`; +} + +/** + * The host-callback gateway alias is ENGINE-SPECIFIC: Docker exposes the host as + * `host.docker.internal`, Podman as `host.containers.internal`. Both are added to + * the host-guard allowlist so a mixed fleet keeps working. + */ +export function hostGatewayAlias(engine: DockerEngine): string { + return engine === 'podman' ? 'host.containers.internal' : 'host.docker.internal'; +} + +/** + * Rewrite the server's own `CODEMAN_API_URL` to a container-reachable one by + * swapping ONLY the hostname for the engine's host-gateway alias, preserving + * scheme AND port (prod is HTTPS on 3000, so hardcoding http://…:3000 breaks + * every hook). Falls back to `https://:3000` when the input is absent or + * unparseable. + */ +export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string { + const alias = hostGatewayAlias(engine); + if (!processApiUrl) return `https://${alias}:3000`; + try { + const url = new URL(processApiUrl); + url.hostname = alias; + // origin drops any trailing path/slash and keeps scheme + (non-default) port + return url.origin; + } catch { + return `https://${alias}:3000`; + } +} + +/** + * Stable hash of the drift-relevant `docker create` inputs, stored on the + * container as the `codeman.confighash` label. On launch, a mismatch between the + * desired hash and the running container's label triggers the recreate-on-drift + * prompt (host config edits actually take effect). + */ +export function dockerConfigHash( + docker: Pick< + SessionDocker, + | 'engine' + | 'image' + | 'containerWorkdir' + | 'network' + | 'networkName' + | 'resources' + | 'gpus' + | 'mountCredentials' + | 'extraCreateArgs' + > +): string { + const normalized = JSON.stringify({ + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + networkName: docker.networkName ?? null, + resources: docker.resources ?? null, + gpus: docker.gpus ?? null, + mountCredentials: docker.mountCredentials, + extraCreateArgs: docker.extraCreateArgs ?? null, + }); + return createHash('sha256').update(normalized).digest('hex').slice(0, 12); +} + +/** + * Build the flattened per-session Docker metadata from a host profile + a case, + * resolving every default (mirror of toSessionRemote). The `configHash` is + * computed last over the resolved values. + */ +export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker { + const engine: DockerEngine = host.engine ?? 'docker'; + const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath; + const base: Omit = { + hostId: host.id, + label: host.label, + engine, + image: host.image || DEFAULT_AGENT_IMAGE, + containerName: dockerCase.container ?? dockerContainerName(dockerCase.name), + hostWorkspacePath: dockerCase.hostWorkspacePath, + containerWorkdir, + network: host.network ?? 'bridge', + networkName: host.networkName, + resources: host.resources ?? DEFAULT_DOCKER_RESOURCES, + gpus: host.gpus, + mountCredentials: host.mountCredentials ?? true, + hooksEnabled: host.hooksEnabled ?? true, + resumeOnStart: host.resumeOnStart ?? true, + daemonHost: host.daemonHost, + context: host.context, + commands: host.commands, + extraCreateArgs: host.extraCreateArgs, + extraExecArgs: host.extraExecArgs, + }; + return { ...base, configHash: dockerConfigHash(base) }; +} + +// ========== Shell escaping ========== + +/** + * POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote). + * Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value + * interpolated into the outer `bash -c "..."` launch layer is escaped through + * this so a path with spaces stays a single shell token. Operator-entered fields + * are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth. + */ +export function shellescape(str: string): string { + return "'" + str.replace(/'/g, "'\\''") + "'"; +} + +// ========== Pure command-arg builders ========== + +/** A resolved bind mount (source existence already checked by the caller). */ +export interface DockerMount { + src: string; + dst: string; + readonly?: boolean; +} + +/** + * Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager) + * resolves the environment-dependent bits (host uid, existing cred mounts, the + * derived api url, Desktop detection) so this builder stays pure and unit-testable. + */ +export interface DockerCreateContext { + docker: SessionDocker; + /** Codeman session id (only the first 8 chars are used, for the codeman.session label). */ + sessionId: string; + /** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */ + instance: string; + /** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */ + userArgs: string[]; + /** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */ + credentialMounts: DockerMount[]; + /** Extra bind mounts (e.g. the read-only hook-secret file). */ + extraMounts: DockerMount[]; + /** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */ + envCreate: Record; + /** Whether to add `--add-host :host-gateway` (skipped on Docker Desktop, where the alias is native). */ + addHostGateway: boolean; + /** Engine host-gateway alias (host.docker.internal / host.containers.internal). */ + gatewayAlias: string; +} + +/** + * Engine prefix tokens shared by every docker invocation (mirror of + * buildSshConnectionArgs). Returns e.g. ['docker'] or ['podman','--context','ctx']. + */ +export function buildDockerBaseArgs(docker: Pick): string[] { + const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) parts.push('--context', shellescape(docker.context)); + if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost)); + return parts; +} + +function mountSpec(m: DockerMount): string { + return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`; +} + +function resourceFlags(resources?: DockerResourceLimits): string[] { + if (!resources) return []; + const flags: string[] = []; + if (resources.memory) { + // memory-swap == memory disables swap, making --memory a REAL OOM cap. + flags.push('--memory', resources.memory, '--memory-swap', resources.memory); + } + if (resources.cpus) flags.push('--cpus', resources.cpus); + if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit)); + if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`); + if (resources.shmSize) flags.push('--shm-size', resources.shmSize); + return flags; +} + +function networkArg(network: DockerNetworkMode, networkName?: string): string { + if (network === 'custom' && networkName) return networkName; + return network; // 'bridge' | 'none' +} + +/** + * Build the `docker create` token list (from `create` through the `sleep + * infinity` CMD) for a long-lived, hardened, per-case container. PURE: every + * dynamic value is shellescaped; the caller joins with spaces into the launch + * string. Security invariants baked in: --cap-drop ALL, --security-opt + * no-new-privileges, --pids-limit, --memory==--memory-swap, --init, + * --pull=never, --restart no, NEVER --privileged, NEVER the docker socket. + */ +export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { + const { + docker, + sessionId, + instance, + userArgs, + credentialMounts, + extraMounts, + envCreate, + addHostGateway, + gatewayAlias, + } = ctx; + + const args: string[] = [ + 'create', + '--name', + shellescape(docker.containerName), + '--label', + 'codeman.managed=1', + '--label', + shellescape(`codeman.instance=${instance}`), + '--label', + shellescape(`codeman.session=${sessionId.slice(0, 8)}`), + '--label', + shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`), + '--pull=never', + '--init', + '--restart', + 'no', + ...userArgs, + '--workdir', + shellescape(docker.containerWorkdir), + // Workspace bind: mirror the host path inside the container so the transcript + // projHash correlates and file features read real host bytes. + '--mount', + shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })), + ...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]), + ...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]), + ]; + + if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`); + + args.push( + ...resourceFlags(docker.resources), + // GPU passthrough (needs the NVIDIA container toolkit on the host). No storage + // cap is set, so the container's writable layer + volumes grow elastically as + // data flows in (bounded only by host disk). + ...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []), + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--network', + networkArg(docker.network, docker.networkName) + ); + + for (const [key, value] of Object.entries(envCreate)) { + args.push('--env', shellescape(`${key}=${value}`)); + } + + // Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here. + for (const extra of docker.extraCreateArgs ?? []) { + args.push(shellescape(extra)); + } + + args.push(shellescape(docker.image), 'sleep', 'infinity'); + return args; +} + +// ========== Credential mount resolution (IO) ========== + +/** Host cred paths mapped to their in-container HOME location. */ +const CREDENTIAL_PATHS: Array<{ rel: string }> = [ + { rel: '.claude' }, + { rel: '.claude.json' }, + { rel: '.codex' }, + { rel: '.gemini' }, + { rel: '.config/gcloud' }, + { rel: '.config/opencode' }, +]; + +/** + * Resolve which host credential dirs/files EXIST and map them to their container + * HOME location. Only-existing avoids docker auto-creating root-owned empty dirs + * in the user's home. `~/.claude` also carries the transcripts (bind-mounted so + * host watchers + `--resume` see them) and is therefore mounted read-WRITE. + */ +export function resolveCredentialMounts(home: string = homedir()): DockerMount[] { + const mounts: DockerMount[] = []; + for (const { rel } of CREDENTIAL_PATHS) { + const src = join(home, rel); + if (existsSync(src)) { + mounts.push({ src, dst: `${CONTAINER_HOME}/${rel}` }); + } + } + return mounts; +} + +// ========== Daemon probes (IO; no-op under VITEST) ========== + +export interface DockerAvailability { + ok: boolean; + engine: DockerEngine; + rootless: boolean; + isDesktop: boolean; + cgroupV2: boolean; + /** Best-effort: are --memory/--cpus/--pids-limit actually enforced on this engine? */ + capsEnforced: boolean; + error?: string; +} + +const DOCKER_PROBE_TIMEOUT_MS = 15_000; + +interface DockerInfoJson { + ServerVersion?: string; + CgroupVersion?: string; + SecurityOptions?: string[]; + OperatingSystem?: string; + OSType?: string; + Name?: string; +} + +async function runDockerInfo(engine: DockerEngine): Promise { + try { + const { stdout } = await execFileAsync(engine, ['info', '--format', '{{json .}}'], { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }); + return JSON.parse(stdout) as DockerInfoJson; + } catch { + return null; + } +} + +function classifyDockerInfo(engine: DockerEngine, info: DockerInfoJson): DockerAvailability { + const security = info.SecurityOptions ?? []; + const rootless = security.some((opt) => opt.includes('rootless')); + const cgroupV2 = info.CgroupVersion === '2'; + const os = `${info.OperatingSystem ?? ''}`.toLowerCase(); + const isDesktop = os.includes('docker desktop') || os.includes('desktop'); + // Under rootless, resource caps are only reliably enforced with cgroup v2 + + // systemd delegation. We can't detect delegation from `docker info`, so we + // treat rootless+cgroupv2 as "likely enforced" and rootless+cgroupv1 as not. + const capsEnforced = !rootless || cgroupV2; + return { ok: true, engine, rootless, isDesktop, cgroupV2, capsEnforced }; +} + +/** + * Probe the container engine: server up, cgroup version, rootless, Desktop, and + * whether resource caps are enforceable. Auto-detects docker then podman when no + * engine is given. No-op canned value under VITEST. + */ +export async function checkDockerAvailable(engine?: DockerEngine): Promise { + if (IS_TEST_MODE) { + return { + ok: true, + engine: engine ?? 'docker', + rootless: false, + isDesktop: false, + cgroupV2: true, + capsEnforced: true, + }; + } + const candidates: DockerEngine[] = engine ? [engine] : ['docker', 'podman']; + for (const candidate of candidates) { + const info = await runDockerInfo(candidate); + if (info) return classifyDockerInfo(candidate, info); + } + return { + ok: false, + engine: engine ?? 'docker', + rootless: false, + isDesktop: false, + cgroupV2: false, + capsEnforced: false, + error: 'Docker/Podman not available. Install docker (or podman) and ensure the daemon is running.', + }; +} + +/** Is the base image present locally? (never triggers an auto-pull). */ +export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise { + if (IS_TEST_MODE) return true; + try { + await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }); + return true; + } catch { + return false; + } +} + +export interface DockerTmuxCheckResult { + ok: boolean; + tmuxPath?: string; + /** Distinguishes "image missing" (build it) from "tmux missing in image" (rebuild it). */ + imageMissing?: boolean; + error?: string; +} + +/** + * Verify the base image is present AND contains tmux (a HARD prerequisite: the + * in-container tmux is what makes reconnect durable). Never triggers a pull + * (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable. + */ +export async function checkDockerTmuxAvailable( + docker: Pick +): Promise { + if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' }; + const engine = docker.engine; + if (!(await checkDockerImagePresent(engine, docker.image))) { + return { + ok: false, + imageMissing: true, + error: `base image ${docker.image} not present: build it with 'node scripts/build-agent-image.mjs' (or pull it)`, + }; + } + try { + const { stdout } = await execFileAsync( + engine, + ['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const tmuxPath = stdout.trim(); + if (!tmuxPath) { + return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` }; + } + return { ok: true, tmuxPath }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` }; + } +} + +/** + * Resolve the host's IP on the default docker bridge (the address a container + * reaches as `host.docker.internal`), so the server can bind a hooks-only listener + * there and in-container hooks can call back. Defaults to the conventional + * 172.17.0.1 when the inspect fails but docker is up; null when docker is absent. + * No-op canned value under VITEST. + */ +export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise { + if (IS_TEST_MODE) return '172.17.0.1'; + const bin = engine === 'podman' ? 'podman' : 'docker'; + try { + const { stdout } = await execFileAsync( + bin, + ['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const ip = stdout.trim(); + return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1'; + } catch { + return null; // docker not available — nothing to bind + } +} + +/** + * Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs + * to THIS instance (by the `codeman.instance` label) but whose case is no longer + * in `docker-cases.json`. The instance scoping is what stops a beta from reaping + * prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort. + */ +export async function reapOrphanedDockerContainers( + configDir: string, + instance: string, + engine: DockerEngine = 'docker' +): Promise { + if (IS_TEST_MODE) return []; + const bin = engine === 'podman' ? 'podman' : 'docker'; + let rows: Array<{ name: string; inst: string }> = []; + try { + const { stdout } = await execFileAsync( + bin, + [ + 'ps', + '-a', + '--filter', + 'label=codeman.managed=1', + '--format', + '{{.Names}}\t{{index .Labels "codeman.instance"}}', + ], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + rows = stdout + .split('\n') + .filter(Boolean) + .map((line) => { + const [name, inst = ''] = line.split('\t'); + return { name, inst }; + }); + } catch { + return []; // daemon down / engine absent — nothing to reap + } + const cases = await readDockerCases(configDir); + const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name))); + const reaped: string[] = []; + for (const { name, inst } of rows) { + if (inst !== instance) continue; // only THIS instance's containers + if (expected.has(name)) continue; // still referenced by a live case + try { + await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS }); + reaped.push(name); + } catch { + /* best-effort */ + } + } + return reaped; +} + +/** + * Read the IN-CONTAINER Claude CLI version (`docker exec claude + * --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude + * would report the wrong version and disable trackpad wheel-forwarding, #154). + * Returns undefined on any failure. No-op under VITEST. + */ +export async function probeDockerCliVersion( + docker: Pick, + mode: SessionMode +): Promise { + if (IS_TEST_MODE) return undefined; + const bin = mode === 'shell' ? null : mode; + if (!bin) return undefined; + try { + const { stdout } = await execFileAsync(docker.engine, ['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 { + return undefined; + } +} diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 74982250..36c483d2 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -18,6 +18,7 @@ import type { EffortLevel, GeminiConfig, SessionRemote, + SessionDocker, } from './types.js'; /** @@ -36,6 +37,8 @@ export interface MuxSession { workingDir: string; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; /** Session mode */ mode: SessionMode; /** Whether webserver is attached to this session */ @@ -79,6 +82,8 @@ export interface CreateSessionOptions { historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; } /** Options for respawning a dead pane. */ @@ -103,6 +108,8 @@ export interface RespawnPaneOptions { historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ remote?: SessionRemote; + /** Docker execution metadata for local tmux sessions wrapping `docker exec` */ + docker?: SessionDocker; } /** Options for pane buffer capture (COD-47 full-history mode). */ diff --git a/src/session.ts b/src/session.ts index a509040a..02ce3e1b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -50,7 +50,9 @@ import { type EffortLevel, type GeminiConfig, type SessionRemote, + type SessionDocker, } from './types.js'; +import { probeDockerCliVersion } from './docker-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -178,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { const DEFAULT_PTY_COLS = 120; const DEFAULT_PTY_ROWS = 40; const TMUX_DISPLAY_TIMEOUT_MS = 2000; +/** Delay before the in-container Claude CLI version probe (lets the container start). */ +const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY @@ -212,8 +216,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }; } -export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string { - return remote ? '/tmp' : workingDir; +export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string { + // Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL + // wrapper pane never needs the workspace as its cwd, so launch it in /tmp. + return remote || docker ? '/tmp' : workingDir; } /** @@ -402,6 +408,10 @@ export class Session extends EventEmitter { // Remote execution metadata, present when this session runs over SSH through local tmux. private readonly _remote?: SessionRemote; + // Docker execution metadata, present when this session runs inside a container via + // local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions). + private readonly _docker?: SessionDocker; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -475,6 +485,8 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Remote execution metadata for sessions launched through SSH inside local tmux. */ remote?: SessionRemote; + /** Docker execution metadata for sessions launched inside a container via local tmux. */ + docker?: SessionDocker; } ) { super(); @@ -548,6 +560,7 @@ export class Session extends EventEmitter { } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; + this._docker = config.docker; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -649,6 +662,11 @@ export class Session extends EventEmitter { return this._claudeSessionId; } + /** Docker execution metadata when this session runs inside a container, else undefined. */ + get docker(): SessionDocker | undefined { + return this._docker; + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal @@ -1008,6 +1026,7 @@ export class Session extends EventEmitter { status: this._status, workingDir: this.workingDir, remote: this._remote, + docker: this._docker, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, @@ -1197,7 +1216,7 @@ export class Session extends EventEmitter { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, - cwd: resolveMuxAttachCwd(this.workingDir, this._remote), + cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), // COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports() // in tmux-manager.ts so the attach client and the tmux session agree. env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'), @@ -1317,7 +1336,7 @@ export class Session extends EventEmitter { // repaint/alt-screen mode; issue #154). Remote sessions run claude on // another host, so a local probe wouldn't reflect their version — skip them // and let the banner scrape handle those. Cached process-wide, best-effort. - if (this.mode === 'claude' && !this._remote && !this._cliVersion) { + if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { this._cliVersion = probedVersion; @@ -1330,6 +1349,31 @@ export class Session extends EventEmitter { } } + // Docker sessions run claude INSIDE the container, so the local probe above + // reports the HOST claude (wrong version, and leaving cliVersion undefined + // silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version + // instead — deferred so the container is up after the mux attach below. + if (this.mode === 'claude' && this._docker && !this._cliVersion) { + const dockerMeta = this._docker; + setTimeout(() => { + if (this._isStopped || this._cliVersion) return; + void probeDockerCliVersion(dockerMeta, this.mode) + .then((version) => { + if (!version || this._isStopped || this._cliVersion) return; + this._cliVersion = version; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + }) + .catch(() => { + /* best-effort */ + }); + }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { @@ -1350,6 +1394,7 @@ export class Session extends EventEmitter { effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, createSessionOptions: { sessionId: this.id, @@ -1368,6 +1413,7 @@ export class Session extends EventEmitter { effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, spawnErrLabel: 'mux attachment', }); @@ -1738,6 +1784,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, createSessionOptions: { sessionId: this.id, @@ -1748,6 +1795,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, spawnErrLabel: 'shell mux attachment', }); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0139d20d..b1914d50 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -29,7 +29,8 @@ const execAsync = promisify(exec); import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; -import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js'; +import { homedir } from 'node:os'; +import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js'; import { ProcessStats, PersistedRespawnConfig, @@ -43,9 +44,22 @@ import { type EffortLevel, type GeminiConfig, type SessionRemote, + type SessionDocker, + type DockerCommandMode, } from './types.js'; import { buildEffortCliArgs } from './session-cli-builder.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js'; +import { + buildDockerBaseArgs, + buildDockerCreateArgs, + containerApiUrl, + CONTAINER_HOME, + defaultDockerCommandForMode, + hostGatewayAlias, + resolveCredentialMounts, + type DockerCreateContext, + type DockerMount, +} from './docker-hosts.js'; import { wrapWithNice, SAFE_PATH_PATTERN, @@ -812,6 +826,226 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' '); } +// ========== Docker cases (COD-Docker) ========== +// +// The docker analog of the remote-SSH launch above. Instead of a local tmux pane +// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it +// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The +// container is per-CASE, so many sessions `docker exec` into the same one. See +// docs/docker-cases-plan.md. + +/** + * DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses + * `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name + * that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never + * adopts/resizes/respawns our session (same defence as the remote socket). + */ +const DOCKER_TMUX_SOCKET = 'codeman-docker'; + +/** + * Deterministic, reattach-stable in-container tmux session name. Derived from the + * same stable field the local muxName uses (first 8 chars of the sessionId), so a + * reconnect re-issues the exact same `new-session -A` and lands back in the SAME + * in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN. + */ +export function dockerTmuxSessionName(sessionId: string): string { + return `codeman-dkr-${sessionId.slice(0, 8)}`; +} + +/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */ +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. + */ +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': + return `${modeCommand} resume ${resumeId}`; + default: + return modeCommand; // shell / opencode: no resume + } +} + +/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */ +export interface DockerLaunchOptions { + mode: SessionMode; + docker: SessionDocker; + sessionId: string; + resumeSessionId?: string; + createContext: DockerCreateContext; + /** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */ + execEnv: Record; + /** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */ + execEnvNames: string[]; +} + +/** + * Build the ONE `bash -c` launch string for a docker session: image-check -> + * ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable + * in-container tmux (resume-aware). PURE and unit-testable. The escaping survives + * four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined + * command -> `docker exec … sh -lc ''` -> tmux `''`. + */ +export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { + const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames } = opts; + const base = buildDockerBaseArgs(docker).join(' '); + const createArgs = buildDockerCreateArgs(createContext).join(' '); + const name = shellescape(docker.containerName); + const workdir = shellescape(docker.containerWorkdir); + const image = shellescape(docker.image); + const dkrName = dockerTmuxSessionName(sessionId); + const sid = sessionId.slice(0, 8); + + let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); + 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}`; + + // `setenv -g` primes the session id so reattaches / newly-created panes inherit + // it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options + // are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared + // in-container tmux server's other sessions keep their own prefix/mouse. + const tmuxInvocation = [ + `tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`, + 'setenv -g CODEMAN_MUX 1', + `new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`, + `set -t ${dkrName} status off`, + `set -t ${dkrName} mouse off`, + `set -t ${dkrName} prefix C-q`, + 'set -s escape-time 0', + ].join(' \\; '); + + const execEnvFlags: string[] = []; + for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`)); + // NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so + // the secret never appears in argv (no `ps` leak) and is not committed. + for (const n of execEnvNames) execEnvFlags.push('--env', n); + for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra)); + + const imageMissingMsg = shellescape( + `Codeman: base image ${docker.image} not present (build: node scripts/build-agent-image.mjs)` + ); + const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); + + const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; + // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. + const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; + const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(tmuxInvocation)}`; + + return [imageCheck, ensure, start, execCmd].join(' ; '); +} + +/** + * Kill ONLY this session's in-container tmux session. The container is shared by + * the case's other sessions, so this NEVER `docker stop`s it — stopping/removing + * the container is an explicit teardown (buildDockerStopCommand) or case-delete + * (buildDockerRemoveCommand). Fired best-effort on session kill. + */ +export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string { + const { docker, sessionId } = options; + const base = buildDockerBaseArgs(docker).join(' '); + const dkrName = dockerTmuxSessionName(sessionId); + return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`; +} + +/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */ +export function buildDockerStopCommand(docker: SessionDocker): string { + return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`; +} + +/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */ +export function buildDockerRemoveCommand(docker: SessionDocker): string { + return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`; +} + +/** + * Resolve the environment-dependent bits of a docker launch (host uid, existing + * credential mounts, derived api url, hook-secret mount, Desktop detection) into + * the pure buildDockerLaunchCommand inputs. IO; only ever called from the real + * launch path (createSession/respawnPane no-op under VITEST). + */ +export function resolveDockerLaunchOptions( + mode: SessionMode, + docker: SessionDocker, + sessionId: string, + resumeSessionId?: string +): DockerLaunchOptions { + const home = homedir(); + const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal + const uid = typeof process.getuid === 'function' ? process.getuid() : 1000; + const userArgs: string[] = + docker.engine === 'podman' + ? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid + : isDesktop + ? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent) + : ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME) + const gatewayAlias = hostGatewayAlias(docker.engine); + + const credentialMounts: DockerMount[] = docker.mountCredentials ? resolveCredentialMounts(home) : []; + const extraMounts: DockerMount[] = []; + const envCreate: Record = { + HOME: CONTAINER_HOME, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + // Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-` + // is refused when that path pre-exists root-owned — which happens when the + // workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-). + // A nonexistent HOME subpath is created+owned by the running uid, so this is robust + // to any workspace location. Non-secret path, safe to be committed on export. + CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`, + }; + if (docker.hooksEnabled) { + // Derive a container-reachable API url (scheme + port preserved; host swapped + // for the engine gateway alias). Prod is HTTPS on 3000. + envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine); + const hookSecretPath = dataPath('hook-secret'); + if (existsSync(hookSecretPath)) { + const dst = `${CONTAINER_HOME}/.codeman/hook-secret`; + extraMounts.push({ src: hookSecretPath, dst, readonly: true }); + envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount + } + } + + const createContext: DockerCreateContext = { + docker, + sessionId, + instance: CODEMAN_INSTANCE, + userArgs, + credentialMounts, + extraMounts, + envCreate, + addHostGateway: !isDesktop, + gatewayAlias, + }; + + const execEnv: Record = { + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + CODEMAN_SESSION_ID: sessionId.slice(0, 8), + CODEMAN_MUX: '1', + }; + // NAME-ONLY exec env forwarded from Codeman's process env (the docker client + // inherits it), so API-key CLIs get their key without it appearing in argv. + const execEnvNames = + mode === 'codex' + ? ['OPENAI_API_KEY', 'CODEX_API_KEY'] + : mode === 'gemini' + ? ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] + : []; + + return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames }; +} + /** * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. @@ -1209,6 +1443,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, + docker, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1228,6 +1463,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, mode, attached: false, name, @@ -1273,7 +1509,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // Build the full command to run inside tmux const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = docker + ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) + : remote + ? buildRemoteLaunchCommand({ mode, remote, sessionId }) + : localFullCmd; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -1324,7 +1564,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Replace the shell with the actual command (no echo in terminal). Keep // pane launch in /tmp, then cd inside bash against the current mount table. - const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; execSync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { @@ -1399,6 +1639,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, mode, attached: false, name, @@ -1484,6 +1725,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, + docker, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -1521,7 +1763,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; - const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; + const fullCmd = docker + ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId)) + : remote + ? buildRemoteLaunchCommand({ mode, remote, sessionId }) + : localFullCmd; try { // For OpenCode: set sensitive env vars via tmux setenv before respawn @@ -1539,7 +1785,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.applyEnvOverrides(muxName, envOverrides); // -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state). - const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; await execAsync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { @@ -1725,6 +1971,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + // Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill + // ONLY this session's in-container tmux session (best-effort). The container is + // PER-CASE and shared by the case's other sessions, so we deliberately do NOT + // `docker stop` it here — stopping/removing is an explicit teardown/case-delete. + if (session.docker && !IS_TEST_MODE) { + try { + exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {}); + } catch { + // Best-effort — never affects the local kill result. + } + } + // Strategy 4: Direct kill by PID as final fallback if (this.isProcessAlive(currentPid)) { try { diff --git a/src/types/api.ts b/src/types/api.ts index b7201022..d5a4578a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -124,7 +124,7 @@ export interface CaseInfo { /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; /** Case storage/execution location */ - location?: 'local' | 'linked-local' | 'remote'; + location?: 'local' | 'linked-local' | 'remote' | 'docker'; /** Whether this is a linked local folder */ linked?: boolean; /** Remote case metadata for display and session creation */ @@ -134,6 +134,14 @@ export interface CaseInfo { username: string; path: string; }; + /** Docker case metadata for display and session creation */ + docker?: { + hostId: string; + container: string; + image?: string; + path: string; + network?: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/types/session.ts b/src/types/session.ts index f74d711d..0744beab 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -98,6 +98,122 @@ export interface SessionRemote extends RemoteSshOptions { commands?: Partial>; } +// ========== Docker cases (COD-Docker) ========== +// +// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact +// analog of the remote-SSH feature above: instead of a local tmux pane running +// `ssh host` into a durable remote tmux server, a local tmux pane runs +// `docker exec -it` into a durable in-container tmux server. The container is +// scoped to the CASE (not the session), so multiple sessions can `docker exec` +// into the same long-lived container. See `docs/docker-cases-plan.md`. + +/** Which CLI backends a Docker case can run (same set as remote). */ +export type DockerCommandMode = Extract; + +/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ +export type DockerEngine = 'docker' | 'podman'; + +/** + * Container network mode. `host` and any inbound `-p` publish are deliberately + * unrepresentable (never in this union, never emitted by the flag builder). + * - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress) + * - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`) + * - `custom`: a user-defined bridge `codeman-net-` (future egress-allowlist chokepoint) + */ +export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; + +/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */ +export interface DockerResourceLimits { + /** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */ + memory?: string; + /** e.g. '2' -> --cpus 2 */ + cpus?: string; + /** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */ + pidsLimit?: number; + /** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */ + nofile?: string; + /** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */ + shmSize?: string; +} + +/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */ +export interface DockerHost { + id: string; + label: string; + /** Engine; when absent the availability probe resolves it (docker, else podman). */ + engine?: DockerEngine; + /** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */ + image: string; + /** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */ + daemonHost?: string; + /** Advanced: docker `--context` name. */ + context?: string; + /** Network mode (default 'bridge'). */ + network?: DockerNetworkMode; + /** Custom bridge name when network === 'custom'. */ + networkName?: string; + resources?: DockerResourceLimits; + /** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus ` (needs the NVIDIA container toolkit). */ + gpus?: string; + /** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */ + mountCredentials?: boolean; + /** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */ + hooksEnabled?: boolean; + /** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */ + resumeOnStart?: boolean; + /** Per-mode command overrides (mirror RemoteHost.commands). */ + commands?: Partial>; + /** Escape hatch: extra `docker create` args (validated like extraSshOptions). */ + extraCreateArgs?: string[]; + /** Escape hatch: extra `docker exec` args. */ + extraExecArgs?: string[]; +} + +/** A case linked to a Docker container (mirror of RemoteCase). */ +export interface DockerCase { + name: string; + type: 'docker'; + hostId: string; + /** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */ + hostWorkspacePath: string; + /** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */ + containerWorkdir?: string; + /** Container name (default codeman-case-). */ + container?: string; + /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ + lastClaudeSessionId?: string; +} + +/** + * Flattened Docker execution metadata carried on a live session (mirror of + * SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json. + */ +export interface SessionDocker { + hostId: string; + label: string; + engine: DockerEngine; + image: string; + /** Per-CASE container name (shared by all sessions of the case). */ + containerName: string; + hostWorkspacePath: string; + containerWorkdir: string; + network: DockerNetworkMode; + networkName?: string; + resources?: DockerResourceLimits; + /** GPU allocation ('all' / '1' / 'device=0,1'). */ + gpus?: string; + mountCredentials: boolean; + hooksEnabled: boolean; + resumeOnStart: boolean; + daemonHost?: string; + context?: string; + commands?: Partial>; + extraCreateArgs?: string[]; + extraExecArgs?: string[]; + /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ + configHash?: string; +} + /** * Valid Claude CLI effort levels (claude >= 2.1.154). * `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a @@ -217,6 +333,8 @@ export interface SessionState { workingDir: string; /** Remote execution metadata, present when this session runs over SSH through local tmux */ remote?: SessionRemote; + /** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */ + docker?: SessionDocker; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/src/web/network-auth-policy.ts b/src/web/network-auth-policy.ts index a7e73423..3fb04974 100644 --- a/src/web/network-auth-policy.ts +++ b/src/web/network-auth-policy.ts @@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean { */ export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com']; +/** + * Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a + * docker case carries `Host: host.docker.internal:` (the derived + * CODEMAN_API_URL), so the always-on host guard must allow it or every in-container + * hook is blocked 403. These names only resolve to the host from within a + * container's network namespace, so they are not a DNS-rebinding surface for a + * normal browser. Both engines' aliases are allowed so a mixed fleet keeps working. + */ +export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal']; + /** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */ export interface HostPolicy { /** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */ @@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean { const bind = parseAuthorityHostname(policy.bindHost); if (bind && hostname === bind) return true; if (policy.tunnelHost && hostname === policy.tunnelHost) return true; + // Docker/Podman container-to-host gateway aliases (for in-container hook curls). + if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true; for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) { if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true; } diff --git a/src/web/public/app.js b/src/web/public/app.js index c27c7f48..8036f532 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1433,6 +1433,25 @@ class CodemanApp { for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) { addListener(event, () => this._onSessionListMaybeChanged()); } + + // Docker export/import: toast + refresh the Manage-tab exports list on completion. + addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success'); + this.refreshDockerExports?.(); + } catch (err) { + console.error('[SSE] docker export complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error'); + } catch (err) { + console.error('[SSE] docker export failed:', err); + } + }); } // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1751bfe3..4924c31e 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -474,6 +474,9 @@ const SSE_EVENTS = { CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', CASE_ORDER_CHANGED: 'case:order-changed', + DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', + DOCKER_EXPORT_FAILED: 'docker:exportFailed', + DOCKER_IMPORT_COMPLETE: 'docker:importComplete', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index 362e9a07..2a33cf09 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -118,8 +118,8 @@ - - + + + v0.0.0 @@ -1282,6 +1282,27 @@ +
+ Session Manager Button + +
+
+ Away Digest Button + +
+
+ Cron Button + +
Redraw Terminal Button
+
+ + One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (node scripts/build-agent-image.mjs). +
+
+ +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 14b30693..098618b3 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -45,7 +45,9 @@ Object.assign(CodemanApp.prototype, { // ═══════════════════════════════════════════════════════════════ formatCasePickerLabel(c) { - return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || ''; + if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`; + if (c?.location === 'docker' && c.docker?.container) return `${c.name} @ ${c.docker.container}`; + return c?.name || ''; }, buildCasePickerOptions(cases = []) { @@ -70,7 +72,10 @@ Object.assign(CodemanApp.prototype, { c.location, c.remote?.hostId, c.remote?.label, - c.remote?.path + c.remote?.path, + c.docker?.container, + c.docker?.image, + c.docker?.path ].filter(Boolean).join(' ').toLowerCase(); return { name: c.name, label, case: c, searchText }; }) @@ -517,7 +522,7 @@ Object.assign(CodemanApp.prototype, { // Remote cases run over ssh — POST /api/sessions stat-validates workingDir on // the LOCAL fs (a remote user@host:/path never exists locally), so route them // through /api/quick-start, which resolves the remote case + launches via ssh. - if (caseData.location === 'remote') { + if (caseData.location === 'remote' || caseData.location === 'docker') { const remoteIds = []; for (let i = 0; i < tabCount; i++) { const res = await fetch('/api/quick-start', { @@ -694,12 +699,16 @@ Object.assign(CodemanApp.prototype, { } const selectedCase = (this.cases || []).find(c => c.name === caseName); - const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote'; + const isRemoteCase = + caseData.location === 'remote' || + caseData.location === 'docker' || + selectedCase?.location === 'remote' || + selectedCase?.location === 'docker'; const workingDir = caseData.path; if (!workingDir) throw new Error('Case path not found'); // Remote cases run over ssh — route through /api/quick-start (see runClaude). - if (caseData.location === 'remote') { + if (caseData.location === 'remote' || caseData.location === 'docker') { const remoteIds = []; for (let i = 0; i < shellCount; i++) { const res = await fetch('/api/quick-start', { @@ -789,7 +798,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run the CLI on the REMOTE host — the local /api/opencode/status // probe and the local-only config/env below don't apply (quick-start rejects them). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); @@ -843,7 +853,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run Codex on the REMOTE host — skip the local status probe and the // local-only config/env below (quick-start rejects them for remote cases). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`); @@ -897,7 +908,8 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; // Remote cases run Gemini on the REMOTE host — skip the local status probe and the // local-only config/env below (quick-start rejects them for remote cases). - const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote'; + const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`); @@ -1567,10 +1579,17 @@ Object.assign(CodemanApp.prototype, { if (tabName === 'case-manage') { submitBtn.style.display = 'none'; this.renderCaseManageList(); + this.refreshDockerExports(); } else { submitBtn.style.display = ''; submitBtn.textContent = - tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link'; + tabName === 'case-create' + ? 'Create' + : tabName === 'case-remote' + ? 'Link Remote' + : tabName === 'case-docker' + ? 'Link Docker' + : 'Link'; } // Focus appropriate input if (tabName === 'case-create') { @@ -1579,6 +1598,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('linkCaseName').focus(); } else if (tabName === 'case-remote') { document.getElementById('remoteCaseName').focus(); + } else if (tabName === 'case-docker') { + document.getElementById('dockerCaseName').focus(); } }, @@ -1596,6 +1617,8 @@ Object.assign(CodemanApp.prototype, { await this.createCase(); } else if (this.caseModalTab === 'case-remote') { await this.linkRemoteCase(); + } else if (this.caseModalTab === 'case-docker') { + await this.linkDockerCase(); } else { await this.linkCase(); } @@ -1619,21 +1642,36 @@ Object.assign(CodemanApp.prototype, { return; } + // One-click "Run in Docker": create the case folder AND a container, then start + // a session inside it. Optional expandable settings override the defaults. + const inDocker = document.getElementById('newCaseDocker')?.checked; + const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases'; + const payload = inDocker + ? { name, description, ...this._collectDockerQuickSettings() } + : { name, description }; + try { - const res = await fetch('/api/cases', { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) + body: JSON.stringify(payload) }); const data = await res.json(); if (data.success) { this.closeCreateCaseModal(); - this.showToast(`Case "${name}" created`, 'success'); // Reload cases and select the new one await this.loadQuickStartCases(name); // Save as last used case await this.saveLastUsedCase(name); + if (inDocker) { + const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : ''; + this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success'); + // Start a session INSIDE the container (routes through quick-start). + await this.runClaude(); + } else { + this.showToast(`Case "${name}" created`, 'success'); + } } else { this.showToast(data.error || 'Failed to create case', 'error'); } @@ -1643,6 +1681,54 @@ Object.assign(CodemanApp.prototype, { } }, + // Show/hide the expandable container-settings section under the Docker checkbox. + toggleDockerQuickSettings() { + const on = document.getElementById('newCaseDocker')?.checked; + const el = document.getElementById('dockerQuickSettings'); + if (el) el.style.display = on ? '' : 'none'; + }, + + // Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so + // the server uses its defaults (no per-case host); `custom` leaves them editable. + applyDockerTemplate() { + const t = document.getElementById('quickDockerTemplate')?.value; + const presets = { + small: { m: '2g', c: '1', g: '' }, + medium: { m: '', c: '', g: '' }, + large: { m: '8g', c: '4', g: '' }, + gpu: { m: '8g', c: '4', g: 'all' }, + }; + const p = presets[t]; + if (!p) return; // 'custom' — leave fields as-is + const set = (id, v) => { + const el = document.getElementById(id); + if (el) el.value = v; + }; + set('quickDockerMemory', p.m); + set('quickDockerCpus', p.c); + set('quickDockerGpus', p.g); + }, + + // Collect only the non-default docker overrides (empty fields fall back to defaults + // server-side; sent as undefined, never null, per the Zod .optional() gotcha). + _collectDockerQuickSettings() { + const val = (id) => (document.getElementById(id)?.value || '').trim(); + const o = {}; + const mem = val('quickDockerMemory'); + if (mem) o.memory = mem; + const cpus = val('quickDockerCpus'); + if (cpus) o.cpus = cpus; + const gpus = val('quickDockerGpus'); + if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus; + const net = document.getElementById('quickDockerNetwork')?.value; + if (net && net !== 'bridge') o.network = net; + const img = val('quickDockerImage'); + if (img) o.image = img; + const mc = document.getElementById('quickDockerMountCreds'); + if (mc && !mc.checked) o.mountCredentials = false; + return o; + }, + async linkCase() { const name = document.getElementById('linkCaseName').value.trim(); const path = document.getElementById('linkCasePath').value.trim(); @@ -1767,6 +1853,177 @@ Object.assign(CodemanApp.prototype, { } }, + async linkDockerCase() { + const name = document.getElementById('dockerCaseName').value.trim(); + const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); + const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; + const network = document.getElementById('dockerNetwork').value; + const memory = document.getElementById('dockerMemory').value.trim(); + const cpus = document.getElementById('dockerCpus').value.trim(); + const mountCredentials = document.getElementById('dockerMountCredentials').checked; + const resumeOnStart = document.getElementById('dockerResumeOnStart').checked; + const statusEl = document.getElementById('dockerLinkStatus'); + + if (!name || !hostWorkspacePath) { + this.showToast('Please enter a case name and workspace path', 'error'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) { + this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error'); + return; + } + if (!hostWorkspacePath.startsWith('/')) { + this.showToast('Workspace path must be absolute', 'error'); + return; + } + + try { + if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) + const resources = {}; + if (memory) resources.memory = memory; + if (cpus) resources.cpus = cpus; + const hostPayload = { + id: hostId, + label: hostId, + image, + network, + mountCredentials, + resumeOnStart, + ...(Object.keys(resources).length ? { resources } : {}), + }; + // PUT (update-or-create) so re-linking with the same host id refreshes its settings. + let hostRes = await fetch('/api/docker-hosts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hostPayload), + }); + let hostData = await hostRes.json(); + if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') { + hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hostPayload), + }); + hostData = await hostRes.json(); + } + if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host'); + + const caseRes = await fetch('/api/cases/docker-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, hostId, hostWorkspacePath }), + }); + const caseData = await caseRes.json(); + if (caseData.success) { + this.closeCreateCaseModal(); + const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; + this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + await this.loadQuickStartCases(name); + await this.saveLastUsedCase(name); + } else { + if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case'; + this.showToast(caseData.error || 'Failed to link docker case', 'error'); + } + } catch (err) { + console.error('Failed to link docker case:', err); + if (statusEl) statusEl.textContent = err.message; + this.showToast('Failed to link docker case: ' + err.message, 'error'); + } + }, + + // ═══════════════════════════════════════════════════════════════ + // Docker export / import UI + // ═══════════════════════════════════════════════════════════════ + + async refreshDockerExports() { + const listEl = document.getElementById('dockerExportsList'); + if (!listEl) return; + try { + const res = await fetch('/api/docker-exports'); + const data = await res.json(); + const exports = data?.data?.exports || []; + if (exports.length === 0) { + listEl.innerHTML = 'No exports yet. Export a docker case from its tab.'; + return; + } + listEl.innerHTML = exports + .map(e => { + const mb = (e.sizeBytes / 1e6).toFixed(1); + const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name; + return `
+ ${nm} (${mb} MB) + + Download + + + +
`; + }) + .join(''); + } catch (err) { + listEl.innerHTML = `Failed to load exports: ${err.message}`; + } + }, + + async exportDockerCaseBundle(caseName, mode = 'full') { + try { + const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); + const data = await res.json(); + if (data.success) { + this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info'); + } else { + this.showToast(data.error || 'Export failed', 'error'); + } + } catch (err) { + this.showToast('Export failed: ' + err.message, 'error'); + } + }, + + async importDockerBundle(bundle) { + const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported'); + if (!newCaseName) return; + const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', ''); + if (!destWorkspacePath) return; + try { + const res = await fetch('/api/docker-cases/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }), + }); + const data = await res.json(); + if (data.success) { + this.showToast(`Imported as "${newCaseName}"`, 'success'); + await this.loadQuickStartCases(newCaseName); + } else { + this.showToast(data.error || 'Import failed', 'error'); + } + } catch (err) { + this.showToast('Import failed: ' + err.message, 'error'); + } + }, + + async deleteDockerExport(filename) { + if (!confirm(`Delete export bundle "${filename}"?`)) return; + try { + const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast('Export deleted', 'success'); + this.refreshDockerExports(); + } else { + this.showToast(data.error || 'Delete failed', 'error'); + } + } catch (err) { + this.showToast('Delete failed: ' + err.message, 'error'); + } + }, + // ═══════════════════════════════════════════════════════════════ // Case Management (reorder + delete) // ═══════════════════════════════════════════════════════════════ @@ -1791,6 +2048,12 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(pathDisplay)}
+ ${ + c.location === 'docker' + ? `` + : '' + }