Server-owned credential authority: one refresher per provider grant (ADR-0055) #223

Merged
dominik.polakovics merged 1 commit from afk/222 into main 2026-07-25 00:46:50 +02:00

Closes #222

What this does

Kills the token-family fork: since #202, every run got a one-time snapshot of the master credential store with no write-back, so a long-lived instance's CLI self-refresh minted a new OAuth token family and invalidated the refresh token held by the master and every sibling snapshot — recurring host-level "not logged in", spawn refusals, and wipe-on-stop able to destroy the only valid family.

The invariant now enforced, exactly as settled in the grilled design: one refresher per provider grant; instances are consumers, never refreshers. Lab never parses token contents and never speaks any provider's OAuth protocol.

Implementation, by settled decision

  1. Blind periodic pokeinternal/credrotate drives each provider's own CLI against the master store every 15 min (RefreshCredentials); the CLI decides whether to rotate. claude-code uses the probe-confirmed claude -p --model haiku trigger under a pinned master CLAUDE_CONFIG_DIR (compat §3b records the 2026-07-24 probe evidence). Pokes are gated on a non-empty master sig — a logged-out host is never poked.
  2. Immediate fan-out — any master CredentialsSig("") change (poke-rotation, adopt-back, or operator re-login) re-injects into every live instance HOME through the existing single InjectCredentials call site, same tick, and re-stamps each run's persisted baseline. The scan runs every 30 s — well inside the probed "minutes" invalidation window. Bootstrap fan-out on the first tick heals the fleet after lab downtime.
  3. Eager adopt-back — the scan detects a home whose sig diverged from its persisted last-injected baseline (runs.cred_sig, migration 0019; stamped at launch from the post-inject observation) and adopts that family into the master immediately, newest credential-file mtime winning across multiple self-refreshers, then fans out.
  4. Adopt-check before every wipe — a pre-wipe hook on instancehome.Manager funnels credrotate.AdoptCheck into all seven wipe/sweep paths (stop, launch rollback, AFK stop, reaper, parked teardown, startup + runtime sweeps). Installed before StartupReconcile, so the startup sweep of orphan homes after downtime is covered — a wipe can never destroy the only valid family.
  5. Seam shapeRefreshCredentials(ctx) / CredentialsSig(home) / AdoptCredentials(instanceHome) on provider.AgentProvider; core owns schedule, comparison, ordering, tie-breaks; adapters own every file path and CLI recipe. Conformance pins the ""-means-master and missing-master-empty-sig conventions and AdoptCredentials' error guards for every adapter.

ADR-0055 records the design, rejected alternatives, and probe evidence.

Two deliberate deviations from the brief's letter

  • CredentialsSig returns (sig string, mtime time.Time), not bare string. The settled design puts the newest-mtime adopt tie-break in core, but core never learns adapter file paths — so the seam surfaces the newest credential-file mtime as typed filesystem metadata (never token content). The sig itself stays opaque, equality-compared only.
  • The codex refresh trigger is a candidate recipe, not live-verified (codex exec --sandbox read-only --skip-git-repo-check ok, flags confirmed against codex-cli 0.144.4 --help). This host has no codex login, and probing a live grant double-spends its refresh token — the same reason the issue dropped the grace-window probe. The issue explicitly scoped this as blocking only that adapter's RefreshCredentials; compat §11 records the recipe, the two open probe questions, and an operator-supervised verification procedure, plus a live re-verification checklist row.

Known bounded trade-off: a stop-path AdoptCheck can wait behind an in-flight poke (shared mutex; one poke per provider per 15 min, bounded by a 3 min refresh timeout, typically seconds).

