Repository navigation
Give each credential exactly one refresher #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package accounts | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| // A credential handed to the team vault must stop being refreshed here: the | ||
| // provider rotates refresh tokens on use, so two refreshers invalidate each | ||
| // other. Moving the file out of the scanned store is what disowns it, while | ||
| // keeping it readable for rollback. | ||
| func TestMigrateStoredAwayDisownsButKeepsRecord(t *testing.T) { | ||
| store := CodexStore{Dir: t.TempDir()} | ||
| account := StoredCodexAccount{ | ||
| Email: "lc+8@cmux.com", | ||
| Auth: CodexAuthFile{Tokens: &CodexTokens{ | ||
| AccessToken: "access", | ||
| RefreshToken: "refresh", | ||
| IDToken: "id", | ||
| }}, | ||
| } | ||
| if err := store.SaveStored(account); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if stored, err := store.ListStored(); err != nil || len(stored) != 1 { | ||
| t.Fatalf("precondition: stored=%d err=%v", len(stored), err) | ||
| } | ||
|
|
||
| path, ok, err := store.MigrateStoredAway("lc+8@cmux.com") | ||
| if err != nil || !ok { | ||
| t.Fatalf("migrate: ok=%v err=%v", ok, err) | ||
| } | ||
|
|
||
| stored, err := store.ListStored() | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if len(stored) != 0 { | ||
| t.Fatalf("account still in the active store after handover: %+v", stored) | ||
| } | ||
| if filepath.Dir(path) != filepath.Join(store.Dir, MigratedDirName) { | ||
| t.Fatalf("record landed at %s, want it under %s", path, MigratedDirName) | ||
| } | ||
| body, err := os.ReadFile(path) | ||
| if err != nil { | ||
| t.Fatalf("rollback record unreadable: %v", err) | ||
| } | ||
| if len(body) == 0 { | ||
| t.Fatal("rollback record is empty") | ||
| } | ||
| info, err := os.Stat(filepath.Dir(path)) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if perm := info.Mode().Perm(); perm != 0o700 { | ||
| t.Fatalf("record directory mode %o, want 700", perm) | ||
| } | ||
| } | ||
|
|
||
| func TestMigrateStoredAwayReportsMissingAccount(t *testing.T) { | ||
| store := CodexStore{Dir: t.TempDir()} | ||
| if _, ok, err := store.MigrateStoredAway("nobody@example.com"); ok || err != nil { | ||
| t.Fatalf("ok=%v err=%v, want false and no error", ok, err) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package proxy | ||
|
|
||
| import ( | ||
| "context" | ||
| "sync/atomic" | ||
| "testing" | ||
|
|
||
| "github.com/manaflow-ai/subrouter/internal/accounts" | ||
| "github.com/manaflow-ai/subrouter/selectacct" | ||
| ) | ||
|
|
||
| // In team mode the vault owns the credentials and refreshes them. The provider | ||
| // rotates the refresh token on every use, so a second refresher invalidates the | ||
| // vault's chain and the vault invalidates this machine's: both sides end up | ||
| // dead. Observed in production across a laptop and a mac mini sharing one | ||
| // account store, where 11 of 12 accounts failed with refresh_token_reused. | ||
| // | ||
| // So a server with a credential broker must never refresh a local OAuth | ||
| // account, on any path. | ||
| func TestTeamModeNeverRefreshesLocalAccounts(t *testing.T) { | ||
| local := []accounts.Account{ | ||
| {ID: "a@example.com", Provider: accounts.ProviderCodex, AuthMode: accounts.AuthModeOAuth, Token: "a"}, | ||
| {ID: "b@example.com", Provider: accounts.ProviderCodex, AuthMode: accounts.AuthModeOAuth, Token: "b"}, | ||
| } | ||
|
|
||
| // The store must contain the accounts, not just the in-memory slice: | ||
| // reloadAccounts re-reads it, so a test that relies on whatever the developer | ||
| // happens to have in ~/.subrouter passes locally and fails in CI. | ||
| newStore := func() accounts.CodexStore { | ||
| store := accounts.CodexStore{Dir: t.TempDir()} | ||
| for _, account := range local { | ||
| if err := store.SaveStored(accounts.StoredCodexAccount{ | ||
| Email: account.ID, | ||
| Provider: accounts.ProviderCodex, | ||
| Auth: accounts.CodexAuthFile{Tokens: &accounts.CodexTokens{ | ||
| AccessToken: "access-" + account.ID, | ||
| RefreshToken: "refresh-" + account.ID, | ||
| IDToken: "id-" + account.ID, | ||
| }}, | ||
| }); err != nil { | ||
| t.Fatalf("seed store: %v", err) | ||
| } | ||
| } | ||
| return store | ||
| } | ||
|
|
||
| newServer := func(refreshes *int32, credBroker CredentialBroker) Server { | ||
| return Server{ | ||
| Accounts: local, | ||
| SchedulerRef: selectacct.NewSchedulerRef(selectacct.NewScheduler(nil)), | ||
| AccountRef: NewAccountRef(newStore(), local, nil), | ||
| RefreshAccountFn: func(_ context.Context, a accounts.Account) (accounts.Account, error) { | ||
| atomic.AddInt32(refreshes, 1) | ||
| return a, nil | ||
| }, | ||
| CredentialBroker: credBroker, | ||
| UsageScoreTTL: 0, | ||
| } | ||
| } | ||
|
|
||
| t.Run("reloadAccounts", func(t *testing.T) { | ||
| var teamRefreshes int32 | ||
| team := newServer(&teamRefreshes, &fakeCredentialBroker{}) | ||
| if _, _, err := team.reloadAccounts(context.Background()); err != nil { | ||
| t.Fatalf("reload in team mode: %v", err) | ||
| } | ||
| if got := atomic.LoadInt32(&teamRefreshes); got != 0 { | ||
| t.Fatalf("team mode refreshed %d local accounts, want 0", got) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("refreshUsageScoresIfStale", func(t *testing.T) { | ||
| var teamRefreshes int32 | ||
| team := newServer(&teamRefreshes, &fakeCredentialBroker{}) | ||
| team.refreshUsageScoresIfStale(context.Background()) | ||
| if got := atomic.LoadInt32(&teamRefreshes); got != 0 { | ||
| t.Fatalf("team mode refreshed %d local accounts while scoring, want 0", got) | ||
| } | ||
| }) | ||
|
|
||
| // Local mode is the other half of the contract: with no broker this machine | ||
| // is the only refresher, so it must still refresh or scores go stale. | ||
| t.Run("local mode still refreshes", func(t *testing.T) { | ||
| var localRefreshes int32 | ||
| solo := newServer(&localRefreshes, nil) | ||
| if _, _, err := solo.reloadAccounts(context.Background()); err != nil { | ||
| t.Fatalf("reload in local mode: %v", err) | ||
| } | ||
| if got := atomic.LoadInt32(&localRefreshes); got == 0 { | ||
| t.Fatal("local mode refreshed nothing; the gate is too wide") | ||
| } | ||
| }) | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
UploadAccountadopts the credential beforeMigrateStoredAway. 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