ADR 007: Per-Licence setup-token OAuth Tokens for claude-runner Accounts
Status: Accepted 2026-10-01 (owner decision: move to token mode) — plumbing implemented, default off; no host is switched and no token is minted/delivered by the implementing PR. Pilot on lap2 first.
Date: 2026-10-01
Relates: ADR 002 (account topology), ADR 006 (WSL worker host class), docs/playbooks/claude-oauth-reauth.md, #4326, #3803, #4854
Context
Topology (confirmed 2026-10-01): two Claude licences, mapped to two Linux accounts —
claude-runner (radieu) and claude-runner-2 (ecotrans). bms-4 runs agent sessions on both with the
dispatcher load-balancing across them for even usage (scripts/lib/account_routing.py). The same
two accounts are wanted on lap2 and windows-dev WSL as additional agent hosts. The Windows
host sessions on windows-dev and lap2 themselves are interactive and work on whichever licence is
logged in there — they are out of scope and unchanged.
ADR 002 settled on one claude auth login per host per account (copying .credentials.json
causes a refresh-token race). With 3 hosts × 2 accounts that is 6 browser logins, 6 refresh crons,
and — on WSL, whose instance is unstable (#6185) — frequent re-auth.
Decision
Authorise twice, not per host: mint one claude setup-token token per licence and give each
Linux account its own token on every host that has that account.
claude-runner→ token A on bms-4, lap2 WSL, windows-dev WSL;claude-runner-2→ token B on the same.- Delivered as
/etc/p24/claude-oauth-token.<account>.env(CLAUDE_CODE_OAUTH_TOKEN=…, 0600, owned by that account) — a host never holds the other licence’s token for an account it does not run. - Tokens are not refreshed, so the multi-host refresh race behind the
.credentials.jsoncopy ban does not apply to them. Copying these tokens is safe; copying.credentials.jsonstays forbidden. - Opt-in per host:
claude_shared_oauth_token_enabled: true. Then the refresh cron is removed (role andbms-4.yml) and the role fails unless every account’s file exists, is owned by that account and is 0600. Consumers load the token through one helper,scripts/lib/claude-token-env.sh(Python twinscripts/lib/claude_token.py), which parses the file (a root caller never executes a file another uid can rewrite) and, only when a token is present, unsetsANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN(both outrank the OAuth token). With no token present it is a strict no-op. - Callers wired:
spawn-worker.sh(before the session check, which is skipped in token mode),spawn-infra-task-worker.sh,claude-nightly.sh.j2(inside therunuser -lcommand, after.claude-env),run-nightly-triage.sh,run-nightly-design-issues.sh,p24-infra-nightly.sh,queue-analyst.py,run-windows-dev-worker.sh(forwards the token to the container via-e VAR, no value in argv), and login shells via/etc/profile.d(removed again when the flag is off). - Monitoring:
claude-auth-synthetic-check.pyandcheck-sub-usage.pyprobe with the account’s token (fixes headroom going blind/zero once.credentials.jsonstops refreshing); an auth failure without rate-limit headers no longer writes a bogus 0% status; the unreadable-token-file case fails instead of falling back to stale creds.claude_runner_token_expires_secondsis derived from the token file’s age (335-day assumed life, conservative). - lap2 and windows-dev WSL are not in the Ansible inventory; same file layout via their own provisioning, plus the synthetic-check cron must be deployed there by hand (playbook §4).
Consequences
- (+) Two browser authorisations for the whole fleet; WSL restarts no longer force re-auth.
- (−) A token leak or revocation darkens that licence on all hosts at once; each licence needs an expiry calendar entry and an alert before it lapses.
- (−) Tier 1 secrets with fleet-wide blast radius: SOPS,
secret-managerrole only, narrow recipients. - (−) Usage/headroom (#7832 — confirmed account-global at source; residual risk is staleness): every
host draws on the same two licence quotas. The headroom number is account-global, confirmed from the
code:
check-sub-usage.pyPOSTs tohttps://api.anthropic.com/v1/messageswith the account’s own OAuth token and reads the licence-wide rate-limit headers (anthropic-ratelimit-unified-5h-utilization/-7d-utilization, lines ~500–554), which report that licence’s true fleet-wide usage — so every host reading with a given licence’s token sees the same number, not its own local consumption. The dispatcher does not fetch it live, though: each host’s cron writes its own~/.claude/sub-status.jsonevery ~10 min, andscripts/lib/account_routing.py::account_headroom()reads that host-local file through a 300 s in-memory TTL cache, with a near-limit (headroom ≤ 10%) live-re-read bypass but no file-mtime staleness guard. So the real failure mode is not per-host-vs-global ambiguity but cross-host staleness: between two hosts’ 10-min cron refreshes, both can read a stale “idle” value for the same licence and co-dispatch to it. Proposal (gate 2, not implemented here): (a) add a staleness guard — whensub-status.json’slast_updated/mtime is older than ~2× the cron interval, treat headroom asNone(unknown) so an ancient value never ranks a licence as idle; and/or (b) since the source is account-global, converge the hosts on one value by publishing each probe to a shared per-account store (e.g. a Supabase row) and ranking from the most-recent row across hosts, rather than from N independent host-local files. Add before onboarding lap2/WSL to the balancer. - (−)
claude_runner_token_expires_seconds(refresh script) still reads.credentials.json; follow-up. - Verified 2026-10-01 against https://code.claude.com/docs/en/authentication (“Generate a long-lived token”,
“Authentication precedence”):
- Lifetime:
setup-tokengenerates “a one-year OAuth token”; no refresh is documented — expiry needs a calendar entry per licence. - Precedence:
CLAUDE_CODE_OAUTH_TOKEN(rank 5) beats/logincredentials in.credentials.json(rank 7), but is outranked by cloud-provider vars,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEYandapiKeyHelper. The nightly/spawn scripts thereforeunset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKENin token mode. - Limits: inference only — no Remote Control sessions, no claude.ai connectors (local MCP works);
--bareignores the token (no script here uses--bare); requires Pro/Max/Team/Enterprise; Desktop/cloud sessions do not read it.
- Lifetime:
- ToS/concurrency of one licence on many machines: accepted as a monitored risk (ADR 002, reaffirmed 2026-10-01).
Remaining gates before enabling on a production host (not in the implementing PR)
secret-manager: mint both tokens; create the narrow SOPS filesecrets/claude-oauth.env.sopswith keysCLAUDE_OAUTH_TOKEN_CLAUDE_RUNNER+CLAUDE_OAUTH_TOKEN_CLAUDE_RUNNER_2; update the CLAUDE.md SOPS file map. DONE in code (#7832): the.sops.yamlrecipient rule is pre-declared (developer + CIAGE_KEY_GHA+ lap2-windows, mirroringanthropic-admin) and thesecrets-sync.ymldelivery jobs exist and are guarded —sync-claude-oauth-bms4(claude-runner + claude-runner-2 on bms-4) andsync-claude-oauth-vps-i1(claude-runner on vps-i1;github-runnerdeliberately excluded as a non-ADR-007 account). A no-op until the SOPS file is minted; per-account placeholder/empty values are skipped (never writes a blank token). Still owned by secret-manager: the actual mint + encrypt of the file, and the CLAUDE.md SOPS-map entry. vps-i1 note: vps-i1 is not in the §Decision host list; the sync job exists so a minted token fans out IF the owner adds vps-i1 to the rollout, and needs theclaude-admin-sudo-for-/etc/p24pre-activation check (playbook §3) first.- Headroom (#7832): account-global confirmed (see §Consequences). Still to do: the staleness guard + cross-host convergence proposal in §Consequences — not implemented here.
- Remaining
claudecallers (#7832 — DONE/triaged): the self-hosted GitHub workflows (nightly-design-issues,nightly-devops-triage,resource-incident-triage,weekly-supabase-advisor-audit,update-claude-env) andwhatsapp-extractor/thread_builder.pynow load the helper inline;auto-fix-gh-actionsis covered by itsrunuser -l claude-runnerlogin shell (the/etc/profile.dhook);claude-agent-runner.shsources the helper.bms4-proxy/session_custody.pyis a custody state machine, not aclaudeinvoker (nothing to wire).scripts/tests/test_claude_token_wiring.pynow gates CI so no new caller can be added unwired. Residual (sys-admin follow-up): three standalone deployed services runclaudein an env they inherit, not via the helper —telegram-claude-bot/claude-session-manager/server.py(tmux session) and the two lint-discovered callersgmail-session-manager/server.pyandgithub-agent/local-listener.py— each needs a systemdEnvironmentFile=-/etc/p24/claude-oauth-token.<account>.envon its unit. Do not enable token mode on a host that runs an un-EnvironmentFile=’d one of these until done. - Pilot on lap2, then windows-dev WSL, then bms-4; ADR 002 cleanup once adopted.
- Compliance:
dev_r_services/ ops-doc entries for the new credential class once the tokens exist. - Residual risk accepted: the token is exported to every child process of a worker (incl. roles denied
SOPS/SSH); inference-only scope bounds it.
CLAUDE_CODE_SUBPROCESS_ENV_SCRUBnot verified. Log-redaction ofsk-ant-oat*in worker logs is a hardening follow-up.