Files
Codeman/docs/docker-cases.md
T
Codeman maintainer a36c1f62db fix(docker): set CLAUDE_CODE_TMPDIR + document hook reachability limit
Found in live testing: claude refuses its default /tmp/claude-<uid> temp dir when
that path pre-exists root-owned (happens when the workspace bind-mount traverses
it, e.g. a workspace under /tmp/claude-<uid>). Set CLAUDE_CODE_TMPDIR to a
nonexistent HOME subpath the running uid creates+owns, so docker claude sessions
are robust to any workspace location.

Also document the hook-reachability constraint: in-container hooks POST to
host.docker.internal (the bridge gateway), so they only fire when Codeman is
reachable from the container (bind 0.0.0.0 + password); on a loopback-only bind
they don't fire and idle detection falls back to output-based (which works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:19:50 +02:00

7.0 KiB

Docker cases

Run a case inside an isolated Docker container instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can export the container to move it to another machine.

Docker mode is a location overlay on cases, the direct analog of remote SSH cases: where a remote case runs a local tmux pane doing ssh host into a durable remote tmux server, a docker case runs a local tmux pane doing docker exec -it into a durable in-container tmux server. It is not a separate SessionMode, so claude / shell / opencode / codex / gemini all work inside the container.

One-time setup: build the base image

The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:

node scripts/build-agent-image.mjs          # builds codeman/agent:base
# options: --engine docker|podman  --image <ref>  --no-cache

The image is secret-free: credentials are delivered at runtime (bind mounts or docker exec --env), never baked in, so exports never leak them.

Create a docker case

App → New case → Docker tab:

  • Case Name / Workspace Path: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds CLAUDE.md + .claude/settings.local.json (hooks) into it, and file previews / attachments work on the real bytes.
  • Host ID: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
  • Network: bridge (internet on, default), none (fully isolated), or a custom bridge.
  • Advanced: memory / CPU caps, Mount host credentials (on = your existing ~/.claude login just works; off = a sealed sandbox you log into inside the container), Resume last conversation on relaunch.

Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (codeman-case-<name>); subsequent sessions attach to the same one.

Equivalent API:

curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'

Lifecycle

  • Reconnect after a Codeman restart lands back in the same live agent (the in-container tmux survives).
  • Container stop / host reboot recreates the container and, when a resume id was captured, resumes the last conversation from the bind-mounted transcript.
  • Killing one session only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
  • Deleting the case docker rm -fs the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.

Isolation & security

Every container runs hardened: --cap-drop ALL, --security-opt no-new-privileges, non-root (--user <hostUid>:0 so workspace files stay host-owned), --pids-limit, --memory == --memory-swap, --init. Never --privileged, never the docker socket. The default convenient profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by docker commit); the sealed profile (mountCredentials:false + network:none) is the opt-in for genuinely untrusted work.

Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory.

Export / Import (move to another machine)

Export (from the Docker tab, or POST /api/docker-cases/:name/export): choose

  • Full image + workspace: docker commit the container to an image, docker save it, tar the workspace, and a manifest, all into one portable <case>-<ts>.codeman-container.tgz (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready.
  • Workspace only: just the project files (fast, small).

The container is paused across the capture so the image and workspace are consistent; a full /var/lib/docker is guarded against with a free-space precheck; the intermediate image is always cleaned up.

Import (POST /api/docker-cases/import, or the Manage tab): copy the .tgz onto the new machine's ~/.codeman/docker-exports/, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is docker loaded and re-tagged into a quarantined namespace (codeman/imported-<case>:<ts>) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines.

GET /api/docker-exports lists bundles; GET /api/docker-exports/:filename downloads one; DELETE removes one.

Hooks require the server to be reachable from the container

In-container hooks (permission events, hook-based idle/stop/task notifications) POST to CODEMAN_API_URL, which is derived as https://host.docker.internal:<port> (host.docker.internal → the docker bridge gateway, e.g. 172.17.0.1, via --add-host …:host-gateway). For that callback to succeed, the Codeman server must be listening on an interface the container can reach.

  • If Codeman binds loopback-only (127.0.0.1, the default and the production systemd config), a container reaching 172.17.0.1:<port> cannot connect, so in-container hooks do not fire. The session still works fully: idle/stop detection falls back to output-based detection through the docker exec PTY (which always works), and claude runs with --dangerously-skip-permissions so there are no permission prompts to forward anyway.
  • To enable in-container hooks, run Codeman where the container can reach it: bind 0.0.0.0 with CODEMAN_PASSWORD set (CODEMAN_HOST=0.0.0.0), or otherwise make 172.17.0.1:<port> reachable. The host guard already allowlists host.docker.internal / host.containers.internal, and the hook secret is mounted, so hooks work as soon as the callback is reachable.

This is an environmental constraint, not a code limitation: the host-gateway mapping, CODEMAN_API_URL derivation, host-guard allowlist, and hook-secret mount are all wired correctly.

Notes & limits

  • Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
  • Per-session envOverrides / effort / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
  • macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).

Design + rationale: docker-cases-plan.md.