Compare commits

..
Author SHA1 Message Date
Ark0N d1928f300e Merge pull request #160 from Ark0N/feat/docker-session-mode
v1.4.1: Docker session mode hardening + File Viewer button
2026-07-20 01:41:58 +02:00
Codeman maintainer ca731c67b3 feat(docker): harden session mode + File Viewer button (v1.4.1)
Docker cases: seamless Claude auth (seed ~/.claude.json instead of the
corruption-prone single-file mount), full credential-store isolation for
claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts,
seed the rest), auto-build the base image on first use, C.UTF-8 locale
(fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)"
case-menu tags, and w<n>-<case> tab naming for docker/remote sessions.
Also: opt-in File Viewer header button; fix a TZ-boundary flaky test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:36:21 +02:00
Ark0N a3fe0ae728 Merge pull request #159 from Ark0N/feat/docker-session-mode
docs: reflect shipped Docker session mode (1.4.0)
2026-07-19 22:28:41 +02:00
Codeman maintainer 82825cbfb3 docs: reflect shipped Docker session mode (1.4.0) across CLAUDE.md/README/security
- CLAUDE.md: rewrite the Docker cases Key Pattern to the shipped 1.4.0 state
  (removes the stale "not on master / Phases remaining" framing); add
  docker-quickcreate/templates/GPU/elastic-disk/export-import, the
  CODEMAN_DOCKER_BRIDGE_HOOKS listener, docker state files, env vars, route +
  SSE counts, and the build-agent-image command.
- README.md: new "Isolated Docker Sessions" section + a More Features bullet.
- docs/security-architecture.md: new §10 "Docker container isolation" (hardening,
  commit-safe creds, blast radius, untrusted-import safety, bridge-hooks) +
  Quick-reference env vars.

docs/docker-cases.md (user guide) and docs/docker-cases-plan.md (design) were
shipped with the feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:23:05 +02:00
Ark0N d1868516f7 Merge pull request #158 from Ark0N/feat/docker-session-mode
feat: Docker session mode (isolated per-case containers + export/import) — v1.4.0
2026-07-19 21:59:47 +02:00
Codeman maintainer 3e1272a675 feat(docker): resource templates, GPU, elastic disk, bridge-hooks listener
- One-click "Run in Docker" gains an expandable settings panel with a Template
  picker (Small 2G/1 · Medium 4G/2 default · Large 8G/4 · GPU 8G/4/all) plus
  memory/cpu/gpu/network/image/mount-creds overrides. Any tweak creates a dedicated
  per-case host; the plain checkbox keeps using the shared `default` host.
- GPU passthrough: `gpus` on DockerHost/SessionDocker -> `--gpus <value>` in create
  args (needs the NVIDIA container toolkit). Elastic disk: no `--storage-opt` cap,
  so container storage grows as data flows in.
