diff --git a/internal/provider/account.go b/internal/provider/account.go index 3abe5bf2..b9b841c4 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -56,6 +56,10 @@ type Account struct { // logins_on.go): the access token to run the agent's binary with. token func(ctx context.Context) (string, error) + // standIn is a saved Claude account served in the place of Claude + // Code's own while Claude Code is signed out (claude_dirs.go). + standIn bool + // codeAssist is where a Gemini CLI or Antigravity account's requests // go (google.go). codeAssist string @@ -561,11 +565,11 @@ func withoutClaudeWiring(env []string) []string { func claudeAccount() (Provider, bool) { c, _, ok := claudeCredential() if !ok { - return Provider{}, false + return claudeStandInAccount() } user, statusPlan, signedOut := claudeIdentity() if signedOut { - return Provider{}, false + return claudeStandInAccount() } plan := c.OAuth.SubscriptionType if statusPlan != "" { @@ -581,7 +585,11 @@ func claudeAccount() (Provider, bool) { user = "Claude " + strings.ToUpper(plan[:1]) + plan[1:] } } - acct := &Account{Agent: "claude", User: user, Plan: plan} + return claudeProvider(&Account{Agent: "claude", User: user, Plan: plan}), true +} + +// claudeProvider is the Claude Code provider of acct. +func claudeProvider(acct *Account) Provider { // nothing is sent to Anthropic in Claude Code's name: a request on the // account runs Claude Code itself (the gateway's bridge, a test), so // one that would go straight to the API with its sign-in is refused @@ -594,7 +602,7 @@ func claudeAccount() (Provider, bool) { return ms, catalog.SaveLive("claude", claudeBase, ms) } return Provider{ID: "claude", Name: "Claude Code", Icon: "claudecode-color", Anthropic: claudeBase, - Catalog: "anthropic", Website: "https://claude.ai", Account: acct}, true + Catalog: "anthropic", Website: "https://claude.ai", Account: acct} } // refreshRefused is a refresh the vendor answered and turned down: the diff --git a/internal/provider/claude_dirs.go b/internal/provider/claude_dirs.go index 91877a91..895c478f 100644 --- a/internal/provider/claude_dirs.go +++ b/internal/provider/claude_dirs.go @@ -9,6 +9,7 @@ package provider import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -146,3 +147,56 @@ func takeClaudeDir(l *savedLogin, c claudeCredentials) (bool, error) { l.Lapsed = "" return true, nil } + +// claudeStandIn is the saved account served in the place of Claude Code's +// own while Claude Code is signed out — the user logged out of it, as it +// tells them to when magpie's token is set beside a claude.ai sign-in. It +// is the account seen last, which is the one Claude Code was signed in to +// until then (rememberLogins sees that one every 30s), passing over one +// whose saved sign-in is gone while another has one; "" when none is +// saved. magpie keeps every account's sign-in itself, so a logout signs +// none of them out of magpie: the stand-in runs Claude Code in a config +// directory of its own, as the others on do. +func claudeStandIn(ls []savedLogin) string { + user, signedIn := "", false + var seen time.Time + for _, l := range ls { + if l.Agent != "claude" { + continue + } + _, ok := parseClaudeCredentials(l.Auth) + if user == "" || ok && !signedIn || ok == signedIn && l.Seen.After(seen) { + user, seen, signedIn = l.User, l.Seen, ok + } + } + return user +} + +// claudeSignedOut is why a saved Claude account can't be used: its saved +// sign-in is gone, and it has to be signed in again; "" when it has one. +func claudeSignedOut(l savedLogin) string { + if _, ok := parseClaudeCredentials(l.Auth); ok { + return "" + } + return "its sign-in is gone; sign in again" +} + +// claudeStandInAccount is the Claude Code provider while Claude Code is +// signed out, on claudeStandIn's account; false with no account saved. +func claudeStandInAccount() (Provider, bool) { + loginsMu.Lock() + ls := readLogins() + loginsMu.Unlock() + user := claudeStandIn(ls) + if user == "" { + return Provider{}, false + } + acct := &Account{Agent: "claude", User: user, standIn: true} + for _, l := range ls { + if l.Agent == "claude" && strings.EqualFold(l.User, user) { + acct.Plan = l.Plan + } + } + acct.token = func(context.Context) (string, error) { return claudeSavedDir(user) } + return claudeProvider(acct), true +} diff --git a/internal/provider/claude_logout_test.go b/internal/provider/claude_logout_test.go new file mode 100644 index 00000000..ca0c7f5b --- /dev/null +++ b/internal/provider/claude_logout_test.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// Claude Code wired to magpie, its accounts served by magpie: the user logs +// out of Claude Code (it says to, over magpie's ANTHROPIC_AUTH_TOKEN), and +// the accounts saved in magpie are still served — the one it was signed in +// to first, in a config directory of its own, the others on behind it — and +// none of them is dropped from the list (StringKe on Discord: all six went). +func TestClaudeAccountsOutliveLogout(t *testing.T) { + home := claudeHome(t) + t.Setenv("CLAUDE_CONFIG_DIR", "") + noAnthropic(t) + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) + writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "own@example.com"}}) + far := time.Now().Add(24 * time.Hour).UnixMilli() + oauth := func(tok string) map[string]any { + return map[string]any{"claudeAiOauth": map[string]any{"accessToken": tok, "refreshToken": "r-" + tok, "expiresAt": far, "subscriptionType": "max"}} + } + seen := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + writeFile(t, loginsPath(), []map[string]any{ + {"agent": "claude", "user": "on@example.com", "plan": "max", "on": true, "seen": seen, "auth": oauth("tok-on")}, + {"agent": "claude", "user": "off@example.com", "plan": "pro", "seen": seen, "auth": oauth("tok-off")}, + }) + loginsMu.Lock() + loginsSeenAt = time.Time{} + loginsMu.Unlock() + if p, ok := find(All(), "claude"); !ok || p.Account.User != "own@example.com" { + t.Fatalf("signed in: %v %+v", ok, p.Account) + } + + // /logout: Claude Code's sign-in is gone + os.Remove(cred) + os.Remove(filepath.Join(home, ".claude.json")) + forgetClaudeCredential() + loginsMu.Lock() + loginsSeenAt = time.Time{} + loginsMu.Unlock() + + p, ok := find(All(), "claude") + if !ok || p.Account == nil || p.Account.User != "own@example.com" { + t.Fatalf("logged out: %v %+v", ok, p.Account) + } + dir, own, err := p.Account.Token(context.Background()) + if err != nil || !own || dir != claudeAccountDir("own@example.com") { + t.Fatalf("dir %q %v %v", dir, own, err) + } + if c, ok := readClaudeDir(dir); !ok || c.OAuth.RefreshToken != "sk-ant-ort01-old" { + t.Fatalf("dir credentials %+v", c.OAuth) + } + var also []string + for _, q := range p.AlsoOn() { + also = append(also, q.Account.User) + } + if len(also) != 1 || also[0] != "on@example.com" { + t.Fatalf("also on: %v", also) + } + ls := Logins("claude") + if len(ls) != 3 { + t.Fatalf("logins: %+v", ls) + } + for _, l := range ls { + if l.Active || l.On != (l.User != "off@example.com") { + t.Fatalf("login %+v", l) + } + } + if u := InUseLogin("claude"); u != "own@example.com" { + t.Fatalf("in use %q", u) + } + for _, x := range savedButSignedOut() { + if x.Agent == "claude" { + t.Fatalf("excluded: %+v", x) + } + } + + // it can be paused behind the one on, as when Claude Code was signed in to it + if err := SetLoginOn("claude", "own@example.com", false); err != nil { + t.Fatal(err) + } + if p, _ = find(All(), "claude"); !p.OwnPaused() { + t.Fatal("not paused") + } + if u := InUseLogin("claude"); u != "on@example.com" { + t.Fatalf("in use %q", u) + } +} + +// Logged out of Claude Code with accounts in magpie, an account signed in +// in magpie stays magpie's: Claude Code isn't signed in to it again, which +// would only bring back its warning over magpie's token. +func TestClaudeSignInWhileLoggedOut(t *testing.T) { + home := claudeHome(t) + t.Setenv("CLAUDE_CONFIG_DIR", "") + noAnthropic(t) + far := time.Now().Add(24 * time.Hour).UnixMilli() + writeFile(t, loginsPath(), []map[string]any{ + {"agent": "claude", "user": "old@example.com", "plan": "max", "seen": time.Now().Add(-time.Hour).UTC(), + "auth": map[string]any{"claudeAiOauth": map[string]any{"accessToken": "tok-old", "refreshToken": "r-old", "expiresAt": far}}}, + }) + fakeClaudeLogin(t, fakeClaudeAccount{email: "new@example.com", plan: "pro", refresh: "sk-ant-ort01-new"}, "", true) + + st, err := StartSignIn("claude") + if err != nil { + t.Fatal(err) + } + finishInBrowser(t, st, "the-code") + if st = waitDone(t, st.ID); st.State != "done" || st.Using { + t.Fatalf("state %+v", st) + } + if _, err := os.Stat(filepath.Join(home, ".claude", ".credentials.json")); !os.IsNotExist(err) { + t.Fatalf("Claude Code signed in: %v", err) + } + forgetClaudeCredential() + var users []string + for _, l := range Logins("claude") { + if l.On { + users = append(users, l.User) + } + } + if len(users) != 2 { + t.Fatalf("on: %v", users) + } +} diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 7b04bf95..6c2a847c 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -47,6 +47,10 @@ type Login struct { // Returns is the account magpie signed the agent out of when it was // spent, and signs it back in to once it has room again (#408). Returns bool `json:"returns,omitempty"` + + // first is the saved Claude account served in the place of Claude + // Code's own while it is signed out (claudeStandIn). + first bool } type savedLogin struct { @@ -356,16 +360,15 @@ func savedButSignedOut() []Exclusion { } var out []Exclusion for _, a := range loginAgents { - if saved[a] == 0 { + // Claude Code signed out, its saved accounts are served all the + // same (claudeStandIn) + if saved[a] == 0 || a == "claude" { continue } if _, ok := liveLogin(a); ok { continue } why, signIn := "nothing at "+codexAuthPath(), "codex login" - if a == "claude" { - why, signIn = claudeNotSignedInWhy(), "claude, then /login" - } n := "1 account is" if saved[a] > 1 { n = fmt.Sprintf("%d accounts are", saved[a]) @@ -376,27 +379,6 @@ func savedButSignedOut() []Exclusion { return out } -// claudeNotSignedInWhy says which of liveLogin's checks found no Claude Code -// sign-in: its credentials (on a Mac, the keychain first), what -// `claude auth status` says, or the account's name. -func claudeNotSignedInWhy() string { - c, _, ok := claudeCredential() - if !ok { - if claudeKeychain { - return `no "Claude Code-credentials" in the keychain magpie could read, and nothing at ` + claudeCredentialsPath() - } - return "nothing at " + claudeCredentialsPath() - } - user, plan, signedOut := claudeIdentity() - if signedOut { - return "its credentials are there, but claude auth status says no one is signed in" - } - if u, _ := claudeSignedInUser(c.OAuth.SubscriptionType, plan, user); u == "" { - return "its credentials are there, but neither " + claudeProfilePath() + " nor claude auth status names the account" - } - return "its sign-in could not be read" -} - // liveLogin reads the account an agent is signed in to now. func liveLogin(agent string) (savedLogin, bool) { switch agent { @@ -564,6 +546,10 @@ func Logins(agent string) []Login { } var out []Login ls := readLogins() + standIn := "" + if _, ok := active["claude"]; !ok { + standIn = claudeStandIn(ls) + } back := map[string]string{} for a, user := range active { if r, ok := loginReturnOf(a, user); ok { @@ -575,10 +561,14 @@ func Logins(agent string) []Login { continue } using := strings.EqualFold(active[l.Agent], l.User) - lg := Login{Agent: l.Agent, User: l.User, Plan: l.Plan, Seen: l.Seen, Active: using, On: using || l.On, - Paused: using && pausedOwn(ls, l.Agent, l.User)} + first := l.Agent == "claude" && strings.EqualFold(standIn, l.User) + lg := Login{Agent: l.Agent, User: l.User, Plan: l.Plan, Seen: l.Seen, Active: using, On: using || first || l.On, + Paused: (using || first) && pausedOwn(ls, l.Agent, l.User), first: first} if !using { lg.Lapsed = l.Lapsed + if lg.Lapsed == "" && l.Agent == "claude" { + lg.Lapsed = claudeSignedOut(l) + } lg.Returns = l.On && strings.EqualFold(back[l.Agent], l.User) } out = append(out, lg) @@ -595,7 +585,7 @@ func InUseLogin(agent string) string { func inUseOf(ls []Login) string { for _, l := range ls { - if l.Active && !l.Paused { + if (l.Active || l.first) && !l.Paused { return l.User } } diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index 7b507084..befb75fe 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -57,7 +57,14 @@ func SetLoginOn(agent, user string, on bool) error { loginsMu.Lock() defer loginsMu.Unlock() ls := readLogins() - if live, ok := liveLogin(agent); ok && strings.EqualFold(live.User, user) { + live, ok := liveLogin(agent) + if !ok && agent == "claude" { + // Claude Code signed out, the account served in its place is as its own + if u := claudeStandIn(ls); u != "" { + live, ok = savedLogin{Agent: agent, User: u}, true + } + } + if ok && strings.EqualFold(live.User, user) { // the account the agent is signed in to stays so: off, it is // paused, passed over while another is on (#263) — the user's // own ChatGPT sign-in kept for Codex's remote control, a shared @@ -108,7 +115,7 @@ func pausedOwn(ls []savedLogin, agent, user string) bool { // is signed in to, paused while another of its accounts is on (#263): the // gateway passes over it, the agent staying signed in to it. func (p Provider) OwnPaused() bool { - if p.Account == nil || p.Account.token != nil || !slices.Contains(loginAgents, p.Account.Agent) { + if p.Account == nil || p.Account.token != nil && !p.Account.standIn || !slices.Contains(loginAgents, p.Account.Agent) { return false } loginsMu.Lock() @@ -158,17 +165,17 @@ func (p Provider) AlsoOn() []Provider { if p.Account != nil && (p.Account.Agent == "gemini" || p.Account.Agent == "antigravity") { return googleAlsoOn(p.Account.Agent) } - if p.Account == nil || p.Account.token != nil || (p.Account.Agent != "claude" && p.Account.Agent != "codex") { + if p.Account == nil || p.Account.token != nil && !p.Account.standIn || (p.Account.Agent != "claude" && p.Account.Agent != "codex") { return nil } var out []Provider for _, l := range Logins(p.Account.Agent) { - if l.Active || !l.On { + if l.Active || l.first || !l.On { continue } agent, user := l.Agent, l.User a := *p.Account - a.User, a.Plan = user, l.Plan + a.User, a.Plan, a.standIn = user, l.Plan, false a.token = func(ctx context.Context) (string, error) { tok, _, err := savedLoginToken(ctx, agent, user) return tok, err diff --git a/internal/provider/signedout_test.go b/internal/provider/signedout_test.go index 8bcb6611..ab3d13a7 100644 --- a/internal/provider/signedout_test.go +++ b/internal/provider/signedout_test.go @@ -45,41 +45,49 @@ func TestSavedButSignedOut(t *testing.T) { } } -// A Claude account saved in magpie but not offered says which check found -// no sign-in: no credentials, or credentials Claude Code says are signed out. -func TestSavedButSignedOutClaudeSaysWhy(t *testing.T) { +// Claude accounts saved in magpie are offered while Claude Code is signed +// out — no credentials, or credentials Claude Code says are signed out — +// and are not said to be left out; one whose saved sign-in is gone is +// listed as signed out, not dropped. +func TestSavedButSignedOutClaudeServed(t *testing.T) { home := claudeHome(t) t.Setenv("CLAUDE_CONFIG_DIR", "") - if err := writeLogins([]savedLogin{{Agent: "claude", User: "a@x.com", Auth: []byte(`{}`)}}); err != nil { + if err := writeLogins([]savedLogin{ + {Agent: "claude", User: "a@x.com", Auth: []byte(`{}`)}, + {Agent: "claude", User: "b@x.com", Auth: []byte(`{"claudeAiOauth":{"accessToken":"tok-b","refreshToken":"r-b"}}`)}, + }); err != nil { t.Fatal(err) } - why := func() string { + check := func(state string) { + t.Helper() forgetClaudeCredential() forgetClaudeStatus() for _, x := range Excluded() { - if x.Agent == "claude" && x.SignedOut { - return x.Why + if x.Agent == "claude" { + t.Fatalf("%s: excluded %+v", state, x) } } - return "" - } - if w := why(); !strings.Contains(w, "nothing at "+filepath.Join(home, ".claude", ".credentials.json")) { - t.Fatalf("no credentials: %q", w) + if p, ok := claudeAccount(); !ok || p.Account.User != "b@x.com" { + t.Fatalf("%s: account %v %+v", state, ok, p.Account) + } + ls := Logins("claude") + if len(ls) != 2 || ls[0].User != "a@x.com" || ls[0].Lapsed == "" || ls[1].Lapsed != "" || !ls[1].On { + t.Fatalf("%s: logins %+v", state, ls) + } } + check("no credentials") shellFakes(t) claudeSignIn(t, home, time.Now().Add(time.Hour)) exe := filepath.Join(home, "claude") os.WriteFile(exe, []byte("#!/bin/sh\necho '{\"loggedIn\": false}'\n"), 0o755) claudeExecutable = func() string { return exe } - if w := why(); !strings.Contains(w, "claude auth status says no one is signed in") { - t.Fatalf("signed out: %q", w) - } + check("auth status signed out") } // A saved Claude account whose Claude Code is signed out (a banned account -// logged out, say) names its accounts, so they can be removed from magpie -// while none is offered; removing one drops it from magpie's store and -// leaves Claude Code's own files as they are. +// logged out, say) is listed, so it can be removed from magpie; removing +// one drops it from magpie's store and leaves Claude Code's own files as +// they are. func TestSavedButSignedOutCanBeRemoved(t *testing.T) { home := claudeHome(t) t.Setenv("CLAUDE_CONFIG_DIR", "") @@ -89,22 +97,14 @@ func TestSavedButSignedOutCanBeRemoved(t *testing.T) { }); err != nil { t.Fatal(err) } - var claude *Exclusion - for _, x := range Excluded() { - if x.Agent == "claude" && x.SignedOut { - claude = &x - } - } - if claude == nil || len(claude.Users) != 1 || claude.Users[0] != "banned@x.com" { - t.Fatalf("claude: %+v", claude) + if ls := Logins("claude"); len(ls) != 1 || ls[0].User != "banned@x.com" { + t.Fatalf("claude: %+v", ls) } if err := ForgetLogin("claude", "banned@x.com"); err != nil { t.Fatal(err) } - for _, x := range Excluded() { - if x.Agent == "claude" { - t.Errorf("removed, yet: %+v", x) - } + if _, ok := claudeAccount(); ok { + t.Error("removed, yet served") } if ls := readLogins(); len(ls) != 1 || ls[0].Agent != "codex" { t.Errorf("logins: %+v", ls) diff --git a/internal/provider/signin.go b/internal/provider/signin.go index 038213a2..8d6e7d73 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -756,8 +756,20 @@ func addLogin(l savedLogin) (using bool, err error) { defer loginsMu.Unlock() l.Seen = time.Now().UTC().Truncate(time.Second) live, signedIn := liveLogin(l.Agent) - using = !signedIn || sameLogin(live, l) ls := readLogins() + using = !signedIn || sameLogin(live, l) + if first := claudeStandIn(ls); !signedIn && l.Agent == "claude" && first != "" { + // logged out of Claude Code with accounts in magpie: it stays so, + // as the user left it (a claude.ai sign-in beside magpie's token + // has Claude Code warn), and the account is magpie's alone. The + // one served first till now, seen last, stays on behind it. + using = false + for i := range ls { + if ls[i].Agent == "claude" && strings.EqualFold(ls[i].User, first) && !ls[i].Paused && !sameLogin(ls[i], l) { + ls[i].On = true + } + } + } if signedIn && !using { // the current account, as fresh as the agent has it live.Seen = l.Seen diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 2a9207d8..30cd87bd 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -238,7 +238,9 @@ func fetchSubscriptionUsage() []SubscriptionQuota { return true } if p, ok := claudeAccount(); ok && !hidden["claude"] { - if ls := accountsOf("claude"); len(ls) > 1 { + // signed out, Claude Code's own allowance is none: the account in + // its place is a saved one, read as the others are + if ls := accountsOf("claude"); len(ls) > 1 || p.Account.standIn { fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...) } else { fetches = append(fetches, withUser(p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User)) }))