claude: the Claude accounts saved in magpie are served while Claude Code is logged out, and none is dropped (StringKe on Discord: CC(API) -> Magpie -> CC Subscription, and as soon as I /logout in Claude Code all six accounts I manage in magpie disappear) — magpie served its saved Claude accounts only beside the one Claude Code was signed in to: claudeAccount gave no provider without Claude Code's own credentials (or with claude auth status saying no one is signed in), so the gateway had no Claude account at all and the page said the saved ones "are only offered beside the account it is signed in to". Claude Code itself tells the user to log out: with magpie's ANTHROPIC_AUTH_TOKEN beside a claude.ai sign-in it warns "ANTHROPIC_AUTH_TOKEN overriding Claude subscription login · unset it or /logout to sign it out". magpie keeps every account's sign-in in logins.json, so a logout now signs none of them out of magpie: the account seen last (the one Claude Code was signed in to until then, which magpie saw every 30s) stands in its place, run in a config directory of its own as the others on are, listed on, and it can be paused behind another as Claude Code's own could; the others on are served behind it, the Usage page reads it as a saved account, and the saved-but-signed-out notice is no longer given for Claude. An account whose saved sign-in is unreadable is listed as signed out ("its sign-in is gone; sign in again"), never dropped, and isn't picked to stand in while another has one. An account signed in in magpie while Claude Code is logged out and magpie has Claude accounts no longer signs Claude Code back in (which only brought back its warning); it is magpie's alone, on, and the one served first till then stays on behind it. Prompts, identity lines and headers are unchanged; magpie still never refreshes a Claude sign-in nor asks Anthropic anything itself. TestClaudeAccountsOutliveLogout, TestClaudeSignInWhileLoggedOut and TestSavedButSignedOutClaudeServed fail without the change; TestSavedButSignedOutCanBeRemoved now removes the account from the list. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestFetchNewLeavesListedPlugins, a bun plugin test, failed once under load and passes alone and in a rerun of the package).

This commit is contained in:
yetone committed 2026-10-01 20:13:58 +08:00
1 parent f45e9737b3
commit 41d50e15c8
8 files changed
+270 -68

No files matched your search

+12 -4
View File
@@ -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
+54
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+18 -28
View File
@@ -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
}
}
+12 -5
View File
@@ -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
+29 -29
View File
@@ -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)
+13 -1
View File
@@ -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
+3 -1
View File
@@ -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)) }))