diff --git a/docs/docker-cases-plan.md b/docs/docker-cases-plan.md new file mode 100644 index 00000000..871e1a5f --- /dev/null +++ b/docs/docker-cases-plan.md @@ -0,0 +1,415 @@ + + +# 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. + +## 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/src/docker-hosts.ts b/src/docker-hosts.ts new file mode 100644 index 00000000..517f22ef --- /dev/null +++ b/src/docker-hosts.ts @@ -0,0 +1,570 @@ +/** + * @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' + | '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, + 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, + 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), + '--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}` }; + } +} + +/** + * 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/tmux-manager.ts b/src/tmux-manager.ts index 0139d20d..9c29b952 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,220 @@ 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', + }; + 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 +1437,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 +1457,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, mode, attached: false, name, @@ -1273,7 +1503,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 +1558,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 +1633,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { createdAt: Date.now(), workingDir, remote, + docker, mode, attached: false, name, @@ -1484,6 +1719,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 +1757,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 +1779,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 +1965,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/session.ts b/src/types/session.ts index f74d711d..3aa92144 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -98,6 +98,118 @@ 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; + /** 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; + 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 +329,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/schemas.ts b/src/web/schemas.ts index 91485a45..dead6075 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -359,6 +359,112 @@ export const RemoteCaseLinkSchema = z.object({ .regex(NO_SHELL_META, 'Invalid characters in remote path'), }); +// ========== Docker cases ========== +// +// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md), +// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`, +// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch +// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the +// double-quote layer) exactly like remotePath/identityFile. `--privileged` and +// any docker-socket mount are structurally unrepresentable (never accepted). + +const DockerResourceLimitsSchema = z + .object({ + memory: z + .string() + .regex(/^\d+[bkmg]?$/i, 'Memory must be like 512m / 4g') + .optional(), + cpus: z + .string() + .regex(/^\d+(\.\d+)?$/, 'CPUs must be a number') + .optional(), + pidsLimit: z.number().int().positive().max(100000).optional(), + nofile: z + .string() + .regex(/^\d+:\d+$/, 'nofile must be soft:hard') + .optional(), + shmSize: z + .string() + .regex(/^\d+[bkmg]?$/i, 'shm-size must be like 256m') + .optional(), + }) + .strict(); + +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 reference') + .regex(NO_SHELL_META, 'Invalid characters in image reference'), + daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(), + context: z + .string() + .max(128) + .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid docker context') + .optional(), + network: z.enum(['bridge', 'none', 'custom']).optional(), + networkName: z + .string() + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid network name') + .optional(), + resources: DockerResourceLimitsSchema.optional(), + mountCredentials: z.boolean().optional(), + hooksEnabled: z.boolean().optional(), + resumeOnStart: z.boolean().optional(), + commands: RemoteCommandOverridesSchema, // same shell/claude/opencode/codex/gemini shape + extraCreateArgs: z + .array( + z + .string() + .min(1) + .max(1024) + .regex(NO_SHELL_INJECTION, 'Invalid characters in create arg') + .refine(noCommandSubstitution, 'Invalid characters in create arg') + ) + .max(32) + .optional(), + extraExecArgs: z + .array( + z + .string() + .min(1) + .max(1024) + .regex(NO_SHELL_INJECTION, 'Invalid characters in exec arg') + .refine(noCommandSubstitution, 'Invalid characters in exec arg') + ) + .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(/^\//, 'Workspace path must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in workspace path'), + containerWorkdir: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Container workdir must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in container workdir') + .optional(), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name') + .optional(), +}); + // ========== Quick Start ========== /** diff --git a/test/docker-exec-options.test.ts b/test/docker-exec-options.test.ts new file mode 100644 index 00000000..7bbbd42d --- /dev/null +++ b/test/docker-exec-options.test.ts @@ -0,0 +1,159 @@ +/** + * Unit tests for the docker launch/kill command builders in tmux-manager.ts + * (mirror of test/remote-ssh-options.test.ts). Pure string assertions: the + * escaping must survive bash -c -> docker exec -> sh -lc -> tmux. + */ +import { describe, it, expect } from 'vitest'; +import { + buildDockerLaunchCommand, + buildDockerKillCommand, + buildDockerStopCommand, + buildDockerRemoveCommand, + dockerTmuxSessionName, + type DockerLaunchOptions, +} from '../src/tmux-manager.js'; +import { DEFAULT_AGENT_IMAGE, toSessionDocker, type DockerCreateContext } from '../src/docker-hosts.js'; +import type { DockerCase, DockerHost, SessionMode } from '../src/types.js'; + +// The exact adopt-guard the in-container Codeman would use to discover its own sessions. +const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/; + +const HOST: DockerHost = { id: 'local', label: 'Local', image: DEFAULT_AGENT_IMAGE }; +const CASE: DockerCase = { + name: 'myproj', + type: 'docker', + hostId: 'local', + hostWorkspacePath: '/home/arkon/cases/myproj', +}; + +function launchOpts(overrides: Partial = {}): DockerLaunchOptions { + const docker = overrides.docker ?? toSessionDocker(HOST, CASE); + const createContext: DockerCreateContext = { + docker, + sessionId: '1a2b3c4d5e6f', + instance: '', + userArgs: ['--user', '1000:0'], + credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }], + extraMounts: [], + envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' }, + addHostGateway: true, + gatewayAlias: 'host.docker.internal', + }; + return { + mode: 'claude', + docker, + sessionId: '1a2b3c4d5e6f', + createContext, + execEnv: { TERM: 'xterm-256color', CODEMAN_SESSION_ID: '1a2b3c4d', CODEMAN_MUX: '1' }, + execEnvNames: [], + ...overrides, + }; +} + +describe('dockerTmuxSessionName', () => { + it('is stable from the first 8 chars of the sessionId', () => { + expect(dockerTmuxSessionName('1a2b3c4d5e6f')).toBe('codeman-dkr-1a2b3c4d'); + }); + it('deliberately FAILS the in-container adopt guard', () => { + // 'k'/'r' are not hex, so an in-container Codeman never adopts our session + expect(SAFE_MUX_NAME_PATTERN.test(dockerTmuxSessionName('1a2b3c4d5e6f'))).toBe(false); + }); +}); + +describe('buildDockerLaunchCommand', () => { + it('image-check precedes ensure precedes start precedes exec', () => { + const cmd = buildDockerLaunchCommand(launchOpts()); + const iImage = cmd.indexOf('docker image inspect'); + const iEnsure = cmd.indexOf('docker inspect'); + const iStart = cmd.indexOf('docker start'); + const iExec = cmd.indexOf('exec docker exec -it'); + expect(iImage).toBeGreaterThanOrEqual(0); + expect(iImage).toBeLessThan(iEnsure); + expect(iEnsure).toBeLessThan(iStart); + expect(iStart).toBeLessThan(iExec); + }); + + it('ensures the container idempotently (inspect-or-create) with --pull=never', () => { + const cmd = buildDockerLaunchCommand(launchOpts()); + expect(cmd).toContain("docker inspect 'codeman-case-myproj' >/dev/null 2>&1 || docker create"); + expect(cmd).toContain('--pull=never'); + expect(cmd).toContain("docker start 'codeman-case-myproj'"); + }); + + it('execs a TTY into the durable in-container tmux', () => { + const cmd = buildDockerLaunchCommand(launchOpts()); + expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'"); + expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID'); + expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d'); + expect(cmd).toContain("sh -lc '"); + }); + + it('injects the resume flag ONLY when a resume id is passed', () => { + const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' })); + expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def'); + const without = buildDockerLaunchCommand(launchOpts()); + expect(without).not.toContain('--resume'); + }); + + it('uses codex resume syntax and drops an unsafe resume id', () => { + const codex = buildDockerLaunchCommand( + launchOpts({ mode: 'codex' as SessionMode, resumeSessionId: '01H-codex-id' }) + ); + expect(codex).toContain('exec codex resume 01H-codex-id'); + const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' })); + expect(unsafe).not.toContain('--resume'); + expect(unsafe).not.toContain('rm -rf'); + }); + + it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => { + const codex = buildDockerLaunchCommand( + launchOpts({ mode: 'codex' as SessionMode, execEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'] }) + ); + expect(codex).toContain('--env OPENAI_API_KEY'); + expect(codex).not.toMatch(/--env OPENAI_API_KEY=/); // never a value + }); + + it('primes CODEMAN_SESSION_ID / CODEMAN_MUX at exec time', () => { + const cmd = buildDockerLaunchCommand(launchOpts()); + expect(cmd).toContain("--env 'CODEMAN_SESSION_ID=1a2b3c4d'"); + expect(cmd).toContain("--env 'CODEMAN_MUX=1'"); + }); + + it('keeps a workspace path with spaces a single token through every layer', () => { + const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' }); + const cmd = buildDockerLaunchCommand(launchOpts({ docker })); + // workdir single-quoted at the docker exec layer + expect(cmd).toContain("--workdir '/home/arkon/my cases/proj'"); + // and the cd inside the (nested-escaped) paneCommand still references the spaced path + expect(cmd).toContain('/home/arkon/my cases/proj'); + }); + + it('honors a per-host command override', () => { + const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } }; + const cmd = buildDockerLaunchCommand(launchOpts({ docker })); + expect(cmd).toContain('exec claude --model opus'); + }); +}); + +describe('buildDockerKillCommand (multi-session safe)', () => { + it('kills ONLY this session in-container tmux, never the shared container', () => { + const docker = toSessionDocker(HOST, CASE); + const cmd = buildDockerKillCommand({ docker, sessionId: '1a2b3c4d5e6f' }); + expect(cmd).toBe("docker exec 'codeman-case-myproj' tmux -L codeman-docker kill-session -t 'codeman-dkr-1a2b3c4d'"); + expect(cmd).not.toContain('docker stop'); + expect(cmd).not.toContain('docker rm'); + }); +}); + +describe('explicit teardown commands', () => { + it('stop and remove target the whole container', () => { + const docker = toSessionDocker(HOST, CASE); + expect(buildDockerStopCommand(docker)).toBe("docker stop -t 10 'codeman-case-myproj'"); + expect(buildDockerRemoveCommand(docker)).toBe("docker rm -f 'codeman-case-myproj'"); + }); + + it('uses the podman engine prefix when configured', () => { + const docker = toSessionDocker({ ...HOST, engine: 'podman' }, CASE); + expect(buildDockerStopCommand(docker)).toBe("podman stop -t 10 'codeman-case-myproj'"); + }); +}); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts new file mode 100644 index 00000000..cb8e8d0c --- /dev/null +++ b/test/docker-hosts.test.ts @@ -0,0 +1,280 @@ +/** + * Unit tests for the Docker cases storage + pure command-arg builders + probes + * (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops + * under VITEST, so probes return canned values and never spawn a real daemon. + */ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + buildDockerBaseArgs, + buildDockerCreateArgs, + checkDockerAvailable, + checkDockerImagePresent, + checkDockerTmuxAvailable, + containerApiUrl, + DEFAULT_AGENT_IMAGE, + DEFAULT_DOCKER_RESOURCES, + dockerConfigHash, + dockerContainerName, + dockerDisplayPath, + defaultDockerCommandForMode, + hostGatewayAlias, + probeDockerCliVersion, + readDockerCases, + readDockerHosts, + resolveCredentialMounts, + toSessionDocker, + writeDockerCases, + writeDockerHosts, + type DockerCreateContext, +} from '../src/docker-hosts.js'; +import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js'; + +const HOST: DockerHost = { id: 'local', label: 'Local Docker', image: DEFAULT_AGENT_IMAGE }; +const CASE: DockerCase = { + name: 'myproj', + type: 'docker', + hostId: 'local', + hostWorkspacePath: '/home/arkon/cases/myproj', +}; + +describe('docker-hosts storage', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'codeman-docker-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('round-trips hosts and cases through JSON storage', async () => { + await writeDockerHosts(dir, [HOST]); + await writeDockerCases(dir, [{ ...CASE, lastClaudeSessionId: 'abc-123' }]); + expect(await readDockerHosts(dir)).toEqual([HOST]); + const cases = await readDockerCases(dir); + expect(cases[0].lastClaudeSessionId).toBe('abc-123'); + }); + + it('returns [] for a missing file', async () => { + expect(await readDockerHosts(dir)).toEqual([]); + expect(await readDockerCases(dir)).toEqual([]); + }); +}); + +describe('naming / display / defaults', () => { + it('derives a valid per-case container name', () => { + expect(dockerContainerName('myproj')).toBe('codeman-case-myproj'); + // valid docker name charset: starts alnum, then [a-zA-Z0-9_.-] + expect(dockerContainerName('my_proj-2')).toMatch(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/); + }); + + it('maps each mode to a default pane command', () => { + expect(defaultDockerCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions'); + expect(defaultDockerCommandForMode('shell')).toBe('exec bash -l'); + expect(defaultDockerCommandForMode('codex')).toBe('exec codex'); + expect(defaultDockerCommandForMode('gemini')).toBe('exec gemini'); + }); + + it('formats a container:workdir display path from both shapes', () => { + expect(dockerDisplayPath({ container: 'codeman-case-x', path: '/w' })).toBe('codeman-case-x:/w'); + const sd = toSessionDocker(HOST, CASE); + expect(dockerDisplayPath(sd)).toBe('codeman-case-myproj:/home/arkon/cases/myproj'); + }); +}); + +describe('hostGatewayAlias / containerApiUrl', () => { + it('returns the engine-specific gateway alias', () => { + expect(hostGatewayAlias('docker')).toBe('host.docker.internal'); + expect(hostGatewayAlias('podman')).toBe('host.containers.internal'); + }); + + it('swaps only the hostname, preserving scheme and port', () => { + expect(containerApiUrl('https://127.0.0.1:3000', 'docker')).toBe('https://host.docker.internal:3000'); + expect(containerApiUrl('http://127.0.0.1:3000', 'docker')).toBe('http://host.docker.internal:3000'); + expect(containerApiUrl('https://127.0.0.1:8443', 'docker')).toBe('https://host.docker.internal:8443'); + expect(containerApiUrl('https://127.0.0.1:3000', 'podman')).toBe('https://host.containers.internal:3000'); + }); + + it('falls back to https://:3000 for absent or unparseable input', () => { + expect(containerApiUrl(undefined, 'docker')).toBe('https://host.docker.internal:3000'); + expect(containerApiUrl('not a url', 'podman')).toBe('https://host.containers.internal:3000'); + }); +}); + +describe('toSessionDocker / dockerConfigHash', () => { + it('resolves every default (convenient, bridge, resume-on-start)', () => { + const sd = toSessionDocker(HOST, CASE); + expect(sd.engine).toBe('docker'); + expect(sd.image).toBe(DEFAULT_AGENT_IMAGE); + expect(sd.containerName).toBe('codeman-case-myproj'); + expect(sd.hostWorkspacePath).toBe('/home/arkon/cases/myproj'); + expect(sd.containerWorkdir).toBe('/home/arkon/cases/myproj'); // mirror + expect(sd.network).toBe('bridge'); + expect(sd.resources).toEqual(DEFAULT_DOCKER_RESOURCES); + expect(sd.mountCredentials).toBe(true); + expect(sd.hooksEnabled).toBe(true); + expect(sd.resumeOnStart).toBe(true); + expect(sd.configHash).toMatch(/^[0-9a-f]{12}$/); + }); + + it('honors host overrides and a custom container workdir', () => { + const host: DockerHost = { + ...HOST, + engine: 'podman', + network: 'none', + mountCredentials: false, + hooksEnabled: false, + resumeOnStart: false, + }; + const sd = toSessionDocker(host, { ...CASE, containerWorkdir: '/work', container: 'my-box' }); + expect(sd.engine).toBe('podman'); + expect(sd.network).toBe('none'); + expect(sd.mountCredentials).toBe(false); + expect(sd.containerName).toBe('my-box'); + expect(sd.containerWorkdir).toBe('/work'); + }); + + it('hash is stable for equal inputs and changes when a drift field changes', () => { + const a = toSessionDocker(HOST, CASE); + const b = toSessionDocker(HOST, CASE); + expect(a.configHash).toBe(b.configHash); + const c = toSessionDocker({ ...HOST, image: 'codeman/agent:other' }, CASE); + expect(c.configHash).not.toBe(a.configHash); + // lastClaudeSessionId is NOT a drift field + expect(dockerConfigHash(a)).toBe(dockerConfigHash({ ...a })); + }); +}); + +describe('buildDockerBaseArgs', () => { + it('defaults to docker with no extra flags', () => { + expect(buildDockerBaseArgs({ engine: 'docker' })).toEqual(['docker']); + }); + it('emits podman + context + daemon host', () => { + const args = buildDockerBaseArgs({ engine: 'podman', context: 'remote', daemonHost: 'ssh://u@h' }); + expect(args[0]).toBe('podman'); + expect(args.join(' ')).toContain("--context 'remote'"); + expect(args.join(' ')).toContain("-H 'ssh://u@h'"); + }); +}); + +describe('buildDockerCreateArgs', () => { + function ctx(overrides: Partial = {}): DockerCreateContext { + return { + docker: toSessionDocker(HOST, CASE), + sessionId: '1a2b3c4d5e6f', + instance: '', + userArgs: ['--user', '1000:0'], + credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }], + extraMounts: [ + { src: '/home/arkon/.codeman/hook-secret', dst: '/home/agent/.codeman/hook-secret', readonly: true }, + ], + envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' }, + addHostGateway: true, + gatewayAlias: 'host.docker.internal', + ...overrides, + }; + } + + it('bakes in the security + lifecycle invariants', () => { + const s = buildDockerCreateArgs(ctx()).join(' '); + expect(s).toContain('--cap-drop ALL'); + expect(s).toContain('--security-opt no-new-privileges'); + expect(s).toContain('--pull=never'); + expect(s).toContain('--init'); + expect(s).toContain('--restart no'); + expect(s).toContain('--memory 4g --memory-swap 4g'); + expect(s).toContain('--pids-limit 512'); + expect(s).toContain('--ulimit nofile=4096:8192'); + expect(s).toContain('codeman.managed=1'); + expect(s).toContain("'codeman.session=1a2b3c4d'"); // first 8 chars only + expect(s).toContain('--network bridge'); + }); + + it('NEVER emits privileged mode or a docker-socket mount', () => { + const s = buildDockerCreateArgs(ctx()).join(' '); + expect(s).not.toContain('--privileged'); + expect(s).not.toContain('docker.sock'); + }); + + it('ends with the image then the sleep-infinity CMD', () => { + const args = buildDockerCreateArgs(ctx()); + expect(args.slice(-3)).toEqual([`'${DEFAULT_AGENT_IMAGE}'`, 'sleep', 'infinity']); + }); + + it('includes the resolved user args and the workspace bind', () => { + const s = buildDockerCreateArgs(ctx()).join(' '); + expect(s).toContain('--user 1000:0'); + expect(s).toContain("--mount 'type=bind,src=/home/arkon/cases/myproj,dst=/home/arkon/cases/myproj'"); + }); + + it('shell-escapes a workspace path containing spaces into a single token', () => { + const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' }); + const args = buildDockerCreateArgs(ctx({ docker })); + // the whole mount spec (with the space) is ONE single-quoted token + expect(args).toContain("'type=bind,src=/home/arkon/my cases/proj,dst=/home/arkon/my cases/proj'"); + // and the workdir is single-quoted too + expect(args).toContain("'/home/arkon/my cases/proj'"); + }); + + it('adds the host-gateway only when requested', () => { + expect(buildDockerCreateArgs(ctx({ addHostGateway: true })).join(' ')).toContain( + '--add-host host.docker.internal:host-gateway' + ); + expect(buildDockerCreateArgs(ctx({ addHostGateway: false })).join(' ')).not.toContain('--add-host'); + }); + + it('omits credential mounts in sealed mode', () => { + const s = buildDockerCreateArgs(ctx({ credentialMounts: [] })).join(' '); + expect(s).not.toContain('/home/agent/.claude'); + }); + + it('emits create-time env flags', () => { + const s = buildDockerCreateArgs(ctx()).join(' '); + expect(s).toContain("--env 'HOME=/home/agent'"); + expect(s).toContain("--env 'CODEMAN_API_URL=https://host.docker.internal:3000'"); + }); + + it('uses the custom network name for custom mode', () => { + const docker: SessionDocker = { ...toSessionDocker(HOST, CASE), network: 'custom', networkName: 'codeman-net-x' }; + expect(buildDockerCreateArgs(ctx({ docker })).join(' ')).toContain('--network codeman-net-x'); + }); +}); + +describe('resolveCredentialMounts', () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'codeman-home-')); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('only mounts credential paths that exist', () => { + mkdirSync(join(home, '.claude'), { recursive: true }); + const mounts = resolveCredentialMounts(home); + expect(mounts).toContainEqual({ src: join(home, '.claude'), dst: '/home/agent/.claude' }); + expect(mounts.find((m) => m.dst.endsWith('.codex'))).toBeUndefined(); + }); +}); + +describe('daemon probes (no-op under VITEST)', () => { + it('checkDockerAvailable returns a canned available result', async () => { + const a = await checkDockerAvailable(); + expect(a.ok).toBe(true); + expect(a.capsEnforced).toBe(true); + expect(a.engine).toBe('docker'); + }); + + it('checkDockerTmuxAvailable + image present are canned-true', async () => { + expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true); + expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true); + }); + + it('probeDockerCliVersion is undefined under test', async () => { + expect( + await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude') + ).toBeUndefined(); + }); +});