Skip to content

ADR-0006: Central Claude Code OAuth token injection via a local fetch command

Status

Accepted (2026-07-14). Extends ADR-0002 and refines ADR-0003: ADR-0003 assumed the values billet carries into a Workspace are not secrets and reasoned only about a port number. This ADR adds a value that is a secret (a Claude Code OAuth token) and therefore must reach claude by a path that is safe for a credential — never an environment export over SSH.

Amended (2026-09-14): the *_claude_home volume the Decision relies on is now shipped by the base docker-compose.snippet.yml as the Claude Locker (ADR-0012 for the noun), rather than left to each consumer to invent; existing consumer volume names stay grandfathered. The same snippet sets CLAUDE_CONFIG_DIR=/home/dev/.claude under environment: so Claude Code's account state (.claude.json) lands on the same volume as the injected settings.json; the value is fixed because the merge program hardcodes ~/.claude/settings.json, and any other path would split the two. And the injection now repairs its own target before writing: in the same exec -u <remote_user> session, a root-owned empty ~/.claude is made login-user-owned under the policy of ADR-0013 item 6, and the merge program's not exists() guard becomes a writability check that fails with a [billet]-prefixed error naming the directory's owner instead of a traceback. Token delivery — stdin only, never argv — is unchanged.

Context

claude running inside a Workspace container must be authenticated, but the two ways operators reach it both bypass the container's compose-provided environment:

  • billet connect lands the operator in tmux over the container's loopback sshd (ADR-0003). An sshd login shell inherits neither environment: nor env_file: nor a compose override — this is the same non-inheritance ADR-0003 already documents for BILLET_CONTAINER_SSH_PORT and the personal bootstrap.
  • squadra fleet runners launch claude the same way (through the container's sshd).

Since 2026-09-04 the Workspace entrypoint republishes the container's non-secret environment into those login shells via /etc/environment and pam_env (see the ADR-0003 amendment) — the session still inherits nothing, and that channel is world-readable and may silently skip a value, so it carries no secrets and the decision below stands unchanged.

So .env / environment: / a compose override cannot reliably reach claude, and an interactive claude login inside every container — re-done on every rebuild — is exactly the unattended-unfriendly step the fleet exists to remove. We want ONE central token, obtained once, delivered with zero per-repo compose/devcontainer changes.

A token is also a secret with a lifecycle (rotation, revocation, audit) that billet has no business owning. billet is stateless and its config is explicitly "nothing here is a secret."

Decision

The operator authors a shell command, [billet].claude_token_cmd, that prints a long-lived CLAUDE_CODE_OAUTH_TOKEN to STDOUT. billet runs that command LOCALLY at start time, captures the token, and injects it into the container's user-level ~/.claude/settings.json env block. Empty (the default) disables the feature — fully backward compatible.

Why a fetch-command hook (indirection), not a token in config

This is the credential-helper pattern git (credential.helper), docker (credsStore), aws (credential_process), and kubectl (exec plugins) all use. billet stores a command, never the secret. The secret stays in the operator's store (macOS Keychain, 1Password, Vault, or the environment), where rotation, revocation, and audit already live. Recipes ship in config.example.toml. The command runs locally, on the operator's machine, where those stores are already unlocked — the same place billet already runs the operator's personal_bootstrap_cmd string.

Why settings.json env, not an environment export

Claude Code applies the env block of the user-level ~/.claude/settings.json to every session and to subprocesses it spawns, regardless of how claude was launched — so it reaches claude over sshd where an environment export in the remote shell does not, and without depending on the lossy, world-readable /etc/environment republication the ADR-0003 amendment later added for non-secrets. This is verified against the official docs:

  • Settings (code.claude.com/docs/en/settings): user settings live in ~/.claude/settings.json and "apply to all projects"; the env key holds "environment variables applied to every session and to subprocesses Claude Code spawns from it."
  • Authentication (code.claude.com/docs/en/iam): in the auth precedence list, CLAUDE_CODE_OAUTH_TOKEN is "a long-lived OAuth token generated by claude setup-token … for CI pipelines and scripts where browser login isn't available." It ranks above the subscription OAuth credentials from /login, so the injected token wins over any stale interactive login in the container.

The target file sits on the persisted named volume *_claude_home mounted at /home/dev/.claude, so it survives rebuilds. billet writes it as the container login user (facts.remote_user, docker compose exec -u <remote_user>) at mode 0600, and merges — it reads any existing settings.json, sets only env.CLAUDE_CODE_OAUTH_TOKEN, and preserves every other key (including other env keys). This explicitly supersedes ADR-0003's "not a secret, safe to export over SSH" assumption for this value: the token is a secret and is delivered through settings.json precisely so it is never env-exported over SSH.

This is a separate mechanism from the existing agent-teams .claude/settings.local.json host-side write (repo-level, non-secret flag). That block is unchanged.

The "token never in argv" rule

argv is world-readable via ps / /proc on every machine in the path. The token therefore appears in no argv at any hop:

  • On the Mac, claude_token_cmd runs as sh -c <operator command> — the command is the argv, the token is its STDOUT, captured into process memory and threaded by function call only (never stored on a long-lived object, never logged).
  • To the Host, billet pipes its remote script over ssh … bash -se (script on STDIN).
  • On the Host, the injection is docker compose exec -T -u <user> <service> python3 - with the merge program — and the token, embedded as a Python repr() literal — fed on STDIN via a quoted heredoc (-T forwards STDIN into the container). The token is program data on STDIN, never an argument.

Loud failure, no fallback

A non-zero exit, empty/whitespace-only STDOUT, or a timeout on claude_token_cmd raises and aborts start. billet never falls back to another source and never proceeds with an empty token. STDERR is surfaced to the operator (that is where op / vault / security print "not signed in / sealed"). The local fetch is bounded by a timeout so a hung store never hangs start forever.

Re-run every start (rotation-friendly ideal end-state)

The injection re-runs on every compose_up and is idempotent (a merge). Because billet re-invokes the fetcher each start, the mechanism is already shaped for the ideal end-state: short-lived tokens minted per session. Nothing in the design assumes the token is long-lived; a shorter-lived credential just means the fetch does more of the work, and the path stays open.

Consequences

  • Zero per-repo change: no compose, devcontainer, or .env edit. A repo opts in purely by the operator setting one global claude_token_cmd.
  • One GlobalConfig field, one small access.secret.ClaudeTokenAccess resolver over the local ProcessRunner, and one explicit claude_oauth_token: str | None parameter threaded cli → WorkspaceManager.apply_start → ComposeContainerAccess.compose_up. None/empty skips injection entirely (no exec, no settings write).
  • The ProcessRunner seam gains an optional timeout on its buffered path so a secret fetch cannot hang start.
  • The container's python3 (guaranteed for squadra / gswa-backend / billet devcontainers) does a robust JSON read-merge-write; no bespoke shell JSON handling.

Alternatives considered

  • compose environment: / env_file: / a compose override. Rejected: an sshd login shell inherits none of them (ADR-0003). Since the 2026-09-04 amendment to that ADR the entrypoint does republish such values into login shells, so claude can now see them — but that channel is world-readable and silently skips values it cannot quote, which disqualifies it for a credential on its own terms. The rejection stands on secrecy, not on reachability.
  • apiKeyHelper inside the container. Viable long-term (it also reads a command), but it would require a per-container script and still needs the secret to reach the container; settings.json env needs nothing installed in the image and no per-repo change.
  • Token literal in config.toml. Rejected outright: puts a secret in billet's only operator-authored file, defeats rotation/revocation/audit, and contradicts the config's "nothing here is a secret" contract.
  • Token as docker exec -e VAR=value or on any argv. Rejected: argv is world-readable via ps / /proc.
  • Interactive claude login per container. Rejected: it is the unattended-hostile step the fleet exists to eliminate, and it does not survive rebuilds cleanly.