diff --git a/.changeset/git-host-auth-clis.md b/.changeset/git-host-auth-clis.md index 46e80387..1d1c06df 100644 --- a/.changeset/git-host-auth-clis.md +++ b/.changeset/git-host-auth-clis.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -Docker images: optional GitHub CLI and Azure CLI for private GitHub and Azure DevOps repositories. Both are opt-in and off by default. Build the server image with `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` (under `build: args:` in `docker/docker-compose.override.yml`) to add `gh` and/or `az` with the `azure-devops` extension, plus system Git credential helpers that route github.com through `gh auth git-credential` and dev.azure.com / *.visualstudio.com through a new `az`-backed helper (`docker/git-credential-azure-cli`, which also honours `AZURE_DEVOPS_EXT_PAT`). Sign the CLIs in once from a shell session and Add Case → Clone Repo can clone private repositories; until then a private clone still fails fast with an authentication error. The Docker-case agent image takes the same switches from `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` (the `environment:` of the override file, or in front of `build-agent-image.mjs`), and a seeded Docker case now also copies the `gh` sign-in (`~/.config/gh/hosts.yml`, `config.yml`) and the `az` sign-in files from `~/.azure` into its container, read-only and per file like the other CLIs. The Clone Repo authentication error now says how to sign the server's git in instead of claiming private repositories cannot be cloned. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update; with neither switch set the rebuilt image is unchanged. +Docker images: optional GitHub CLI and Azure CLI for private GitHub and Azure DevOps repositories. Both are opt-in and off by default. Build the server image with `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` (under `build: args:` in `docker/docker-compose.override.yml`) to add `gh` and/or `az` with the `azure-devops` extension, plus system Git credential helpers that route github.com through `gh auth git-credential` and dev.azure.com / *.visualstudio.com through a new `az`-backed helper (`docker/git-credential-azure-cli`, which also honours `AZURE_DEVOPS_EXT_PAT`). Sign the CLIs in once from a shell session and Add Case → Clone Repo can clone private repositories; until then a private clone still fails fast with an authentication error. The Docker-case agent image takes the same switches from `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` (the `environment:` of the override file, or in front of `build-agent-image.mjs`), and, only with those switches on, a seeded Docker case also copies the `gh` sign-in (`~/.config/gh/hosts.yml`, `config.yml`) and the `az` sign-in files from `~/.azure` into newly created case containers, read-only and per file like the other CLIs. In multi-user mode, Clone Repo runs a non-admin's clone and preflight with git's credential helpers cleared, so the server account's sign-in is never lent to them. The Clone Repo authentication error now says how to sign the server's git in instead of claiming private repositories cannot be cloned. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update; with neither switch set the rebuilt image is functionally unchanged. diff --git a/docker/README.md b/docker/README.md index 27508ea9..444269d6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -66,7 +66,7 @@ The `build: args:` pair controls the Codeman server image. The `environment:` pa They are not `.env` settings: turning a CLI on is a per-host choice, which is what the override file is for, and a new `.env.example` key makes the in-app updater refuse to update every existing installation until its `.env` gains the key. -The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. +The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing functional behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. With both off the image is functionally unchanged; it still carries the `AZURE_EXTENSION_DIR` variable, an empty extensions directory and one small layer that copies and then removes the helper script. ### Signing in @@ -86,9 +86,11 @@ az login --use-device-code # then: az devops configure --defaults organizati After that, **Add Case → Clone Repo** accepts private `https://` URLs on those hosts, and `git clone` works from any session. Until a CLI is signed in its helper prints nothing, so a private clone fails immediately with the usual authentication error rather than waiting on a prompt. +**Multi-user mode:** every Codeman user's git runs as the same server account, so these sign-ins would otherwise be shared. Clone Repo therefore runs a **non-admin**'s clone and preflight with every git credential helper cleared (`git -c credential.helper=`): a non-admin can clone public repositories and anything their own SSH setup allows, but not a private https repository through the admin's `gh`/`az` sign-in. Admins, and single-user mode, keep the helpers. A non-admin's own agent sessions still run as that same account; see `docs/security-architecture.md`, multi-user mode. + Azure DevOps is authenticated with an Entra ID access token that the helper requests from `az` for each Git operation, so nothing is written to disk beyond `az`'s own sign-in. An account that has to use a personal access token can set `AZURE_DEVOPS_EXT_PAT` for the container instead (for example under `environment:` in `docker-compose.override.yml`); the helper prefers it when present. SSH remotes are unaffected by any of this and keep using the account's own keys. -In a Docker case built with the CLIs on, a case with credential seeding on copies these sign-ins into its container at launch (`~/.config/gh/hosts.yml` and `config.yml`, plus the sign-in files from `~/.azure`). A case container created before you signed in only picks them up once it is recreated. +Docker cases copy these sign-ins into a case container only when the matching agent-image switch is on (`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` for `~/.config/gh/hosts.yml` and `config.yml`, `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` for the sign-in files from `~/.azure`) and the case has credential seeding on. With a switch off they are never copied, even when the files exist, because a GitHub token or an Azure refresh token is usable by anything in the container. The copies are made when the container is **created**, so an existing case container never picks them up: after turning a switch on, signing in, or rebuilding the agent image, **recreate the case container** (remove it; the next session in that case creates a fresh one). The GitHub agent skill for `gh` installs into the runtime account's home in the same session: diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index d6395341..09b4607a 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -76,12 +76,14 @@ COPY --from=docker:29-cli \ # user signs in to here is what authenticates, and nothing when they have not # (the clone then fails fast with AUTH_REQUIRED, exactly as before). # -# Each is OPT-IN and OFF by default: the image is unchanged unless the build -# gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which a deployment sets -# under `build: args:` in docker-compose.override.yml (docker/README.md, -# "Private repositories"). Off means nothing at all: no apt repository, no -# package, no extension and no credential-helper entry. The Azure CLI is the -# heavy one (~600 MB, mostly its bundled Python). The base docker-compose.yaml +# Each is OPT-IN and OFF by default: the image is functionally unchanged +# unless the build gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which +# a deployment sets under `build: args:` in docker-compose.override.yml +# (docker/README.md, "Private repositories"). Off installs no apt repository, +# package, extension or credential-helper entry; all that remains is the +# AZURE_EXTENSION_DIR variable, its empty directory and one layer that copies +# and then removes the helper script. The Azure CLI is the heavy one (~600 MB, +# mostly its bundled Python). The base docker-compose.yaml # and .env deliberately do not carry them: turning a CLI on is a per-host # choice, which is what the override file is for, and a new .env.example key # would make the self-updater refuse existing installs until their .env gained diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index caddf3e2..327c189f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -60,7 +60,7 @@ Model is NOT a session field: it is a composition entry in the profile's config ### Docker cases -**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}`, plus per-file seeds of the `gh` and `az` sign-ins (`~/.config/gh/{hosts.yml,config.yml}`, five sign-in files from `~/.azure`, never its logs or extensions), which the agent image's system git credential helpers read for github.com / Azure DevOps (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + every enabled npm CLI from the registry, since the `CLI_NPM_PACKAGES` build arg is generated from `stock.ts` and entries carrying `agentImageLayer` or no `npmPackage` get their own layers, see `docs/docker-cases.md`; OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. **Adopting an already-running container** (`DockerCase.owned === false`, `POST /api/cases/docker-adopt` + the read-only `POST /api/docker-cases/adopt-preflight`, `GET /api/docker-hosts/:hostId/containers`, `POST /api/docker-cases/browse`): the mirror of remote-SSH's `owned:false` attach. For an adopted container the launch chain only LOOKS and then execs — no image gate (the image is theirs), no create, and above all no `start`, since starting a container we do not own is precisely the mutation adoption promises never to perform; a missing or stopped container fails closed with an actionable message. Credential seeding is skipped too (those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do), so its CLIs must already be authenticated inside it. Absent `owned` = owned, so every pre-existing case is byte-identical. ⚠️ The guarantee is NEGATIVE, so it cannot be observed by using the feature — only by asserting the mutating verbs are absent — and it is therefore enforced at four deliberately independent layers: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION (no shape of caller bug can produce a `docker stop`/`rm` for a container we do not own), `removeDockerContainer` refuses again at the lowest layer, `checkDockerConfigDrift` reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would always report drift and the launch gate would 409 forever, offering a recreate we may not perform), and the orphan reaper skips it through a check independent of the two conditions that already cover it. ⚠️ **The export path is the one place that still touched the container** and both halves had to be closed: a full export `docker commit`s it (refused for an adopted case — it packages someone else's container, with their logins, into a bundle Codeman hands out) and even a workspace-only export `docker pause`d it first for snapshot consistency (skipped: the freeze stops the owner's processes for as long as the tar takes). ⚠️ `owned` is applied AFTER the config hash; `dockerConfigHash` takes an explicit field list, so ownership can never shift an existing case's hash and mass-trip the drift gate, whose only remedy is "recreate the container". ⚠️ The container workdir is verified INSIDE the container: it defaults to `hostWorkspacePath` for an OWNED case only because the create-time bind mount puts the host directory at that exact path, and adoption mounts nothing, so the two are independent facts — without the check `docker exec --workdir ` fails with an OCI chdir error the pane surfaces as a bare `execvp failed`. ⚠️ Run-mode availability comes from the CONTAINER (`availableModes`), live-probed rather than trusted from attach time: gating the dropdown on host CLI availability (#201) is right for local sessions and wrong here. The probe modes and the BINARY each mode looks for both come from the CLI registry (`enabledCliIds()` / `discovery.binaries[0]`), never a local table — a hand-written list silently froze once already, missing `omp` and hiding that mode on every docker case; `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe reports both missing on a container that has them, and a mode with no binary (`shell`) is reported available without a lookup. ⚠️ **A FAILED probe means opposite things per ownership.** For an adopted case it is a real fault (only the user can start that container). For an owned case it is the NORMAL state before the first session — the launch chain creates the container on demand — so recording it as an error hid every agent mode on every freshly linked Docker case behind "start it yourself first", for a container Codeman was about to create itself; `CaseInfo.docker.owned` exists on the wire so the frontend can tell the two apart. ⚠️ Claude launches WITHOUT `--dangerously-skip-permissions` when the container's exec user is root: Claude Code refuses the flag as root ("cannot be used with root/sudo privileges", still true in 2.1.261) and the refusal is visible only inside the container, so the pane just dies. Our base image runs a non-root user and never hits it; an adopted container's user belongs to its owner and is frequently root. Which flag to drop is a per-CLI fact, so it is `overlays.docker.rootCommand` in the registry rather than an id branch. ⚠️ **Admin-only in multi-user mode**, unlike `docker-link` right next to it: linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has already confined to the caller's space, while an adopted container's mounts are whatever its owner gave it — one mounting `/` hands the adopter a shell over the whole host, defeating exactly the workspace scoping that mode exists to enforce. The container listing and the in-container directory browser are gated with it (both are machine-level reads over containers belonging to anyone); the preflight is NOT, because the run menu probes it for every docker case, so it admits a non-admin only for a container already linked to a case they can access. Tests: `test/docker-adopted-container.test.ts`. +**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}`, plus, ONLY when `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` is `1` (`enabledByEnv`, read at container create), per-file seeds of the `gh` and `az` sign-ins (`~/.config/gh/{hosts.yml,config.yml}`, five sign-in files from `~/.azure`, never its logs or extensions), which the agent image's system git credential helpers read for github.com / Azure DevOps (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + every enabled npm CLI from the registry, since the `CLI_NPM_PACKAGES` build arg is generated from `stock.ts` and entries carrying `agentImageLayer` or no `npmPackage` get their own layers, see `docs/docker-cases.md`; OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. **Adopting an already-running container** (`DockerCase.owned === false`, `POST /api/cases/docker-adopt` + the read-only `POST /api/docker-cases/adopt-preflight`, `GET /api/docker-hosts/:hostId/containers`, `POST /api/docker-cases/browse`): the mirror of remote-SSH's `owned:false` attach. For an adopted container the launch chain only LOOKS and then execs — no image gate (the image is theirs), no create, and above all no `start`, since starting a container we do not own is precisely the mutation adoption promises never to perform; a missing or stopped container fails closed with an actionable message. Credential seeding is skipped too (those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do), so its CLIs must already be authenticated inside it. Absent `owned` = owned, so every pre-existing case is byte-identical. ⚠️ The guarantee is NEGATIVE, so it cannot be observed by using the feature — only by asserting the mutating verbs are absent — and it is therefore enforced at four deliberately independent layers: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION (no shape of caller bug can produce a `docker stop`/`rm` for a container we do not own), `removeDockerContainer` refuses again at the lowest layer, `checkDockerConfigDrift` reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would always report drift and the launch gate would 409 forever, offering a recreate we may not perform), and the orphan reaper skips it through a check independent of the two conditions that already cover it. ⚠️ **The export path is the one place that still touched the container** and both halves had to be closed: a full export `docker commit`s it (refused for an adopted case — it packages someone else's container, with their logins, into a bundle Codeman hands out) and even a workspace-only export `docker pause`d it first for snapshot consistency (skipped: the freeze stops the owner's processes for as long as the tar takes). ⚠️ `owned` is applied AFTER the config hash; `dockerConfigHash` takes an explicit field list, so ownership can never shift an existing case's hash and mass-trip the drift gate, whose only remedy is "recreate the container". ⚠️ The container workdir is verified INSIDE the container: it defaults to `hostWorkspacePath` for an OWNED case only because the create-time bind mount puts the host directory at that exact path, and adoption mounts nothing, so the two are independent facts — without the check `docker exec --workdir ` fails with an OCI chdir error the pane surfaces as a bare `execvp failed`. ⚠️ Run-mode availability comes from the CONTAINER (`availableModes`), live-probed rather than trusted from attach time: gating the dropdown on host CLI availability (#201) is right for local sessions and wrong here. The probe modes and the BINARY each mode looks for both come from the CLI registry (`enabledCliIds()` / `discovery.binaries[0]`), never a local table — a hand-written list silently froze once already, missing `omp` and hiding that mode on every docker case; `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe reports both missing on a container that has them, and a mode with no binary (`shell`) is reported available without a lookup. ⚠️ **A FAILED probe means opposite things per ownership.** For an adopted case it is a real fault (only the user can start that container). For an owned case it is the NORMAL state before the first session — the launch chain creates the container on demand — so recording it as an error hid every agent mode on every freshly linked Docker case behind "start it yourself first", for a container Codeman was about to create itself; `CaseInfo.docker.owned` exists on the wire so the frontend can tell the two apart. ⚠️ Claude launches WITHOUT `--dangerously-skip-permissions` when the container's exec user is root: Claude Code refuses the flag as root ("cannot be used with root/sudo privileges", still true in 2.1.261) and the refusal is visible only inside the container, so the pane just dies. Our base image runs a non-root user and never hits it; an adopted container's user belongs to its owner and is frequently root. Which flag to drop is a per-CLI fact, so it is `overlays.docker.rootCommand` in the registry rather than an id branch. ⚠️ **Admin-only in multi-user mode**, unlike `docker-link` right next to it: linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has already confined to the caller's space, while an adopted container's mounts are whatever its owner gave it — one mounting `/` hands the adopter a shell over the whole host, defeating exactly the workspace scoping that mode exists to enforce. The container listing and the in-container directory browser are gated with it (both are machine-level reads over containers belonging to anyone); the preflight is NOT, because the run menu probes it for every docker case, so it admits a non-admin only for a container already linked to a case they can access. Tests: `test/docker-adopted-container.test.ts`. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`. @@ -217,7 +217,7 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write **Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. -- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. +- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. - ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`. - **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there. - **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree. diff --git a/docs/docker-cases.md b/docs/docker-cases.md index 7b953d86..f8a1459a 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -81,7 +81,7 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md). -The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. A left-out CLI's sign-in files are still seeded if they exist on the host; they are inert without it. +The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated. ## Quickest path: one-click "Run in Docker" diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 106d738c..db24b9b4 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -497,7 +497,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000). 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 :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` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) 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. +- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, and, only when their opt-in switches `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` are `1`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) 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. @@ -516,6 +516,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md). - **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user). - **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users//cases`, checked BEFORE any disk write. - **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only. +- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. - **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6). --- diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md index c1d4fe3a..5298bd11 100644 --- a/docs/wiki/Docker-Cases.md +++ b/docs/wiki/Docker-Cases.md @@ -120,17 +120,20 @@ Codeman reads it host-side for history and resume. **Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), off by default, and its git then uses them as credential -helpers for github.com and Azure DevOps. Their sign-ins are seeded like everything else, file by file: -`~/.config/gh/hosts.yml` and `config.yml`, and the sign-in files from `~/.azure` (not its -logs or extensions). So once `gh auth login` / `az login` have been run where Codeman runs, -agents in a Docker case can clone and push private repos on those hosts. Two limits: +helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are +seeded like everything else, file by file: `~/.config/gh/hosts.yml` and `config.yml`, and the +sign-in files from `~/.azure` (not its logs or extensions). With a switch off they are never +copied in, even if the files exist. So once a switch is on and `gh auth login` / `az login` +have been run where Codeman runs, agents in a Docker case can clone and push private repos on +those hosts. Two limits: - A token held in a desktop keyring or an encrypted token cache (Windows, macOS) is not inside those files and does not carry in. Sign in inside the container instead. The Docker server image and a headless Linux host keep it in the files, so they carry. -- The copy happens only when the file is not already in the container, so a sign-in made - after a case container was created reaches that container only once it is recreated - (or once you sign in inside it). +- The sign-ins are mounted when a case container is **created**, so an existing container + never picks them up. After turning a switch on, signing in, or rebuilding the agent image, + **recreate the case container**: remove it, and the next session in that case creates a + fresh one. (Or sign in inside the existing container instead.) This hands a GitHub token and an Azure sign-in to every agent in a seeded Docker case, the same trust you already give it with Claude, Codex or gcloud. Turn seeding off for a case that diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 67994a4c..5509e89b 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -812,6 +812,14 @@ interface CredStorePolicy { seedFiles?: string[]; /** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */ seedWhole?: boolean; + /** + * Seed this store ONLY when this environment variable is exactly `1`, read when + * the container is created. For credentials that belong to an opt-in tool rather + * than to an agent CLI every case already trusts: they are not inert just because + * the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is + * usable by anything in the container, and the agent in it is prompt-injectable). + */ + enabledByEnv?: string; } const CRED_STORES: CredStorePolicy[] = [ @@ -857,18 +865,22 @@ const CRED_STORES: CredStorePolicy[] = [ { rel: '.config/gcloud', seedWhole: true }, { rel: '.config/opencode', seedWhole: true }, // GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the - // Docker server image, a headless Linux host), `config.yml` the preferences. The - // agent image routes github.com git credentials through `gh`, so this seed is what - // lets an agent clone/push a private repo. A token that lives in a desktop keyring - // is not in `hosts.yml` and does not carry in; sign `gh` in inside the container. - { rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'] }, + // Docker server image, a headless Linux host), `config.yml` the preferences. An + // agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials + // through `gh`, so this seed is what lets an agent clone/push a private repo. A + // token that lives in a desktop keyring is not in `hosts.yml` and does not carry + // in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch + // that builds gh into the agent image is on, never merely because the file exists. + { rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' }, // Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`, // `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is // needed to authenticate; the agent image carries its own extensions outside HOME. // `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so // this carries a sign-in from the Docker server image or a Linux host. + // OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account. { rel: '.azure', + enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ', seedFiles: [ 'azureProfile.json', 'msal_token_cache.json', @@ -901,10 +913,14 @@ const CRED_STORES: CredStorePolicy[] = [ * 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 { +export function resolveDockerCredentialArtifacts( + home: string = homedir(), + env: NodeJS.ProcessEnv = process.env +): DockerClaudeArtifacts { const mounts: DockerMount[] = []; const seedCopies: DockerSeedCopy[] = []; for (const store of CRED_STORES) { + if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue; const hostBase = join(home, store.rel); if (!existsSync(hostBase)) continue; const containerBase = `${CONTAINER_HOME}/${store.rel}`; diff --git a/src/git-clone.ts b/src/git-clone.ts index 05c0af9e..b9cd3708 100644 --- a/src/git-clone.ts +++ b/src/git-clone.ts @@ -195,6 +195,8 @@ export interface CloneOptions { /** `--depth 1`: history-less but much faster on large repos. */ shallow?: boolean; timeoutMs?: number; + /** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */ + withoutCredentialHelpers?: boolean; } export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure }; @@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean { // ─── Pure: argv + env ──────────────────────────────────────────────────────── +/** + * Global git options that empty the credential-helper list for one run. + * + * Every Codeman user in multi-user mode runs git as the SAME OS account, so a + * helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a + * user's own `gh auth setup-git`) would read private repositories on the + * signed-in admin's behalf for anyone who can reach Clone Repo. An empty + * `credential.helper` resets the helper list, and a command-line `-c` is read + * last, so it also drops the URL-scoped `credential..helper` entries the + * image configures (verified against a real private repo: refs with the helper, + * `could not read Username` with it cleared). Public repositories are + * unaffected. It must precede the subcommand. + */ +export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper=']; + /** * argv for the clone. `--` separates flags from operands so neither the * repository nor the destination can ever be read as an option. */ export function buildCloneArgs(opts: CloneOptions): string[] { - const args = ['clone']; + const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone']; // `--single-branch` is what makes "just this tag/branch" cheap on a big repo. if (opts.ref) args.push('--single-branch', '--branch', opts.ref); if (opts.shallow) args.push('--depth', '1'); @@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] { } /** argv for the preflight. `--symref` is what reveals the remote's default branch. */ -export function buildLsRemoteArgs(repository: string): string[] { - return ['ls-remote', '--symref', '--', repository]; +export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] { + return [ + ...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), + 'ls-remote', + '--symref', + '--', + repository, + ]; } /** @@ -790,7 +813,8 @@ export function isGitAvailable(): boolean { */ export async function probeGitRemote( repository: string, - timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS + timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS, + opts: { withoutCredentialHelpers?: boolean } = {} ): Promise { if (!isGitAvailable()) { return { @@ -800,7 +824,7 @@ export async function probeGitRemote( failure: classifyGitFailure('', false, 'ENOENT: git not found'), }; } - const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES); + const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES); if (run.code !== 0 || run.spawnError) { return { reachable: false, diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 643283e9..b4802344 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -128,6 +128,18 @@ const APP_VERSION = (() => { const LOCAL_CLONE_ADMIN_ONLY = 'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.'; +/** + * Whether a clone or preflight must run with git's credential helpers cleared: + * a non-admin in multi-user mode. Every user's git runs as the one server + * account, so its helpers (the Docker image's opt-in `gh`/`az` ones, or any + * `gh auth setup-git`) would otherwise read a private repository with the + * signed-in admin's credentials, the same boundary the local-transport rule + * above guards. Admins and single-user mode keep the account's own helpers. + */ +export function cloneWithoutCredentialHelpers(req: FastifyRequest): boolean { + return isMultiUserMode() && !isAdmin(req); +} + /** * The one line of git's stderr worth appending to an error message. * @@ -475,7 +487,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config if (!isGitAvailable()) { return { success: true, data: { parse: parsed, gitAvailable: false } }; } - const remote = await probeGitRemote(parsed.repository); + const remote = await probeGitRemote(parsed.repository, undefined, { + withoutCredentialHelpers: cloneWithoutCredentialHelpers(req), + }); return { success: true, data: { parse: parsed, remote, gitAvailable: true } }; } ); @@ -491,8 +505,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config * request died mid-clone still sees the case appear over SSE when git finishes. * * Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`, - * this writes only inside the caller's own `resolveCasesDir`. The one exception - * is a `local`-transport source, which would read through that boundary. + * this writes only inside the caller's own `resolveCasesDir`. Two things would + * otherwise read through that boundary: a `local`-transport source (refused for + * non-admins) and the server account's git credential helpers, which every user + * shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`). * * Repository contents win over scaffolding: an existing CLAUDE.md is left * alone, and hooks are MERGED into whatever `.claude/settings.local.json` the @@ -562,6 +578,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const clone = await cloneRepository({ repository: parsed.repository, destination: casePath, + withoutCredentialHelpers: cloneWithoutCredentialHelpers(req), ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}), }); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 0a09df43..123c73ec 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -351,6 +351,40 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3); }); + /** Host files for both opt-in stores, present whether or not the switches are on. */ + function writeGhAzHostFiles(): void { + mkdirSync(join(home, '.config', 'gh'), { recursive: true }); + writeFileSync(join(home, '.config', 'gh', 'hosts.yml'), ''); + writeFileSync(join(home, '.config', 'gh', 'config.yml'), ''); + mkdirSync(join(home, '.azure'), { recursive: true }); + writeFileSync(join(home, '.azure', 'azureProfile.json'), '{}'); + writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}'); + } + const isGhOrAz = (p: string) => /\.azure|\.config[\\/]gh/.test(p); + + it('gh + az: the DEFAULT environment seeds neither, even when the host files exist', () => { + writeGhAzHostFiles(); + for (const env of [{}, { CODEMAN_AGENT_IMAGE_INSTALL_GH: '0', CODEMAN_AGENT_IMAGE_INSTALL_AZ: '' }]) { + const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home, env); + expect(mounts.filter((m) => isGhOrAz(m.src))).toEqual([]); + expect(seedCopies.filter((s) => isGhOrAz(s.to))).toEqual([]); + } + }); + + it('gh + az: each store follows ONLY its own switch, and only the exact value 1', () => { + writeGhAzHostFiles(); + const dests = (env: NodeJS.ProcessEnv) => resolveDockerCredentialArtifacts(home, env).seedCopies.map((s) => s.to); + const ghOnly = dests({ CODEMAN_AGENT_IMAGE_INSTALL_GH: '1' }); + expect(ghOnly).toContain('/home/agent/.config/gh/hosts.yml'); + expect(ghOnly.some((d) => d.includes('.azure'))).toBe(false); + const azOnly = dests({ CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1' }); + expect(azOnly).toContain('/home/agent/.azure/msal_token_cache.json'); + expect(azOnly.some((d) => d.includes('.config/gh'))).toBe(false); + expect( + dests({ CODEMAN_AGENT_IMAGE_INSTALL_GH: 'true', CODEMAN_AGENT_IMAGE_INSTALL_AZ: 'yes' }).some(isGhOrAz) + ).toBe(false); + }); + it('gh + az: seed only the sign-in files, never logs/extensions/caches', () => { mkdirSync(join(home, '.config', 'gh'), { recursive: true }); writeFileSync(join(home, '.config', 'gh', 'hosts.yml'), ''); @@ -361,7 +395,10 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}'); writeFileSync(join(home, '.azure', 'config'), ''); - const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home); + const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home, { + CODEMAN_AGENT_IMAGE_INSTALL_GH: '1', + CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1', + }); const dests = seedCopies.map((s) => s.to); expect(dests).toContain('/home/agent/.config/gh/hosts.yml'); expect(dests).toContain('/home/agent/.config/gh/config.yml'); diff --git a/test/git-clone.test.ts b/test/git-clone.test.ts index 9c5fb11d..9b588055 100644 --- a/test/git-clone.test.ts +++ b/test/git-clone.test.ts @@ -201,6 +201,25 @@ describe('buildCloneArgs / buildLsRemoteArgs', () => { 'd', ]); }); + + it('clears every credential helper, BEFORE the subcommand, only when asked', () => { + // Multi-user non-admin clones must not borrow the server account's git sign-in. + // `-c` is a global option: after `clone` git would read it as an unknown flag. + expect( + buildCloneArgs({ repository: 'https://example.com/r.git', destination: 'd', withoutCredentialHelpers: true }) + ).toEqual(['-c', 'credential.helper=', 'clone', '--', 'https://example.com/r.git', 'd']); + expect(buildLsRemoteArgs('https://example.com/r.git', { withoutCredentialHelpers: true })).toEqual([ + '-c', + 'credential.helper=', + 'ls-remote', + '--symref', + '--', + 'https://example.com/r.git', + ]); + // Absent or false leaves the argv exactly as it was before the option existed. + expect(buildCloneArgs({ repository: 'r', destination: 'd', withoutCredentialHelpers: false })[0]).toBe('clone'); + expect(buildLsRemoteArgs('r', {})[0]).toBe('ls-remote'); + }); }); describe('gitNonInteractiveEnv', () => { diff --git a/test/routes/case-clone-credential-helpers.test.ts b/test/routes/case-clone-credential-helpers.test.ts new file mode 100644 index 00000000..dc441364 --- /dev/null +++ b/test/routes/case-clone-credential-helpers.test.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview Clone Repo must not lend the server account's git sign-in to + * non-admins in multi-user mode (PR #472 review). + * + * Every Codeman user's git runs as the one server account, so a credential + * helper that account has (the Docker image's opt-in `gh`/`az` helpers, or any + * `gh auth setup-git`) would otherwise clone a PRIVATE repository with the + * signed-in admin's credentials into a non-admin's case space, the same + * boundary the local-transport rule guards. These tests pin the ROUTE decision: + * who gets `withoutCredentialHelpers`. The argv it becomes is pinned in + * `test/git-clone.test.ts`, and the real-git clone path in + * `case-clone-routes.test.ts`. + * + * Only the two network calls are mocked, so no git runs and nothing leaves the + * machine; everything else in `git-clone.ts` (URL parsing included) is real. + * + * Port: N/A (app.inject). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; + +const calls = vi.hoisted(() => ({ + probe: [] as Array<{ repository: string; opts: unknown }>, + clone: [] as Array>, +})); + +vi.mock('../../src/git-clone.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isGitAvailable: () => true, + probeGitRemote: async (repository: string, _timeoutMs?: number, opts?: unknown) => { + calls.probe.push({ repository, opts }); + return { reachable: false, branches: [], tags: [] }; + }, + cloneRepository: async (opts: Record) => { + calls.clone.push(opts); + return { ok: false, failure: { code: 'AUTH_REQUIRED', message: 'needs auth', stderr: '' } }; + }, + }; +}); + +const REPO = 'https://github.com/example/private-repo.git'; + +type Who = { username: string; role: 'admin' | 'user' } | undefined; + +async function run(who: Who, multiUser: boolean): Promise<{ probe: unknown; clone: unknown }> { + const prev = process.env.CODEMAN_MULTIUSER; + if (multiUser) process.env.CODEMAN_MULTIUSER = '1'; + else delete process.env.CODEMAN_MULTIUSER; + try { + const { app } = await createRouteTestHarness(registerCaseRoutes, who ? { authUser: who } : undefined); + await app.inject({ method: 'POST', url: '/api/cases/clone-preflight', payload: { repository: REPO } }); + await app.inject({ + method: 'POST', + url: '/api/cases/clone', + payload: { name: `cred-${Math.random().toString(36).slice(2, 10)}`, repository: REPO }, + }); + await app.close(); + expect(calls.probe, 'the preflight never reached probeGitRemote').toHaveLength(1); + expect(calls.clone, 'the clone never reached cloneRepository').toHaveLength(1); + return { + probe: (calls.probe[0].opts as { withoutCredentialHelpers?: boolean } | undefined)?.withoutCredentialHelpers, + clone: calls.clone[0].withoutCredentialHelpers, + }; + } finally { + if (prev === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prev; + } +} + +describe('Clone Repo credential helpers by caller', () => { + beforeEach(() => { + calls.probe.length = 0; + calls.clone.length = 0; + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('clears them for a NON-ADMIN in multi-user mode (preflight AND clone)', async () => { + expect(await run({ username: 'mallory', role: 'user' }, true)).toEqual({ probe: true, clone: true }); + }); + + it('keeps them for an admin in multi-user mode', async () => { + expect(await run({ username: 'root', role: 'admin' }, true)).toEqual({ probe: false, clone: false }); + }); + + it('keeps them in single-user mode, where the sole user owns the account', async () => { + expect(await run(undefined, false)).toEqual({ probe: false, clone: false }); + }); +});