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 connectlands the operator in tmux over the container's loopback sshd (ADR-0003). An sshd login shell inherits neitherenvironment:norenv_file:nor a compose override — this is the same non-inheritance ADR-0003 already documents forBILLET_CONTAINER_SSH_PORTand the personal bootstrap.- squadra fleet runners launch
claudethe 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.jsonand "apply to all projects"; theenvkey 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_TOKENis "a long-lived OAuth token generated byclaude 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_cmdruns assh -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 Pythonrepr()literal — fed on STDIN via a quoted heredoc (-Tforwards 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
.envedit. A repo opts in purely by the operator setting one globalclaude_token_cmd. - One
GlobalConfigfield, one smallaccess.secret.ClaudeTokenAccessresolver over the localProcessRunner, and one explicitclaude_oauth_token: str | Noneparameter threadedcli → WorkspaceManager.apply_start → ComposeContainerAccess.compose_up.None/empty skips injection entirely (no exec, no settings write). - The
ProcessRunnerseam gains an optionaltimeouton its buffered path so a secret fetch cannot hangstart. - 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, soclaudecan 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. apiKeyHelperinside 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.jsonenvneeds 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=valueor on any argv. Rejected: argv is world-readable viaps//proc. - Interactive
claudelogin per container. Rejected: it is the unattended-hostile step the fleet exists to eliminate, and it does not survive rebuilds cleanly.