Skip to content

Give each credential exactly one refresher - #108

Merged
lawrencecchen merged 2 commits into
mainfrom
fix/team-mode-single-refresher
Jul 30, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
fix/team-mode-single-refresher

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Why

OpenAI rotates a Codex refresh token on every use and invalidates the previous one. A credential can therefore have exactly one refresher, and two of them destroy each other.

That is what happened in production: a laptop and a mac mini shared one account store, both refreshed independently, and 11 of 12 accounts now fail with refresh_token_reused, some recorded as far back as 2026-05-25. sr status on both hosts shows the same error for the same accounts. Re-adding accounts into that arrangement just kills them again, so this is a precondition for the hosted-vault migration rather than a cleanup.

What was wrong

Team mode already leases from the broker and never sends a local refresh token upstream. But the local scheduler still refreshed every OAuth account to score usage, on two ungated paths:

  • reloadAccounts → scoreAccounts
  • refreshUsageScoresIfStale → scoreAccounts

scoreAccounts refreshes each OAuth account before fetching usage. So a team-mode machine kept rotating tokens the vault owns, invalidating them for the vault, while the vault invalidated them here. Someone had already noticed the scheduler is irrelevant under a broker (proxy.go gates NoteRouted on CredentialBroker == nil); the scoring paths were simply missed.

Changes

Both refresh paths return early when a credential broker is configured. Local mode is untouched and still refreshes, since there it is the only refresher.

Upload now hands over ownership as well as bytes. sr account import moves each uploaded local credential to <store>/migrated/, which ListStored does not scan, so the daemon stops refreshing it. The file stays readable for rollback and the command prints where each record went. The old wording promised "local files remain only as migration records" while leaving them fully refreshable; now that is actually true.

Tests

TestTeamModeNeverRefreshesLocalAccounts covers both paths under a broker and asserts zero refreshes, plus a third case asserting local mode still refreshes so the gate cannot be widened into breaking scoring. Verified it fails without the gate (team mode refreshed 1 local accounts, want 0).

TestMigrateStoredAwayDisownsButKeepsRecord asserts the account leaves the active store, the record remains readable, and the directory is 0700.

go test ./internal/... ./cmd/... passes.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Stop local refresh of shared OAuth credentials in team mode and hand off ownership on upload to prevent refresh_token_reused and broken token chains.

  • Bug Fixes

    • In team mode (CredentialBroker set), short-circuit reloadAccounts and refreshUsageScoresIfStale so no local OAuth refresh occurs; local mode unchanged. Tests now seed a temp store to make refresh behavior deterministic in CI.
  • Migration

    • sr account import now moves uploaded codex credentials to <store>/migrated/ (0700). The store does not scan this dir, so the daemon stops refreshing them. The rollback record remains and the path is printed.

Written for commit a766657. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Cloud account imports now hand off local Codex credentials after upload.
    • Migrated credentials remain available as rollback records in a protected directory.
    • Import results now report uploaded accounts, migrated credentials, and rollback record location.
  • Bug Fixes

    • Team mode no longer refreshes or updates usage scores for local accounts when centralized credential management is enabled.
    • Local mode continues to refresh accounts normally.

OpenAI rotates a Codex refresh token on every use and invalidates the previous
one, so a credential can have exactly one refresher. Two hosts sharing an
account store therefore destroy each other's tokens: this was observed across a
laptop and a mac mini, where 11 of 12 shared accounts failed with
refresh_token_reused, some dating back to May.

Team mode already routes through broker leases and never sends local refresh
tokens upstream, but the local scheduler still refreshed every OAuth account to
score usage, on two paths (reloadAccounts and refreshUsageScoresIfStale).
Neither was gated on the broker, so a team-mode machine kept rotating tokens the
vault owns. Both paths now return early when a credential broker is configured;
local mode is unchanged and still refreshes, which a test pins from both sides.

Uploading a credential now also hands over ownership rather than only copying
the bytes: the local file moves to <store>/migrated/, which the store does not
scan, so the daemon stops refreshing it while the record stays readable for
rollback.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Cloud imports now hand off uploaded Codex credentials into a migrated directory, while broker-backed team mode bypasses local OAuth refresh and usage-score updates. Tests cover migration records, permissions, missing accounts, and team-versus-local refresh behavior.

Changes

Codex credential handover

Layer / File(s) Summary
Migrated Codex storage
internal/accounts/codex_store.go
Adds MigratedDirName and MigrateStoredAway to move stored account JSON files into a 0700 migrated directory.
Cloud import handover and validation
cmd/subrouter/sr_cloud.go, internal/accounts/codex_store_migrate_test.go
Cloud imports migrate successful Codex uploads and report uploaded and migrated counts; tests verify rollback records, active-store removal, permissions, and missing-account handling.

Team-mode refresh control

