Playbook: Two authorisations for all agent hosts (per-licence setup-token)

Category: Agent Operations · Decision: ADR 007 Use when: claude-runner / claude-runner-2 must run on bms-4, lap2 WSL and windows-dev WSL without a separate claude auth login per host. Windows host interactive sessions are unaffected.

Verified against the official docs (2026-10-01): token lifetime is one year, no refresh; it beats .credentials.json but loses to ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN (scripts unset both in token mode); inference only (no claude.ai connectors / Remote Control); ignored by --bare. Still pilot on one host. Never copy ~/.claude/.credentials.json between hosts (refresh-token race, claude-oauth-reauth.md Option C). The tokens below are a different, non-refreshing credential.

1. Mint one token per licence (browser, twice in total)

claude setup-token   # log in as radieu  -> token A  (claude-runner)
claude setup-token   # log in as ecotrans -> token B (claude-runner-2)

Never paste values into chat, issues or logs.

2. Store (secret-manager role only)

Delegate to secret-manager (secret-manager.md): one narrow SOPS file secrets/claude-oauth.env.sops with one key per licence — CLAUDE_OAUTH_TOKEN_CLAUDE_RUNNER (radieu, token A) and CLAUDE_OAUTH_TOKEN_CLAUDE_RUNNER_2 (ecotrans, token B). Record mint dates for rotation. The .sops.yaml creation rule is already pre-declared (3 recipients: developer + CI AGE_KEY_GHA + lap2-windows, mirroring anthropic-admin.env.sops) so the file does not fall through to the 10-recipient catch-all — do not widen it. CI (AGE_KEY_GHA) must stay a recipient because the delivery jobs below decrypt it in CI.

3. Deliver to each host

Target: /etc/p24/claude-oauth-token.claude-runner.env and …claude-runner-2.env, each one line CLAUDE_CODE_OAUTH_TOKEN=<value>, mode 0600, owned by that Linux account (the role/bms-4.yml assert this), under /etc/p24 (0755 root). Only create a file for an account that exists on the host.

CI delivery jobs exist (#7832): sync-claude-oauth-bms4 writes both accounts on bms-4 (claude-runner + claude-runner-2), and sync-claude-oauth-vps-i1 writes claude-runner on vps-i1 (vps-i1 also runs github-runner, which is outside ADR 007’s two-licence model and is NOT delivered). Both jobs are guarded: a clean no-op until secrets/claude-oauth.env.sops exists, and they skip (never write a blank file) any account whose value is empty or a placeholder. They do NOT restart any container. A token rotation (push to the file on main) fans out automatically via the claude-oauth detect-changes filter; a manual run is gh workflow run secrets-sync.yml -f target=claude-oauth.

vps-i1 pre-activation check (sys-admin): before claude_shared_oauth_token_enabled: true on vps-i1, confirm claude-admin’s NOPASSWD sudo whitelist permits install/install -d targeting /etc/p24 (the existing whitelisted uses target /opt/p24-infra). The sync job uses sudo install -d /etc/p24 and sudo install -o claude-runner …; until the SOPS file is minted this is a no-op, so it blocks nothing today.

For lap2 / windows-dev WSL (not in Ansible inventory) place the files via their own provisioning.

4. Enable

bms-4 / vps-i1 (Ansible): host_vars claude_shared_oauth_token_enabled: true, then ansible-playbook … --tags claude-runner. It fails unless every account’s file exists, is owned by the account and is 0600; then removes the refresh cron and installs the login-shell hook. Scripts load the token through scripts/lib/claude-token-env.sh (spawn/nightly/triage/design/analyst; windows-dev container gets it via -e CLAUDE_CODE_OAUTH_TOKEN). Other systemd units that call claude need EnvironmentFile= (see ADR 007 gates).

lap2 / windows-dev WSL (outside Ansible): place the two token files (same owner/mode), make sure /etc/cron.d/claude-token-refresh is absent, deploy /etc/cron.d/claude-auth-synthetic-check and check-sub-usage cron by hand (otherwise the host has no auth/headroom monitoring), and keep scripts/ current (deploy-vps-scripts.yml) so lib/claude-token-env.sh exists.

5. Verify (per account)

ssh root@<host> "runuser -l claude-runner   -c 'claude -p say-ok --print 2>&1 | head -3'"
ssh root@<host> "runuser -l claude-runner-2 -c 'claude -p say-ok --print 2>&1 | head -3'"
ssh root@<host> "test ! -e /etc/cron.d/claude-token-refresh && echo refresh-cron-removed"

6. Known gaps / rotation

  • claude-auth-synthetic-check and check-sub-usage probe with the account’s token; the expiry gauge is derived from the token file’s age (335-day assumed life). A token file that exists but is empty/unreadable fails the synthetic check instead of falling back to stale creds.
  • Headroom (ADR 007 gate 2): confirmed account-global at the source — check-sub-usage.py reads the licence’s fleet-wide anthropic-ratelimit-unified-*-utilization headers from the Anthropic API with the account’s own token, so every host sees the same true number for a licence. The residual risk is staleness: each host caches it in its own ~/.claude/sub-status.json (10-min cron) and account_routing.py reads that with a 300 s TTL + near-limit live re-read but no file-mtime staleness guard — between two hosts’ cron refreshes both can read a stale “idle” value and co-dispatch. A staleness guard + convergence proposal is in ADR 007 §Consequences; add it before onboarding lap2 / WSL to the balancer.
  • Callers (ADR 007 gate 3): the listed claude callers are now wired to the helper or documented exempt, and scripts/tests/test_claude_token_wiring.py fails CI if a new caller is added unwired. Two standalone services still need a server-side systemd EnvironmentFile= (gmail-session-manager, github-agent) — a sys-admin follow-up; do not enable token mode on a host that runs them until that is done.
  • Revocation/expiry of a token = that licence is dark on every host. Re-run §1–3 before expiry.
  • Rollback — three steps, in this order (the scripts prefer a token file whenever it exists, regardless of the flag):
    1. Delete /etc/p24/claude-oauth-token.*.env on the host (otherwise token mode stays active after the flag is off).
    2. Set claude_shared_oauth_token_enabled: false and re-run the role (login-shell hook removed, refresh cron returns).
    3. claude auth login per account (the old .credentials.json is stale after token mode). The role/bms-4.yml print a WARNING when token files remain while the flag is off.