From 38de1a26f59690257b3e3e57b5c76a9197a515ec Mon Sep 17 00:00:00 2001 From: Carlo van Overbeek Date: Fri, 19 Jun 2026 22:30:13 +0200 Subject: [PATCH] claude-docker: allow other model serving gateways --- claude-docker/README.md | 48 +++++++- .../changes/add-llm-gateway/.openspec.yaml | 2 + .../changes/add-llm-gateway/design.md | 112 ++++++++++++++++++ .../changes/add-llm-gateway/proposal.md | 57 +++++++++ .../specs/external-cli-tools/spec.md | 68 +++++++++++ .../add-llm-gateway/specs/llm-gateway/spec.md | 86 ++++++++++++++ .../specs/persistent-session-storage/spec.md | 23 ++++ .../openspec/changes/add-llm-gateway/tasks.md | 40 +++++++ claude-docker/run.sh | 32 +++++ 9 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 claude-docker/openspec/changes/add-llm-gateway/.openspec.yaml create mode 100644 claude-docker/openspec/changes/add-llm-gateway/design.md create mode 100644 claude-docker/openspec/changes/add-llm-gateway/proposal.md create mode 100644 claude-docker/openspec/changes/add-llm-gateway/specs/external-cli-tools/spec.md create mode 100644 claude-docker/openspec/changes/add-llm-gateway/specs/llm-gateway/spec.md create mode 100644 claude-docker/openspec/changes/add-llm-gateway/specs/persistent-session-storage/spec.md create mode 100644 claude-docker/openspec/changes/add-llm-gateway/tasks.md diff --git a/claude-docker/README.md b/claude-docker/README.md index 58348c5..bfc15e2 100644 --- a/claude-docker/README.md +++ b/claude-docker/README.md @@ -1,6 +1,6 @@ # claude-docker -Run Claude Code in a container that inherits your setup but not your filesystem. Workspace access is scoped to the directories you pass in; your statusline, skills, agents, and slash commands ride along as read-only bind-mounts. CLI tools are preinstalled (`gh`, `glab`, `aws`, `openspec`, `uv`, `pnpm`, `tfenv`, `git-lfs`) — language runtimes are not: `tfenv` and `uv` fetch your project-pinned Terraform / Python on demand. Host credentials (`gh`, `glab`, `aws`, `tfe`) are opt-in per flag; nothing leaks in by default. +Run Claude Code in a container that inherits your setup but not your filesystem. Workspace access is scoped to the directories you pass in; your statusline, skills, agents, and slash commands ride along as read-only bind-mounts. CLI tools are preinstalled (`gh`, `glab`, `aws`, `openspec`, `uv`, `pnpm`, `tfenv`, `git-lfs`) — language runtimes are not: `tfenv` and `uv` fetch your project-pinned Terraform / Python on demand. Host credentials (`gh`, `glab`, `aws`, `tfe`) are opt-in per flag; nothing leaks in by default. An optional `--gateway` flag routes the model traffic itself through a self-hosted LLM gateway (e.g. LiteLLM) for outage redundancy or non-Anthropic models. The VCS and cloud CLIs (`gh`, `glab`, `aws`) need a flag to see host credentials — see [Credential opt-in](#credential-opt-in). The rest work out of the box. @@ -28,7 +28,7 @@ claude-docker ~/repo -- --resume # any claude flag after -- ### Credential opt-in -**Credentials are off by default.** No AWS / GitHub / GitLab / Terraform Cloud config, tokens, or env vars reach the container unless you explicitly opt in: +**Credentials are off by default.** No AWS / GitHub / GitLab / Terraform Cloud / LLM-gateway config, tokens, or env vars reach the container unless you explicitly opt in: | Flag | Effect | |--------------|--------| @@ -36,6 +36,7 @@ claude-docker ~/repo -- --resume # any claude flag after -- | `--gh` | Forward `GH_TOKEN` / `GITHUB_TOKEN`; if neither is set on the host, the wrapper extracts a token via `gh auth token` (host keychain) and forwards that. Unmasks in-container `gh auth login` state persisted in `claude-code-root` — without this flag, `/root/.config/gh/` is hidden by a tmpfs overlay so a prior login can't leak into a non-opted-in session. | | `--glab` | Mount the platform-appropriate `glab-cli` config dir read-only (macOS: `~/Library/Application Support/glab-cli`, Linux: `~/.config/glab-cli`) and forward `GITLAB_TOKEN`. Unmasks in-container `glab auth login` state — without the flag, `/root/.config/glab-cli/` is hidden by a tmpfs overlay. | | `--tfe` | Mount `~/.terraform.d/credentials.tfrc.json` read-only when present and forward `TF_TOKEN_app_terraform_io`. Targets `app.terraform.io` (HCP Terraform) only — self-hosted Terraform Enterprise hostnames and other `TF_TOKEN_` variables are not forwarded. Unmasks in-container `terraform login` state — without the flag, `/root/.terraform.d/` is hidden by a tmpfs overlay. See [Terraform Cloud workflow](#terraform-cloud-workflow). | +| `--gateway` | Route Claude Code through an Anthropic-Messages-compatible LLM gateway (e.g. a self-hosted LiteLLM proxy) for outage redundancy or non-Anthropic models. Forward `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` (bearer) and, when set, the model overrides `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`. Masks the Anthropic OAuth credential for the session (see below) so auth comes solely from the forwarded token. See [LLM gateway workflow](#llm-gateway-workflow). | Combine as needed: `claude-docker --aws --gh ~/repo`. @@ -89,13 +90,13 @@ The chosen dir takes the place of `~/.claude` for every item in the parity table ### Statusline tag for active opt-ins -`run.sh` exports `CLAUDE_DOCKER_FLAGS` into the container with the comma-separated list of active opt-ins (`gh`, `aws`, `glab`, `tfe`, `ephemeral`, `ro`) and wraps the host statusline script so a yellow `docker:` tag is prepended to whatever your personal statusline renders. The variable is set by the wrapper for the statusline to read — not a user-tunable knob. `--yolo` / `--dangerously-skip-permissions` is not surfaced here — Claude Code's own mode indicator already makes it obvious. The wrapper is a no-op passthrough when no opt-ins are active, so your statusline looks unchanged on a plain `claude-docker ~/repo`. +`run.sh` exports `CLAUDE_DOCKER_FLAGS` into the container with the comma-separated list of active opt-ins (`gh`, `aws`, `glab`, `tfe`, `gateway`, `ephemeral`, `ro`) and wraps the host statusline script so a yellow `docker:` tag is prepended to whatever your personal statusline renders. The variable is set by the wrapper for the statusline to read — not a user-tunable knob. `--yolo` / `--dangerously-skip-permissions` is not surfaced here — Claude Code's own mode indicator already makes it obvious. The wrapper is a no-op passthrough when no opt-ins are active, so your statusline looks unchanged on a plain `claude-docker ~/repo`. The image sets `IS_SANDBOX=1` — historically required to let `--yolo` / `--dangerously-skip-permissions` work when claude ran as root. The entrypoint now drops to the host UID before exec'ing claude, so the root-user check no longer triggers in steady state; `IS_SANDBOX=1` remains as a safety net for the legacy `HOST_UID=0` fall-through path. OS-level hardening comes from `--cap-drop ALL` (with `CHOWN`, `SETUID`, `SETGID`, `DAC_READ_SEARCH` re-added for transient entrypoint use only), `--security-opt no-new-privileges`, the Docker default seccomp profile, `--init` (tini reaps subprocess zombies), and the bind-mount layout. See [File ownership](#file-ownership) and [Threat model](#threat-model) below. ## Auth model -Credentials are opt-in per run — see [Credential opt-in](#credential-opt-in) above for the per-flag effect, mounts, and env-var forwarding. The subsections below cover the two workflows that need more than a one-line table cell. +Credentials are opt-in per run — see [Credential opt-in](#credential-opt-in) above for the per-flag effect, mounts, and env-var forwarding. The subsections below cover the workflows that need more than a one-line table cell. ### AWS SSO flow (`--aws`) @@ -131,6 +132,42 @@ The image ships `tfenv` (a pure-bash terraform version manager) and **does not** Token alternative: instead of (or in addition to) the credentials file, export `TF_TOKEN_app_terraform_io=` on the host and `--tfe` will forward it. The terraform CLI honours both. +### LLM gateway workflow + +`--gateway` points Claude Code at an Anthropic-Messages-compatible LLM gateway instead of `api.anthropic.com` — useful as an outage fallback and for reaching non-Anthropic models the gateway fronts (e.g. via a self-hosted LiteLLM proxy). Standing up the gateway itself is out of scope here; the wrapper only forwards the connection settings and isolates your Anthropic credential. + +Claude Code always speaks the Anthropic wire format to the gateway regardless of which backend it routes to, so a non-Anthropic model is just a **model id** — you do not need autodiscovery. Set it explicitly one of two ways: + +**Option 1 — set the model directly (simplest).** `ANTHROPIC_MODEL` takes a literal id and overrides everything else (`settings.docker.json`, the `opus[1m]` pin). Whatever string you put here is sent straight to the gateway: + +```bash +export ANTHROPIC_BASE_URL=https://litellm.internal:4000 +export ANTHROPIC_AUTH_TOKEN=sk-litellm-... +export ANTHROPIC_MODEL=gpt-4o # main model — must match a LiteLLM model_name +export ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-4o-mini # background/fast model (see below) +claude-docker --gateway ~/repo +``` + +**Option 2 — remap the aliases.** Keep the alias-based UX (the `opus`/`sonnet`/`haiku` entries in the `/model` picker and the `"model": "opus[1m]"` in `settings.docker.json`) but remap what each alias resolves to: + +```bash +export ANTHROPIC_BASE_URL=https://litellm.internal:4000 +export ANTHROPIC_AUTH_TOKEN=sk-litellm-... +export ANTHROPIC_DEFAULT_OPUS_MODEL=bedrock-claude-sonnet # what "opus" becomes +export ANTHROPIC_DEFAULT_SONNET_MODEL=gemini-pro +export ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-4o-mini +claude-docker --gateway ~/repo +``` + +Note the `[1m]` suffix in the default `settings.docker.json` is an Anthropic context-window variant and means nothing to a non-Anthropic backend — with a gateway, prefer Option 1 (`ANTHROPIC_MODEL`) to avoid that ambiguity. + +Notes: + +- **Background model.** Claude Code makes background/fast calls against the haiku slot; if your gateway doesn't serve whatever haiku resolves to, those calls fail — so always point `ANTHROPIC_DEFAULT_HAIKU_MODEL` at a small model it does serve. This is the easiest part to miss. +- **Model ids are LiteLLM names.** The ids above must match the `model_name` values in your LiteLLM `model_list` config exactly — not the upstream provider's name. Check your gateway config for the right strings. +- **Autodiscovery is optional.** `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` only populates the in-session `/model` picker from the gateway's `/v1/models` so you can switch interactively — it's a convenience on top of the env vars above, never a requirement. You can also just type a model id into `/model` without it. +- **Credential isolation.** In `--gateway` mode the wrapper masks the persisted Anthropic OAuth token (`/root/.claude/.credentials.json`) with an empty read-only overlay, so a session routed through a third-party gateway can neither use nor read your subscription credential — auth comes solely from `ANTHROPIC_AUTH_TOKEN`. This also means OAuth / `claude login` is unavailable while the flag is set; drop the flag to go back to the Anthropic API on your subscription. Session and project history under `/root/.claude/projects/` still persists and stays unified across gateway and non-gateway runs, so `claude --resume` lists them together. + ## File ownership Files created inside the container appear on the host owned by the user who launched `claude-docker`, not by `root`. The wrapper forwards `HOST_UID` / `HOST_GID` and the in-container entrypoint creates a matching passwd entry and drops to it via `runuser` before exec'ing claude. Persistent state in the `claude-code-root` and `claude-code-home` named volumes is chowned on first start, so an existing volume from before this change is fixed up the next time you run `claude-docker`. @@ -140,8 +177,9 @@ Files created inside the container appear on the host owned by the user who laun The container narrows blast radius vs. running `claude --yolo` on the host, but it is **not** a full sandbox: - **Protected:** host filesystem outside your passed workspaces, host `~/.aws/credentials` (long-lived keys), host AWS/glab config dirs are read-only from inside (container can't persist changes back). -- **Exposed (per session):** your passed workspaces are read-write (unless `--ro`); host credentials when opted in — short-lived AWS SSO bearer tokens (`~/.aws/sso/cache`), the glab config token, `~/.terraform.d/credentials.tfrc.json`, and `GH_TOKEN` / `GITLAB_TOKEN` / `TF_TOKEN_app_terraform_io` / `AWS_*` env vars are all readable inside the container; full outbound network with no egress filtering. +- **Exposed (per session):** your passed workspaces are read-write (unless `--ro`); host credentials when opted in — short-lived AWS SSO bearer tokens (`~/.aws/sso/cache`), the glab config token, `~/.terraform.d/credentials.tfrc.json`, and `GH_TOKEN` / `GITLAB_TOKEN` / `TF_TOKEN_app_terraform_io` / `AWS_*` env vars are all readable inside the container; under `--gateway`, `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` (your gateway key) are readable inside too; full outbound network with no egress filtering. - **Exposed (cross-session):** the persistent `claude-code-root` and `claude-code-home` named volumes hold the Claude OAuth token, in-container `gh` / `glab` / `terraform login` state, shell history, and conversation history. `claude --resume` can replay sessions from **any** past workspace — see [Resuming sessions across workspaces](#resuming-sessions-across-workspaces). Skipped under `--ephemeral`. +- **Anthropic credential isolation under `--gateway`:** when routing through a third-party gateway, the persisted Anthropic OAuth token (`/root/.claude/.credentials.json`) is masked by an empty read-only overlay so it is neither used nor readable in that session — the gateway sees only the `ANTHROPIC_AUTH_TOKEN` you forwarded, never your subscription credential. The mask is non-destructive: the token reappears on the next non-gateway run. - **Runtime code-fetch:** `npx`, `pnpm dlx`, `uvx`, and `tfenv install` fetch and execute arbitrary code from public sources on first use — npm and PyPI for the package managers, `releases.hashicorp.com` for `tfenv install`. Under `--yolo`, a prompt-injected workspace can trigger these. `pnpm dlx` adds zero marginal blast radius vs the already-reachable `npx`; `uvx` is a *new* PyPI execution primitive (no Python runtime existed in the image before); `tfenv install` is a *new* HashiCorp release-channel execution primitive whose downloaded `terraform` binary is intentionally **not** sha256-pinned in the image (versions are project-pinned via `.terraform-version`, so the image stays neutral on version policy). Build-time installs of the CLIs themselves are pinned by version + sha256 where the ecosystem supports it (uv binary, glab .deb, AWS CLI, tfenv source archive), and by version only for npm-backed packages (claude-code, openspec, pnpm) — `--ignore-scripts` blocks lifecycle scripts at install time but does not protect against a compromised registry serving a malicious tarball at the pinned version. - **If a session is compromised:** assume exfiltration already happened (full network egress). Then: rotate the host sessions for every flag that was passed (`gh auth refresh` / re-login, `glab auth login`, `aws sso login`, `terraform login`), revoke the Claude OAuth credential, and clear the named volumes (`docker volume rm claude-code-root claude-code-home`) to flush in-container auth state and cross-workspace conversation history that `claude --resume` could otherwise replay. diff --git a/claude-docker/openspec/changes/add-llm-gateway/.openspec.yaml b/claude-docker/openspec/changes/add-llm-gateway/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/claude-docker/openspec/changes/add-llm-gateway/design.md b/claude-docker/openspec/changes/add-llm-gateway/design.md new file mode 100644 index 0000000..74582d2 --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/design.md @@ -0,0 +1,112 @@ +## Context + +`claude-docker`'s `run.sh` wraps `docker run` and is the single place where host +credentials, mounts, and env vars are granted to the container. Today it has no +notion of the model endpoint: Claude Code defaults to `api.anthropic.com` and +authenticates with a subscription OAuth token that lives in `/root/.claude/.credentials.json` +inside the `claude-code-home` named volume (mounted at run.sh:395). The wrapper +forwards only a curated allowlist of env vars (run.sh:195-205) and already has a +"mask persisted state when its opt-in is off" pattern using tmpfs overlays +(run.sh:391-393). This change adds a model-endpoint pathway that fits those +existing mechanisms rather than inventing new ones. + +Claude Code talks the Anthropic Messages wire format to whatever +`ANTHROPIC_BASE_URL` points at, so a LiteLLM gateway can front non-Anthropic +backends transparently — model choice is just a model id. `ANTHROPIC_AUTH_TOKEN` +(Bearer) takes precedence over the OAuth credential, so functionally the gateway +is used once the env vars are set; the credential masking is a defense-in-depth +requirement, not a functional one. + +## Goals / Non-Goals + +**Goals:** +- A per-run `--gateway` opt-in that forwards endpoint/auth/model env vars from the + host into the container, consistent with the `--aws`/`--gh`/`--glab`/`--tfe` + opt-in discipline. +- Guarantee the Anthropic subscription credential is absent from the sandbox + whenever `--gateway` is active, so a session routed through a third-party + gateway cannot read or exfiltrate it. +- Preserve session/project history persistence in gateway mode. +- Provider-agnostic naming and docs (gateway, not "litellm"/"anthropic failover"). + +**Non-Goals:** +- Running or configuring the LiteLLM gateway itself (infra outside this repo). +- Auto-detecting or defaulting an endpoint — the user supplies host env vars. +- Bedrock/Vertex native modes (`CLAUDE_CODE_USE_BEDROCK`/`_VERTEX`); those are a + separate pathway and out of scope. +- Persisting a separate gateway credential store across runs. + +## Decisions + +### Flag name: `--gateway` +Provider-agnostic; frames the feature as "route through an LLM gateway" rather +than naming today's specific tool. Alternatives considered: `--litellm` (ties the +interface to one implementation) and `--proxy` (overloaded with HTTP/network +proxy). Rejected both. + +### Env forwarding reuses the existing allowlist loop +Add the gateway vars to the same `ENV_VARS`/`ENV_ARGS` mechanism (run.sh:195-205) +guarded by a `WITH_GATEWAY` flag. Each var forwards only when set on the host +(the existing `[ -n "${!v:-}" ]` guard), so unset model overrides never become +empty strings in the container. Forwarded vars: `ANTHROPIC_BASE_URL`, +`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, +`ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, +`ANTHROPIC_DEFAULT_FABLE_MODEL`, `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`. + +### Credential masking: empty read-only file over the credential path +The existing masks use `--tmpfs` over a directory (e.g. `/root/.config/gh`). The +Anthropic credential is a single file inside the `claude-code-home` volume, so a +directory tmpfs is the wrong granularity — tmpfs-ing `/root/.claude` would hide +session history too. Instead, when `WITH_GATEWAY=1`, stage an empty file and +bind-mount it read-only over `/root/.claude/.credentials.json`. This masks only +the credential, leaves `/root/.claude/projects/` and other config intact, and is +non-destructive (the underlying volume file is untouched and reappears on a +non-gateway run). The staged file is created under the same temp-staging approach +the script already uses for host-config parity (run.sh ~253+), so Colima/virtiofs +mount quirks are handled identically. + +Alternative considered: bind-mounting `/dev/null` over the file. Rejected as +hacky and platform-fragile compared to an empty staged file. + +Edge case: if `.credentials.json` does not yet exist in the volume (fresh +install, never logged in), bind-mounting over a non-existent target can error on +some Docker backends. The implementation stages the empty file and mounts it; +Docker creates the mountpoint for bind mounts, so this is expected to be a no-op. +Confirm both the "credential present" and "no prior login" cases against a real +daemon during verification. + +### Statusline tag via `CLAUDE_DOCKER_FLAGS` +Append `gateway` to the `DOCKER_FLAGS` array (run.sh:241-251) when `WITH_GATEWAY=1` +so the statusline visibly marks the session — important because the session may be +hitting a non-Anthropic model and the user should see that at a glance. + +## Risks / Trade-offs + +- **Token still readable by the user before launch (it's their own host env).** → + Expected; the isolation goal is about the *sandbox/session* not holding the + Anthropic subscription token, not about hiding the gateway token from its owner. +- **Masking the credential breaks `claude login` / OAuth refresh while in gateway + mode.** → Intended: gateway mode authenticates solely via `ANTHROPIC_AUTH_TOKEN`. + Documented in the README; users wanting OAuth simply omit `--gateway`. +- **Bind-mount over a missing credential file errors on some backends.** → Stage + the empty file and rely on Docker creating the mountpoint; confirm the + no-prior-login path against a real daemon during verification. +- **Model override misconfiguration (gateway lacks a haiku-class model) causes + background calls to fail.** → Out of scope to fix here, but documented: point + `ANTHROPIC_DEFAULT_HAIKU_MODEL` at a model the gateway actually serves. +- **Forwarding an auth token by default would cut against credentials-off-by-default.** + → Mitigated by gating all forwarding behind the explicit `--gateway` flag; + verified that nothing is forwarded without it. + +## Migration Plan + +Additive and opt-in; no migration. Existing invocations behave identically +because all new behavior is gated on `--gateway`. Rollback is removing the flag +branch. To adopt: export `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` (and any +model overrides) on the host, then run `claude-docker --gateway `. + +## Open Questions + +- None blocking. The exact behavior of bind-mounting over a non-existent + `.credentials.json` will be confirmed empirically against a real Docker daemon + during verification. diff --git a/claude-docker/openspec/changes/add-llm-gateway/proposal.md b/claude-docker/openspec/changes/add-llm-gateway/proposal.md new file mode 100644 index 0000000..271f8db --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/proposal.md @@ -0,0 +1,57 @@ +## Why + +Today `claude-docker` can only reach Anthropic's hosted API, authenticated by a +subscription OAuth token persisted in the `claude-code-home` named volume. If +that endpoint has an outage there is no fallback, and there is no way to reach +other models the team already runs behind a self-hosted LiteLLM proxy. We need a +per-run opt-in that routes Claude Code through an Anthropic-Messages-compatible +LLM gateway — for outage redundancy and access to non-Anthropic backends — +without weakening the project's credentials-off-by-default posture. + +## What Changes + +- Add a provider-agnostic `--gateway` opt-in flag to `run.sh`, following the + existing per-run credential-opt-in discipline (`--aws`/`--gh`/`--glab`/`--tfe`). +- When `--gateway` is set, forward from the host environment: `ANTHROPIC_BASE_URL` + and `ANTHROPIC_AUTH_TOKEN` (Bearer auth to the gateway), plus optional model + overrides (`ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, + `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, + `ANTHROPIC_DEFAULT_FABLE_MODEL`) and optional `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`. + Non-Anthropic backends are selected purely by model id, since Claude Code speaks + the Anthropic wire format to the gateway regardless of the routed model. +- **Credential isolation:** in `--gateway` mode the Anthropic subscription OAuth + credential (`/root/.claude/.credentials.json`) MUST be absent from the sandbox, + masked via the existing tmpfs/empty-mount precedent, so a session routed through + a third-party gateway cannot read or exfiltrate the Anthropic token. Auth comes + solely from the forwarded `ANTHROPIC_AUTH_TOKEN`. Session history elsewhere + under `/root/.claude` still persists. +- Tag the active gateway opt-in via the existing `CLAUDE_DOCKER_FLAGS` statusline + mechanism so a session visibly shows it is running against a gateway. +- Document the flag in `README.md`; verify behavior against the spec scenarios + (manual/argv-level, per project convention — no committed test harness). + +## Capabilities + +### New Capabilities +- `llm-gateway`: Routing Claude Code through an Anthropic-Messages-compatible LLM + gateway via a per-run `--gateway` opt-in — forwarding endpoint/auth/model env + vars and isolating the Anthropic subscription credential while gateway auth is + in effect. + +### Modified Capabilities +- `external-cli-tools`: The "Credentials opt-in" requirement gains a new + credential pathway — host `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` forwarding + — which must follow the same per-run dedicated-flag opt-in discipline (no + forwarding without `--gateway`). +- `persistent-session-storage`: The invariant that credentials always persist in + the named volume gains a deliberate carve-out — in `--gateway` mode the + Anthropic OAuth credential file is masked so it is neither used nor visible, + while session/project history continues to persist. + +## Impact + +- `claude-docker/run.sh`: new flag parsing, env-var forwarding, credential + masking, statusline tagging, and `--help` text. +- `claude-docker/README.md`: new gateway usage/security section. +- No image/Dockerfile changes; no new runtime dependencies; no new test harness. + Behavior is unchanged unless `--gateway` is passed. diff --git a/claude-docker/openspec/changes/add-llm-gateway/specs/external-cli-tools/spec.md b/claude-docker/openspec/changes/add-llm-gateway/specs/external-cli-tools/spec.md new file mode 100644 index 0000000..271b877 --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/specs/external-cli-tools/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Credentials opt-in + +Host credentials (files or env vars) SHALL NOT reach the container unless the user explicitly opts in per-run. `run.sh` defaults to no credential mounts and no token env forwarding. Opt-ins are granted via dedicated flags: + +- `--aws`: mount `~/.aws/config` at `/root/.aws/config:ro` and, when present, `~/.aws/sso/` at `/root/.aws/sso:ro`; forward `AWS_PROFILE`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` when set on the host. +- `--gh`: forward `GH_TOKEN` or `GITHUB_TOKEN` when set on the host. If neither + is set, `run.sh` SHALL attempt to retrieve the active token by running + `gh auth token` on the host and forward the result as `GH_TOKEN`. If `gh` is + not on the host PATH or the command fails, `run.sh` SHALL continue silently + without a token. +- `--glab`: mount the platform-appropriate glab config dir — `~/Library/Application Support/glab-cli` on macOS, `~/.config/glab-cli` on Linux — at `/root/.config/glab-cli:ro`; forward `GITLAB_TOKEN` when set on the host. +- `--gateway`: forward the LLM-gateway endpoint, auth, and model-selection env vars from the host — `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` — each only when set on the host. Detailed gateway behavior, including isolation of the Anthropic subscription credential, is defined by the `llm-gateway` capability. + +All credential bind-mounts SHALL be read-only so a compromised container cannot rewrite host config or tokens. `~/.aws/credentials` and `~/.aws/cli/cache/` SHALL NEVER be mounted, even under `--aws`. + +#### Scenario: No flags means no credentials + +- **GIVEN** host has `~/.aws/config`, `~/.config/glab-cli/config.yml`, and `GH_TOKEN=ghp_x` exported +- **AND** a prior container run completed `gh auth login` (state persisted in `claude-code-root`) +- **WHEN** user runs `claude-docker ~/repo` +- **THEN** `/root/.aws/` does not exist inside the container +- **AND** `/root/.config/glab-cli/` is empty inside the container +- **AND** `echo $GH_TOKEN` inside the container is empty +- **AND** `gh auth status` inside the container reports "not logged in" + +#### Scenario: No gateway env without --gateway + +- **GIVEN** the host exports `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` +- **WHEN** user runs `claude-docker ~/repo` without `--gateway` +- **THEN** `echo $ANTHROPIC_BASE_URL` inside the container is empty +- **AND** `echo $ANTHROPIC_AUTH_TOKEN` inside the container is empty + +#### Scenario: --aws grants scoped AWS access + +- **GIVEN** the host has completed `aws sso login --profile X` and exports `AWS_PROFILE=X` +- **WHEN** user runs `claude-docker --aws ~/repo` +- **THEN** `aws sts get-caller-identity` inside the container returns the host's identity +- **AND** `~/.aws/credentials` is not present inside the container +- **AND** writes to `/root/.aws/` from inside the container fail with EROFS + +#### Scenario: --glab grants read-only token access + +- **GIVEN** the host has a valid `~/.config/glab-cli/config.yml` +- **WHEN** user runs `claude-docker --glab ~/repo` +- **THEN** `glab auth status` reports "logged in" without prompting +- **AND** writes to `/root/.config/glab-cli/` from inside the container fail with EROFS + +#### Scenario: --gh forwards host env token + +- **GIVEN** `GH_TOKEN=ghp_x` is exported in the host shell +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** `echo $GH_TOKEN` inside the container prints `ghp_x` + +#### Scenario: --gh falls back to gh auth token + +- **GIVEN** neither `GH_TOKEN` nor `GITHUB_TOKEN` is set in the host shell +- **AND** the host has `gh` on PATH and the user is authenticated (`gh auth status` succeeds) +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** `echo $GH_TOKEN` inside the container prints the token returned by `gh auth token` + +#### Scenario: --gh is silent when gh is unavailable + +- **GIVEN** neither `GH_TOKEN` nor `GITHUB_TOKEN` is set in the host shell +- **AND** `gh` is not on the host PATH (or `gh auth token` exits non-zero) +- **WHEN** user runs `claude-docker --gh ~/repo` +- **THEN** the container starts without a `GH_TOKEN` env var and no error is printed diff --git a/claude-docker/openspec/changes/add-llm-gateway/specs/llm-gateway/spec.md b/claude-docker/openspec/changes/add-llm-gateway/specs/llm-gateway/spec.md new file mode 100644 index 0000000..6b3676b --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/specs/llm-gateway/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: Gateway opt-in routes Claude Code through a custom endpoint + +`run.sh` SHALL support a `--gateway` flag that opts the session into routing +Claude Code through an Anthropic-Messages-compatible LLM gateway (e.g. a +self-hosted LiteLLM proxy) instead of the default Anthropic API. The flag is off +by default; without it, no gateway env vars reach the container and behavior is +unchanged. + +When `--gateway` is set, `run.sh` SHALL forward the following host environment +variables into the container when they are set on the host: + +- `ANTHROPIC_BASE_URL` — the gateway endpoint. +- `ANTHROPIC_AUTH_TOKEN` — Bearer credential presented to the gateway. +- model overrides `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, + `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, + `ANTHROPIC_DEFAULT_FABLE_MODEL`. +- `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`. + +A host variable that is unset SHALL be silently skipped (not forwarded as empty). + +#### Scenario: --gateway forwards endpoint and auth + +- **GIVEN** the host exports `ANTHROPIC_BASE_URL=https://litellm.internal:4000` and `ANTHROPIC_AUTH_TOKEN=sk-gw-xyz` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** `echo $ANTHROPIC_BASE_URL` inside the container prints `https://litellm.internal:4000` +- **AND** `echo $ANTHROPIC_AUTH_TOKEN` inside the container prints `sk-gw-xyz` + +#### Scenario: model overrides forwarded when set + +- **GIVEN** the host exports `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and `ANTHROPIC_MODEL=my-gateway-model` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** `echo $ANTHROPIC_MODEL` inside the container prints `my-gateway-model` + +#### Scenario: unset gateway vars are not forwarded as empty + +- **GIVEN** the host exports `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` but does not set `ANTHROPIC_MODEL` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** `ANTHROPIC_MODEL` is unset inside the container (not defined as an empty string) + +#### Scenario: no gateway forwarding without the flag + +- **GIVEN** the host exports `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` +- **WHEN** the user runs `claude-docker ~/repo` without `--gateway` +- **THEN** `echo $ANTHROPIC_BASE_URL` inside the container is empty +- **AND** `echo $ANTHROPIC_AUTH_TOKEN` inside the container is empty + +### Requirement: Anthropic subscription credential is isolated in gateway mode + +In `--gateway` mode `run.sh` SHALL mask the Anthropic subscription OAuth +credential persisted at `/root/.claude/.credentials.json` (in the +`claude-code-home` named volume) so it is neither used nor readable inside the +sandbox, even though the named volume is otherwise mounted. Authentication to the +model endpoint SHALL come solely from the forwarded `ANTHROPIC_AUTH_TOKEN`. +Session and project history elsewhere under `/root/.claude` SHALL continue to +persist. + +#### Scenario: OAuth credential absent under --gateway + +- **GIVEN** a prior non-gateway run completed `claude login`, persisting `/root/.claude/.credentials.json` in `claude-code-home` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** `/root/.claude/.credentials.json` inside the container is empty or absent (contains no usable token) + +#### Scenario: session history still persists under --gateway + +- **GIVEN** a prior run left session files under `/root/.claude/projects/` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** the prior session files under `/root/.claude/projects/` remain readable + +#### Scenario: OAuth credential restored when gateway not used + +- **GIVEN** a prior run completed `claude login`, persisting the credential +- **WHEN** the user runs `claude-docker ~/repo` without `--gateway` +- **THEN** `/root/.claude/.credentials.json` inside the container contains the persisted token + +### Requirement: Active gateway opt-in is surfaced in the statusline + +When `--gateway` is active, `run.sh` SHALL include a `gateway` marker in the +`CLAUDE_DOCKER_FLAGS` value so the statusline tags the session as running against +a gateway. + +#### Scenario: gateway tag present + +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** the `CLAUDE_DOCKER_FLAGS` env var inside the container contains `gateway` diff --git a/claude-docker/openspec/changes/add-llm-gateway/specs/persistent-session-storage/spec.md b/claude-docker/openspec/changes/add-llm-gateway/specs/persistent-session-storage/spec.md new file mode 100644 index 0000000..cfe57e5 --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/specs/persistent-session-storage/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: Sessions persist across container runs + +All Claude session files, credentials, and project records SHALL live in a named Docker volume that survives `--rm` container lifecycles. As a deliberate exception, when the `--gateway` opt-in is active the Anthropic subscription OAuth credential at `/root/.claude/.credentials.json` SHALL be masked so it is neither used nor readable in that session, while session and project records under `/root/.claude/projects/` SHALL continue to persist as normal. The masking does not delete the persisted credential; it is hidden only for the duration of gateway-mode runs and is visible again on a subsequent non-gateway run. + +#### Scenario: Sessions survive exit + +- **WHEN** the user exits a Claude session and relaunches the container +- **THEN** prior session files under `/root/.claude/projects/` remain readable + +#### Scenario: Gateway mode masks the Anthropic credential but keeps history + +- **GIVEN** a prior non-gateway run persisted `/root/.claude/.credentials.json` and session files under `/root/.claude/projects/` +- **WHEN** the user runs `claude-docker --gateway ~/repo` +- **THEN** `/root/.claude/.credentials.json` inside the container contains no usable token +- **AND** the prior session files under `/root/.claude/projects/` remain readable + +#### Scenario: Credential reappears on a non-gateway run + +- **GIVEN** a prior gateway-mode run masked the Anthropic credential +- **WHEN** the user next runs `claude-docker ~/repo` without `--gateway` +- **THEN** `/root/.claude/.credentials.json` inside the container contains the persisted token diff --git a/claude-docker/openspec/changes/add-llm-gateway/tasks.md b/claude-docker/openspec/changes/add-llm-gateway/tasks.md new file mode 100644 index 0000000..f49e5ea --- /dev/null +++ b/claude-docker/openspec/changes/add-llm-gateway/tasks.md @@ -0,0 +1,40 @@ +## 1. Wrapper: --gateway flag and env forwarding + +- [x] 1.1 In `run.sh`, add `WITH_GATEWAY=0` alongside the other `WITH_*` initializers and a `--gateway) WITH_GATEWAY=1 ;;` arm in the case statement +- [x] 1.2 Under `WITH_GATEWAY=1`, append the gateway vars to the `ENV_VARS` array so the existing `[ -n "${!v:-}" ]` filter forwards each only when set on the host: `ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL ANTHROPIC_DEFAULT_FABLE_MODEL CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` — mirror the `[ "$WITH_TFE" = "1" ] && ENV_VARS+=(...)` line +- [x] 1.3 Append `gateway` to `DOCKER_FLAGS` when `WITH_GATEWAY=1` so the statusline tag reflects the opt-in + +## 2. Wrapper: Anthropic credential isolation + +- [x] 2.1 When `WITH_GATEWAY=1`, stage an empty file (under the same temp-staging dir used for host-config parity) and append a read-only bind mount of it over `/root/.claude/.credentials.json` to `MOUNT_ARGS`, so the persisted OAuth token is masked while the rest of `claude-code-home` stays mounted. Comment points at the credential-isolation rationale +- [x] 2.2 Mask applied independent of `EPHEMERAL` (verified via stub harness: `--ephemeral --gateway` still emits the credential overlay mount and forwards env, and no `claude-code-home` named volume is mounted). Mount ordering relies on Docker's documented parent-first ordering so the nested file overlay wins over the volume — same mechanism as the existing settings.json/CLAUDE.md overlays +- [ ] 2.3 No-prior-login case: bind-mounting the staged empty file over a not-yet-existent `/root/.claude/.credentials.json` — Docker auto-creates the bind-mount target, so this is expected to be a no-op error-wise. **Needs real-Docker confirmation** (no daemon in the dev sandbox); covered by the design's open question and the manual e2e checks in 4.3/4.5 + +## 3. Wrapper: help text + +- [x] 3.1 Added a `--gateway` row to the `print_help` heredoc matching the `--tfe` style (forwards `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` + model overrides; masks the Anthropic OAuth credential) +- [x] 3.2 `claude-docker --help` round-trips: `--gateway` present in both help text and the case statement (verified by flag-diff) + +## 4. Verification against scenarios + +No committed test file — matching the project convention (manual verification recorded here; the `llm-gateway` spec scenarios are the behavior contract). Argv-level checks below were verified during development by stubbing `docker` on PATH and inspecting the generated `docker run` arguments; checks that require inspecting state *inside* a live container need a real daemon (none in the dev sandbox). + +- [x] 4.1 Env forwarding both directions: `--gateway` forwards `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`; without the flag neither is forwarded (verified at argv level) +- [x] 4.2 Model overrides: `ANTHROPIC_MODEL` forwarded when set; `ANTHROPIC_DEFAULT_SONNET_MODEL` not forwarded when unset (verified — the `-e NAME` form means an unset var is absent, never an empty string) +- [x] 4.3 Credential isolation (argv level): `--gateway` emits the RO overlay over `/root/.claude/.credentials.json`; no-flag run does not. **Real-Docker e2e** (file actually empty inside container + `projects/` still readable) pending a daemon +- [ ] 4.4 Credential restored on a subsequent no-flag run (mask non-destructive) — **needs real Docker**; logically guaranteed since the overlay is a per-run mount that touches nothing in the volume +- [ ] 4.5 No prior login: `claude-docker --gateway ~/repo` starts cleanly with no `.credentials.json` in the volume — **needs real Docker** (see 2.3) +- [x] 4.6 Statusline tag: `--gateway` sets `CLAUDE_DOCKER_FLAGS` containing `gateway`; `--gh --gateway` includes both markers (verified at argv level) + +## 5. Documentation + +- [x] 5.1 Added a `--gateway` row to the **Credential opt-in** table in `README.md`, matching the `--tfe` voice (forwards endpoint/auth/model env vars; nothing forwarded without the flag) +- [x] 5.2 Extended the **Auth model** section: source is host env, Bearer auth via `ANTHROPIC_AUTH_TOKEN`, Anthropic OAuth credential masked for the session (covered in the new LLM gateway sub-section) +- [x] 5.3 Added an "LLM gateway workflow" sub-section: usage flow, model selection by id, the haiku/background-model caveat, and that gateway mode disables OAuth/`claude login`; also noted history stays unified for `claude --resume` +- [x] 5.4 Updated the **Threat model**: per-session exposure of the gateway key, plus a dedicated bullet on the `--gateway` Anthropic-credential isolation guarantee + +## 6. Validation + +- [x] 6.1 `openspec validate add-llm-gateway --strict` exits 0 +- [ ] 6.2 ShellCheck `run.sh` clean at `--severity=warning` — **not run in dev sandbox** (no shellcheck/docker/pip available); additions mirror existing shellcheck-clean patterns (quoted expansions, guarded array appends) and `bash -n` passes. CI's lint job is the gate +- [x] 6.3 `--ephemeral --gateway` spot-check: env forwarding happens and the named-volume mount is skipped cleanly (verified at argv level) diff --git a/claude-docker/run.sh b/claude-docker/run.sh index fd91ed8..821cabe 100755 --- a/claude-docker/run.sh +++ b/claude-docker/run.sh @@ -43,6 +43,12 @@ Wrapper flags: ~/.terraform.d/credentials.tfrc.json (:ro) when present and forward TF_TOKEN_app_terraform_io; unmask in-container `terraform login` state. + --gateway Route Claude Code through an Anthropic-Messages- + compatible LLM gateway (e.g. LiteLLM): forward + ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN and the + ANTHROPIC_*_MODEL overrides when set, and mask the + Anthropic OAuth credential for the session so auth + comes solely from the forwarded token. --iterm Wrap claude in tmux -CC (iTerm2 control mode → native panes). Equivalent to CLAUDE_DOCKER_TMUX=cc. --tmux Wrap claude in plain tmux (works in any terminal). @@ -89,6 +95,7 @@ WITH_AWS=0 WITH_GH=0 WITH_GLAB=0 WITH_TFE=0 +WITH_GATEWAY=0 CLAUDE_CONFIG_DIR="${CLAUDE_DOCKER_CONFIG_DIR:-$HOME/.claude}" saw_sep=0 for arg in "$@"; do @@ -105,6 +112,7 @@ for arg in "$@"; do --gh) WITH_GH=1 ;; --glab) WITH_GLAB=1 ;; --tfe) WITH_TFE=1 ;; + --gateway) WITH_GATEWAY=1 ;; --iterm) CLAUDE_DOCKER_TMUX=cc ;; --tmux) CLAUDE_DOCKER_TMUX=1 ;; --claude-dir=*) CLAUDE_CONFIG_DIR="${arg#--claude-dir=}" ;; @@ -189,6 +197,12 @@ ENV_VARS=() [ "$WITH_GLAB" = "1" ] && ENV_VARS+=(GITLAB_TOKEN) [ "$WITH_AWS" = "1" ] && ENV_VARS+=(AWS_PROFILE AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN) [ "$WITH_TFE" = "1" ] && ENV_VARS+=(TF_TOKEN_app_terraform_io) +# --gateway: route claude through an Anthropic-Messages-compatible LLM gateway. +# Forward the endpoint, bearer token, and model-selection vars; the loop below +# forwards each only when set on the host, so unset model overrides never become +# empty strings in the container. Non-Anthropic backends are picked purely by +# model id — claude speaks the Anthropic wire format to the gateway regardless. +[ "$WITH_GATEWAY" = "1" ] && ENV_VARS+=(ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL ANTHROPIC_DEFAULT_FABLE_MODEL CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY) # Guarded: bash 3.2 under `set -u` errors on empty-array expansion. if [ "${#ENV_VARS[@]}" -gt 0 ]; then for v in "${ENV_VARS[@]}"; do @@ -235,6 +249,7 @@ DOCKER_FLAGS=() [ "$WITH_AWS" = "1" ] && DOCKER_FLAGS+=("aws") [ "$WITH_GLAB" = "1" ] && DOCKER_FLAGS+=("glab") [ "$WITH_TFE" = "1" ] && DOCKER_FLAGS+=("tfe") +[ "$WITH_GATEWAY" = "1" ] && DOCKER_FLAGS+=("gateway") [ "$EPHEMERAL" = "1" ] && DOCKER_FLAGS+=("ephemeral") [ "$RO_WORKSPACES" = "1" ] && DOCKER_FLAGS+=("ro") if [ "${#DOCKER_FLAGS[@]}" -gt 0 ]; then @@ -304,6 +319,23 @@ fi [ -f "$CLAUDE_CONFIG_DIR/settings.docker.json" ] \ && MOUNT_ARGS+=("-v" "$CLAUDE_CONFIG_DIR/settings.docker.json:/root/.claude/settings.json:ro") +# --gateway credential isolation: when routing through a third-party LLM gateway, +# mask the Anthropic subscription OAuth token (/root/.claude/.credentials.json, +# persisted in the claude-code-home volume) by overlaying an empty read-only +# file. The session then authenticates solely via the forwarded +# ANTHROPIC_AUTH_TOKEN and cannot read or exfiltrate the subscription credential. +# Session/project history elsewhere under /root/.claude still persists — this is +# the deliberate, surgical carve-out from persistent-session-storage, chosen over +# masking the whole dir so `claude --resume` history survives. Docker orders +# mounts parent-first, so this nested overlay always wins over the +# claude-code-home volume mount, exactly like the settings.json/CLAUDE.md +# overlays above; Docker auto-creates the mountpoint, so it is safe even when no +# prior `claude login` credential exists in the volume. +if [ "$WITH_GATEWAY" = "1" ]; then + : > "$stage/empty-credentials.json" + MOUNT_ARGS+=("-v" "$stage/empty-credentials.json:/root/.claude/.credentials.json:ro") +fi + # Container-only .git/config overlay: enable relative-path worktrees inside the # container without touching the host's on-disk repo config. The host file # stays unmodified, so host tools that bundle an old libgit2 (notably