- CODEMAN_DOCKER_BRIDGE_HOOKS=1: opt-in second listener on the docker bridge gateway
  (auto-detected 172.17.0.1, override CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY
  the hook endpoints and delegates into the secret-gated pipeline, so in-container
  hooks fire on a loopback-only server. Non-hook paths -> 403; host-internal, not LAN.

Verified live: Large template applies real 8GB/4CPU limits; a secret-authenticated
hook POST from inside a container now reaches the handler (was connection-refused);
non-hook paths return 403; template UI + GPU field verified via Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:35:38 +02:00
Codeman maintainer db6cd838b1 feat(docker): one-click "Run in Docker" case creation + Export button
- New POST /api/cases/docker-quickcreate: creates a normal case (folder in
  CASES_DIR, scaffolded CLAUDE.md + hooks) AND links it to a hardened container
  with default settings, auto-provisioning a shared `default` docker host — the
  user never touches host/image/network fields.
- Create New tab gains a "Run in isolated Docker container" checkbox; on submit it
  calls docker-quickcreate then auto-starts a claude session inside the container.
- Case Manage list gains an Export (full-image) button per docker case.
- SSE listeners for docker:exportComplete/exportFailed toast + refresh the exports
  list.

Verified end-to-end on the live instance: one-click create put the case in
~/codeman-cases/<name>, auto-created the default host, launched claude in the
container; export button produces a bundle; checkbox + button render (Playwright).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:28:13 +02:00
Codeman maintainer 66a41f5aa9 chore: version packages 2026-07-19 19:01:50 +02:00
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
Codeman maintainer 8b2c857c3f feat(settings): wire session, away-digest, and cron button visibility toggles
Per-device App Settings > Header Displays toggles that show/hide the session
manager and away-digest header buttons (default OFF) and the cron footer
button (default ON). Adds the load/save/apply/default/displayKeys wiring in
settings-ui.js plus the marker CSS in styles.css. Client-only display keys,
stripped from the settings PUT so they never reach the strict server schema
(mirrors the showAttachmentsButton pattern); session/away stay hidden on
phones via the existing mobile.css rules. The button markup and checkbox
rows landed earlier in 5728b86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:59:23 +02:00
Codeman maintainer 583678c950 docs(docker): add user-facing docs/docker-cases.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:55:21 +02:00
Codeman maintainer 5728b86a68 feat(docker): frontend Docker tab, run wiring, and export/import UI
- index.html: Create Case "Docker" tab (name/workspace/host/image/network +
  advanced memory/cpus/mountCredentials/resumeOnStart), and a Docker-exports
  section in the Manage tab
- session-ui.js: linkDockerCase (POST docker-host, PUT on conflict, then
  docker-link; omitted optionals as undefined not null), case-picker label
  "name @ container" + search fields, switchCaseModalTab/submitCaseModal docker
  branch, and export/import UI (refresh/export/import/delete). Docker cases route
  through /api/quick-start like remote (runClaude/runShell/runOpenCode/Codex/Gemini)
- verified in a real browser (Playwright): Docker tab renders, linking through the
  UI creates the case and it appears in the picker as "uitest @ codeman-case-uitest"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:53:14 +02:00
Codeman maintainer 39ef17b6af feat(docker): export/import (move a container to another machine) + boot reaper
- src/docker-export.ts: full-image export (pause-consistent commit + save|stream +
  workspace tar + manifest -> one .codeman-container.tgz) and workspace-only; import
  validates manifest + per-member sha256, traversal-guards the workspace tar, docker
  load + quarantine re-tag (never overwrites a local tag). Bounded by
  runWithConversionLimit; free-space precheck; docker rmi in finally; sealed
  containers refuse full-image export.
- routes: POST /api/docker-cases/:name/export (background + SSE), GET/DELETE
  /api/docker-exports, GET download, POST /api/docker-cases/import (-> new host+case)
- instance-scoped boot reaper (docker-hosts.reapOrphanedDockerContainers) wired after
  restoreMuxSessions; never touches another instance's containers
- SSE docker:exportComplete/exportFailed/importComplete (both registries)
- fix: stream pipeline in saveImageToTar so the bundle isn't truncated

VERIFIED end-to-end on real docker: full export -> 326MB valid bundle -> delete
case -> import -> new container runs from the quarantined image with the workspace
file AND the in-image change both restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:38:01 +02:00
Codeman maintainer 814362b67b docs(docker): record implementation status (phases 0-5 done, e2e verified)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:38:53 +02:00
Codeman maintainer e9f9497259 feat(docker): allowlist container-to-host gateway aliases in host guard
An in-container hook curl carries Host: host.docker.internal:<port> (the derived
CODEMAN_API_URL), so the always-on host guard must allow host.docker.internal /
host.containers.internal or every in-container hook is blocked 403. Exact-match
only; not a browser DNS-rebinding surface (resolves to the host only from inside
a container netns). Verified end-to-end: quick-start launches claude/shell in a
real container with the workspace bind-mounted and hooks scaffolded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:32:45 +02:00
Codeman maintainer 8768ca4a5a feat(docker): docker-hosts CRUD, docker-link, and quick-start branch
- case-routes: GET/POST/PUT/DELETE /api/docker-hosts, POST /api/cases/docker-link
  (creates workspace, probes daemon + tmux-in-image), docker listing in
  GET /api/cases, docker-unlink (best-effort docker rm -f) in DELETE, single GET
- session-routes: /api/quick-start docker branch (rejects envOverrides/effort/
  per-CLI config, probes availability + tmux, casePath=hostWorkspacePath, seeds
  resume id, scaffolds hooks+CLAUDE.md if missing, threads docker into Session,
  Ralph auto-config skipped for docker)
- CaseInfo gains location:'docker' + docker{} block
- typecheck clean; 157 route+docker tests pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:28:44 +02:00
Codeman maintainer df9214ba9a feat(docker): thread SessionDocker through Session + recovery
- Session: _docker field, constructor config, toState, createSessionOptions/
  respawnPaneOptions (both interactive + shell paths), docker getter
- resolveMuxAttachCwd returns /tmp for docker sessions (local wrapper only execs)
- skip the LOCAL claude version probe for docker; probe the IN-CONTAINER version
  instead (deferred) so wheel-forwarding stays enabled (#154)
- server restoreMuxSessions round-trips MuxSession.docker / SessionState.docker
- full CI suite green (3444 passed)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:22:38 +02:00
Codeman maintainer 5f4c89b990 fix(docker): auto-assign agent uid (node:22 already occupies uid 1000)
node:22-bookworm-slim ships a `node` user at uid 1000, so `useradd -u 1000`
failed. Auto-assign the uid and rely on gid-0 + group-writable HOME so any
runtime `--user <hostUid>:0` can write $HOME. Verified: image builds; toolchain
(node/tmux/claude/codex/gemini/opencode) present; `--user 1000:0` writes
/home/agent and `claude --version` runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:14:44 +02:00
Codeman maintainer 54615e2371 feat(docker): agent base image + local build script
docker/agent.Dockerfile: node:22 + claude/codex/gemini/opencode CLIs + git/
tmux/ripgrep/curl, secret-free, OpenShift arbitrary-uid-writable HOME (gid 0).
scripts/build-agent-image.mjs: local build (decision "build locally on first
use"), docker/podman auto-detect, --engine/--image/--no-cache flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:11:21 +02:00
Codeman maintainer 828b1664f7 feat(docker): Docker session mode foundation (types, storage, tmux builders)
Phase 0-2 of the Docker cases feature (docs/docker-cases-plan.md). Docker is a
LOCATION OVERLAY on cases (not a 6th SessionMode), mirroring the remote-SSH
feature: a local tmux pane runs `docker exec -it` into a durable in-container
tmux server. The container is per-CASE, so multiple sessions share it.

- types: DockerHost/DockerCase/SessionDocker + docker? on SessionState/MuxSession
- src/docker-hosts.ts: storage, toSessionDocker, pure buildDockerBaseArgs/
  buildDockerCreateArgs (cap-drop, no-new-privileges, --pull=never, mem==swap,
  never privileged/socket), containerApiUrl, hostGatewayAlias, config-hash,
  credential-mount resolution, daemon probes (VITEST no-op)
- schemas: DockerHostSchema + DockerCaseLinkSchema (NO_SHELL_META guards)
- tmux-manager: buildDockerLaunchCommand (image-check -> ensure -> start -> exec,
  resume-aware), buildDockerKillCommand (in-container tmux only, multi-session
  safe), stop/remove; wired into createSession/respawnPane/killSession
- 40 unit tests (docker-hosts + docker-exec-options), typecheck clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:09:48 +02:00
Codeman maintainer 6f4b2b8a17 chore: version packages
Release 1.3.5. Consumes the changeset from PR #155: re-issue the
codeman_session cookie on every authenticated request so the browser cookie
lifetime tracks the server-side sliding TTL, fixing the recurring native Basic
Auth dialog during active use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:15:29 +02:00
Codeman maintainer a531f48e17 chore(gitignore): ignore local screenshot and design capture dirs
screenshots-readme/, screenshots-readme-real/, screenshots-real/ and
design-explorations/ are local capture scratch that was untracked but not
ignored, so an unqualified `git add -A` during a COM could sweep them into a
release (this has happened before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:15:29 +02:00
Ark0N 7d5ea0bd50 Merge PR #155 from dennisentruencer/fix/sliding-auth-cookie: slide the session cookie so active users aren't logged out
Re-issue the codeman_session cookie on every authenticated request so the browser cookie lifetime tracks the server-side sliding TTL (authSessions already used refreshOnGet: true). Fixes the recurring native Basic Auth dialog during active use.

Reviewed: no token rotation (same server-generated token re-issued, so no fixation vector), forged cookies are not blessed, logout still emits only the clearing cookie and server-side invalidation holds, cookie attributes identical to the Basic Auth path. Verified against the merge result: tsc --noEmit, lint, format:check, check:frontend-syntax, check:lockfile, and npm run test:ci (3404 passed) all green.
2026-07-16 23:49:10 +02:00
Codeman maintainer a9ae141eec chore: version packages 2026-07-16 23:34:03 +02:00
Codeman maintainer 7b79d4207c fix(terminal): restore Claude scroll-back on macOS trackpads (#154)
Deterministic claude --version probe seeds cliVersion so wheel-forwarding
to Claude's transcript engages (banner scrape was unreliable on 2.1.187+
and resumed sessions). Shift+wheel reads the dominant axis so a trackpad's
horizontal Shift-scroll reaches local scrollback. New per-device
"Wheel Scrolls Local History" opt-out. Wheel reports use a fire-and-forget
send path so they no longer flicker the pending-bytes indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:33:39 +02:00
DennisandClaude Opus 4.8 a842f2db4d fix(auth): re-issue session cookie on each request (sliding expiry)
The codeman_session cookie was only set on the Basic Auth path with a fixed
lifetime from login and never refreshed, while the server-side session store
slides its TTL (refreshOnGet). So the browser cookie expired mid-use, the next
request arrived cookie-less and fell through to Basic Auth, popping the native
username/password dialog — perceived as a random logout while actively working.

Re-issue the cookie on every authenticated (valid-cookie) request so the browser
lifetime tracks the server-side sliding TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:24:24 +00:00
45 changed files with 6867 additions and 154 deletions
+8
View File
@@ -52,8 +52,16 @@ Thumbs.db
# Generated output
out/
screenshots-echo-diag/
screenshots-readme/
screenshots-readme-real/
screenshots-real/
scripts/remotion/out/
# Local UI/README capture scratch (screenshot runs, design mockups). Not build
# output, but never meant for git — an unqualified `git add -A` during a COM has
# swept dirs like these into a release before.
design-explorations/
# Artifacts that should not be tracked
test-results/
tmp/
+67
View File
@@ -1,5 +1,72 @@
# aicodeman
## 1.4.1
### Patch Changes
- **Docker session mode** hardening + fixes, plus a File Viewer header button.
**What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control.
This release fixes the rough edges found running it for real:
Docker cases:
- **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt).
- **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local.
- **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs.
- **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts).
- **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts).
- **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus.
- **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w<n>-<case>` convention instead of `codeman-<id>`.
Other:
- **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header.
- Fixed a timezone-boundary flaky test in the away-digest route suite.
## 1.4.0
### Minor Changes
- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine.
- Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-<name>`), so multiple sessions share it; killing one session never stops the shared container.
- New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME).
- Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in.
- Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`.
- Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`.
- Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based.
Also wire session, away-digest, and cron header-button visibility toggles in App Settings.
## 1.3.5
### Patch Changes
- a842f2d: fix(auth): slide the session cookie so active users aren't logged out
Re-issue the `codeman_session` cookie on every authenticated request so the
browser cookie lifetime tracks the server-side sliding TTL (the session store
already uses `refreshOnGet`). Previously the cookie was only set on the Basic
Auth path with a fixed 24h lifetime from login, so the browser dropped it
mid-use; the next request arrived cookie-less, fell through to Basic Auth and
popped the native username/password dialog, perceived as a random logout while
actively working.
## 1.3.4
### Patch Changes
- Fix "Run Shell" not switching the terminal to the newly created shell session. Clicking Run Shell created the shell tab but left the previous session's terminal on screen, so you had to manually click the new tab to actually enter it. Root cause: `runShell()` pre-set `activeSessionId` to the new session's id right before calling `selectSession()`, and `selectSession()` early-returns when the requested id already matches the active one, so it skipped the terminal buffer load, tab activation, and focus. Removed the premature assignment in both the local and remote-SSH shell branches so `selectSession()` runs to completion (matching `runClaude`/`runCodex`/`runGemini`/`runOpenCode`, which already avoid this). Verified end-to-end in a real browser with a negative/positive control.
## 1.3.3
### Patch Changes
- Fix terminal scroll-back in Claude sessions, especially on macOS trackpads (#154).
- **Deterministic CLI version detection.** `cliVersion` was often `undefined` because it was scraped from the `Claude Code vX.Y.Z` startup banner, which newer Claude Code builds (2.1.187+) don't reliably print and resumed sessions never show. With the version unknown, wheel-forwarding to Claude's transcript was silently disabled — and since repaint-mode Claude keeps no local terminal scrollback, scrolling up reached nothing. A new `getClaudeCliVersion()` probe (`claude --version`, cached, local-only) seeds the version at session start so forwarding engages. Restored sessions pick it up on restart.
- **Trackpad Shift+scroll.** The wheel handler now reads the dominant axis, so a macOS trackpad's Shift+two-finger scroll — which the browser reports as horizontal `deltaX` — reaches xterm's local scrollback instead of collapsing to a fixed one line per tick.
- **Opt-out setting.** New per-device App Settings → Input → "Wheel Scrolls Local History" (default off) pins the plain wheel to local scrollback (the pre-#144 behavior) for shell and other non-repaint sessions.
- **No more "queued bytes" flicker on scroll.** Wheel-scroll reports now use a fire-and-forget send path (seq-less input frame) instead of the durable exactly-once input queue, so they no longer appear in the pending-bytes connection indicator or churn localStorage. Keystrokes, taps, and clicks still use the durable queue.
## 1.3.2
### Patch Changes
+74 -67
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -366,6 +366,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
@@ -376,6 +377,21 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
---
## Isolated Docker Sessions
Run a case inside its own hardened Docker container instead of directly on your host — for security isolation, reproducible toolchains, and one-click portability.
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket. Your existing `~/.claude` login is bind-mounted (credentials stay on the host, never captured in exports); a **sealed** profile (no host mounts, network off) is one toggle away.
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
Prerequisite: Docker (or Podman) and the base image — build it once with `node scripts/build-agent-image.mjs`. Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
---
## Remote Access — Cloudflare Tunnel
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
+65
View File
@@ -0,0 +1,65 @@
# Codeman agent base image (built locally by scripts/build-agent-image.mjs).
#
# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO
# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.)
# or name-only `docker exec --env`, never baked in, so `docker save` exports stay
# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a
# reconnect durable), so it is installed here and probed before launch.
#
# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0,
# group-writable" convention: on Linux we run `--user <hostUid>:0`, so the agent
# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME
# writable even though the uid is not the baked 1000.
FROM node:22-bookworm-slim
# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`),
# `procps` for `ps`, `tmux` for the durable in-container session.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
tmux \
ripgrep \
curl \
ca-certificates \
less \
procps \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# The agent CLIs (all four backends Codeman supports). Pinning is left to the
# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2).
RUN npm install -g \
@anthropic-ai/claude-code \
@openai/codex \
@google/gemini-cli \
opencode-ai \
&& npm cache clean --force
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable
# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid.
# UTF-8 locale so tmux/Ink render Unicode box-drawing instead of VT100 ACS `q`
# glyphs (C.UTF-8 is built into glibc; no locales package needed). Codeman also
# sets these at run time so containers built before this line still get UTF-8.
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
ENV HOME=/home/agent
# `.claude` (+ `.claude/projects` mount point) and `.codex` (+ `.codex/sessions`) are
# pre-created gid-0 group-writable so the container owns its OWN credential config
# dirs: tokens/settings/config are seeded in as writable copies and each CLI's runtime
# state (backups, tasks, refreshed tokens) stays container-local, while ONLY the shared
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir.)
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
/home/agent/.claude/projects /home/agent/.codex/sessions \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
USER agent
WORKDIR /home/agent
# Codeman overrides the command with `sleep infinity` at create time; this is the
# fallback so a hand-run container also idles rather than exiting.
CMD ["sleep", "infinity"]
+433
View File
@@ -0,0 +1,433 @@
<!-- Design doc generated via ultracode multi-agent workflow (wf_e3a7498b-26f): 3 architecture proposals -> judge panel -> synthesis -> completeness critic. -->
# Docker Session Mode, Implementation Plan
## Decisions (locked 2026-07-19, by repo owner)
1. **Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in.
2. **Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time).
3. **Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.)
4. **Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker.
Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort.
## Implementation status (branch `feat/docker-session-mode`)
DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it):
- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests.
- Phase 2: `tmux-manager` `buildDockerLaunchCommand` (image-check -> ensure -> start -> exec, resume-aware), `buildDockerKillCommand` (in-container tmux only, multi-session safe), stop/remove; wired into `createSession`/`respawnPane`/`killSession`. 14 unit tests.
- Phase 3: `Session` threading (`_docker`, toState, option builders, in-container cliVersion probe, `resolveMuxAttachCwd`), `server.ts` recovery round-trip.
- Phase 4: `case-routes` `/api/docker-hosts` CRUD + `/api/cases/docker-link` + listing + docker-unlink; `session-routes` `/api/quick-start` docker branch (rejects per-session config, probes availability + tmux, scaffolds hooks, seeds resume id).
- Phase 5 (partial): `docker/agent.Dockerfile` + `scripts/build-agent-image.mjs` (built + verified: node 22, tmux, claude/codex/gemini/opencode, arbitrary-uid HOME). Host-guard allowlists `host.docker.internal`/`host.containers.internal` for in-container hooks.
- Full CI green (3445 tests).
REMAINING:
- Phase 6: export / import (`docker commit` + `save | gzip` + workspace tar + manifest; `load` + quarantined re-tag), GC / boot reaper, disk-safety prechecks, drift-recreate route, SSE `docker:*` events. THE "move to a new machine" feature.
- Phase 7: frontend Create Case "Docker" tab + `linkDockerCase` + run wiring + case-picker labels + export/import UI.
- Phase 8: CLAUDE.md "Docker cases" Key Pattern + `docs/docker-cases.md` + COM.
- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic).
## 1. Goal & user stories
Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`.
User stories:
- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup.
- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4).
- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1).
- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click.
- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone.
Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers.
## 2. Chosen architecture and why
The design grafts the strongest idea from each of the three proposals:
- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing.
- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login.
- One-artifact export + in-app import route (from "Container-as-Cargo"): the genuinely new, high-value capability Codeman lacks.
### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model)
Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-<slug>` (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-<id8>`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-<id8>`.
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 <claudeSessionId>` (codex uses `resume <id>`, gemini `--resume <id>`) 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 `<workspace>/.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.<mode>` (`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 = <container path>`; we deliberately diverge and use the host path.
- Transcript correlation: Claude writes transcripts under `~/.claude/projects/<hash-of-CWD>/`. 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-<slug>`, 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 <gatewayAlias>: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 `<workspace>/.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=<derived>` (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=<that path>` 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=<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 <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 <hostUid>: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 <macUid>` (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<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
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 <ctx>
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<Record<DockerCommandMode, string>>;
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-<slug>
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<Record<DockerCommandMode, string>>;
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 '<tmux ...>'`: the whole `docker inspect || docker create <dozens of --mount/--env/shellescaped host paths>` 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 <ctx>` or `-H <daemonHost>`. `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 <image> >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image <ref> 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 <image> 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=<CODEMAN_INSTANCE> \
--label codeman.case=myproj --label codeman.session=<id8> \
--label codeman.confighash=<hash> \
--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 <hostUid>: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=<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 <all create args above> ; \
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 <claudeSessionId>'\'' \; 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 <claudeSessionId>` (codex `resume <id>`, gemini `--resume <id>`) 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 <name> 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-<slug> tmux -L codeman-docker kill-session -t codeman-dkr-<id8> ; docker stop -t 10 codeman-case-<slug>`. 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-<slug>`), 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=<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 <image> --format '{{.Id}}' # image PRESENT (no auto-pull)
docker run --rm --pull=never <image> 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 <container> 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-<slug> codeman/export-<slug>:<ts>` (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-<slug>:<ts> | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/<slug>-<ts>.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 <hostWorkspacePath> -czf <slug>-<ts>.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-<slug>:<ts>` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`.
The three files are wrapped in one bundle `<slug>-<ts>.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 <fresh dir>` 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-<slug>:<ts>` 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 `<details>` 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 <hostUid>: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-<id8>`) 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.
+94
View File
@@ -0,0 +1,94 @@
# Docker cases
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container.
## One-time setup: build the base image
The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:
```bash
node scripts/build-agent-image.mjs # builds codeman/agent:base
# options: --engine docker|podman --image <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.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
| Template | Memory | CPUs | GPUs |
|----------|--------|------|------|
| Small | 2 GB | 1 | none |
| Medium (default) | 4 GB | 2 | none |
| Large | 8 GB | 4 | none |
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
## Create a docker case (full control)
App → **New case → Docker** tab:
- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes.
- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge.
- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**.
Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-<name>`); subsequent sessions attach to the same one.
Equivalent API:
```bash
curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
```
## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
## Isolation & security
Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user <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 load`ed 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 by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
## Notes & limits
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
+20 -2
View File
@@ -30,7 +30,8 @@ an explicit, guided opt‑in.
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
8. [Multi‑instance isolation](#8-multiinstance-isolation)
9. [Transport security headers](#9-transport-security-headers)
10. [Quick reference](#10-quick-reference)
10. [Docker container isolation](#10-docker-container-isolation)
11. [Quick reference](#11-quick-reference)
---
@@ -471,7 +472,22 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
---
## 10. Quick reference
## 10. Docker container isolation
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
- **Instance isolation** — every managed container is labeled `codeman.instance=<CODEMAN_INSTANCE>`; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (`-L codeman-docker`) + session name (`codeman-dkr-*`) deliberately fail a nested Codeman's discovery pattern.
Full feature guide: [`docker-cases.md`](docker-cases.md).
---
## 11. Quick reference
| Env / flag | Effect |
|------------|--------|
@@ -482,6 +498,8 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
| `CODEMAN_DOCKER_BRIDGE_HOOKS=1` | Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, `403` elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
| `CODEMAN_DOCKER_BRIDGE_HOST` | Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
**Audit log:** session lifecycle and server start are recorded in
`~/.codeman/session-lifecycle.jsonl`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.3.2",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.3.2",
"version": "1.4.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.3.2",
"version": "1.4.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Build the Codeman agent base image locally (decision: "build locally on first
* use", see docs/docker-cases-plan.md). No registry account required.
*
* Usage:
* node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]
*
* Defaults: engine=docker (falls back to podman if docker is absent),
* image=codeman/agent:base
*/
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile');
const DEFAULT_IMAGE = 'codeman/agent:base';
function parseArgs(argv) {
const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--image') args.image = argv[++i];
else if (a === '--engine') args.engine = argv[++i];
else if (a === '--no-cache') args.noCache = true;
else if (a === '-h' || a === '--help') args.help = true;
}
return args;
}
function engineAvailable(engine) {
const r = spawnSync(engine, ['--version'], { stdio: 'ignore' });
return r.status === 0;
}
function resolveEngine(preferred) {
if (preferred) {
if (!engineAvailable(preferred)) {
console.error(`[build-agent-image] engine "${preferred}" not found on PATH`);
process.exit(1);
}
return preferred;
}
if (engineAvailable('docker')) return 'docker';
if (engineAvailable('podman')) return 'podman';
console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.');
process.exit(1);
}
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]');
process.exit(0);
}
const engine = resolveEngine(args.engine);
const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image];
if (args.noCache) buildArgs.push('--no-cache');
buildArgs.push(REPO_ROOT);
console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`);
const child = spawn(engine, buildArgs, { stdio: 'inherit' });
child.on('exit', (code) => {
if (code === 0) {
console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`);
} else {
console.error(`\n[build-agent-image] build failed (exit ${code}).`);
}
process.exit(code ?? 1);
});
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
/**
* capture-readme-gifs.mjs
*
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
* server + page.route mocks), drives a scripted timeline in the page, records
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
*
* Scenes:
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
* windows open one by one and stream tool-call activity live (driven
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
*
* Usage: node scripts/capture-readme-gifs.mjs
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
* Requires: ffmpeg
*/
import { chromium } from 'playwright';
import { execSync } from 'child_process';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
PORT,
SESSION_IDS,
STANDARD_SESSIONS,
buildInitPayload,
startStaticServer,
setupRoutes,
injectState,
outPath,
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
} from './capture-readme-screenshots.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const GIF_COLORS = 192;
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
// on flat dark UI, and error-diffusion dither shimmers between frames.
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
execSync(
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
{ stdio: 'inherit' }
);
}
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
// Terminal content visible before the agents spawn
const TERMINAL_PRESPAWN = [
'',
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
'',
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
'',
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
` ${GRY}░${RST} ./src/index.ts`,
` ${GRY}░${RST} ./src/session.ts`,
` ${GRY}░${RST} ./src/web/server.ts`,
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
'',
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
'',
].join('\r\n');
function makeAgent(agentId, description, startedOffsetMs) {
return {
agentId,
sessionId: 'claude-sess-w1-0001',
projectHash: 'abc123',
filePath: `/tmp/${agentId}.jsonl`,
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
lastActivityAt: Date.now(),
status: 'active',
toolCallCount: 0,
entryCount: 0,
fileSize: 4000,
description,
model: 'claude-haiku-4-5-20251001',
modelShort: 'haiku',
totalInputTokens: 0,
totalOutputTokens: 0,
parentSessionId: SESSION_IDS.w1,
};
}
// Timeline events: t (ms from scene start) + kind
// term — write raw data to the session terminal
// discover — register subagent + open + position its floating window
// tool — stream a tool call into an agent window
// msg — stream an assistant message into an agent window
// complete — flip an agent to completed
function buildSubagentTimeline() {
const T = (lines) => lines.join('\r\n') + '\r\n';
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
return [
{
t: 600,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
{
t: 1000,
kind: 'discover',
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
x: 440, y: 45,
},
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
{
t: 2000,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
{
t: 2500,
kind: 'discover',
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
x: 880, y: 45,
},
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
{
t: 3300,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
{
t: 3800,
kind: 'discover',
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
x: 660, y: 400,
},
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
{
t: 4500,
kind: 'term',
data: T([
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
'',
]),
},
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
{
t: 9300,
kind: 'term',
data: T([
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
'',
]),
},
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
{
t: 11600,
kind: 'term',
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
},
];
}
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
async function recordSubagentScene(browser, videoDir) {
console.log('\n1/2 Recording subagent-demo...');
const context = await browser.newContext({
viewport: SUBAGENT_VIEWPORT,
deviceScaleFactor: 1,
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
});
const recStart = Date.now();
const page = await context.newPage();
page.setDefaultTimeout(30000);
// Start with NO subagents — they appear during the recording
const initPayload = buildInitPayload(STANDARD_SESSIONS);
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
await page.evaluate(() => {
try { window.app?.fitAddon?.fit(); } catch {}
window.app?.terminal?.scrollToBottom();
});
await sleep(500);
const timeline = buildSubagentTimeline();
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
const sceneStart = Date.now();
// Run the whole timeline inside the page so events interleave naturally
await page.evaluate((events) => {
const app = window.app;
for (const ev of events) {
setTimeout(() => {
try {
if (ev.kind === 'term') {
app.terminal.write(ev.data);
app.terminal.scrollToBottom();
} else if (ev.kind === 'discover') {
app._onSubagentDiscovered(ev.agent);
app.openSubagentWindow(ev.agent.agentId);
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
setTimeout(() => {
const win = app.subagentWindows.get(ev.agent.agentId);
if (win?.element) {
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
win.element.style.left = `${ev.x}px`;
win.element.style.top = `${ev.y}px`;
}
}, 520);
setTimeout(() => {
const win = app.subagentWindows.get(ev.agent.agentId);
if (win?.element) win.element.style.transition = '';
app.updateConnectionLines();
}, 850);
} else if (ev.kind === 'tool') {
app._onSubagentToolCall({
agentId: ev.agentId,
tool: ev.tool,
input: ev.input,
timestamp: new Date().toISOString(),
});
} else if (ev.kind === 'msg') {
app._onSubagentMessage({
agentId: ev.agentId,
role: 'assistant',
text: ev.text,
timestamp: new Date().toISOString(),
});
} else if (ev.kind === 'complete') {
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
}
} catch (err) {
console.error('timeline event failed', ev, err);
}
}, ev.t);
}
}, timeline);
await sleep(totalMs + 500);
await page.close();
const videoPath = await page.video().path();
await context.close();
return {
videoPath,
ss: (sceneStart - recStart) / 1000 - 0.4,
duration: (totalMs + 400) / 1000,
};
}
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
const TYPE_INTERVAL_MS = 110;
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
const ZEROLAG_TAIL_HOLD = 1800;
const ZEROLAG_HTML = `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
<style>
* { margin: 0; box-sizing: border-box; }
body {
width: 1280px; height: 470px; background: #0a0a0c;
display: flex; align-items: center; justify-content: center; gap: 48px;
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.pane { width: 560px; }
.card {
background: #131316; border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
}
.card-head {
display: flex; align-items: baseline; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
}
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
.sub { font-size: 12.5px; color: #8b8b92; }
.term { padding: 16px 8px 12px 16px; height: 165px; }
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
.tag {
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
}
.tag b { color: #22c55e; font-weight: 600; }
.bad-tag b { color: #ef4444; }
</style>
</head>
<body>
<div class="pane">
<div class="card good">
<div class="card-head">
<span class="dot"></span>
<span class="title">With zerolag-input</span>
<span class="sub">instant local echo</span>
</div>
<div class="term" id="termLeft"></div>
</div>
<div class="tag">keystrokes echo in <b>0 ms</b></div>
</div>
<div class="pane">
<div class="card bad">
<div class="card-head">
<span class="dot"></span>
<span class="title">Without</span>
<span class="sub">server round-trip echo</span>
</div>
<div class="term" id="termRight"></div>
</div>
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
</div>
</body>
</html>`;
async function recordZerolagScene(browser, videoDir) {
console.log('\n2/2 Recording zerolag-demo...');
const context = await browser.newContext({
viewport: ZEROLAG_VIEWPORT,
deviceScaleFactor: 1,
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
});
const recStart = Date.now();
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
await page.waitForFunction(() => typeof Terminal !== 'undefined');
await page.evaluate(() => {
const theme = {
background: '#131316',
foreground: '#e8e8ea',
cursor: '#22c55e',
cursorAccent: '#131316',
};
const mk = (id) => {
const term = new Terminal({
cols: 44,
rows: 5,
fontSize: 20,
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
cursorBlink: true,
cursorStyle: 'block',
theme,
});
term.open(document.getElementById(id));
term.write('\x1b[32m❯\x1b[0m ');
return term;
};
window.termLeft = mk('termLeft');
window.termRight = mk('termRight');
});
await sleep(600);
const sceneStart = Date.now();
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
await page.evaluate(
({ text, interval, flushEvery }) => {
let i = 0;
const remoteQueue = [];
const typer = setInterval(() => {
if (i >= text.length) { clearInterval(typer); return; }
const ch = text[i++];
window.termLeft.write(ch); // local echo: instant
remoteQueue.push(ch); // server echo: waits for the round-trip
}, interval);
const flusher = setInterval(() => {
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
}, flushEvery);
},
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
);
await sleep(totalMs + 400);
await page.close();
const videoPath = await page.video().path();
await context.close();
return {
videoPath,
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
duration: (totalMs + 600) / 1000,
};
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log('='.repeat(60));
console.log('Codeman README GIF Capture');
console.log('='.repeat(60));
const server = await startStaticServer();
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
let browser;
try {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
const sub = await recordSubagentScene(browser, videoDir);
const subGif = outPath('images', 'subagent-demo.gif');
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
console.log(` Saved: ${subGif}`);
const zl = await recordZerolagScene(browser, videoDir);
const zlGif = outPath('images', 'zerolag-demo.gif');
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
console.log(` Saved: ${zlGif}`);
console.log('\nDone.');
} catch (err) {
console.error('\nFatal error:', err.message);
console.error(err.stack);
process.exitCode = 1;
} finally {
if (browser) await browser.close().catch(() => {});
server.close();
rmSync(videoDir, { recursive: true, force: true });
}
}
process.on('SIGINT', () => process.exit(1));
main();
+64 -12
View File
@@ -51,6 +51,9 @@ async function newCtx(browser) {
localStorage.setItem('codeman:skin', skin);
localStorage.setItem('codeman-font-size', String(font));
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
// Don't auto-hide subagent windows that belong to a non-active tab — the
// subagent scene re-homes agents and needs both windows visible at once.
blob.subagentActiveTabOnly = false;
if (planUsage) blob.showPlanUsageLimits = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
} catch {
@@ -136,9 +139,9 @@ async function sceneSubagent(browser) {
const sessions = await listSessions(page);
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
let agents = [];
for (let i = 0; i < 25; i++) {
for (let i = 0; i < 45; i++) {
agents = await page.evaluate(() =>
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
);
@@ -151,6 +154,44 @@ async function sceneSubagent(browser) {
await context.close();
return;
}
// The window body renders from app.subagentActivity, which fills ONLY from live
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
// So sit connected and wait for live activity to accumulate, then open the two
// agents that actually have content (otherwise the windows read "No activity yet").
let active = [];
for (let i = 0; i < 100; i++) {
active = await page.evaluate(() =>
Array.from(window.app.subagentActivity?.entries?.() || [])
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
.map(([id, arr]) => ({ id, n: arr.length }))
.sort((a, b) => b.n - a.n)
);
if (active.length >= 2) break;
// xhigh-effort agents churn in bursts between long thinking pauses, so be
// patient (~150s); accept a single populated window after ~45s if that's all.
if (i >= 30 && active.length >= 1) break;
await sleep(1500);
}
console.log(' agents with live activity:', JSON.stringify(active));
const openIds = (active.length ? active : agents).map((a) => a.id);
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
// Codeman id and never becomes the real Claude conversation UUID, so the window
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
// to the agents' (shared) sessionId so the windows open AND show their live activity.
await page.evaluate(
(ids) => {
const activeId = window.app.activeSessionId;
const tab = window.app.sessions.get(activeId);
ids.slice(0, 2).forEach((id) => {
const a = window.app.subagents.get(id);
if (!a) return;
a.parentSessionId = activeId;
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
});
},
openIds
);
await page.evaluate(
(ids) => {
ids.slice(0, 2).forEach((id) => {
@@ -159,22 +200,33 @@ async function sceneSubagent(browser) {
} catch {}
});
},
agents.map((a) => a.id)
openIds
);
await sleep(2000);
await page.evaluate(() => {
// Viewport-relative tiling: center two subagent windows over the terminal so
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
// recipe) instead of overflowing at narrower widths.
const wins = Array.from(window.app.subagentWindows.values());
const place = [
{ left: 360, top: 60, w: 430, h: 330 },
{ left: 810, top: 60, w: 430, h: 330 },
];
const W = window.innerWidth;
const H = window.innerHeight;
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
const winH = Math.min(360, Math.floor(H * 0.56));
const top = Math.floor(H * 0.16);
const gap = 16;
const totalW = winW * 2 + gap;
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
wins.slice(0, 2).forEach((win, i) => {
const el = win.element;
const p = place[i];
el.style.left = p.left + 'px';
el.style.top = p.top + 'px';
el.style.width = p.w + 'px';
el.style.height = p.h + 'px';
// Force visible: a freshly opened window may be hidden by the activeTabOnly
// rule before we override it (we also seed subagentActiveTabOnly:false).
win.hidden = false;
win.minimized = false;
el.style.display = 'flex';
el.style.left = startLeft + i * (winW + gap) + 'px';
el.style.top = top + 'px';
el.style.width = winW + 'px';
el.style.height = winH + 'px';
});
});
await sleep(1500);
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
/**
* @fileoverview Docker case export / import: move a container (toolchain + any
* in-image changes) PLUS its workspace to another machine as one portable
* `.codeman-container.tgz`, and restore it.
*
* A full-image export = `docker commit` the running container to an image ->
* `docker save` that image -> tar the bind-mounted workspace -> a manifest, all
* bundled into one gzip tarball. A workspace-only export skips the image (fast,
* files-only). Import validates the manifest + per-member checksums, extracts the
* workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it
* into a quarantined namespace (never overwriting a local tag), and hands the
* caller enough to recreate a hardened case on the destination.
*
* Safety (all from the design critic): pause the container spanning the workspace
* tar AND the commit so the two artifacts are mutually consistent; a free-space
* precheck (a full docker graph wedges EVERY session on the host); `docker rmi`
* the intermediate image in a finally; sealed containers refuse a full-image
* export (an in-container login would ride the committed layer); import rejects
* absolute / `..` tar members and checksum mismatches. Bounded by
* runWithConversionLimit so N exports cannot fork-bomb the host.
*
* @module docker-export
*/
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import type { DockerEngine, SessionDocker } from './types.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const IS_TEST_MODE = !!process.env.VITEST;
/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */
export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
/** Manifest schema version (bump on any breaking field change). */
export const DOCKER_EXPORT_SCHEMA = 1;
export type DockerExportMode = 'full' | 'workspace';
export interface DockerExportManifest {
schemaVersion: number;
caseName: string;
mode: DockerExportMode;
engine: DockerEngine;
image: string;
containerWorkdir: string;
network: string;
createdAt: number;
codemanVersion: string;
mountCredentials: boolean;
/** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */
secretFree: boolean;
/** sha256 of each bundle member that is present. */
checksums: { image?: string; workspace?: string };
}
// ========== Pure helpers (unit-tested) ==========
/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */
export function dockerArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
return argv;
}
/** Portable bundle filename for a case export. */
export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string {
const suffix = mode === 'workspace' ? 'workspace' : 'container';
return `${caseName}-${timestamp}.codeman-${suffix}.tgz`;
}
/** Quarantined image tag for an imported bundle (never overwrites a local tag). */
export function importedImageTag(caseName: string, timestamp: number): string {
return `codeman/imported-${caseName}:${timestamp}`;
}
/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */
export function exportImageTag(caseName: string, timestamp: number): string {
return `codeman/export-${caseName}:${timestamp}`;
}
/**
* Reject a tar member path that would escape the extraction root (absolute path
* or a `..` component). The import-side traversal guard.
*/
export function isSafeTarMember(member: string): boolean {
const trimmed = member.trim();
if (!trimmed || trimmed === './') return true;
if (trimmed.startsWith('/')) return false;
// Normalize separators and check each component.
return !trimmed.split('/').some((part) => part === '..');
}
/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */
export function parseLoadedImageRef(loadOutput: string): string | null {
const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i);
if (idMatch) return idMatch[1];
const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i);
if (refMatch) return refMatch[1];
return null;
}
// ========== IO helpers ==========
function run(
cmd: string,
args: string[],
opts: { timeout?: number } = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let timer: NodeJS.Timeout | undefined;
if (opts.timeout) {
timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`${cmd} timed out after ${opts.timeout}ms`));
}, opts.timeout);
}
child.stdout.on('data', (d) => (stdout += d));
child.stderr.on('data', (d) => (stderr += d));
child.on('error', (err) => {
if (timer) clearTimeout(timer);
reject(err);
});
child.on('close', (code) => {
if (timer) clearTimeout(timer);
if (code === 0) resolve({ stdout, stderr });
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`));
});
});
}
/**
* Stream `docker save <tag>` stdout to a raw tar file (no shell, no double-gzip).
* Uses stream `pipeline` so completion means the write stream is FULLY flushed to
* disk (a naive child 'close' resolves before the last chunks land, truncating the
* file — a real bug caught in end-to-end testing), AND waits for a clean exit code.
*/
async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise<void> {
const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
child.stderr.on('data', (d) => (stderr += d));
const exited = new Promise<void>((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`))
);
});
// pipeline resolves only after the destination has fully flushed.
await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]);
}
async function sha256File(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(path);
stream.on('data', (d) => hash.update(d));
stream.on('error', reject);
stream.on('end', () => resolve(hash.digest('hex')));
});
}
async function freeBytes(path: string): Promise<number> {
try {
const stat = await fs.statfs(path);
return Number(stat.bavail) * Number(stat.bsize);
} catch {
return Number.POSITIVE_INFINITY; // statfs unsupported — don't block
}
}
async function isContainerRunning(argv: string[], container: string): Promise<boolean> {
try {
const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], {
timeout: 15_000,
});
return stdout.trim() === 'true';
} catch {
return false;
}
}
export interface ExportResult {
bundlePath: string;
manifest: DockerExportManifest;
sizeBytes: number;
}
/**
* Export a docker case to a portable bundle. Bounded by runWithConversionLimit.
* `full` mode commits + saves the image AND tars the workspace; `workspace` mode
* tars just the workspace. The container is paused across the artifact capture so
* image and workspace are mutually consistent.
*/
export async function exportDockerCase(params: {
docker: SessionDocker;
caseName: string;
timestamp: number;
exportsDir: string;
mode: DockerExportMode;
codemanVersion: string;
}): Promise<ExportResult> {
const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params;
if (mode === 'full' && !docker.mountCredentials) {
throw new Error(
'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.'
);
}
if (IS_TEST_MODE) {
// No real docker/tar under vitest — return a deterministic stub.
const manifest: DockerExportManifest = {
schemaVersion: DOCKER_EXPORT_SCHEMA,
caseName,
mode,
engine: docker.engine,
image: docker.image,
containerWorkdir: docker.containerWorkdir,
network: docker.network,
createdAt: timestamp,
codemanVersion,
mountCredentials: docker.mountCredentials,
secretFree: true,
checksums: {},
};
return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 };
}
return runWithConversionLimit(async () => {
if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
const free = await freeBytes(exportsDir);
if (free < DOCKER_EXPORT_MIN_FREE_BYTES) {
throw new Error(
`not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.`
);
}
const argv = dockerArgv(docker);
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
mkdirSync(stageDir, { recursive: true });
const wasRunning = await isContainerRunning(argv, docker.containerName);
let commitTag: string | undefined;
try {
if (wasRunning) {
await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {});
}
const checksums: DockerExportManifest['checksums'] = {};
if (mode === 'full') {
commitTag = exportImageTag(caseName, timestamp);
// Blank instance-specific committed env so the image carries no stale host refs.
await run(
argv[0],
[
...argv.slice(1),
'commit',
'-c',
'ENV CODEMAN_API_URL=',
'-c',
'ENV CODEMAN_HOOK_SECRET_FILE=',
docker.containerName,
commitTag,
],
{ timeout: 300_000 }
);
const imageTar = join(stageDir, 'image.tar');
await saveImageToTar(argv, commitTag, imageTar);
checksums.image = await sha256File(imageTar);
}
const workspaceTar = join(stageDir, 'workspace.tar');
await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 });
checksums.workspace = await sha256File(workspaceTar);
const manifest: DockerExportManifest = {
schemaVersion: DOCKER_EXPORT_SCHEMA,
caseName,
mode,
engine: docker.engine,
image: docker.image,
containerWorkdir: docker.containerWorkdir,
network: docker.network,
createdAt: timestamp,
codemanVersion,
mountCredentials: docker.mountCredentials,
// Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free.
secretFree: docker.mountCredentials,
checksums,
};
await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
const members =
mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar'];
await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 });
const stat = await fs.stat(bundlePath);
return { bundlePath, manifest, sizeBytes: stat.size };
} finally {
// Always remove the intermediate image + stage dir, and unpause.
if (commitTag) {
await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {});
}
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
if (wasRunning) {
await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {});
}
}
});
}
export interface ImportResult {
manifest: DockerExportManifest;
/** Quarantined image ref the destination case should use (full mode only). */
importedImage?: string;
/** Directory the workspace was extracted into. */
workspacePath: string;
}
/**
* Import a bundle produced by exportDockerCase: validate the manifest + per-member
* checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in
* full mode, `docker load` the image and re-tag it into a quarantined namespace.
*/
export async function importDockerBundle(params: {
bundlePath: string;
destWorkspace: string;
engine: DockerEngine;
timestamp: number;
}): Promise<ImportResult> {
const { bundlePath, destWorkspace, engine, timestamp } = params;
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
if (IS_TEST_MODE) {
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
}
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
mkdirSync(stageDir, { recursive: true });
try {
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
}
// Integrity: verify checksums before trusting any member.
const workspaceTar = join(stageDir, 'workspace.tar');
if (manifest.checksums.workspace) {
const actual = await sha256File(workspaceTar);
if (actual !== manifest.checksums.workspace)
throw new Error('workspace checksum mismatch (corrupt or tampered bundle)');
}
// Traversal guard: reject absolute / `..` members before extraction.
const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 });
for (const member of memberList.split('\n').filter(Boolean)) {
if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`);
}
mkdirSync(destWorkspace, { recursive: true });
await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 });
let importedImage: string | undefined;
if (manifest.mode === 'full') {
const imageTar = join(stageDir, 'image.tar');
if (manifest.checksums.image) {
const actual = await sha256File(imageTar);
if (actual !== manifest.checksums.image)
throw new Error('image checksum mismatch (corrupt or tampered bundle)');
}
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
const loadedRef = parseLoadedImageRef(stdout);
if (!loadedRef) throw new Error('could not determine loaded image ref');
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
importedImage = importedImageTag(manifest.caseName, timestamp);
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
}
return { manifest, importedImage, workspacePath: destWorkspace };
} finally {
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
}
}
/** List export bundles in the exports dir (newest first), with size + mtime. */
export async function listDockerExports(
exportsDir: string
): Promise<Array<{ name: string; sizeBytes: number; mtimeMs: number }>> {
if (!existsSync(exportsDir)) return [];
const entries = await fs.readdir(exportsDir).catch(() => [] as string[]);
const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = [];
for (const name of entries) {
if (!name.endsWith('.tgz')) continue;
try {
const stat = await fs.stat(join(exportsDir, name));
out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
} catch {
/* skip */
}
}
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
}
+956
View File
@@ -0,0 +1,956 @@
/**
* @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-<name>`), 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, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { dataPath } from './config/instance.js';
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-<name>` 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<T>(path: string): Promise<T[]> {
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<T>(configDir: string, path: string, value: T[]): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
await fs.writeFile(path, JSON.stringify(value, null, 2));
}
export async function readDockerHosts(configDir: string): Promise<DockerHost[]> {
return readJsonArray<DockerHost>(dockerHostsPath(configDir));
}
export async function writeDockerHosts(configDir: string, hosts: DockerHost[]): Promise<void> {
await writeJsonArray(configDir, dockerHostsPath(configDir), hosts);
}
export async function readDockerCases(configDir: string): Promise<DockerCase[]> {
return readJsonArray<DockerCase>(dockerCasesPath(configDir));
}
export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise<void> {
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<DockerCommandMode, string> = {
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<SessionDocker, 'containerName' | 'containerWorkdir'> | { 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://<alias>:3000` when the input is absent or
* unparseable.
*/
export function containerApiUrl(processApiUrl: string | undefined, engine: DockerEngine): string {
const alias = hostGatewayAlias(engine);
if (!processApiUrl) return `https://${alias}:3000`;
try {
const url = new URL(processApiUrl);
url.hostname = alias;
// origin drops any trailing path/slash and keeps scheme + (non-default) port
return url.origin;
} catch {
return `https://${alias}:3000`;
}
}
/**
* Stable hash of the drift-relevant `docker create` inputs, stored on the
* container as the `codeman.confighash` label. On launch, a mismatch between the
* desired hash and the running container's label triggers the recreate-on-drift
* prompt (host config edits actually take effect).
*/
export function dockerConfigHash(
docker: Pick<
SessionDocker,
| 'engine'
| 'image'
| 'containerWorkdir'
| 'network'
| 'networkName'
| 'resources'
| 'gpus'
| 'mountCredentials'
| 'extraCreateArgs'
>
): string {
const normalized = JSON.stringify({
engine: docker.engine,
image: docker.image,
containerWorkdir: docker.containerWorkdir,
network: docker.network,
networkName: docker.networkName ?? null,
resources: docker.resources ?? null,
gpus: docker.gpus ?? null,
mountCredentials: docker.mountCredentials,
extraCreateArgs: docker.extraCreateArgs ?? null,
});
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
}
/**
* Build the flattened per-session Docker metadata from a host profile + a case,
* resolving every default (mirror of toSessionRemote). The `configHash` is
* computed last over the resolved values.
*/
export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): SessionDocker {
const engine: DockerEngine = host.engine ?? 'docker';
const containerWorkdir = dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath;
const base: Omit<SessionDocker, 'configHash'> = {
hostId: host.id,
label: host.label,
engine,
image: host.image || DEFAULT_AGENT_IMAGE,
containerName: dockerCase.container ?? dockerContainerName(dockerCase.name),
hostWorkspacePath: dockerCase.hostWorkspacePath,
containerWorkdir,
network: host.network ?? 'bridge',
networkName: host.networkName,
resources: host.resources ?? DEFAULT_DOCKER_RESOURCES,
gpus: host.gpus,
mountCredentials: host.mountCredentials ?? true,
hooksEnabled: host.hooksEnabled ?? true,
resumeOnStart: host.resumeOnStart ?? true,
daemonHost: host.daemonHost,
context: host.context,
commands: host.commands,
extraCreateArgs: host.extraCreateArgs,
extraExecArgs: host.extraExecArgs,
};
return { ...base, configHash: dockerConfigHash(base) };
}
// ========== Shell escaping ==========
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
export function shellescape(str: string): string {
return "'" + str.replace(/'/g, "'\\''") + "'";
}
// ========== Pure command-arg builders ==========
/** A resolved bind mount (source existence already checked by the caller). */
export interface DockerMount {
src: string;
dst: string;
readonly?: boolean;
}
/**
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
* resolves the environment-dependent bits (host uid, existing cred mounts, the
* derived api url, Desktop detection) so this builder stays pure and unit-testable.
*/
export interface DockerCreateContext {
docker: SessionDocker;
/** Codeman session id (only the first 8 chars are used, for the codeman.session label). */
sessionId: string;
/** CODEMAN_INSTANCE ('' for prod) — scopes the boot reaper so a beta never reaps prod. */
instance: string;
/** Pre-resolved uid/userns tokens: ['--user','1000:0'] | ['--userns','keep-id'] | []. */
userArgs: string[];
/** Existing host credential bind mounts (convenient mode). Empty in sealed mode. */
credentialMounts: DockerMount[];
/** Extra bind mounts (e.g. the read-only hook-secret file). */
extraMounts: DockerMount[];
/** Create-time env (NON-secret, committed-safe): HOME, TERM, COLORTERM, CODEMAN_API_URL, CODEMAN_HOOK_SECRET_FILE. */
envCreate: Record<string, string>;
/** Whether to add `--add-host <alias>: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<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const parts: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) parts.push('--context', shellescape(docker.context));
if (docker.daemonHost) parts.push('-H', shellescape(docker.daemonHost));
return parts;
}
function mountSpec(m: DockerMount): string {
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
}
function resourceFlags(resources?: DockerResourceLimits): string[] {
if (!resources) return [];
const flags: string[] = [];
if (resources.memory) {
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
}
if (resources.cpus) flags.push('--cpus', resources.cpus);
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
if (resources.nofile) flags.push('--ulimit', `nofile=${resources.nofile}`);
if (resources.shmSize) flags.push('--shm-size', resources.shmSize);
return flags;
}
function networkArg(network: DockerNetworkMode, networkName?: string): string {
if (network === 'custom' && networkName) return networkName;
return network; // 'bridge' | 'none'
}
/**
* Build the `docker create` token list (from `create` through the `sleep
* infinity` CMD) for a long-lived, hardened, per-case container. PURE: every
* dynamic value is shellescaped; the caller joins with spaces into the launch
* string. Security invariants baked in: --cap-drop ALL, --security-opt
* no-new-privileges, --pids-limit, --memory==--memory-swap, --init,
* --pull=never, --restart no, NEVER --privileged, NEVER the docker socket.
*/
export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
const {
docker,
sessionId,
instance,
userArgs,
credentialMounts,
extraMounts,
envCreate,
addHostGateway,
gatewayAlias,
} = ctx;
const args: string[] = [
'create',
'--name',
shellescape(docker.containerName),
'--label',
'codeman.managed=1',
'--label',
shellescape(`codeman.instance=${instance}`),
'--label',
shellescape(`codeman.session=${sessionId.slice(0, 8)}`),
'--label',
shellescape(`codeman.confighash=${docker.configHash ?? dockerConfigHash(docker)}`),
'--pull=never',
'--init',
'--restart',
'no',
...userArgs,
'--workdir',
shellescape(docker.containerWorkdir),
// Workspace bind: mirror the host path inside the container so the transcript
// projHash correlates and file features read real host bytes.
'--mount',
shellescape(mountSpec({ src: docker.hostWorkspacePath, dst: docker.containerWorkdir })),
...credentialMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
...extraMounts.flatMap((m) => ['--mount', shellescape(mountSpec(m))]),
];
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
args.push(
...resourceFlags(docker.resources),
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
// cap is set, so the container's writable layer + volumes grow elastically as
// data flows in (bounded only by host disk).
...(docker.gpus ? ['--gpus', shellescape(docker.gpus)] : []),
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--network',
networkArg(docker.network, docker.networkName)
);
for (const [key, value] of Object.entries(envCreate)) {
args.push('--env', shellescape(`${key}=${value}`));
}
// Operator escape-hatch args (schema-validated NO_SHELL_INJECTION), escaped again here.
for (const extra of docker.extraCreateArgs ?? []) {
args.push(shellescape(extra));
}
args.push(shellescape(docker.image), 'sleep', 'infinity');
return args;
}
/**
* PURE argv for building the agent base image locally (the programmatic mirror of
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
*/
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
}
// ========== Credential mount resolution (IO) ==========
/** Container Claude config dir (created gid-0 writable in the image). */
export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`;
/** In-container path of the seeded (writable) `~/.claude.json`. */
export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`;
/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */
export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`;
/** Read-only seed paths for the files copied into the container's `.claude`. */
const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`;
const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`;
const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`;
/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */
const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`;
/**
* PURE: merge the host `~/.claude.json` into a config that makes an
* already-authenticated Claude skip its INTERACTIVE onboarding inside the container
* (the host file itself lacks these flags — the host install is grandfathered, so a
* verbatim copy still triggers the theme picker + login wizard + folder-trust
* prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is
* skipped), and marks the workspace project trusted + onboarded. Auth still comes
* from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`.
*/
export function buildSeamlessClaudeConfig(
hostConfig: Record<string, unknown>,
workspacePath: string,
theme = 'dark'
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...hostConfig };
merged.hasCompletedOnboarding = true;
if (typeof merged.theme !== 'string') merged.theme = theme;
const projects = { ...((merged.projects as Record<string, Record<string, unknown>> | undefined) ?? {}) };
const existing = (projects[workspacePath] as Record<string, unknown> | undefined) ?? {};
const seenCount = existing.projectOnboardingSeenCount;
projects[workspacePath] = {
...existing,
hasTrustDialogAccepted: true,
hasCompletedProjectOnboarding: true,
projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1,
};
merged.projects = projects;
return merged;
}
/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */
function readHostClaudeTheme(home: string): string | undefined {
try {
const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown };
return typeof parsed.theme === 'string' ? parsed.theme : undefined;
} catch {
return undefined;
}
}
/**
* Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges
* in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig),
* writes the result to a per-container seed file under `~/.codeman/docker-seeds/`,
* and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME
* once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename
* EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when
* parse/write fails (auth still works; the wizard may show). Returns null when the
* host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write).
*/
export function resolveClaudeJsonSeedMount(
home: string = homedir(),
containerName?: string,
workspacePath?: string
): DockerMount | null {
const src = join(home, '.claude.json');
if (!existsSync(src)) return null;
const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true };
if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount;
try {
const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record<string, unknown>;
const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark');
const seedsDir = dataPath('docker-seeds');
if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true });
const seedFile = join(seedsDir, `${containerName}.json`);
writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 });
return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true };
} catch {
return rawMount; // partial host write / unreadable — auth still carries, wizard may show
}
}
/** A file (or dir, when `recursive`) copied into the container HOME once at launch
* (`[ -e to ] || cp [-a] from to`). */
export interface DockerSeedCopy {
from: string;
to: string;
/** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */
recursive?: boolean;
}
export interface DockerClaudeArtifacts {
/** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */
mounts: DockerMount[];
/** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */
seedCopies: DockerSeedCopy[];
}
/**
* Resolve the ISOLATED Claude artifacts for a docker session (replaces the old
* whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross
* the boundary and seeds the rest as writable copies:
* - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`).
* - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard).
* - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds
* copied into the container's own `~/.claude` (token + global prefs carry in;
* the container refreshes its own copy and never writes back to the host).
* Everything else Claude writes (backups, tasks, teams, session-env, history) stays
* container-local. IO (reads host files, writes the merged `.claude.json` seed).
*/
export function resolveDockerClaudeArtifacts(
home: string,
containerName: string,
workspacePath: string
): DockerClaudeArtifacts {
const mounts: DockerMount[] = [];
const seedCopies: DockerSeedCopy[] = [];
// The ONE genuinely-shared part: conversation transcripts (dir mount → renames work).
const projectsSrc = join(home, '.claude', 'projects');
if (existsSync(projectsSrc)) {
mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` });
}
// ~/.claude.json → merged, onboarding-complete seed at HOME root.
const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath);
if (jsonSeed) {
mounts.push(jsonSeed);
seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME });
}
// credentials (token) + settings (theme/model/effort/permissions) + stats-cache
// (drives the model/effort status indicator) → writable copies inside the
// container's own ~/.claude (never a wholesale mount → no host pollution).
const files: Array<[rel: string, seed: string, dest: string]> = [
['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`],
['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`],
['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`],
];
for (const [rel, seed, dest] of files) {
const src = join(home, '.claude', rel);
if (existsSync(src)) {
mounts.push({ src, dst: seed, readonly: true });
seedCopies.push({ from: seed, to: dest });
}
}
return { mounts, seedCopies };
}
/**
* Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog
* of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its
* `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer +
* `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are
* seeded. The other three have no host-read/resume dependency and are fully
* seed-copied (writable copy in the container, no write-back to the host).
*/
interface CredStorePolicy {
/** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */
rel: string;
/** Subdirs bind-mounted RW (shared: resume + host reads). */
shareDirs?: string[];
/** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */
shareFiles?: string[];
/** Files seeded (RO mount → cp) into the container's own copy. */
seedFiles?: string[];
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
seedWhole?: boolean;
}
const CRED_STORES: CredStorePolicy[] = [
{ rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] },
{ rel: '.gemini', seedWhole: true },
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
];
/**
* Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old
* whole-dir RW mounts that let each in-container CLI write its refreshed tokens +
* session state back into the host). Every path is existsSync-gated (on most hosts
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
*/
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
const mounts: DockerMount[] = [];
const seedCopies: DockerSeedCopy[] = [];
for (const store of CRED_STORES) {
const hostBase = join(home, store.rel);
if (!existsSync(hostBase)) continue;
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud'
if (store.seedWhole) {
const seed = `${CRED_SEED_DIR}/${seedName}`;
mounts.push({ src: hostBase, dst: seed, readonly: true });
seedCopies.push({ from: seed, to: containerBase, recursive: true });
continue;
}
for (const sub of store.shareDirs ?? []) {
const src = join(hostBase, sub);
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` });
}
for (const file of store.shareFiles ?? []) {
const src = join(hostBase, file);
if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` });
}
for (const file of store.seedFiles ?? []) {
const src = join(hostBase, file);
if (existsSync(src)) {
const seed = `${CRED_SEED_DIR}/${seedName}-${file}`;
mounts.push({ src, dst: seed, readonly: true });
seedCopies.push({ from: seed, to: `${containerBase}/${file}` });
}
}
}
return { mounts, seedCopies };
}
// ========== 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<DockerInfoJson | null> {
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<DockerAvailability> {
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<boolean> {
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 EnsureImageResult {
ok: boolean;
/** true when this call actually ran a build (vs. the image already existing). */
built: boolean;
alreadyPresent: boolean;
error?: string;
}
/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */
const inFlightImageBuilds = new Map<string, Promise<EnsureImageResult>>();
/**
* Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and
* dist/index.js (esbuild prod: dist sits at repo root), since both are one level
* under the repo root. Returns null when the Dockerfile is absent (npm-global
* installs don't ship docker/ — Docker cases are a git-clone feature).
*/
function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null {
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile');
return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null;
}
/**
* Ensure the agent base image exists, BUILDING it locally on first use so a missing
* image is never a hard blocker (decision: "build locally on first use",
* docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per
* engine:image shared by concurrent callers), and a no-op under VITEST. Only the
* DEFAULT image is auto-built — we can never build a user's custom ref, and the
* `--pull=never` invariant forbids pulling. `onProgress` receives build output
* lines for SSE surfacing.
*/
export async function ensureAgentBaseImage(
engine: DockerEngine,
image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
): Promise<EnsureImageResult> {
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
if (await checkDockerImagePresent(engine, image)) {
return { ok: true, built: false, alreadyPresent: true };
}
if (image !== DEFAULT_AGENT_IMAGE) {
return {
ok: false,
built: false,
alreadyPresent: false,
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
};
}
const key = `${engine}:${image}`;
const existing = inFlightImageBuilds.get(key);
if (existing) return existing;
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
inFlightImageBuilds.set(key, build);
return build;
}
function buildAgentImage(
engine: DockerEngine,
image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean }
): Promise<EnsureImageResult> {
const resolved = resolveAgentDockerfile();
if (!resolved) {
return Promise.resolve({
ok: false,
built: false,
alreadyPresent: false,
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
});
}
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const forward = (buf: Buffer) => {
for (const line of buf.toString('utf-8').split('\n')) {
const trimmed = line.trimEnd();
if (trimmed) opts.onProgress?.(trimmed);
}
};
child.stdout?.on('data', forward);
child.stderr?.on('data', forward);
child.on('error', (err) => {
resolve({
ok: false,
built: false,
alreadyPresent: false,
error: `could not spawn ${engine} build: ${err.message}`,
});
});
child.on('exit', (code) => {
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
});
});
}
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<SessionDocker, 'engine' | 'image'>
): Promise<DockerTmuxCheckResult> {
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: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
};
}
try {
const { stdout } = await execFileAsync(
engine,
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const tmuxPath = stdout.trim();
if (!tmuxPath) {
return { ok: false, error: `base image ${docker.image} is missing tmux (required for durable sessions)` };
}
return { ok: true, tmuxPath };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, error: `could not verify tmux in ${docker.image}: ${msg}` };
}
}
/**
* Resolve the host's IP on the default docker bridge (the address a container
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
* there and in-container hooks can call back. Defaults to the conventional
* 172.17.0.1 when the inspect fails but docker is up; null when docker is absent.
* No-op canned value under VITEST.
*/
export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise<string | null> {
if (IS_TEST_MODE) return '172.17.0.1';
const bin = engine === 'podman' ? 'podman' : 'docker';
try {
const { stdout } = await execFileAsync(
bin,
['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const ip = stdout.trim();
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1';
} catch {
return null; // docker not available — nothing to bind
}
}
/**
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
*/
export async function reapOrphanedDockerContainers(
configDir: string,
instance: string,
engine: DockerEngine = 'docker'
): Promise<string[]> {
if (IS_TEST_MODE) return [];
const bin = engine === 'podman' ? 'podman' : 'docker';
let rows: Array<{ name: string; inst: string }> = [];
try {
const { stdout } = await execFileAsync(
bin,
[
'ps',
'-a',
'--filter',
'label=codeman.managed=1',
'--format',
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
rows = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, inst = ''] = line.split('\t');
return { name, inst };
});
} catch {
return []; // daemon down / engine absent — nothing to reap
}
const cases = await readDockerCases(configDir);
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
const reaped: string[] = [];
for (const { name, inst } of rows) {
if (inst !== instance) continue; // only THIS instance's containers
if (expected.has(name)) continue; // still referenced by a live case
try {
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
reaped.push(name);
} catch {
/* best-effort */
}
}
return reaped;
}
/**
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> 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<SessionDocker, 'engine' | 'containerName'>,
mode: SessionMode
): Promise<string | undefined> {
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;
}
}
+7
View File
@@ -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). */
+73 -3
View File
@@ -50,7 +50,9 @@ import {
type EffortLevel,
type GeminiConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
@@ -62,6 +64,7 @@ import {
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
execPattern,
getClaudeCliVersion,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -177,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
const DEFAULT_PTY_COLS = 120;
const DEFAULT_PTY_ROWS = 40;
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
/** Delay before the in-container Claude CLI version probe (lets the container start). */
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
/**
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
@@ -211,8 +216,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
}
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string {
return remote ? '/tmp' : workingDir;
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
return remote || docker ? '/tmp' : workingDir;
}
/**
@@ -401,6 +408,10 @@ export class Session extends EventEmitter {
// Remote execution metadata, present when this session runs over SSH through local tmux.
private readonly _remote?: SessionRemote;
// Docker execution metadata, present when this session runs inside a container via
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
private readonly _docker?: SessionDocker;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -474,6 +485,8 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
docker?: SessionDocker;
}
) {
super();
@@ -547,6 +560,7 @@ export class Session extends EventEmitter {
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
@@ -648,6 +662,11 @@ export class Session extends EventEmitter {
return this._claudeSessionId;
}
/** Docker execution metadata when this session runs inside a container, else undefined. */
get docker(): SessionDocker | undefined {
return this._docker;
}
// Adopt a Claude conversation ID observed from an external source (e.g. hook
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
@@ -1007,6 +1026,7 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
remote: this._remote,
docker: this._docker,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,
@@ -1196,7 +1216,7 @@ export class Session extends EventEmitter {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
@@ -1308,6 +1328,52 @@ export class Session extends EventEmitter {
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
// Seed the CLI version deterministically for LOCAL Claude sessions. The
// banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code
// builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions
// never show it — which left cliVersion undefined and silently disabled
// wheel-forwarding to Claude's own transcript (the only route to history in
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
// another host, so a local probe wouldn't reflect their version — skip them
// and let the banner scrape handle those. Cached process-wide, best-effort.
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = getClaudeCliVersion();
if (probedVersion) {
this._cliVersion = probedVersion;
this.emit('cliInfoUpdated', {
version: this._cliVersion,
model: this._cliModel,
accountType: this._cliAccountType,
latestVersion: this._cliLatestVersion,
});
}
}
// Docker sessions run claude INSIDE the container, so the local probe above
// reports the HOST claude (wrong version, and leaving cliVersion undefined
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
// instead — deferred so the container is up after the mux attach below.
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
const dockerMeta = this._docker;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
void probeDockerCliVersion(dockerMeta, this.mode)
.then((version) => {
if (!version || this._isStopped || this._cliVersion) return;
this._cliVersion = version;
this.emit('cliInfoUpdated', {
version: this._cliVersion,
model: this._cliModel,
accountType: this._cliAccountType,
latestVersion: this._cliLatestVersion,
});
})
.catch(() => {
/* best-effort */
});
}, DOCKER_CLI_VERSION_PROBE_DELAY_MS);
}
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
@@ -1328,6 +1394,7 @@ export class Session extends EventEmitter {
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
},
createSessionOptions: {
sessionId: this.id,
@@ -1346,6 +1413,7 @@ export class Session extends EventEmitter {
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
},
spawnErrLabel: 'mux attachment',
});
@@ -1716,6 +1784,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
},
createSessionOptions: {
sessionId: this.id,
@@ -1726,6 +1795,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
},
spawnErrLabel: 'shell mux attachment',
});
+306 -5
View File
@@ -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,24 @@ 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,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
@@ -812,6 +828,267 @@ 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 <container>
// 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<string, string>;
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
execEnvNames: string[];
/**
* Files to copy from read-only seed mounts into the container's writable HOME once
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
* are writable copies (not host mounts), so the container never re-auths and never
* writes its runtime state back into the host `~/.claude`.
*/
seedCopies?: DockerSeedCopy[];
}
/**
* 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>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = 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 (it is normally auto-built on first use)`
);
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; }`;
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
// constants (no shell metachars), so the whole inner command is shell-quoted once.
const seedSteps = (seedCopies ?? []).map((s) => {
const cp = s.recursive ? 'cp -a' : 'cp';
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
});
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
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[] = [];
const extraMounts: DockerMount[] = [];
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
// sessions/+history) and seeds everything else (tokens, settings, configs) as
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
// its runtime state back into the host dirs. Only when credentials are mounted.
let seedCopies: DockerSeedCopy[] = [];
if (docker.mountCredentials) {
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
const credArtifacts = resolveDockerCredentialArtifacts(home);
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
}
const envCreate: Record<string, string> = {
HOME: CONTAINER_HOME,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
// is refused when that path pre-exists root-owned — which happens when the
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
// to any workspace location. Non-secret path, safe to be committed on export.
CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
};
if (docker.hooksEnabled) {
// Derive a container-reachable API url (scheme + port preserved; host swapped
// for the engine gateway alias). Prod is HTTPS on 3000.
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
const hookSecretPath = dataPath('hook-secret');
if (existsSync(hookSecretPath)) {
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
}
}
const createContext: DockerCreateContext = {
docker,
sessionId,
instance: CODEMAN_INSTANCE,
userArgs,
credentialMounts,
extraMounts,
envCreate,
addHostGateway: !isDesktop,
gatewayAlias,
};
const execEnv: Record<string, string> = {
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
// renders box-drawing correctly even when reattaching to a container created
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
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, seedCopies };
}
/**
* 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 +1486,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 +1506,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
createdAt: Date.now(),
workingDir,
remote,
docker,
mode,
attached: false,
name,
@@ -1273,7 +1552,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 +1607,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 +1682,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
createdAt: Date.now(),
workingDir,
remote,
docker,
mode,
attached: false,
name,
@@ -1484,6 +1768,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 +1806,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 +1828,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 +2014,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 {
+9 -1
View File
@@ -124,7 +124,7 @@ export interface CaseInfo {
/** Whether CLAUDE.md exists */
hasClaudeMd?: boolean;
/** Case storage/execution location */
location?: 'local' | 'linked-local' | 'remote';
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/** Remote case metadata for display and session creation */
@@ -134,6 +134,14 @@ export interface CaseInfo {
username: string;
path: string;
};
/** Docker case metadata for display and session creation */
docker?: {
hostId: string;
container: string;
image?: string;
path: string;
network?: string;
};
}
// ========== Error Handling Utilities ==========
+118
View File
@@ -98,6 +98,122 @@ export interface SessionRemote extends RemoteSshOptions {
commands?: Partial<Record<RemoteCommandMode, string>>;
}
// ========== 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<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
/** 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-<slug>` (future egress-allowlist chokepoint)
*/
export type DockerNetworkMode = 'bridge' | 'none' | 'custom';
/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */
export interface DockerResourceLimits {
/** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */
memory?: string;
/** e.g. '2' -> --cpus 2 */
cpus?: string;
/** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */
pidsLimit?: number;
/** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */
nofile?: string;
/** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */
shmSize?: string;
}
/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */
export interface DockerHost {
id: string;
label: string;
/** Engine; when absent the availability probe resolves it (docker, else podman). */
engine?: DockerEngine;
/** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */
image: string;
/** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */
daemonHost?: string;
/** Advanced: docker `--context` name. */
context?: string;
/** Network mode (default 'bridge'). */
network?: DockerNetworkMode;
/** Custom bridge name when network === 'custom'. */
networkName?: string;
resources?: DockerResourceLimits;
/** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus <value>` (needs the NVIDIA container toolkit). */
gpus?: string;
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
mountCredentials?: boolean;
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
hooksEnabled?: boolean;
/** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */
resumeOnStart?: boolean;
/** Per-mode command overrides (mirror RemoteHost.commands). */
commands?: Partial<Record<DockerCommandMode, string>>;
/** 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-<slug>). */
container?: string;
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
lastClaudeSessionId?: string;
}
/**
* Flattened Docker execution metadata carried on a live session (mirror of
* SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json.
*/
export interface SessionDocker {
hostId: string;
label: string;
engine: DockerEngine;
image: string;
/** Per-CASE container name (shared by all sessions of the case). */
containerName: string;
hostWorkspacePath: string;
containerWorkdir: string;
network: DockerNetworkMode;
networkName?: string;
resources?: DockerResourceLimits;
/** GPU allocation ('all' / '1' / 'device=0,1'). */
gpus?: string;
mountCredentials: boolean;
hooksEnabled: boolean;
resumeOnStart: boolean;
daemonHost?: string;
context?: string;
commands?: Partial<Record<DockerCommandMode, string>>;
extraCreateArgs?: string[];
extraExecArgs?: string[];
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
configHash?: string;
}
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
@@ -217,6 +333,8 @@ export interface SessionState {
workingDir: string;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
docker?: SessionDocker;
/** ID of currently assigned task, null if none */
currentTaskId: string | null;
/** Timestamp when session was created */
+41 -1
View File
@@ -8,7 +8,7 @@
* @module utils/claude-cli-resolver
*/
import { execSync } from 'node:child_process';
import { execSync, execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { homedir } from 'node:os';
@@ -83,3 +83,43 @@ export function getAugmentedPath(): string {
_augmentedPath = currentPath;
return _augmentedPath;
}
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
let _claudeVersion: string | null | undefined = undefined;
/**
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
* can't be determined. Runs `claude --version` once and caches the result.
*
* This is a deterministic alternative to scraping the interactive startup
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
* reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never
* show it, which left `cliVersion` undefined and silently disabled features
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
*/
export function getClaudeCliVersion(): string | null {
if (_claudeVersion !== undefined) return _claudeVersion;
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
// it on the session directly.
if (process.env.VITEST) {
_claudeVersion = null;
return _claudeVersion;
}
try {
const dir = findClaudeDir();
const bin = dir ? join(dir, 'claude') : 'claude';
// execFileSync (no shell) — the resolved path may contain spaces, and there
// is no untrusted input, but avoid a shell either way.
const out = execFileSync(bin, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
env: { ...process.env, PATH: getAugmentedPath() },
});
const match = out.match(/(\d+\.\d+\.\d+)/);
_claudeVersion = match ? match[1] : null;
} catch {
_claudeVersion = null;
}
return _claudeVersion;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
+13
View File
@@ -151,6 +151,19 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
// Sliding cookie: re-issue on every authenticated request so the browser
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
// Without this the cookie has a fixed lifetime from login; the browser
// drops it mid-use, the next request arrives cookie-less and falls through
// to Basic Auth — popping the native username/password dialog, which reads
// as a random logout while actively working.
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: https,
sameSite: 'lax',
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
path: '/',
});
done();
return;
}
+12
View File
@@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean {
*/
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
/**
* Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a
* docker case carries `Host: host.docker.internal:<port>` (the derived
* CODEMAN_API_URL), so the always-on host guard must allow it or every in-container
* hook is blocked 403. These names only resolve to the host from within a
* container's network namespace, so they are not a DNS-rebinding surface for a
* normal browser. Both engines' aliases are allowed so a mixed fleet keeps working.
*/
export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal'];
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
export interface HostPolicy {
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
@@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean {
const bind = parseAuthorityHostname(policy.bindHost);
if (bind && hostname === bind) return true;
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
// Docker/Podman container-to-host gateway aliases (for in-container hook curls).
if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true;
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
}
+76
View File
@@ -1433,6 +1433,49 @@ class CodemanApp {
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
addListener(event, () => this._onSessionListMaybeChanged());
}
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success');
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker export complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error');
} catch (err) {
console.error('[SSE] docker export failed:', err);
}
});
// Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained.
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
duration: 8000,
});
});
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
else this.showToast('Agent image ready. Starting the container...', 'success');
} catch (err) {
console.error('[SSE] docker image build complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
} catch (err) {
console.error('[SSE] docker image build failed:', err);
}
});
}
// ═══════════════════════════════════════════════════════════════
@@ -2369,6 +2412,39 @@ class CodemanApp {
this._reliableSend(sessionId, input, opts?.useMux === true);
}
/**
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
* so it isn't persisted, retried, or counted in the pending-bytes connection
* indicator (which was flickering "11b/22b queued" on every scroll tick). A
* dropped scroll tick is harmless; keystrokes still go through _sendInputAsync.
* The server applies a seq-less {t:'i'} frame / seq-less POST unconditionally
* and sends no ACK (ws-routes.ts, session-routes input handler).
*/
_sendInputEphemeral(sessionId, input) {
if (!sessionId || !input) return;
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'i', d: input }));
return;
} catch {
// socket died mid-send — fall through to a best-effort POST
}
}
// No usable WS for this session: best-effort POST, not queued. Dropped on
// failure — a scroll tick lost while offline needs no recovery.
try {
fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
keepalive: true,
}).catch(() => {});
} catch {
// ignore — loss-tolerant
}
}
/** Record one input frame and kick delivery. The record lives until ACKed. */
_reliableSend(sessionId, data, useMux) {
const seq = this._nextSeq(sessionId);
+7
View File
@@ -474,6 +474,13 @@ const SSE_EVENTS = {
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_ORDER_CHANGED: 'case:order-changed',
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
};
// ═══════════════════════════════════════════════════════════════
+150 -3
View File
@@ -118,12 +118,13 @@
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
@@ -557,7 +558,7 @@
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -1179,6 +1180,16 @@
</div>
<!-- Input Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
<div class="settings-item-text">
<span class="settings-item-label">Wheel Scrolls Local History</span>
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsTerminalWheelLocal">
<span class="slider"></span>
</label>
</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
<div class="settings-item-text">
<span class="settings-item-label">Local Echo</span>
@@ -1258,6 +1269,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
<span class="settings-item-label">File Viewer</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowFileViewerButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
<span class="settings-item-label">Attachments Button</span>
<label class="switch switch-sm">
@@ -1272,6 +1290,27 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)">
<span class="settings-item-label">Session Manager Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowSessionButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the away digest button in the header (opens the 'what happened while you were away' summary)">
<span class="settings-item-label">Away Digest Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowAwayDigestButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the Cron button in the footer toolbar (opens the cron jobs manager)">
<span class="settings-item-label">Cron Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowCronButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
<span class="settings-item-label">Redraw Terminal Button</span>
<label class="switch switch-sm">
@@ -1818,6 +1857,7 @@
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
</div>
<div class="modal-body">
@@ -1832,6 +1872,53 @@
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
</div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Template</label>
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
<option value="small">Small — 2 GB RAM, 1 CPU</option>
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
<option value="large">Large — 8 GB RAM, 4 CPU</option>
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
<option value="custom">Custom</option>
</select>
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
</div>
<div class="form-row">
<label>Memory</label>
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label>CPUs</label>
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label>GPUs</label>
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
</div>
<div class="form-row">
<label>Network</label>
<select id="quickDockerNetwork">
<option value="bridge">bridge (internet on)</option>
<option value="none">none (fully isolated)</option>
</select>
</div>
<div class="form-row">
<label>Image</label>
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
</div>
</div>
</details>
</div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
@@ -1906,12 +1993,72 @@
</div>
</details>
</div>
<!-- Docker Tab -->
<div class="modal-tab-content hidden" id="case-docker">
<div class="form-row">
<label>Case Name</label>
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Runs inside an isolated container. Multiple sessions can share the same container.</span>
</div>
<div class="form-row">
<label>Workspace Path</label>
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
</div>
<div class="form-row">
<label>Host ID</label>
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
</div>
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
<select id="dockerNetwork">
<option value="bridge">bridge (internet on, default)</option>
<option value="none">none (fully isolated, no network)</option>
<option value="custom">custom bridge</option>
</select>
</div>
<details class="advanced-options">
<summary>Advanced container settings</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Memory</label>
<input type="text" id="dockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
<span class="form-hint">Optional, e.g. 4g / 512m. Enforced as a hard OOM cap where the engine supports it.</span>
</div>
<div class="form-row">
<label>CPUs</label>
<input type="text" id="dockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="dockerMountCredentials" checked> Mount host credentials (~/.claude etc.)</label>
<span class="form-hint">On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.</span>
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="dockerResumeOnStart" checked> Resume last conversation on relaunch</label>
</div>
</div>
</details>
<span class="form-hint" id="dockerLinkStatus" style="margin-top: 8px; display: block;"></span>
</div>
<!-- Manage Tab -->
<div class="modal-tab-content hidden" id="case-manage">
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
<div id="dockerExportsSection" style="margin-top: 16px; border-top: 1px solid var(--border, #333); padding-top: 12px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
<strong style="font-size: 13px;">Docker exports</strong>
<button class="btn-toolbar" onclick="app.refreshDockerExports()">Refresh</button>
</div>
<div class="case-manage-list" id="dockerExportsList"><span class="form-hint">No exports yet. Export a docker case from its tab.</span></div>
</div>
</div>
</div>
<div class="form-actions">
+30
View File
@@ -3098,6 +3098,32 @@ Object.assign(CodemanApp.prototype, {
}
},
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
// File Viewer). Toggles the file browser panel open/closed without a trip
// through settings. Persists via the same `showFileBrowser` flag the Panels
// section + the panel's own close (X) use, so the three stay in sync.
toggleFileBrowserButton() {
const panel = this.$('fileBrowserPanel');
const isOpen = panel?.classList.contains('visible');
const btn = document.querySelector('.btn-file-viewer');
if (isOpen) {
this.closeFileBrowserPanel();
if (btn) btn.setAttribute('aria-expanded', 'false');
return;
}
if (!this.activeSessionId) {
this.showToast('Open a session to browse its files', 'info');
return;
}
const settings = this.loadAppSettingsFromStorage();
settings.showFileBrowser = true;
this.saveAppSettingsToStorage(settings);
const checkbox = document.getElementById('appSettingsShowFileBrowser');
if (checkbox) checkbox.checked = true;
this.applyMonitorVisibility();
if (btn) btn.setAttribute('aria-expanded', 'true');
},
closeFileBrowserPanel() {
const panel = this.$('fileBrowserPanel');
if (panel) {
@@ -3130,6 +3156,10 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
settings.showFileBrowser = false;
this.saveAppSettingsToStorage(settings);
const checkbox = document.getElementById('appSettingsShowFileBrowser');
if (checkbox) checkbox.checked = false;
const headerBtn = document.querySelector('.btn-file-viewer');
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
},
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
+310 -37
View File
@@ -45,7 +45,18 @@ Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
formatCasePickerLabel(c) {
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
return c?.name || '';
},
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
dockerCaseTag(hostId) {
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
return hostId;
},
buildCasePickerOptions(cases = []) {
@@ -70,7 +81,10 @@ Object.assign(CodemanApp.prototype, {
c.location,
c.remote?.hostId,
c.remote?.label,
c.remote?.path
c.remote?.path,
c.docker?.container,
c.docker?.image,
c.docker?.path
].filter(Boolean).join(' ').toLowerCase();
return { name: c.name, label, case: c, searchText };
})
@@ -480,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
input.value = Math.max(1, current - 1);
},
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
// w2-<case> for agents, s1-<case> for shells), shared by the local and
// remote/docker launch paths so all tabs follow the same naming convention.
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
let startNumber = 1;
for (const [, session] of this.sessions || []) {
const match = session.name && session.name.match(re);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
return startNumber;
},
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
@@ -517,13 +547,16 @@ Object.assign(CodemanApp.prototype, {
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
// through /api/quick-start, which resolves the remote case + launches via ssh.
if (caseData.location === 'remote') {
if (caseData.location === 'remote' || caseData.location === 'docker') {
// Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName);
const remoteIds = [];
for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'claude' })
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
@@ -541,16 +574,7 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -694,25 +718,31 @@ Object.assign(CodemanApp.prototype, {
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
selectedCase?.location === 'remote' ||
selectedCase?.location === 'docker';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
if (caseData.location === 'remote') {
if (caseData.location === 'remote' || caseData.location === 'docker') {
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
const remoteIds = [];
for (let i = 0; i < shellCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell' })
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
this.activeSessionId = remoteIds[0];
// Don't pre-set activeSessionId — selectSession early-returns when the
// IDs match, skipping the buffer load, tab activation, and focus (see runCodex).
await this.selectSession(remoteIds[0]);
}
this.terminal.focus();
@@ -720,16 +750,7 @@ Object.assign(CodemanApp.prototype, {
}
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Create all shell sessions in parallel
const sessionNames = [];
@@ -770,9 +791,11 @@ Object.assign(CodemanApp.prototype, {
));
}
// Switch to first session
// Switch to first session. Don't pre-set activeSessionId — selectSession
// early-returns when the IDs match, skipping the buffer load, tab
// activation, and focus (see runCodex), which left the new shell tab
// created but not shown until the user manually clicked it.
if (sessionIds.length > 0) {
this.activeSessionId = sessionIds[0];
await this.selectSession(sessionIds[0]);
}
@@ -786,7 +809,8 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
// probe and the local-only config/env below don't apply (quick-start rejects them).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
@@ -815,6 +839,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'opencode',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -840,7 +865,8 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
@@ -866,6 +892,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'codex',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
@@ -894,7 +921,8 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
@@ -919,6 +947,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'gemini',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -1564,10 +1593,17 @@ Object.assign(CodemanApp.prototype, {
if (tabName === 'case-manage') {
submitBtn.style.display = 'none';
this.renderCaseManageList();
this.refreshDockerExports();
} else {
submitBtn.style.display = '';
submitBtn.textContent =
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
tabName === 'case-create'
? 'Create'
: tabName === 'case-remote'
? 'Link Remote'
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
@@ -1576,6 +1612,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('linkCaseName').focus();
} else if (tabName === 'case-remote') {
document.getElementById('remoteCaseName').focus();
} else if (tabName === 'case-docker') {
document.getElementById('dockerCaseName').focus();
}
},
@@ -1593,6 +1631,8 @@ Object.assign(CodemanApp.prototype, {
await this.createCase();
} else if (this.caseModalTab === 'case-remote') {
await this.linkRemoteCase();
} else if (this.caseModalTab === 'case-docker') {
await this.linkDockerCase();
} else {
await this.linkCase();
}
@@ -1616,21 +1656,36 @@ Object.assign(CodemanApp.prototype, {
return;
}
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: { name, description };
try {
const res = await fetch('/api/cases', {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" created`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
if (inDocker) {
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
}
@@ -1640,6 +1695,47 @@ Object.assign(CodemanApp.prototype, {
}
},
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
// the server uses its defaults (no per-case host); `custom` leaves them editable.
applyDockerTemplate() {
const t = document.getElementById('quickDockerTemplate')?.value;
const presets = {
small: { m: '2g', c: '1', g: '' },
medium: { m: '', c: '', g: '' },
large: { m: '8g', c: '4', g: '' },
gpu: { m: '8g', c: '4', g: 'all' },
};
const p = presets[t];
if (!p) return; // 'custom' — leave fields as-is
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.value = v;
};
set('quickDockerMemory', p.m);
set('quickDockerCpus', p.c);
set('quickDockerGpus', p.g);
},
// Collect only the non-default docker overrides (empty fields fall back to defaults
// server-side; sent as undefined, never null, per the Zod .optional() gotcha).
_collectDockerQuickSettings() {
const val = (id) => (document.getElementById(id)?.value || '').trim();
const o = {};
const mem = val('quickDockerMemory');
if (mem) o.memory = mem;
const cpus = val('quickDockerCpus');
if (cpus) o.cpus = cpus;
const gpus = val('quickDockerGpus');
if (gpus && gpus.toLowerCase() !== 'none') o.gpus = gpus;
const net = document.getElementById('quickDockerNetwork')?.value;
if (net && net !== 'bridge') o.network = net;
const img = val('quickDockerImage');
if (img) o.image = img;
const mc = document.getElementById('quickDockerMountCreds');
if (mc && !mc.checked) o.mountCredentials = false;
return o;
},
async linkCase() {
const name = document.getElementById('linkCaseName').value.trim();
const path = document.getElementById('linkCasePath').value.trim();
@@ -1764,6 +1860,177 @@ Object.assign(CodemanApp.prototype, {
}
},
async linkDockerCase() {
const name = document.getElementById('dockerCaseName').value.trim();
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
const network = document.getElementById('dockerNetwork').value;
const memory = document.getElementById('dockerMemory').value.trim();
const cpus = document.getElementById('dockerCpus').value.trim();
const mountCredentials = document.getElementById('dockerMountCredentials').checked;
const resumeOnStart = document.getElementById('dockerResumeOnStart').checked;
const statusEl = document.getElementById('dockerLinkStatus');
if (!name || !hostWorkspacePath) {
this.showToast('Please enter a case name and workspace path', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!hostWorkspacePath.startsWith('/')) {
this.showToast('Workspace path must be absolute', 'error');
return;
}
try {
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
const resources = {};
if (memory) resources.memory = memory;
if (cpus) resources.cpus = cpus;
const hostPayload = {
id: hostId,
label: hostId,
image,
network,
mountCredentials,
resumeOnStart,
...(Object.keys(resources).length ? { resources } : {}),
};
// PUT (update-or-create) so re-linking with the same host id refreshes its settings.
let hostRes = await fetch('/api/docker-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload),
});
let hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode === 'ALREADY_EXISTS') {
hostRes = await fetch(`/api/docker-hosts/${encodeURIComponent(hostId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload),
});
hostData = await hostRes.json();
}
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
const caseRes = await fetch('/api/cases/docker-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
if (statusEl) statusEl.textContent = caseData.error || 'Failed to link docker case';
this.showToast(caseData.error || 'Failed to link docker case', 'error');
}
} catch (err) {
console.error('Failed to link docker case:', err);
if (statusEl) statusEl.textContent = err.message;
this.showToast('Failed to link docker case: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Docker export / import UI
// ═══════════════════════════════════════════════════════════════
async refreshDockerExports() {
const listEl = document.getElementById('dockerExportsList');
if (!listEl) return;
try {
const res = await fetch('/api/docker-exports');
const data = await res.json();
const exports = data?.data?.exports || [];
if (exports.length === 0) {
listEl.innerHTML = '<span class="form-hint">No exports yet. Export a docker case from its tab.</span>';
return;
}
listEl.innerHTML = exports
.map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1);
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
</span>
</div>`;
})
.join('');
} catch (err) {
listEl.innerHTML = `<span class="form-hint">Failed to load exports: ${err.message}</span>`;
}
},
async exportDockerCaseBundle(caseName, mode = 'full') {
try {
const res = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
const data = await res.json();
if (data.success) {
this.showToast(`Exporting "${caseName}" (${mode})... you'll be notified when the bundle is ready`, 'info');
} else {
this.showToast(data.error || 'Export failed', 'error');
}
} catch (err) {
this.showToast('Export failed: ' + err.message, 'error');
}
},
async importDockerBundle(bundle) {
const newCaseName = prompt('New case name for the imported bundle:', bundle.split('-')[0] + '-imported');
if (!newCaseName) return;
const destWorkspacePath = prompt('Absolute host directory to restore the workspace into:', '');
if (!destWorkspacePath) return;
try {
const res = await fetch('/api/docker-cases/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bundle, newCaseName, destWorkspacePath }),
});
const data = await res.json();
if (data.success) {
this.showToast(`Imported as "${newCaseName}"`, 'success');
await this.loadQuickStartCases(newCaseName);
} else {
this.showToast(data.error || 'Import failed', 'error');
}
} catch (err) {
this.showToast('Import failed: ' + err.message, 'error');
}
},
async deleteDockerExport(filename) {
if (!confirm(`Delete export bundle "${filename}"?`)) return;
try {
const res = await fetch(`/api/docker-exports/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast('Export deleted', 'success');
this.refreshDockerExports();
} else {
this.showToast(data.error || 'Delete failed', 'error');
}
} catch (err) {
this.showToast('Delete failed: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════
@@ -1788,6 +2055,12 @@ Object.assign(CodemanApp.prototype, {
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
${
c.location === 'docker'
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
title="Export container (full image + workspace) to move to another machine">&#x1F4E6;</button>`
: ''
}
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
+58 -1
View File
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
@@ -324,6 +325,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -337,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
@@ -1420,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
@@ -1430,12 +1438,16 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
@@ -1608,7 +1620,14 @@ Object.assign(CodemanApp.prototype, {
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
showFileViewerButton: _fvb,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Per-device header/toolbar button toggles — client-only, and absent from
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton: _ssb,
showAwayDigestButton: _adb,
showCronButton: _crb,
...serverSettings
} = settings;
try {
@@ -1765,7 +1784,11 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
showFileViewerButton: false,
showRedrawButton: false,
showSessionButton: false,
showAwayDigestButton: false,
showCronButton: true,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1775,6 +1798,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
webglRendererEnabled: false, // mobile always uses the DOM renderer
skin: 'daylight-blue',
};
@@ -1877,6 +1901,14 @@ Object.assign(CodemanApp.prototype, {
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
}
// File Viewer header button — opt-in, default OFF. Marker class (base is
// display:inline-flex !important); clicking it toggles the file browser panel.
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
const fileViewerBtn = document.querySelector('.btn-file-viewer');
if (fileViewerBtn) {
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -1912,6 +1944,29 @@ Object.assign(CodemanApp.prototype, {
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
}
// Session Manager button — opt-in, hidden by default (App Settings → Display).
// Marker class (base is display:inline-flex !important); phones keep it hidden
// via mobile.css regardless. Sessions stay reachable via the Ctrl+K palette.
const showSessionButton = settings.showSessionButton ?? defaults.showSessionButton ?? false;
const sessionBtn = document.querySelector('.btn-session-manager');
if (sessionBtn) {
sessionBtn.classList.toggle('btn-session-manager--hidden', !showSessionButton);
}
// Away Digest button — opt-in, hidden by default. Same marker pattern.
const showAwayDigestButton = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
const awayDigestBtn = document.querySelector('.btn-away-digest');
if (awayDigestBtn) {
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
}
// Cron button (footer toolbar) — shown by default; hide when disabled.
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
const cronBtn = document.querySelector('.btn-cron');
if (cronBtn) {
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -2148,7 +2203,9 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
+54
View File
@@ -9447,6 +9447,24 @@ kbd {
padding-left: 0.5rem;
}
/* "Run in Docker" quick option — the primary one-click entry point, so its
label + hint read larger and brighter than the standard form label/hint. */
.docker-quick-row .checkbox-row {
font-size: 0.9rem;
color: var(--text);
}
.docker-quick-row .form-hint {
font-size: 0.8rem;
line-height: 1.45;
}
/* The container-settings panel is always shown + expanded (no longer gated on
the checkbox), so its summary header reads a touch larger too. */
.docker-quick-settings > summary {
font-size: 0.85rem;
}
/* ═══════════════════════════════════════════════════════════════
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
@@ -9471,6 +9489,42 @@ kbd {
display: none !important;
}
/* "Session Manager" + "Away Digest" header buttons — opt-in (App Settings →
Display), hidden by default. Same marker pattern as the response viewer: a
base inline-flex !important so an inline style can't override it, and a
more-specific marker rule to hide. Phones keep them hidden regardless via the
higher-specificity mobile.css rule (.btn-icon-header.btn-...). */
.btn-session-manager {
display: inline-flex !important;
}
.btn-session-manager.btn-session-manager--hidden {
display: none !important;
}
.btn-away-digest {
display: inline-flex !important;
}
.btn-away-digest.btn-away-digest--hidden {
display: none !important;
}
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
by default. Clicking it toggles the file browser panel. Same marker pattern as
the response viewer: a base inline-flex !important so an inline style can't
override it, and a more-specific marker rule to hide. */
.btn-file-viewer {
display: inline-flex !important;
}
.btn-file-viewer.btn-file-viewer--hidden {
display: none !important;
}
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
hide it). Toolbar button, not a header icon, so only the hide marker is
needed; out-specify any base .btn-toolbar display. */
.btn-toolbar.btn-cron--hidden {
display: none !important;
}
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
Base inline-flex !important + a more-specific marker rule to hide. */
+29 -8
View File
@@ -385,7 +385,7 @@ Object.assign(CodemanApp.prototype, {
'wheel',
(ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
const lines = this._wheelScrollLines(ev);
if (this._shouldForwardWheelToApp(ev)) {
this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines);
return;
@@ -2520,8 +2520,25 @@ Object.assign(CodemanApp.prototype, {
// unknown version is treated as older). Gemini is a strip mode too but its
// wheel behavior is unverified, so it keeps the local wheel — taps/clicks
// are still forwarded for it (harmless no-ops at worst).
// Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger
// scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and
// Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns
// false on Shift). So under Shift, read whichever axis dominates — otherwise
// deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through
// history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are
// unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving.
_wheelScrollLines(ev) {
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
return Math.round(delta / 25) || (delta > 0 ? 1 : -1);
},
_shouldForwardWheelToApp(ev) {
if (ev.shiftKey) return false;
// Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
// it over forwarding the wheel to the CLI's transcript (issue #154). Cheap —
// loadAppSettingsFromStorage() is cache-backed.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = this.terminal?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(this.activeSessionId);
@@ -2535,12 +2552,13 @@ Object.assign(CodemanApp.prototype, {
},
// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
// cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits
// dozens of wheel events per second and each _sendInputAsync becomes a tmux
// send-keys on the server — unbatched, a single flick would spawn a process
// storm. Per-event tick count is capped (Claude applies its own scroll-speed
// multiplier and acceleration on top), and the queue is bounded so a wild
// scroll can't build a backlog that keeps scrolling after the finger stops.
// cell. Reports are coalesced into one fire-and-forget write per ~40ms: a
// trackpad emits dozens of wheel events per second and each send becomes a
// tmux send-keys on the server — unbatched, a single flick would spawn a
// process storm. Per-event tick count is capped (Claude applies its own
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
@@ -2558,7 +2576,10 @@ Object.assign(CodemanApp.prototype, {
this._wheelSgrFlushTimer = null;
const data = this._wheelSgrQueue;
this._wheelSgrQueue = '';
if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data);
// Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip
// the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker
// in the connection indicator on every scroll tick.
if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data);
},
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
+504 -3
View File
@@ -5,11 +5,13 @@
*/
import { FastifyInstance } from 'fastify';
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
import { exec } from 'node:child_process';
import fs from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -17,13 +19,34 @@ import {
CaseOrderSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
DockerHostSchema,
DockerExportSchema,
DockerImportSchema,
DockerQuickCreateSchema,
} from '../schemas.js';
import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkDockerAvailable,
checkDockerImagePresent,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
readDockerCases,
readDockerHosts,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
} from '../../docker-hosts.js';
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
import {
checkRemoteTmuxAvailable,
readRemoteCases,
@@ -36,6 +59,19 @@ import {
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
const DEFAULT_DOCKER_HOST_ID = 'default';
/** App version for export manifests (best-effort read of package.json). */
const APP_VERSION = (() => {
try {
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown';
} catch {
return 'unknown';
}
})();
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
@@ -49,6 +85,48 @@ async function resolveCasePath(name: string): Promise<string> {
return join(CASES_DIR, name);
}
/**
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
* use so a missing image is never a blocker (the user's ask: "create it when it's
* used for the first time"). Present image → verify tmux (hard prerequisite).
* Default image missing → kick off a BACKGROUND build with SSE progress and return
* `imageBuilding: true` (the case is created regardless; first launch awaits the
* same dedup'd build). Custom image missing → a real error (we can't build a
* foreign ref, and `--pull=never` forbids pulling).
*/
async function ensureCaseImage(
broadcast: EventPort['broadcast'],
sessionDocker: SessionDocker,
name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false };
}
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
return {
ok: false,
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
};
}
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
})
.then((r) =>
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
name,
image: sessionDocker.image,
error: r.error,
})
)
.catch((err) =>
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
);
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
@@ -118,6 +196,35 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
}
// Get docker cases
const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host]));
for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
const host = dockerHostMap.get(dockerCase.hostId);
if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue;
existingNames.add(dockerCase.name);
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
const dockerCaseInfo: CaseInfo = {
name: dockerCase.name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
container,
image: host.image,
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
},
};
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
if (existingIndex === -1) {
cases.push(dockerCaseInfo);
} else {
cases[existingIndex] = dockerCaseInfo;
}
}
// Sort by persisted caseOrder from settings.json
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
@@ -232,6 +339,354 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { case: remoteCase } };
});
// ========== Docker hosts + docker cases (COD-Docker) ==========
app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
app.post('/api/docker-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const host = parseBody(DockerHostSchema, req.body);
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists');
}
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
return { success: true, data: { host } };
});
app.put('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const { id } = req.params as { id: string };
const host = parseBody(DockerHostSchema, { ...(req.body as object), id });
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const index = hosts.findIndex((item) => item.id === id);
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const next = [...hosts];
next[index] = host;
await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
return { success: true, data: { host } };
});
app.delete('/api/docker-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
const { id } = req.params as { id: string };
const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (cases.some((item) => item.hostId === id)) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases');
}
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
await writeDockerHosts(
CODEMAN_CONFIG_DIR,
hosts.filter((item) => item.id !== id)
);
return { success: true, data: { id } };
});
app.post(
'/api/cases/docker-link',
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
> => {
const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const };
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
dockerCases.some((item) => item.name === dockerCase.name) ||
linkedCases[dockerCase.name] ||
existsSync(join(CASES_DIR, dockerCase.name))
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// The workspace is a REAL host directory (bind-mounted into the container), so
// create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
// is written by quick-start on first launch, matching local-case behaviour.
if (!existsSync(dockerCase.hostWorkspacePath)) {
try {
mkdirSync(dockerCase.hostWorkspacePath, { recursive: true });
} catch (err) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Could not create workspace: ${getErrorMessage(err)}`
);
}
}
// Courtesy validation: docker daemon must be reachable. The base image is
// auto-built on first use (default image) rather than being a link-time
// blocker, so a missing image kicks off a background build instead of erroring.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
ctx.broadcast(SseEvent.CaseLinked, {
name: dockerCase.name,
path: dockerCase.hostWorkspacePath,
type: 'docker',
});
return {
success: true,
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
// AND link it to a hardened container with default settings, auto-provisioning a
// shared `default` docker host so the user never touches host/image/network fields.
app.post(
'/api/cases/docker-quickcreate',
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
> => {
const body = parseBody(DockerQuickCreateSchema, req.body);
const { name, description } = body;
const casePath = validatePathWithinBase(name, CASES_DIR);
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
// Collision across every case kind.
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (
existsSync(casePath) ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name) ||
linkedCases[name]
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// The checkbox alone (no overrides) uses the shared `default` host; any tweaked
// setting gets a dedicated per-case host so it never mutates the shared default.
const hasOverrides = !!(
body.image ||
body.network ||
body.networkName ||
body.memory ||
body.cpus ||
body.gpus ||
body.mountCredentials !== undefined
);
const resources: { memory?: string; cpus?: string } = {};
if (body.memory) resources.memory = body.memory;
if (body.cpus) resources.cpus = body.cpus;
const desiredHost: DockerHost = {
id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
label: hasOverrides ? `Case: ${name}` : 'Default',
image: body.image || DEFAULT_AGENT_IMAGE,
network: body.network || 'bridge',
...(body.networkName ? { networkName: body.networkName } : {}),
...(Object.keys(resources).length ? { resources } : {}),
...(body.gpus ? { gpus: body.gpus } : {}),
mountCredentials: body.mountCredentials ?? true,
resumeOnStart: true,
hooksEnabled: true,
};
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const existing = hosts.find((item) => item.id === desiredHost.id);
// Reuse the shared default if present; create/refresh a per-case host for overrides.
const host = existing && !hasOverrides ? existing : desiredHost;
if (!existing) {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
} else if (hasOverrides) {
await writeDockerHosts(
CODEMAN_CONFIG_DIR,
hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h))
);
}
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
// error instead of leaving an orphaned case folder. The base image is NOT a
// blocker: a missing default image auto-builds in the background on first use.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
// Scaffold the case folder exactly like a normal case.
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
await writeHooksConfig(casePath);
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
return {
success: true,
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
// ========== Docker export / import ==========
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
// save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
app.post('/api/docker-cases/:name/export', async (req): Promise<ApiResponse<{ started: true; bundle: string }>> => {
const { name } = req.params as { name: string };
const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
if (mode === 'full' && !sessionDocker.mountCredentials) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.'
);
}
const timestamp = Date.now();
const bundle = exportBundleName(name, timestamp, mode);
// Fire-and-forget: the client watches for the SSE completion event.
void exportDockerCase({
docker: sessionDocker,
caseName: name,
timestamp,
exportsDir: DOCKER_EXPORTS_DIR,
mode,
codemanVersion: APP_VERSION,
})
.then((result) => {
ctx.broadcast(SseEvent.DockerExportComplete, {
name,
bundle: basename(result.bundlePath),
sizeBytes: result.sizeBytes,
mode,
});
})
.catch((err) => {
ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
});
return { success: true, data: { started: true, bundle } };
});
app.get('/api/docker-exports', async (): Promise<ApiResponse<{ exports: unknown[] }>> => {
return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
});
// Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
app.get('/api/docker-exports/:filename', async (req, reply) => {
const { filename } = req.params as { filename: string };
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
}
const full = join(DOCKER_EXPORTS_DIR, filename);
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
reply.header('Content-Type', 'application/gzip');
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
reply.header('X-Content-Type-Options', 'nosniff');
return reply.send(createReadStream(full));
});
app.delete('/api/docker-exports/:filename', async (req): Promise<ApiResponse<{ filename: string }>> => {
const { filename } = req.params as { filename: string };
if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
}
const full = join(DOCKER_EXPORTS_DIR, filename);
if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
await fs.rm(full, { force: true });
return { success: true, data: { filename } };
});
// Import a bundle (already present in the exports dir) into a NEW docker case.
app.post('/api/docker-cases/import', async (req): Promise<ApiResponse<{ case: unknown }>> => {
const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
// Name-collision guard across ALL case kinds.
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
dockerCases.some((item) => item.name === newCaseName) ||
linkedCases[newCaseName] ||
existsSync(join(CASES_DIR, newCaseName))
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
const timestamp = Date.now();
let result;
try {
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
}
// Create a dedicated docker host pointing at the quarantined imported image
// (full mode) or the manifest's base image (workspace-only).
const hostId = `imported-${newCaseName}`;
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (!hosts.some((h) => h.id === hostId)) {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
...hosts,
{
id: hostId,
label: `Imported: ${newCaseName}`,
engine: result.manifest.engine,
image: result.importedImage ?? result.manifest.image,
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
? result.manifest.network
: 'bridge') as 'bridge' | 'none' | 'custom',
},
]);
}
const newCase = {
name: newCaseName,
type: 'docker' as const,
hostId,
hostWorkspacePath: destWorkspacePath,
containerWorkdir: result.manifest.containerWorkdir,
};
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
return { success: true, data: { case: newCase } };
});
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
@@ -295,6 +750,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { name } };
}
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const dockerCase = dockerCases.find((item) => item.name === name);
if (dockerCase) {
await writeDockerCases(
CODEMAN_CONFIG_DIR,
dockerCases.filter((item) => item.name !== name)
);
// Best-effort `docker rm -f` the per-case container (case-delete is the
// explicit teardown that removes it; the bind-mounted workspace survives).
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (host) {
const sessionDocker = toSessionDocker(host, dockerCase);
try {
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
} catch {
/* best-effort — never blocks the unlink */
}
// Remove the per-container claude-config seed file (account metadata copy).
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
return { success: true, data: { name } };
}
// Check linked cases first — unlink only, don't delete the actual directory
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
@@ -374,6 +855,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
};
}
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
if (dockerCase) {
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
return {
name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
container,
image: host.image,
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
},
};
}
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
+100 -4
View File
@@ -74,6 +74,15 @@ import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
import {
checkDockerAvailable,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
readDockerCases,
readDockerHosts,
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
@@ -1672,6 +1681,7 @@ export function registerSessionRoutes(
const {
caseName = 'testcase',
sessionName,
mode = 'claude',
openCodeConfig,
codexConfig,
@@ -1684,9 +1694,14 @@ export function registerSessionRoutes(
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
// would wrongly reject a machine that hasn't got the CLI installed locally.
let remote = undefined;
let docker = undefined;
let dockerResumeId: string | undefined;
let casePath: string | null = null;
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const remoteCase = remoteCases.find((item) => item.name === caseName);
const dockerCase = remoteCase
? undefined
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
@@ -1718,6 +1733,63 @@ export function registerSessionRoutes(
casePath = remoteCase.remotePath;
remote = toSessionRemote(host, remoteCase);
} else if (dockerCase) {
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
// branch's rejection of per-session config that would not cross into the
// container (it would silently no-op).
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
codexConfig ||
geminiConfig ||
openCodeConfig
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
const sessionDocker = toSessionDocker(host, dockerCase);
// Ensure the base image exists, auto-building the default image on first use so
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
// this awaits the SAME in-flight build rather than starting a second one.
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
});
if (!ensured.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
}
if (ensured.built) {
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
}
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
// Skip the extra container-run probe for our OWN default image (the baked
// Dockerfile always contains tmux); still verify a custom image.
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
}
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the
// bind-mounted transcript (decision: resume-on-start default ON).
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
// Check OpenCode availability if requested
if (mode === 'opencode') {
@@ -1772,8 +1844,9 @@ export function registerSessionRoutes(
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
const resolvedCasePath = casePath as string;
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
if (!remote && !existsSync(resolvedCasePath)) {
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (!remote && !docker && !existsSync(resolvedCasePath)) {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
@@ -1793,7 +1866,7 @@ export function registerSessionRoutes(
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (!remote && mode !== 'opencode') {
} else if (!remote && !docker && mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
@@ -1801,6 +1874,26 @@ export function registerSessionRoutes(
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
// configured project. Skipped for external CLIs (they use their own systems).
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), generateClaudeMd(caseName, '', templatePath));
}
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath);
} else {
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
}
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (
@@ -1832,6 +1925,7 @@ export function registerSessionRoutes(
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
mode: mode,
@@ -1845,12 +1939,14 @@ export function registerSessionRoutes(
envOverrides,
effort,
remote,
docker,
resumeSessionId: dockerResumeId,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
+169
View File
@@ -359,6 +359,172 @@ 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(),
gpus: z
.string()
.max(128)
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
.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(),
});
export const DockerExportSchema = z.object({
mode: z.enum(['full', 'workspace']).optional(),
});
export const DockerImportSchema = z.object({
// A bare filename resolved WITHIN the exports dir (never an arbitrary path).
bundle: z
.string()
.min(1)
.max(300)
.regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'),
newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
destWorkspacePath: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Destination path must be absolute')
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
});
// One-click "Run in Docker" case creation. name/description behave like a normal
// case; the docker fields are OPTIONAL overrides of the predefined defaults (the
// checkbox alone, with no overrides, uses the shared `default` host).
export const DockerQuickCreateSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
description: z.string().max(1000).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')
.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(),
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(),
gpus: z
.string()
.max(128)
.regex(/^(all|\d+|device=[a-zA-Z0-9,:._-]+)$/, 'GPUs must be all / a count / device=...')
.optional(),
mountCredentials: z.boolean().optional(),
});
// ========== Quick Start ==========
/**
@@ -370,6 +536,9 @@ export const QuickStartSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
.optional(),
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
* mux/container names derive from the session id, not this. Defaults server-side. */
sessionName: z.string().max(128).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+87 -1
View File
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
@@ -288,6 +288,8 @@ export class WebServer extends EventEmitter {
private readonly allowUnauthenticatedNetwork: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
private _dockerBridgeServer: import('node:http').Server | import('node:https').Server | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -1941,6 +1943,20 @@ export class WebServer extends EventEmitter {
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
await this.restoreMuxSessions();
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never
// touches another instance's containers). Runs after restore so containers
// still referenced by a restored session are preserved.
void import('../docker-hosts.js')
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
.then((reaped) => {
if (reaped.length > 0)
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
})
.catch(() => {
/* best-effort — daemon may be absent */
});
}
// Clean up stale sessions from state file that don't have active mux sessions
@@ -1961,6 +1977,15 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
// server. Hooks-only + secret-gated, and the bridge is host-internal (not the LAN).
if (!this.testMode) {
await this._startDockerBridgeHooksListener().catch((err) =>
console.error(`[Docker] bridge-hooks listener error: ${err?.message || err}`)
);
}
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
// managed tunnel are accepted automatically; add any other domain you front this
@@ -2214,6 +2239,10 @@ export class WebServer extends EventEmitter {
// erasing `remote` from state.json on the next persist. mux-sessions.json
// round-trips MuxSession.remote; state.json carries SessionState.remote.
remote: muxSession.remote ?? savedState?.remote,
// Docker metadata round-trips the same way (mux-sessions.json carries
// MuxSession.docker; state.json carries SessionState.docker), so recovery
// rebuilds the `docker exec` launch instead of a broken local command.
docker: muxSession.docker ?? savedState?.docker,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -2399,6 +2428,58 @@ export class WebServer extends EventEmitter {
return this._orchestratorLoop;
}
/**
* Opt-in (CODEMAN_DOCKER_BRIDGE_HOOKS=1): start a SECOND listener on the docker
* bridge gateway IP that serves ONLY the hook endpoints and delegates them into
* the main Fastify pipeline. This lets in-container hooks reach a loopback-bound
* server (they call back via host.docker.internal = the bridge gateway) without
* exposing the full API or the LAN. Bind IP is auto-detected (default bridge
* gateway) or set via CODEMAN_DOCKER_BRIDGE_HOST.
*/
private async _startDockerBridgeHooksListener(): Promise<void> {
if (!isExplicitlyEnabled(process.env.CODEMAN_DOCKER_BRIDGE_HOOKS)) return;
const { detectDockerBridgeGateway } = await import('../docker-hosts.js');
const bridgeHost = (process.env.CODEMAN_DOCKER_BRIDGE_HOST || '').trim() || (await detectDockerBridgeGateway());
if (!bridgeHost) {
console.log('[Docker] CODEMAN_DOCKER_BRIDGE_HOOKS set but no docker bridge gateway found — skipping');
return;
}
// Only the hook endpoints are served on the bridge — never the full API.
const HOOK_PATHS = new Set([
'/api/hook-event',
'/api/status-telemetry',
'/api/v1/hook-event',
'/api/v1/status-telemetry',
]);
const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): void => {
const path = (req.url || '').split('?')[0];
if (!HOOK_PATHS.has(path)) {
res.statusCode = 403;
res.end('forbidden: the docker bridge listener serves hook endpoints only');
return;
}
// Delegate into Fastify (host-guard, Origin/CSRF, and hook-secret gate all apply).
(this.app as unknown as { routing: (r: unknown, s: unknown) => void }).routing(req, res);
};
let server: import('node:http').Server | import('node:https').Server;
if (this.https) {
const https = await import('node:https');
const { key, cert } = getOrCreateSelfSignedCert();
server = https.createServer({ key, cert }, handler);
} else {
const http = await import('node:http');
server = http.createServer(handler);
}
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(this.port, bridgeHost, () => resolve());
});
this._dockerBridgeServer = server;
console.log(
`[Docker] in-container hooks reachable at ${this.https ? 'https' : 'http'}://${bridgeHost}:${this.port} (hook endpoints only)`
);
}
async stop(): Promise<void> {
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
// Set stopping flag to prevent new timer creation during shutdown
@@ -2414,6 +2495,11 @@ export class WebServer extends EventEmitter {
this._eventLoopMonitor = null;
}
if (this._dockerBridgeServer) {
this._dockerBridgeServer.close();
this._dockerBridgeServer = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+25
View File
@@ -370,6 +370,22 @@ export const CaseDeleted = 'case:deleted' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
// ─── Docker cases ────────────────────────────────────────────────────────────
/** A docker case export bundle finished writing. */
export const DockerExportComplete = 'docker:exportComplete' as const;
/** A docker case export failed. */
export const DockerExportFailed = 'docker:exportFailed' as const;
/** A docker bundle was imported into a new case. */
export const DockerImportComplete = 'docker:importComplete' as const;
/** The agent base image started building (first Docker case; auto-build on first use). */
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
/** A line of agent base-image build output (progress surfacing). */
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
/** The agent base image finished building successfully. */
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -551,4 +567,13 @@ export const SseEvent = {
CaseLinked,
CaseDeleted,
CaseOrderChanged,
// Docker cases
DockerExportComplete,
DockerExportFailed,
DockerImportComplete,
DockerImageBuildStarted,
DockerImageBuildProgress,
DockerImageBuildComplete,
DockerImageBuildFailed,
} as const;
+189
View File
@@ -0,0 +1,189 @@
/**
* 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> = {}): 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');
});
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
const cmd = buildDockerLaunchCommand(
launchOpts({
seedCopies: [
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
// whole-dir credential seed → cp -a
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
],
})
);
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
expect(cmd).toContain(
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
);
expect(cmd).toContain(
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
);
// recursive whole-dir seed uses cp -a
expect(cmd).toContain(
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
);
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
});
it('omits the seed-copy step when there are no seedCopies', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).not.toContain('claude.seed.json');
});
});
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'");
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Unit tests for the pure docker export/import helpers (src/docker-export.ts).
* The IO paths no-op under VITEST; these cover the naming, tar-traversal guard,
* load-output parsing, and the sealed-mode refusal.
*/
import { describe, it, expect } from 'vitest';
import {
dockerArgv,
exportBundleName,
exportImageTag,
importedImageTag,
isSafeTarMember,
parseLoadedImageRef,
exportDockerCase,
DOCKER_EXPORT_SCHEMA,
} from '../src/docker-export.js';
import { toSessionDocker } from '../src/docker-hosts.js';
import type { DockerCase, DockerHost } from '../src/types.js';
const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' };
const CASE: DockerCase = {
name: 'myproj',
type: 'docker',
hostId: 'local',
hostWorkspacePath: '/home/arkon/cases/myproj',
};
describe('dockerArgv', () => {
it('is raw (unescaped) argv for spawn', () => {
expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']);
expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([
'podman',
'--context',
'ctx',
'-H',
'ssh://h',
]);
});
});
describe('bundle / tag naming', () => {
it('names bundles by case + timestamp + mode', () => {
expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz');
expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz');
});
it('quarantines imported images and tags export intermediates uniquely', () => {
expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99');
expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99');
});
});
describe('isSafeTarMember (import traversal guard)', () => {
it('accepts normal relative members', () => {
expect(isSafeTarMember('./')).toBe(true);
expect(isSafeTarMember('src/index.ts')).toBe(true);
expect(isSafeTarMember('./a/b/c.txt')).toBe(true);
});
it('rejects absolute and parent-escaping members', () => {
expect(isSafeTarMember('/etc/passwd')).toBe(false);
expect(isSafeTarMember('../outside')).toBe(false);
expect(isSafeTarMember('a/../../b')).toBe(false);
expect(isSafeTarMember('./../../x')).toBe(false);
});
});
describe('parseLoadedImageRef', () => {
it('parses "Loaded image ID: sha256:..."', () => {
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
});
it('parses "Loaded image: repo:tag"', () => {
expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234');
});
it('returns null on unrecognized output', () => {
expect(parseLoadedImageRef('some other text')).toBeNull();
});
});
describe('exportDockerCase (VITEST stub)', () => {
it('returns a deterministic stub manifest without touching docker', async () => {
const docker = toSessionDocker(HOST, CASE);
const res = await exportDockerCase({
docker,
caseName: 'myproj',
timestamp: 42,
exportsDir: '/tmp/exports',
mode: 'full',
codemanVersion: '9.9.9',
});
expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA);
expect(res.manifest.caseName).toBe('myproj');
expect(res.manifest.mode).toBe('full');
expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz');
});
it('refuses a full-image export for a sealed container', async () => {
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
await expect(
exportDockerCase({
docker,
caseName: 'myproj',
timestamp: 42,
exportsDir: '/tmp/exports',
mode: 'full',
codemanVersion: '9.9.9',
})
).rejects.toThrow(/sealed/);
});
it('allows a workspace-only export for a sealed container', async () => {
const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE);
const res = await exportDockerCase({
docker,
caseName: 'myproj',
timestamp: 42,
exportsDir: '/tmp/exports',
mode: 'workspace',
codemanVersion: '9.9.9',
});
expect(res.manifest.mode).toBe('workspace');
});
});
+453
View File
@@ -0,0 +1,453 @@
/**
* 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, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
agentImageBuildArgs,
buildDockerBaseArgs,
buildDockerCreateArgs,
buildSeamlessClaudeConfig,
checkDockerAvailable,
checkDockerImagePresent,
checkDockerTmuxAvailable,
CLAUDE_JSON_SEED,
containerApiUrl,
DEFAULT_AGENT_IMAGE,
DEFAULT_DOCKER_RESOURCES,
dockerConfigHash,
dockerContainerName,
dockerDisplayPath,
defaultDockerCommandForMode,
ensureAgentBaseImage,
hostGatewayAlias,
probeDockerCliVersion,
readDockerCases,
readDockerHosts,
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
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://<alias>: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> = {}): 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');
});
it('emits --gpus only when GPUs are requested (and never a storage cap)', () => {
const withGpu: SessionDocker = { ...toSessionDocker(HOST, CASE), gpus: 'all' };
const s = buildDockerCreateArgs(ctx({ docker: withGpu })).join(' ');
expect(s).toContain("--gpus 'all'");
// elastic disk: no fixed storage cap is ever emitted
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
});
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('NEVER whole-dir RW-mounts a credential store (no host pollution)', () => {
mkdirSync(join(home, '.codex'), { recursive: true });
mkdirSync(join(home, '.gemini'), { recursive: true });
const { mounts } = resolveDockerCredentialArtifacts(home);
// no wholesale RW mount at the store's HOME path
expect(mounts.some((m) => m.dst === '/home/agent/.codex' && !m.readonly)).toBe(false);
expect(mounts.some((m) => m.dst === '/home/agent/.gemini' && !m.readonly)).toBe(false);
});
it('codex: shares sessions/+history.jsonl RW, seeds auth.json/config.toml', () => {
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
writeFileSync(join(home, '.codex', 'history.jsonl'), '');
writeFileSync(join(home, '.codex', 'auth.json'), '{}');
writeFileSync(join(home, '.codex', 'config.toml'), '');
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({ src: join(home, '.codex', 'sessions'), dst: '/home/agent/.codex/sessions' });
expect(mounts).toContainEqual({
src: join(home, '.codex', 'history.jsonl'),
dst: '/home/agent/.codex/history.jsonl',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.codex/auth.json');
expect(dests).toContain('/home/agent/.codex/config.toml');
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.codex')).every((s) => !s.recursive)).toBe(true);
});
it('gemini/gcloud/opencode: whole-dir seed-copy (cp -a, recursive)', () => {
mkdirSync(join(home, '.gemini'), { recursive: true });
mkdirSync(join(home, '.config', 'gcloud'), { recursive: true });
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
// each is mounted read-only at a seed staging path and cp -a'd into the container HOME
expect(seedCopies).toContainEqual({
from: '/home/agent/.codeman/cred-seeds/.gemini',
to: '/home/agent/.gemini',
recursive: true,
});
expect(seedCopies).toContainEqual({
from: '/home/agent/.codeman/cred-seeds/.config-gcloud',
to: '/home/agent/.config/gcloud',
recursive: true,
});
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
});
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('shares only projects (RW) and seeds .claude.json + credentials + settings + stats-cache', () => {
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
writeFileSync(join(home, '.claude.json'), JSON.stringify({ oauthAccount: { id: 1 } }));
writeFileSync(join(home, '.claude', '.credentials.json'), '{"claudeAiOauth":{}}');
writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ theme: 'dark' }));
writeFileSync(join(home, '.claude', 'stats-cache.json'), '{}');
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-x', '/ws/x');
// transcripts shared RW (no readonly), whole ~/.claude never mounted
expect(art.mounts).toContainEqual({ src: join(home, '.claude', 'projects'), dst: '/home/agent/.claude/projects' });
expect(art.mounts.some((m) => m.dst === '/home/agent/.claude')).toBe(false);
// credentials + settings + stats-cache + .claude.json seeded (copied into the container's own HOME)
const dests = art.seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.claude.json');
expect(dests).toContain('/home/agent/.claude/.credentials.json');
expect(dests).toContain('/home/agent/.claude/settings.json');
expect(dests).toContain('/home/agent/.claude/stats-cache.json'); // restores the model/effort status indicator
// the seed mounts are read-only
expect(art.mounts.filter((m) => m.readonly).length).toBeGreaterThanOrEqual(3);
});
it('omits mounts/seeds for artifacts that do not exist', () => {
const art = resolveDockerClaudeArtifacts(home, 'codeman-case-y', '/ws/y');
expect(art.mounts).toEqual([]);
expect(art.seedCopies).toEqual([]);
});
});
describe('resolveClaudeJsonSeedMount', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
it('returns a read-only seed mount when ~/.claude.json exists', () => {
writeFileSync(join(home, '.claude.json'), '{}');
expect(resolveClaudeJsonSeedMount(home)).toEqual({
src: join(home, '.claude.json'),
dst: CLAUDE_JSON_SEED,
readonly: true,
});
});
it('returns null when ~/.claude.json is absent', () => {
expect(resolveClaudeJsonSeedMount(home)).toBeNull();
});
});
describe('buildSeamlessClaudeConfig', () => {
it('forces onboarding-complete + theme + workspace trust while preserving host fields', () => {
const merged = buildSeamlessClaudeConfig({ oauthAccount: { id: 1 }, projects: {} }, '/ws/proj');
expect(merged.hasCompletedOnboarding).toBe(true);
expect(merged.theme).toBe('dark');
expect(merged.oauthAccount).toEqual({ id: 1 }); // host auth account preserved
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
expect(proj.hasTrustDialogAccepted).toBe(true);
expect(proj.hasCompletedProjectOnboarding).toBe(true);
expect(proj.projectOnboardingSeenCount).toBe(1);
});
it('keeps an existing theme and merges into an existing project entry', () => {
const merged = buildSeamlessClaudeConfig(
{ theme: 'light', projects: { '/ws/proj': { allowedTools: ['a'], projectOnboardingSeenCount: 5 } } },
'/ws/proj',
'dark'
);
expect(merged.theme).toBe('light'); // not overwritten when already set
const proj = (merged.projects as Record<string, Record<string, unknown>>)['/ws/proj'];
expect(proj.allowedTools).toEqual(['a']); // existing project fields kept
expect(proj.hasTrustDialogAccepted).toBe(true);
expect(proj.projectOnboardingSeenCount).toBe(5); // preserved, not reset to 1
});
});
describe('agentImageBuildArgs', () => {
it('builds the docker build argv in order', () => {
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo')).toEqual([
'build',
'-f',
'/repo/docker/agent.Dockerfile',
'-t',
'codeman/agent:base',
'/repo',
]);
});
it('adds --no-cache before the context dir when requested', () => {
const args = agentImageBuildArgs('/df', 'img', '/ctx', true);
expect(args).toContain('--no-cache');
expect(args.indexOf('--no-cache')).toBeLessThan(args.indexOf('/ctx'));
expect(args[args.length - 1]).toBe('/ctx');
});
});
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
it('reports the image as already present without spawning a build', async () => {
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
});
});
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();
});
});
+7
View File
@@ -67,6 +67,13 @@ describe('isAllowedRequestHost — anti-DNS-rebinding', () => {
expect(isAllowedRequestHost('eviltrycloudflare.com', loopback)).toBe(false);
});
it('accepts the docker/podman container-to-host gateway aliases (in-container hooks)', () => {
expect(isAllowedRequestHost('host.docker.internal:3000', loopback)).toBe(true);
expect(isAllowedRequestHost('host.containers.internal:3000', loopback)).toBe(true);
// a lookalike is still rejected (exact match only)
expect(isAllowedRequestHost('host.docker.internal.evil.com', loopback)).toBe(false);
});
it('accepts the configured bind host when it is a hostname', () => {
const policy: HostPolicy = { bindHost: 'mybox.local', allowedHosts: [], tunnelHost: null };
expect(isAllowedRequestHost('mybox.local:3000', policy)).toBe(true);
+5 -1
View File
@@ -225,7 +225,11 @@ describe('system-routes', () => {
harness.ctx._session.status = 'working';
harness.ctx.store.getDailyStats.mockReturnValue([
{
date: new Date().toISOString().split('T')[0],
// LOCAL date (not toISOString/UTC): away-digest's dayOverlapsRange parses
// the date as local midnight, so a UTC date near the local-midnight boundary
// (e.g. running at 01:xx CEST = prior-day UTC) would fall outside the 1h
// window and make this assertion TZ/hour-flaky.
date: new Date().toLocaleDateString('en-CA'),
inputTokens: 100,
outputTokens: 200,
estimatedCost: 0.02,
+26
View File
@@ -138,6 +138,8 @@ describe('Codex quick start settings', () => {
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
caseName: 'codex-case',
mode: 'codex',
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
sessionName: 'w1-codex-case',
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
});
expect(selected).toEqual(['sess-1']);
@@ -179,6 +181,30 @@ describe('case selector refresh', () => {
expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']);
});
it('labels dockerized cases with a short "(docker)" tag (or the custom host id)', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
const label = (c: any) => app.formatCasePickerLabel(c);
// default one-click host, the 'local' Docker-tab default, and per-case override
// hosts all collapse to the short "(docker)" tag.
expect(
label({ name: 'sandbox', location: 'docker', docker: { hostId: 'default', container: 'codeman-case-sandbox' } })
).toBe('sandbox (docker)');
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'local' } })).toBe('sandbox (docker)');
expect(label({ name: 'sandbox', location: 'docker', docker: { hostId: 'q-sandbox' } })).toBe('sandbox (docker)');
// a user-named docker host shows its id
expect(label({ name: 'ml', location: 'docker', docker: { hostId: 'gpu-box' } })).toBe('ml (gpu-box)');
});
it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
+43 -1
View File
@@ -370,12 +370,54 @@ describe('terminal touch tap mouse guard', () => {
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
// Default (setting absent) forwards the plain wheel to the CLI transcript.
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
// Opt-out ON → the plain wheel stays on xterm's own scrollback (pre-#144).
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
// OFF again → forwarding resumes.
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('wheel: reads the dominant axis under Shift so a macOS trackpad can page scrollback (issue #154)', () => {
const { app } = loadTerminalUiHarness();
// Plain vertical wheel: unchanged, driven by deltaY.
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
// Sub-25px delta still nudges one line in the gesture's direction.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
});
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
// Wheel reports flush via the ephemeral (fire-and-forget) path, not the
// durable queue — so they never show in the pending-bytes indicator (#154).
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
rows: 24,