diff --git a/docs/docker-cases.md b/docs/docker-cases.md index d929de8b..ee53feb9 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -15,7 +15,22 @@ node scripts/build-agent-image.mjs # builds codeman/agent:base The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them. -## Create a docker case +## Quickest path: one-click "Run in Docker" + +On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in. + +Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker: + +| Template | Memory | CPUs | GPUs | +|----------|--------|------|------| +| Small | 2 GB | 1 | none | +| Medium (default) | 4 GB | 2 | none | +| Large | 8 GB | 4 | none | +| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) | + +**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`. + +## Create a docker case (full control) App → **New case → Docker** tab: @@ -64,10 +79,11 @@ The container is paused across the capture so the image and workspace are consis In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**. -- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:` cannot connect, so **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway. -- To enable in-container hooks, run Codeman where the container can reach it: bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (`CODEMAN_HOST=0.0.0.0`), or otherwise make `172.17.0.1:` reachable. The host guard already allowlists `host.docker.internal` / `host.containers.internal`, and the hook secret is mounted, so hooks work as soon as the callback is reachable. +- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway. +- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart. +- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too). -This is an environmental constraint, not a code limitation: the host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly. +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 diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 7c3cddd1..10389bcc 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -179,6 +179,7 @@ export function dockerConfigHash( | 'network' | 'networkName' | 'resources' + | 'gpus' | 'mountCredentials' | 'extraCreateArgs' > @@ -190,6 +191,7 @@ export function dockerConfigHash( network: docker.network, networkName: docker.networkName ?? null, resources: docker.resources ?? null, + gpus: docker.gpus ?? null, mountCredentials: docker.mountCredentials, extraCreateArgs: docker.extraCreateArgs ?? null, }); @@ -215,6 +217,7 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi 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, @@ -360,6 +363,10 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { 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', @@ -545,6 +552,29 @@ export async function checkDockerTmuxAvailable( } } +/** + * Resolve the host's IP on the default docker bridge (the address a container + * reaches as `host.docker.internal`), so the server can bind a hooks-only listener + * there and in-container hooks can call back. Defaults to the conventional + * 172.17.0.1 when the inspect fails but docker is up; null when docker is absent. + * No-op canned value under VITEST. + */ +export async function detectDockerBridgeGateway(engine: DockerEngine = 'docker'): Promise { + if (IS_TEST_MODE) return '172.17.0.1'; + const bin = engine === 'podman' ? 'podman' : 'docker'; + try { + const { stdout } = await execFileAsync( + bin, + ['network', 'inspect', 'bridge', '--format', '{{(index .IPAM.Config 0).Gateway}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const ip = stdout.trim(); + return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) ? ip : '172.17.0.1'; + } catch { + return null; // docker not available — nothing to bind + } +} + /** * Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs * to THIS instance (by the `codeman.instance` label) but whose case is no longer diff --git a/src/types/session.ts b/src/types/session.ts index 3aa92144..0744beab 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -153,6 +153,8 @@ export interface DockerHost { /** Custom bridge name when network === 'custom'. */ networkName?: string; resources?: DockerResourceLimits; + /** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus ` (needs the NVIDIA container toolkit). */ + gpus?: string; /** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */ mountCredentials?: boolean; /** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */ @@ -198,6 +200,8 @@ export interface SessionDocker { network: DockerNetworkMode; networkName?: string; resources?: DockerResourceLimits; + /** GPU allocation ('all' / '1' / 'device=0,1'). */ + gpus?: string; mountCredentials: boolean; hooksEnabled: boolean; resumeOnStart: boolean; diff --git a/src/web/public/index.html b/src/web/public/index.html index d61b2fc7..2a33cf09 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1865,9 +1865,52 @@
- - One click: creates the case folder AND a hardened container with default settings, then starts the session inside it. Requires the base image (node scripts/build-agent-image.mjs). + + One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (node scripts/build-agent-image.mjs).
+