diff --git a/.changeset/git-host-auth-clis.md b/.changeset/git-host-auth-clis.md new file mode 100644 index 00000000..1d1c06df --- /dev/null +++ b/.changeset/git-host-auth-clis.md @@ -0,0 +1,5 @@ +--- +"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, 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/.env.example b/docker/.env.example index 62919d27..95531008 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -50,6 +50,14 @@ CODEMAN_USERNAME=admin # README.md, "Reverse-proxy host allowlist". # CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com +# The GitHub CLI (gh) and the Azure CLI (az, with the azure-devops extension) +# can be built into the images as git credential helpers, so Codeman can clone +# private GitHub and Azure DevOps repositories. Both are OFF by default and are +# NOT set here: turn them on in docker-compose.override.yml with the build args +# CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ and, for the Docker-case agent image, +# the environment variables CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ. See +# README.md, "Private repositories". + # Optional: authenticate Gemini CLI without an interactive login. GEMINI_API_KEY= diff --git a/docker/README.md b/docker/README.md index cdd169f5..444269d6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -41,6 +41,68 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details: [`../docs/docker-self-update.md`](../docs/docker-self-update.md). +## Private repositories (GitHub and Azure DevOps) + +The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`. + +### Turning them on + +Add the build arguments to `docker-compose.override.yml` (see [Local customisation](#local-customisation)), then rebuild with `Start-Codeman.sh`. Set only the one you need: + +```yaml +services: + codeman: + build: + args: + CODEMAN_INSTALL_GH: '1' + CODEMAN_INSTALL_AZ: '1' + environment: + # The same two switches for the Docker-case agent image Codeman builds. + CODEMAN_AGENT_IMAGE_INSTALL_GH: '1' + CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1' +``` + +The `build: args:` pair controls the Codeman server image. The `environment:` pair controls the agent image for [Docker cases](../docs/docker-cases.md), which Codeman builds on the first Docker case; an agent image that already exists is not rebuilt by this, so run `node scripts/build-agent-image.mjs --no-cache` inside the container afterwards. The same variables work in front of that command when building it by hand. Values must be `0` or `1`; anything else stops the build with an error naming the argument. + +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 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 + +With a CLI on, the system Git configuration routes credentials through it: + +| Host | Credential helper | Sign in with | +| ----------------------------------------------------- | ----------------------------------------- | ---------------------------- | +| `https://github.com`, `https://gist.github.com` | `gh auth git-credential` | `gh auth login` | +| `https://dev.azure.com`, `https://*.visualstudio.com` | `/usr/local/bin/git-credential-azure-cli` | `az login --use-device-code` | + +Codeman itself still collects no Git credentials. Sign the container in once from a **Terminal / Shell** session (Run menu). The session runs as the runtime account, so the sign-in is stored under `CODEMAN_APPDATA_PATH` (`~/.config/gh`, `~/.azure`) and survives rebuilds and container recreation: + +```sh +gh auth login # GitHub.com -> HTTPS -> "Login with a web browser" (device code) +az login --use-device-code # then: az devops configure --defaults organization=https://dev.azure.com/ +``` + +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. + +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: + +```sh +gh skill install cli/cli gh --scope user +gh skill update gh # after a later gh release +``` + +### Versions + +Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild. + ## Local customisation Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git. diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 1c7899e8..f7b46058 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -26,6 +26,88 @@ RUN apt-get update \ openssh-client \ && rm -rf /var/lib/apt/lists/* +# GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system +# git credential helpers as docker/server.Dockerfile, so an agent in a Docker +# case can clone and push to private GitHub / Azure DevOps repositories. The +# sign-ins themselves are NOT baked in: `~/.config/gh` and `~/.azure` are seeded +# per container at launch like every other CLI's credentials (CRED_STORES in +# src/docker-hosts.ts), and a helper whose CLI is not signed in prints nothing, +# so git fails fast instead of prompting. See server.Dockerfile for why the +# vendor apt repositories are configured here rather than via deb_install.sh. +# +# Each is OPT-IN and OFF by default, like the server image: CODEMAN_INSTALL_GH=1 +# / CODEMAN_INSTALL_AZ=1 turn one on; off leaves no repository, package, +# extension or helper entry. scripts/build-agent-image.mjs and the in-app +# auto-build pass them from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ in their own +# environment (for the Compose deployment: `environment:` in +# docker-compose.override.yml), and pass nothing when those are unset, so +# these defaults (off) apply. +ARG CODEMAN_INSTALL_GH=0 +ARG CODEMAN_INSTALL_AZ=0 +RUN set -eux; \ + for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \ + case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \ + done; \ + codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \ + arch="$(dpkg --print-architecture)"; \ + pkgs=""; \ + install -d -m 0755 /etc/apt/keyrings; \ + if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \ + curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + https://cli.github.com/packages/githubcli-archive-keyring.gpg; \ + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \ + echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list; \ + pkgs="${pkgs} gh"; \ + fi; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + curl -fsSL -o /etc/apt/keyrings/microsoft.asc \ + https://packages.microsoft.com/keys/microsoft.asc; \ + chmod go+r /etc/apt/keyrings/microsoft.asc; \ + echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \ + > /etc/apt/sources.list.d/azure-cli.list; \ + pkgs="${pkgs} azure-cli"; \ + fi; \ + if [ -n "${pkgs}" ]; then \ + apt-get update; \ + apt-get install -y --no-install-recommends ${pkgs}; \ + rm -rf /var/lib/apt/lists/*; \ + fi; \ + if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then gh --version; fi; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then az version --output none; fi + +# Outside HOME so the seeded `~/.azure` (auth files only) never has to carry +# extensions. gid 0 + group-writable, the same arbitrary-uid convention as HOME +# below, so `az extension update` works as whatever uid the container runs as. +# Created even without az; an empty directory costs nothing. +ENV AZURE_EXTENSION_DIR=/opt/az-extensions +RUN set -eux; \ + install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + az extension add --name azure-devops --only-show-errors; \ + rm -rf /root/.azure; \ + fi; \ + chgrp -R 0 "${AZURE_EXTENSION_DIR}"; \ + chmod -R g=u "${AZURE_EXTENSION_DIR}" + +# Only an installed CLI gets a helper entry (see server.Dockerfile). +COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli +RUN set -eux; \ + if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \ + for host in https://github.com https://gist.github.com; do \ + git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \ + done; \ + fi; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + chmod 0755 /usr/local/bin/git-credential-azure-cli; \ + for host in https://dev.azure.com 'https://*.visualstudio.com'; do \ + git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \ + git config --system "credential.${host}.useHttpPath" true; \ + done; \ + else \ + rm -f /usr/local/bin/git-credential-azure-cli; \ + fi + # The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from # config/clis.stock.json so a new stock CLI needs no edit here. The default is # today's literal list, so a bare `docker build` still produces the same image. diff --git a/docker/git-credential-azure-cli b/docker/git-credential-azure-cli new file mode 100755 index 00000000..b5ce12b0 --- /dev/null +++ b/docker/git-credential-azure-cli @@ -0,0 +1,34 @@ +#!/bin/sh +# Git credential helper for Azure DevOps, backed by the signed-in Azure CLI. +# +# Configured in the image's system gitconfig for https://dev.azure.com and +# https://*.visualstudio.com (see server.Dockerfile). On `get` it answers with +# an Entra ID access token for the Azure DevOps resource as the password, the +# same token type Git Credential Manager uses for Azure Repos. It never prompts: +# when `az` is not signed in it prints nothing, so git fails fast with its own +# authentication error instead of hanging a request that has no terminal. +# +# AZURE_DEVOPS_EXT_PAT, the azure-devops extension's own PAT variable, is used +# instead when it is set, for accounts that authenticate with a PAT. + +# `store` and `erase` are no-ops: the token belongs to az, which refreshes it. +[ "$1" = "get" ] || exit 0 + +# Drain the request git writes on stdin; the host scoping is in gitconfig. +cat >/dev/null + +if [ -n "${AZURE_DEVOPS_EXT_PAT:-}" ]; then + printf 'username=pat\npassword=%s\n' "$AZURE_DEVOPS_EXT_PAT" + exit 0 +fi + +command -v az >/dev/null 2>&1 || exit 0 + +# 499b84ac-1321-427f-aa17-267ca6975798 is the fixed application ID of Azure +# DevOps: https://learn.microsoft.com/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +token="$(az account get-access-token \ + --resource 499b84ac-1321-427f-aa17-267ca6975798 \ + --query accessToken --output tsv 2>/dev/null)" || exit 0 +[ -n "$token" ] || exit 0 + +printf 'username=azure-cli\npassword=%s\n' "$token" diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index 7fff784d..09b4607a 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -68,6 +68,111 @@ COPY --from=docker:29-cli \ /usr/local/libexec/docker/cli-plugins/docker-buildx \ /usr/local/libexec/docker/cli-plugins/docker-buildx +# GitHub CLI and Azure CLI (with the azure-devops extension), so a user can sign +# this container in to GitHub and Azure DevOps from a Codeman shell session and +# then clone PRIVATE repositories, both from that session and through Add Case +# -> Clone Repo. Codeman still collects no Git credentials itself: the clone +# path (src/git-clone.ts) only inherits HOME and git's config, so whatever the +# 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 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 +# it (docs/docker-self-update.md). +# +# Both come from their vendors' own apt repositories, the same ones the +# documented one-liners configure (https://github.com/cli/cli/blob/trunk/docs/install_linux.md +# and https://learn.microsoft.com/cli/azure/install-azure-cli-linux?pivots=apt). +# Microsoft's `deb_install.sh` is deliberately not piped into the build: it does +# exactly this plus a `gnupg` install, and a remote script run at build time is +# the one step a reviewer cannot read in this file. apt reads an ASCII-armoured +# `.asc` key directly, which is what keeps `gnupg` out of the image. +# +# Not pinned, unlike the agent CLIs below: nothing in Codeman depends on a +# particular gh or az behaviour, so the pinning argument there does not apply. +# The layer cache still keeps whatever version the first build fetched until a +# --no-cache rebuild. +ARG CODEMAN_INSTALL_GH=0 +ARG CODEMAN_INSTALL_AZ=0 +RUN set -eux; \ + for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \ + case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \ + done; \ + codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \ + arch="$(dpkg --print-architecture)"; \ + pkgs=""; \ + install -d -m 0755 /etc/apt/keyrings; \ + if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \ + curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + https://cli.github.com/packages/githubcli-archive-keyring.gpg; \ + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \ + echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list; \ + pkgs="${pkgs} gh"; \ + fi; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + curl -fsSL -o /etc/apt/keyrings/microsoft.asc \ + https://packages.microsoft.com/keys/microsoft.asc; \ + chmod go+r /etc/apt/keyrings/microsoft.asc; \ + echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \ + > /etc/apt/sources.list.d/azure-cli.list; \ + pkgs="${pkgs} azure-cli"; \ + fi; \ + if [ -n "${pkgs}" ]; then \ + apt-get update; \ + apt-get install -y --no-install-recommends ${pkgs}; \ + rm -rf /var/lib/apt/lists/*; \ + fi + +# The azure-devops extension goes into a SYSTEM directory rather than the +# default ~/.azure/cliextensions: HOME is the application-data bind mount, which +# hides anything installed there at build time. The directory is handed to the +# runtime account below (next to /opt/codeman-cli) so `az extension update` +# works from a session. Nothing that runs as root executes from it. It is +# created even without az, so the chown below does not have to know. +ENV AZURE_EXTENSION_DIR=/opt/codeman-az-extensions +RUN set -eux; \ + install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + az extension add --name azure-devops --only-show-errors; \ + rm -rf /root/.azure; \ + fi + +# Git credential helpers, in the SYSTEM gitconfig so they apply to every +# account and survive a fresh application-data directory. Each one answers only +# for its own host and prints nothing when its CLI is not signed in, so git +# falls through to its normal non-interactive failure. Only an installed CLI +# gets an entry: a helper naming a missing binary would print an error on every +# clone from that host. +# github.com `gh auth git-credential`, what `gh auth setup-git` configures. +# Azure DevOps an Entra ID token from `az login` (git-credential-azure-cli), +# for both dev.azure.com and the legacy *.visualstudio.com hosts. +COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli +RUN set -eux; \ + if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \ + for host in https://github.com https://gist.github.com; do \ + git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \ + done; \ + fi; \ + if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \ + chmod 0755 /usr/local/bin/git-credential-azure-cli; \ + for host in https://dev.azure.com 'https://*.visualstudio.com'; do \ + git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \ + git config --system "credential.${host}.useHttpPath" true; \ + done; \ + else \ + rm -f /usr/local/bin/git-credential-azure-cli; \ + fi + # Keep credentials out of the image. Users authenticate these CLIs at runtime # through Codeman sessions, and the configured host bind mount retains state. # @@ -153,7 +258,7 @@ RUN set -eux; \ --shell /bin/bash \ "${CODEMAN_RUNTIME_USER}"; \ fi; \ - chown -R "${PUID}:${PGID}" /opt/codeman-cli + chown -R "${PUID}:${PGID}" /opt/codeman-cli /opt/codeman-az-extensions WORKDIR /opt/codeman diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b024875a..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}` (`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). +- ⚠️ **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 f4664a3e..f8a1459a 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -81,6 +81,8 @@ 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. 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" 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. diff --git a/docs/docker-compose.md b/docs/docker-compose.md index 9f1fab2c..ed8ef130 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image. +It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings. + ## Prerequisites - Docker Engine or Docker Desktop with Docker Compose v2 diff --git a/docs/security-architecture.md b/docs/security-architecture.md index b59fe6c2..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`, and three from `~/.grok`) 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/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 0afd077d..b32c2bba 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -20,12 +20,19 @@ Three ways to get one, all under **+** next to the case picker: | How | Result | | ----------------- | ------------------------------------------------------------------------------------------------------ | | **Create New** | A fresh `~/codeman-cases/` with a scaffolded `CLAUDE.md`. | -| **Clone Repo** | A public repo cloned into `~/codeman-cases/` and registered as a case. | +| **Clone Repo** | A repo cloned into `~/codeman-cases/` and registered as a case. Private repos need this machine's own git credentials (see below). | | **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. | Linked cases keep living where they are. Deleting a case in Codeman removes the registration, and for a linked case that is all it removes. +**Clone Repo never asks for credentials.** It uses whatever the server's own git already has: +an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include +helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`; +then signing those CLIs in once from a shell session is enough. See the private repositories +section of `docker/README.md`. Without credentials a private repo fails straight away with an +authentication error. + **Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not delete `~/codeman-cases/`, but treat that directory as real work, not scratch space. diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md index ff5ccca1..5298bd11 100644 --- a/docs/wiki/Docker-Cases.md +++ b/docs/wiki/Docker-Cases.md @@ -118,6 +118,35 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha container's history). OMP's `sessions/` is the exception and is shared read-write, because 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. 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 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 +should not have them. + +Both CLIs are opt-in. To build the agent image with them, set +`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` and/or `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` where the image +is built: in front of `node scripts/build-agent-image.mjs`, or in the Codeman server's +environment for the image it builds automatically (in the Docker deployment, `environment:` +in `docker-compose.override.yml`), then rebuild the image with `--no-cache`. +`docker/README.md` ("Private repositories") has the details and the matching switches for +the server image. + ## Isolation Every container runs hardened by default: diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md index 2256b545..5fff5707 100644 --- a/docs/wiki/Quick-Start.md +++ b/docs/wiki/Quick-Start.md @@ -43,7 +43,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three | Tab | Use it when | | ----------------- | ------------------------------------------------------------------------------------------------------------------ | | **Create New** | Starting a fresh project. Creates `~/codeman-cases/` and scaffolds a `CLAUDE.md` into it. | -| **Clone Repo** | Working on an existing public repo. Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. | +| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. | | **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. | The gear next to the picker holds two per-case toggles: **Agent Teams** and diff --git a/scripts/lib/cli-catalog.mjs b/scripts/lib/cli-catalog.mjs index 6d2b8d6a..6380b3fe 100644 --- a/scripts/lib/cli-catalog.mjs +++ b/scripts/lib/cli-catalog.mjs @@ -55,9 +55,36 @@ export function agentImageNpmPackages(catalog) { return packages; } -/** The `--build-arg` pairs the agent image takes. PURE. */ -export function agentImageBuildArgPairs(catalog) { - return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]]; +/** + * Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az). + * ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them. + */ +export const GIT_HOST_CLI_BUILD_ARGS = [ + ['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'], + ['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], +]; + +/** + * The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable + * contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same + * as before these existed; anything other than 0/1 is refused rather than guessed at. + */ +export function gitHostCliBuildArgPairs(env) { + const pairs = []; + for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) { + const value = env[envName]; + if (value === undefined || value === '') continue; + if (value !== '0' && value !== '1') { + throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`); + } + pairs.push([argName, value]); + } + return pairs; +} + +/** The `--build-arg` pairs the agent image takes. PURE given `env`. */ +export function agentImageBuildArgPairs(catalog, env = process.env) { + return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)]; } /** Read the committed catalogue. IO. */ diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 6a0d705b..5509e89b 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -606,9 +606,36 @@ export function agentImageNpmPackages(): string[] { return packages; } -/** The `--build-arg` pairs the agent image takes. */ -export function agentImageBuildArgPairs(): Array<[string, string]> { - return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]]; +/** + * Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az). + * ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them. + */ +export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray = [ + ['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'], + ['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], +]; + +/** + * The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable + * contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same + * as before these existed; anything other than 0/1 is refused rather than guessed at. + */ +export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> { + const pairs: Array<[string, string]> = []; + for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) { + const value = env[envName]; + if (value === undefined || value === '') continue; + if (value !== '0' && value !== '1') { + throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`); + } + pairs.push([argName, value]); + } + return pairs; +} + +/** The `--build-arg` pairs the agent image takes. PURE given `env`. */ +export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> { + return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)]; } // ========== Credential mount resolution (IO) ========== @@ -785,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[] = [ @@ -829,6 +864,31 @@ 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. 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', + 'service_principal_entries.json', + 'clouds.config', + 'config', + ], + }, // OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/ // settings.yml — small, no bigger than grok's config.toml/pager.toml), but // that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and @@ -853,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}`; @@ -1136,10 +1200,17 @@ function buildAgentImage( error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`, }); } + let buildArgPairs: Array<[string, string]>; + try { + buildArgPairs = agentImageBuildArgPairs(); + } catch (err) { + // A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure. + return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) }); + } const argv = dockerEngineArgv(docker); const args = [ ...argv.slice(1), - ...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()), + ...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs), ]; return new Promise((resolve) => { // async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop. diff --git a/src/git-clone.ts b/src/git-clone.ts index 369b6c83..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, + ]; } /** @@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError return { code: 'AUTH_REQUIRED', message: - 'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.', + "That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.", stderr: clean, }; } @@ -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/agent-image-build-args-parity.test.ts b/test/agent-image-build-args-parity.test.ts index 89c0cdd1..030235a8 100644 --- a/test/agent-image-build-args-parity.test.ts +++ b/test/agent-image-build-args-parity.test.ts @@ -20,11 +20,15 @@ import { fileURLToPath } from 'node:url'; import { agentImageBuildArgPairs as mjsPairs, agentImageNpmPackages as mjsPackages, + GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs, + gitHostCliBuildArgPairs as mjsGitHostPairs, } from '../scripts/lib/cli-catalog.mjs'; import { agentImageBuildArgPairs as tsPairs, agentImageBuildArgs, agentImageNpmPackages as tsPackages, + GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs, + gitHostCliBuildArgPairs as tsGitHostPairs, } from '../src/docker-hosts.js'; const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8')); @@ -96,3 +100,48 @@ describe('agent-image build args: the .mjs and the TS mirror agree', () => { expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs')); }); }); + +describe('optional gh / az in the agent image: both producers pass the same switches', () => { + const ENV_GH = 'CODEMAN_AGENT_IMAGE_INSTALL_GH'; + const ENV_AZ = 'CODEMAN_AGENT_IMAGE_INSTALL_AZ'; + + it('map the same environment variables to the same Dockerfile ARGs', () => { + expect(tsGitHostArgs).toEqual(mjsGitHostArgs); + expect(tsGitHostArgs.map(([, arg]) => arg)).toEqual(['CODEMAN_INSTALL_GH', 'CODEMAN_INSTALL_AZ']); + }); + + it('agree for every combination, and an unset or empty variable adds nothing', () => { + for (const gh of [undefined, '', '0', '1']) { + for (const az of [undefined, '', '0', '1']) { + const env: NodeJS.ProcessEnv = {}; + if (gh !== undefined) env[ENV_GH] = gh; + if (az !== undefined) env[ENV_AZ] = az; + const expected: Array<[string, string]> = []; + if (gh) expected.push(['CODEMAN_INSTALL_GH', gh]); + if (az) expected.push(['CODEMAN_INSTALL_AZ', az]); + expect(tsGitHostPairs(env)).toEqual(expected); + expect(mjsGitHostPairs(env)).toEqual(expected); + expect(tsPairs(env)).toEqual(mjsPairs(CATALOG, env)); + } + } + }); + + it('keeps the default argv unchanged when neither variable is set', () => { + expect(tsPairs({})).toEqual([['CLI_NPM_PACKAGES', tsPackages().join(' ')]]); + }); + + it('refuses anything but 0 or 1 on both sides, naming the variable', () => { + for (const bad of ['yes', 'true', '2', ' 1', '0 && echo']) { + expect(() => tsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ)); + expect(() => mjsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ)); + } + }); + + it('both Dockerfiles declare the switches, defaulting to OFF (opt-in)', () => { + for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) { + const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8'); + expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_GH=0$/m); + expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_AZ=0$/m); + } + }); +}); diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index ca2aee9d..123c73ec 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -351,6 +351,70 @@ 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'), ''); + writeFileSync(join(home, '.config', 'gh', 'config.yml'), ''); + mkdirSync(join(home, '.azure', 'logs'), { recursive: true }); + mkdirSync(join(home, '.azure', 'cliextensions'), { recursive: true }); + writeFileSync(join(home, '.azure', 'azureProfile.json'), '{}'); + writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}'); + writeFileSync(join(home, '.azure', 'config'), ''); + + 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'); + expect(dests).toContain('/home/agent/.azure/azureProfile.json'); + expect(dests).toContain('/home/agent/.azure/msal_token_cache.json'); + expect(dests).toContain('/home/agent/.azure/config'); + // Absent files are skipped, and nothing outside the sign-in set is seeded. + expect(dests).not.toContain('/home/agent/.azure/service_principal_entries.json'); + expect(dests.some((d) => d.includes('logs') || d.includes('cliextensions'))).toBe(false); + expect(seedCopies.filter((s) => /\.azure|\.config\/gh/.test(s.to)).every((s) => !s.recursive)).toBe(true); + // Every host credential file rides a READ-ONLY mount, so the container never writes back. + const credMounts = mounts.filter((m) => /\.azure|\.config[\\/]gh/.test(m.src)); + expect(credMounts.length).toBe(5); + expect(credMounts.every((m) => m.readonly)).toBe(true); + }); + it('gates every artifact on existsSync (absent stores contribute nothing)', () => { const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home); expect(mounts).toEqual([]); 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 }); + }); +});