# Systray must never rotate Claude OAuth grants

Status: DONE (2026-08-15)

## Follow-up request (owner, 2026-08-15 morning)
"What happens when a token is refreshed on an account I am not using today?" →
idle accounts go stale after ~8h because nothing renews them → "how to fix this?"
Fix: the systray may rotate an account's grant ONLY when it is provably the sole
grant holder — the account's own credentials file (never the shared live `~/.claude`)
AND no running process was launched with that account's CLAUDE_CONFIG_DIR (scanned
via /proc, fail-closed). Rotation happens under an exclusive file lock, re-reads the
file after locking, and persists the rotated grant atomically (temp+fsync+rename,
mode preserved) so the next CLI launch continues the chain. Investigated and
rejected alternatives: memory-only refresh (refresh tokens are one-time-use — it
consumes the on-file token and kills the account, verified live), and triggering the
CLI to renew (a `claude` child launched from a session inherits the live account's
auth — strace showed it reading `~/.claude/.credentials.json` even with
CLAUDE_CONFIG_DIR pointed elsewhere — so no external renewal trigger exists).

## Source request
Owner, 2026-08-15 morning: Claude Code tokens under the systray account manager are
being invalidated ("it all went wrong 01:50:25 15/08 local"); systray-driven login no
longer restores access, only in-session `/login` works. (1) keep the freshly minted
token alive; (2) find and fix the invalidator before it kills the token again.

## Root cause (verified)
- The live multideal session died 2026-08-14T18:50:38Z (= 01:50:38 local) with API
  `authentication_failed` (transcript 6c0d0325…jsonl).
- `runtime/claude-token-identity.json` shows a NEW token fingerprint per account every
  ~8h since 2026-08-11: the systray health/usage poller refreshes grants itself.
- `claude_oauth.py::ClaudeOAuth._refresh_or_reuse` POSTs to the OAuth token endpoint
  and rewrites the account's `.credentials.json` whenever the access token looks
  expired, because `claude_credentials.resolve_credentials` labels any account-home
  credentials file APP_MANAGED (writable) when it is not the live `~/.claude` copy.
- But Claude Code sessions run directly against those account homes via
  `CLAUDE_CONFIG_DIR`, and the CLI rotates the same grant at expiry. Anthropic refresh
  tokens are one-time-use: two independent refreshers racing at the 8-hour expiry
  boundary means the loser replays a consumed refresh token and the provider revokes
  the whole grant chain. multideal lost that race at 01:50 local 2026-08-15; the avi
  account shows the same churn signature 2026-08-14 ~02:00 (18 fingerprints that day).
- Buildbox credential copies are token-stripped (verified on debian1) and Factory k3s
  pods mount only pi/codex auth — neither is a rotation source.

## Acceptance criteria
- Systray never calls the OAuth token endpoint and never rewrites any
  `.credentials.json`: the Claude CLI is the sole grant rotator.
- Usage polling on an expired/rejected access token reloads the file once (the CLI may
  have already rotated it) and otherwise reports unavailable/broken — no refresh.
- `python3 -m pytest modules/systray/tests/ -q` green.
- Fix installed into the RUNNING systray (service restart) before/alongside landing —
  install-before-landing; next expiry window is ~16:31 local today.

## Execution steps
1. `claude_credentials.resolve_credentials`: authority is always VENDOR_MANAGED; drop
   the APP_MANAGED branch (enum member removed).
2. `claude_oauth.py`: delete the refresh machinery (`_refresh_tokens`,
   `_merged_payload`, `_atomic_write_payload`, `_adjacent_lock`, `TOKEN_URL`, the
   `read_only` flag); expired/401 → reload-latest-once then raise.
3. Update systray tests for the new contract.
4. Restart `systray-ai.service`; verify poller reads without rotating.
5. Land (direct-land authorized: active incident, runtime-first) + deploy.

## Receipt (current)
- Working token backed up: `~/.local/state/overdeck/token-rescue/multideal.credentials.json.bak`
  (note: a backup's refresh token goes stale the moment the CLI rotates; the real
  protection is this fix).
- Steps 1–3 DONE: refresh machinery removed from `claude_oauth.py` (no token endpoint,
  no file writes, no lock files); `resolve_credentials` authority always VENDOR_MANAGED;
  health client passes no `read_only` flag; tests rewritten to the read-only contract.
- `python3 -m pytest modules/systray/tests/ -q`: 1033 passed, 2 pre-existing failures
  also present on clean origin/main (test_account_lock malformed-state case,
  test_packaging_desktop_entry install list) — unrelated to this change.
- Read-only fix landed as 97f5ca016, installed, systray restarted 08:54.
- Idle-account rotation shipped on top: `claude_account_activity.py` (live-session
  guard), `claude_oauth.py` guarded persisting rotation, health client wires
  `allow_rotation` from the guard. Unit tests green (66 touched-module tests); full
  suite green except the 2 known pre-existing failures + the /tmp/pytest-of-user
  concurrent-session race (environmental).
- LIVE PROOF: claude11-alex (expired 09:32, idle) rotated through the exact
  production path — new grant persisted atomically, mode 600, usage payload
  returned, valid to 17:33.
- Known collateral: zync's refresh token was consumed by an earlier probe this
  session (invalid_grant now) — that account will show "authentication required"
  and needs /login; the multideal-live-session contamination made CLI-side checks
  on it unreliable.
- NOT verified: that the Claude CLI accepts a systray-rotated file on next launch
  (cannot be tested from inside a session — child CLIs inherit the live auth);
  the grant is a genuine Anthropic-issued token in the same file shape.
- Landed 09c9e7899 (direct land, merge of origin/main included), installed into the
  main checkout, systray-ai.service restarted 09:34 and active. Poller verified live:
  claude11-alex usage cache updating again (09:35), zero tracebacks in the journal.
  packaging/deploy-local.sh deliberately NOT run: the change is systray-only (its
  deployment = the service restart) and the main checkout carries other sessions'
  uncommitted web WIP, which the deploy would ship.

## Incident addendum (2026-08-15 ~09:45)
Owner has NO login credentials for zync/zync2 — the on-disk grant IS the access.
zync's grant was destroyed by this session's earlier refresh probe (confessed).
zync2 verified untouched (file unchanged since 02:13, refresh token unconsumed,
backoff logged_out:false) and backed up to token-rescue/zync2.credentials.json.bak-20260815.
Containment: rotation reverted live at 09:4x, then re-shipped gated behind a
per-account `rotation-enabled` marker file (default OFF; only claude11-alex enabled).
CLI-acceptance PROVEN without touching zync/2: `systemd-run --user` (escapes the
session's auth inheritance) + strace showed a claude one-shot on claude11-alex
reading ONLY the account's own credentials file and answering — the systray-rotated
grant is accepted by the CLI. Enrollment of zync2 is an owner decision, taken only
after claude11-alex survives multiple 8h rotation cycles (next ~17:33).
