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:
1 parent
fcebf8fac0
commit
c7e4f3fe3f
3 files changed
+157
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user