Verification

  • go build ./..., go vet ./..., gofmt -l, golangci-lint run (0 issues), full go test ./... green; internal/credrotate also green under -race.
  • Acceptance criteria each pinned by a named test: rotation→fan-out without self-refresh (TestTick_MasterRotationFansOut), no fan-out on no-op poke (TestTick_NoopPokeDoesNotFanOut, TestTick_PokeRotationFansOutSameTick), self-refresh adopt + newest-mtime tie-break (TestTick_SelfRefreshAdoptedAndFannedOut, TestTick_NewestSelfRefreshWins), adopt-before-wipe ordering (TestWipeFiresPreWipeHookBeforeRemoval, TestSweepAllFiresPreWipeHookOnlyForReapedOrphans, TestAdoptCheck), opaque-sig/""-means-master conformance (TestConformance/*/credential-authority), adapter coverage in credauthority_test.go for both providers with stubbed binaries.

🤖 Generated with Claude Code

Closes #222 ## What this does Kills the token-family fork: since #202, every run got a one-time snapshot of the master credential store with no write-back, so a long-lived instance's CLI self-refresh minted a new OAuth token family and invalidated the refresh token held by the master and every sibling snapshot — recurring host-level "not logged in", spawn refusals, and wipe-on-stop able to destroy the only valid family. The invariant now enforced, exactly as settled in the grilled design: **one refresher per provider grant; instances are consumers, never refreshers.** Lab never parses token contents and never speaks any provider's OAuth protocol. ## Implementation, by settled decision 1. **Blind periodic poke** — `internal/credrotate` drives each provider's own CLI against the master store every 15 min (`RefreshCredentials`); the CLI decides whether to rotate. claude-code uses the probe-confirmed `claude -p --model haiku` trigger under a pinned master `CLAUDE_CONFIG_DIR` (compat §3b records the 2026-07-24 probe evidence). Pokes are gated on a non-empty master sig — a logged-out host is never poked. 2. **Immediate fan-out** — any master `CredentialsSig("")` change (poke-rotation, adopt-back, or operator re-login) re-injects into every live instance HOME through the existing single `InjectCredentials` call site, same tick, and re-stamps each run's persisted baseline. The scan runs every 30 s — well inside the probed "minutes" invalidation window. Bootstrap fan-out on the first tick heals the fleet after lab downtime. 3. **Eager adopt-back** — the scan detects a home whose sig diverged from its persisted last-injected baseline (`runs.cred_sig`, migration 0019; stamped at launch from the post-inject observation) and adopts that family into the master immediately, newest credential-file mtime winning across multiple self-refreshers, then fans out. 4. **Adopt-check before every wipe** — a pre-wipe hook on `instancehome.Manager` funnels `credrotate.AdoptCheck` into all seven wipe/sweep paths (stop, launch rollback, AFK stop, reaper, parked teardown, startup + runtime sweeps). Installed before `StartupReconcile`, so the startup sweep of orphan homes after downtime is covered — a wipe can never destroy the only valid family. 5. **Seam shape** — `RefreshCredentials(ctx)` / `CredentialsSig(home)` / `AdoptCredentials(instanceHome)` on `provider.AgentProvider`; core owns schedule, comparison, ordering, tie-breaks; adapters own every file path and CLI recipe. Conformance pins the `""`-means-master and missing-master-empty-sig conventions and `AdoptCredentials`' error guards for every adapter. ADR-0055 records the design, rejected alternatives, and probe evidence. ## Two deliberate deviations from the brief's letter - **`CredentialsSig` returns `(sig string, mtime time.Time)`**, not bare `string`. The settled design puts the newest-mtime adopt tie-break in core, but core never learns adapter file paths — so the seam surfaces the newest credential-file mtime as typed filesystem metadata (never token content). The sig itself stays opaque, equality-compared only. - **The codex refresh trigger is a candidate recipe, not live-verified** (`codex exec --sandbox read-only --skip-git-repo-check ok`, flags confirmed against codex-cli 0.144.4 `--help`). This host has no codex login, and probing a live grant double-spends its refresh token — the same reason the issue dropped the grace-window probe. The issue explicitly scoped this as blocking only that adapter's `RefreshCredentials`; compat §11 records the recipe, the two open probe questions, and an operator-supervised verification procedure, plus a live re-verification checklist row. Known bounded trade-off: a stop-path `AdoptCheck` can wait behind an in-flight poke (shared mutex; one poke per provider per 15 min, bounded by a 3 min refresh timeout, typically seconds). ## Verification - `go build ./...`, `go vet ./...`, `gofmt -l`, `golangci-lint run` (0 issues), full `go test ./...` green; `internal/credrotate` also green under `-race`. - Acceptance criteria each pinned by a named test: rotation→fan-out without self-refresh (`TestTick_MasterRotationFansOut`), no fan-out on no-op poke (`TestTick_NoopPokeDoesNotFanOut`, `TestTick_PokeRotationFansOutSameTick`), self-refresh adopt + newest-mtime tie-break (`TestTick_SelfRefreshAdoptedAndFannedOut`, `TestTick_NewestSelfRefreshWins`), adopt-before-wipe ordering (`TestWipeFiresPreWipeHookBeforeRemoval`, `TestSweepAllFiresPreWipeHookOnlyForReapedOrphans`, `TestAdoptCheck`), opaque-sig/`""`-means-master conformance (`TestConformance/*/credential-authority`), adapter coverage in `credauthority_test.go` for both providers with stubbed binaries. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(provider,core): server-owned credential authority — one refresher per grant (ADR-0055)
All checks were successful
ci / native (pull_request) Successful in 7m55s
0ed3213f16
Per-run OAuth snapshots forked the provider token family (issue #222): a
long-lived instance's CLI self-refresh invalidated the refresh token held
by the master store and every sibling snapshot — the host read logged-out,
the spawn gate refused new runs, and wipe-on-stop could destroy the only
still-valid family.

Invariant now enforced: one refresher per provider grant; instances are
consumers, never refreshers.

- provider.AgentProvider grows the credential-authority seam:
  RefreshCredentials (blind periodic CLI poke against the master store),
  CredentialsSig (opaque existence+mtime+size change-detector, "" means
  the master store — SpoolSig precedent), AdoptCredentials (atomic
  copy-back). Implemented for claude-code (probe-confirmed
  `claude -p --model haiku` trigger, compat §3b) and codex (candidate
  `codex exec` recipe, compat §11, pending live verification).
- internal/credrotate: the rotation loop — adopt-scan (newest-mtime
  tie-break, eager adopt-back) → sparse poke → master-change detection →
  immediate fan-out through the existing single InjectCredentials call
  site. Bootstrap fan-out heals the fleet after downtime; a re-login is
  detected as a master change and fans out too.
- runs.cred_sig (migration 0019) persists each run's last-injected
  baseline; launch stamps the post-inject signature.
- instancehome.Manager pre-wipe hook funnels credrotate.AdoptCheck into
  every wipe and sweep path (stop, rollback, AFK stop, reaper, parked
  teardown, startup + runtime sweeps), so a wipe can never destroy the
  only valid family.
- Conformance pins the ""-means-master and missing-master-empty-sig
  conventions plus AdoptCredentials' error guards for every adapter.

Closes #222

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

Clean PASS. Verified via CI aggregate success — 'ci / native' (run #295, 7m55s), the full native build/test/vet/lint/-race lane. Conventional commit + Closes #222 (afk auto-close intact). Diff scope matches issue #222's grilled design; migrations present for both dialects; ADR-0055 recorded. Codex RefreshCredentials ships as an issue-sanctioned unverified candidate recipe (non-blocking per the issue's explicit scope; documented in ADR-0055 + compat §11 with an operator verification procedure).

[autoland] verdict: pass Clean PASS. Verified via CI aggregate success — 'ci / native' (run #295, 7m55s), the full native build/test/vet/lint/-race lane. Conventional commit + Closes #222 (afk auto-close intact). Diff scope matches issue #222's grilled design; migrations present for both dialects; ADR-0055 recorded. Codex RefreshCredentials ships as an issue-sanctioned unverified candidate recipe (non-blocking per the issue's explicit scope; documented in ADR-0055 + compat §11 with an operator verification procedure).
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!223
No description provided.