Layer / File(s) Summary
Broker-gated account refresh
internal/proxy/proxy.go
Skips local account refresh and stale usage-score processing when a credential broker is configured.
Refresh behavior regression coverage
internal/proxy/team_mode_refresh_test.go, internal/proxy/dashboard_test.go
Tests confirm team mode avoids local refreshes while local mode continues refreshing; the dashboard test only reorders imports.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CloudImport
  participant CodexStore
  participant MigratedDirectory
  CloudImport->>CodexStore: Upload Codex credential
  CloudImport->>CodexStore: MigrateStoredAway(label)
  CodexStore->>MigratedDirectory: Move active JSON record
  MigratedDirectory-->>CloudImport: Return rollback record path
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: ensuring each credential has a single refresher across team mode and credential import flows.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/team-mode-single-refresher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/subrouter/sr_cloud.go`:
- Around line 776-790: Make the UploadAccount and MigrateStoredAway sequence in
the account upload flow failure-safe: ensure local disowning succeeds before
leaving the remote account active, or compensate by disabling/deleting the newly
uploaded broker account if migration fails. Preserve rollback data and return
the migration error, and add a test covering migration failure to verify no
active remote adoption remains while the local credential is still present.

In `@internal/proxy/team_mode_refresh_test.go`:
- Around line 26-30: Update the test setup around the newServer helper and
AccountRef to persist both fixture accounts into the temporary CodexStore before
constructing the Server, so reloadAccounts can load them and invoke
RefreshAccountFn. Add an assertion that the reload count equals the number of
fixture accounts, preserving the existing team and local-mode assertions; run go
test ./... before handoff.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5da6b109-2d18-41d5-a365-6f6b333b3111

📥 Commits

Reviewing files that changed from the base of the PR and between 96ebb68 and 1e88284.

📒 Files selected for processing (6)
  • cmd/subrouter/sr_cloud.go
  • internal/accounts/codex_store.go
  • internal/accounts/codex_store_migrate_test.go
  • internal/proxy/dashboard_test.go
  • internal/proxy/proxy.go
  • internal/proxy/team_mode_refresh_test.go

Comment thread cmd/subrouter/sr_cloud.go
Comment on lines 776 to +790
if _, err := client.UploadAccount(ctx, upload.body); err != nil {
return fmt.Errorf("upload %s %s: %w", upload.kind, upload.label, err)
}
uploaded++
fmt.Fprintf(r.out, "uploaded %-20s %s\n", upload.kind, upload.label)
// Hand over ownership as well as the bytes. The provider rotates the
// refresh token on every use, so leaving a refreshable local copy makes
// this machine and the vault invalidate each other's chain. The record
// is kept for rollback, just where the daemon will not refresh it.
if upload.kind != "codex" {
continue
}
path, ok, err := r.store.MigrateStoredAway(upload.label)
if err != nil {
return fmt.Errorf("hand over %s: %w", upload.label, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make remote adoption and local disowning failure-safe.

UploadAccount adopts the credential before MigrateStoredAway. If the move fails, the vault has accepted the refresh chain while the local active file remains, recreating the two-refreshers condition this PR prevents. Stage the upload until migration succeeds, or compensate by disabling/deleting the newly uploaded broker account before returning the error; cover this failure path with a test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/subrouter/sr_cloud.go` around lines 776 - 790, Make the UploadAccount and
MigrateStoredAway sequence in the account upload flow failure-safe: ensure local
disowning succeeds before leaving the remote account active, or compensate by
disabling/deleting the newly uploaded broker account if migration fails.
Preserve rollback data and return the migration error, and add a test covering
migration failure to verify no active remote adoption remains while the local
credential is still present.

Comment on lines +26 to +30
newServer := func(refreshes *int32, credBroker CredentialBroker) Server {
return Server{
Accounts: local,
SchedulerRef: selectacct.NewSchedulerRef(selectacct.NewScheduler(nil)),
AccountRef: NewAccountRef(accounts.CodexStore{Dir: t.TempDir()}, local, nil),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Populate the test store before reloading accounts.

AccountRef points to a fresh empty CodexStore, so reloadAccounts loads zero records and never reaches RefreshAccountFn. This makes the team assertions vacuous and causes the local-mode assertion to fail in CI. Persist the two fixture accounts before constructing the server, and assert that the reload count matches the fixture size.

As per coding guidelines, run go test ./... before handoff; the supplied CI run currently fails this test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/team_mode_refresh_test.go` around lines 26 - 30, Update the
test setup around the newServer helper and AccountRef to persist both fixture
accounts into the temporary CodexStore before constructing the Server, so
reloadAccounts can load them and invoke RefreshAccountFn. Add an assertion that
the reload count equals the number of fixture accounts, preserving the existing
team and local-mode assertions; run go test ./... before handoff.

Sources: Coding guidelines, Pipeline failures

The local-mode half of TestTeamModeNeverRefreshesLocalAccounts asserted that a
server without a broker still refreshes. It passed locally only because
reloadAccounts re-reads the account store and this machine has 16 real accounts
in it; CI has none, so nothing was refreshed and the assertion failed.

The test now writes its own OAuth accounts into a temp store. Verified with an
empty HOME, which is what CI has.
@lawrencecchen
lawrencecchen merged commit 6c191c5 into main Jul 30, 2026
6 checks passed
@lawrencecchen
lawrencecchen deleted the fix/team-mode-single-refresher branch July 30, 2026 00:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant