claude: an account's saved sign-in isn't overwritten with the account Claude Code was just switched to (netfishx on X: Pro, Max and Team accounts, "Keep Claude Code signed in to" on; after routing moved once or twice the Usage page showed one account's plan and allowance on another's card) — magpie switching Claude Code (to the kept account, or back to it) writes the new sign-in and profile, and a Claude Code started on the account before then writes that one's profile into ~/.claude.json: the credential was the Team's, the profile the Pro's. rememberLogins paired them, and upsertLogin, matching the Pro's record by the profile, wrote the Team's sign-in and plan into it ("pro@x.com · Team"). The Pro's runs, in its own config directory seeded from that record, then spent the Team account, and their rate_limit_events and plan showed on the Pro's card. upsertLogin now refuses a Claude sign-in another saved account holds (access or refresh token) while the record the profile names doesn't: the account the profile names holding it already, or Claude Code having renewed it, it is saved as before, so a record crossed by an earlier version doesn't hold the owner back (re-adding the crossed account puts its own sign-in back). TestClaudeUsageUnavailableAfterAccountSwitch switched by the profile alone; it now writes the new account's sign-in as a switch does. Not tried with real Claude accounts (one here). claude_crossed_test (switch to Team, the Pro's profile written after, switch back: each record and each saved account's directory keeps its own sign-in and plan; a crossed record doesn't stop the Team's own being saved, renewed or not) fails without the fix on the first case and passes with -race -count=5; go test ./internal/provider/ ./internal/gateway/, go vet, linux(nogui)/windows builds pass.

This commit is contained in:
yetone committed 2026-10-06 13:39:48 +08:00
1 parent fcebf8fac0
commit c7e4f3fe3f
3 files changed
+157

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
package provider
import (
"path/filepath"
"strings"
"testing"
"time"
)
// claudeThree saves three Claude accounts as Claude Code signs in to
// each, last the Pro, and gives the helpers that sign it in and name it.
func claudeThree(t *testing.T) (prof func(email, org string), creds func(tok, plan string)) {
t.Helper()
home := claudeHome(t)
cred := claudeSignIn(t, home, time.Now().Add(time.Hour))
profile := filepath.Join(home, ".claude.json")
prof = func(email, org string) {
writeFile(t, profile, map[string]any{"oauthAccount": map[string]any{"emailAddress": email,
"organizationUuid": "org-" + email, "organizationName": org}})
}
creds = func(tok, plan string) {
writeFile(t, cred, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "tok-" + tok, "refreshToken": "r-" + tok,
"expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": plan}})
}
for _, a := range [][3]string{{"team@x.com", "team", "Acme"}, {"max@x.com", "max", "max@x.com's Organization"}, {"pro@x.com", "pro", "pro@x.com's Organization"}} {
prof(a[0], a[2])
creds(a[0], a[1])
forgetAccountCaches()
rememberLogins(true)
}
return prof, creds
}
func claudeEmail(user string) string {
email, _, _ := strings.Cut(user, " · ")
return email
}
// netfishx on X: Pro, Max and Team accounts, Claude Code kept signed in
// to one. magpie switches Claude Code to the Team account, and a Claude
// Code started on the Pro a moment before writes the Pro's profile after:
// the sign-in is the Team's, the profile the Pro's. That isn't saved as
// the Pro's, whose runs (its own config directory) would then spend the
// Team and show the Team's plan and usage on the Pro's card.
func TestClaudeSwitchThenStaleProfileKeepsRecords(t *testing.T) {
prof, _ := claudeThree(t)
team := "team@x.com · Acme"
if err := SwitchLogin("claude", team); err != nil {
t.Fatal(err)
}
prof("pro@x.com", "pro@x.com's Organization")
time.Sleep(10 * time.Millisecond)
forgetAccountCaches()
rememberLogins(true)
if err := SwitchLogin("claude", team); err != nil { // kept: switched back
t.Fatal(err)
}
forgetAccountCaches()
rememberLogins(true)
plans := map[string]string{"team@x.com": "team", "max@x.com": "max", "pro@x.com": "pro"}
for _, l := range readLogins() {
if l.Agent != "claude" {
continue
}
c, _ := parseClaudeCredentials(l.Auth)
if e := claudeEmail(l.User); c.OAuth.AccessToken != "tok-"+e || l.Plan != plans[e] {
t.Errorf("%s holds %s, plan %q", l.User, c.OAuth.AccessToken, l.Plan)
}
}
for _, l := range Logins("claude") {
if l.Active {
continue
}
dir, err := claudeSavedDir(l.User)
if err != nil {
t.Fatal(err)
}
if d, _ := readClaudeDir(dir); d.OAuth.AccessToken != "tok-"+claudeEmail(l.User) {
t.Errorf("runs on %s use %s", l.User, d.OAuth.AccessToken)
}
}
}
// Records crossed before (the Pro's holding the Team's sign-in) don't stop
// the Team's own sign-in being saved: the Team's record holding it too, or
// Claude Code having renewed it since.
func TestClaudeCrossedRecordDoesntHoldTheOwnerBack(t *testing.T) {
prof, creds := claudeThree(t)
loginsMu.Lock()
ls := readLogins()
var teamAuth []byte
for _, l := range ls {
if strings.HasPrefix(l.User, "team@") {
teamAuth = l.Auth
}
}
for i := range ls {
if strings.HasPrefix(ls[i].User, "pro@") {
ls[i].Auth = teamAuth
}
}
if err := writeLogins(ls); err != nil {
t.Fatal(err)
}
loginsMu.Unlock()
for _, tok := range []string{"team@x.com", "team@x.com-renewed"} {
prof("team@x.com", "Acme")
creds(tok, "team")
forgetAccountCaches()
rememberLogins(true)
for _, l := range readLogins() {
if c, _ := parseClaudeCredentials(l.Auth); strings.HasPrefix(l.User, "team@") && c.OAuth.AccessToken != "tok-"+tok {
t.Errorf("Team's record holds %s, Claude Code %s", c.OAuth.AccessToken, "tok-"+tok)
}
}
}
}
@@ -218,6 +218,9 @@ func TestClaudeUsageUnavailableAfterAccountSwitch(t *testing.T) {
writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{
"oauthAccount": map[string]any{"emailAddress": "b@example.com"},
})
writeFile(t, filepath.Join(home, ".claude", ".credentials.json"), map[string]any{"claudeAiOauth": map[string]any{
"accessToken": "tok-b", "refreshToken": "r-b", "expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max"}})
forgetClaudeCredential()
forgetClaudeStatus()
rememberLogins(true)
out.Store(subscriptionNotice)
+37
View File
@@ -273,6 +273,9 @@ func writePrivate(path string, b []byte) error {
func upsertLogin(ls []savedLogin, l savedLogin) []savedLogin {
l.User = codexName(ls, l)
if claudeSignInOfAnother(ls, l) {
return ls
}
for i := range ls {
if sameLogin(ls[i], l) {
// a refused Claude credential stays refused while it is the
@@ -1199,3 +1202,37 @@ func forgetAccountCaches() {
subscriptionUsageCache.data = nil
subscriptionUsageCache.Unlock()
}
// claudeSignInOfAnother says the Claude sign-in l carries is another saved
// account's, not the one its profile names: Claude Code's credential and
// ~/.claude.json were read from two moments, as when magpie switches
// Claude Code (keeping it signed in to one account) and a Claude Code
// started on the account before writes that one's profile after. Saved,
// it put the one account's sign-in and plan under the other's name, whose
// runs and Usage card then were the first's (netfishx on X). The account
// the profile names holding the sign-in already, it is that one's.
func claudeSignInOfAnother(ls []savedLogin, l savedLogin) bool {
if l.Agent != "claude" {
return false
}
c, ok := parseClaudeCredentials(l.Auth)
if !ok {
return false
}
holds := func(x savedLogin) bool {
o, ok := parseClaudeCredentials(x.Auth)
return ok && (c.OAuth.AccessToken != "" && o.OAuth.AccessToken == c.OAuth.AccessToken ||
c.OAuth.RefreshToken != "" && o.OAuth.RefreshToken == c.OAuth.RefreshToken)
}
another := false
for _, x := range ls {
if x.Agent != "claude" || !holds(x) {
continue
}
if sameLogin(x, l) {
return false
}
another = true
}
return another
}