diff --git a/internal/gateway/claude_auth_test.go b/internal/gateway/claude_auth_test.go new file mode 100644 index 000000000..ec6f626b7 --- /dev/null +++ b/internal/gateway/claude_auth_test.go @@ -0,0 +1,133 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// claudeRefuses has the fake Claude Code fail as Claude Code does on a +// sign-in Anthropic refused, on the accounts whose token is one of toks +// (all of them with none), and answer on the others. +func claudeRefuses(t *testing.T, calls string, toks ...string) { + t.Helper() + refused := `[ -z "` + strings.Join(toks, "") + `" ]` + for _, tok := range toks { + refused += ` || [ "$tok" = "` + tok + `" ]` + } + script := `#!/bin/sh +case "$1" in auth) exit 1;; esac +creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json" +while read -r line; do + tok=$(grep -o 'tok-[a-z]*' "$creds" | head -1) + echo "$tok" >> '` + calls + `' + if ` + refused + `; then + echo '{"type":"result","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}' + else + echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}' + echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"healthy account"}}}' + echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}}' + echo '{"type":"stream_event","event":{"type":"message_stop"}}' + echo '{"type":"result","is_error":false,"result":""}' + fi +done +` + binary := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))[0] + if err := os.WriteFile(filepath.Join(binary, "claude"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + +func askClaudeModel(s *Server, text string) *httptest.ResponseRecorder { + body := `{"model":"claude/claude-sonnet-5","max_tokens":100,"messages":[{"role":"user","content":"` + text + `"}]}` + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(body))) + return rec +} + +// An account Anthropic refused goes to the next one at once, with no rest +// to wait out and then fail again: its sign-in is not run again. +func TestClaudeAuthFailureFallsBackWithoutRetryingTheLogin(t *testing.T) { + claudeMadeFirst(t, false) + s := New() + t.Cleanup(s.subscription.abortAll) + calls := filepath.Join(t.TempDir(), "calls") + claudeRefuses(t, calls, "tok-a") + for _, text := range []string{"first", "second"} { + if rec := askClaudeModel(s, text); rec.Code != 200 || !strings.Contains(rec.Body.String(), "healthy account") { + t.Fatalf("%s: %d %s", text, rec.Code, rec.Body.String()) + } + } + if b, _ := os.ReadFile(calls); strings.Count(string(b), "tok-a\n") != 1 { + t.Fatalf("refused login run again: %s", b) + } + routes := s.Trace(context.Background(), 0, 0).Routes + slices.SortFunc(routes, func(a, b Route) int { return int(a.Seq - b.Seq) }) + for i, route := range routes { + if len(route.Tries) != 2 || route.Tries[0].Fail != failAuth || route.Tries[0].Rest != nil { + t.Fatalf("request %d: the refusal not told as one, or rested: %+v", i, route.Tries) + } + } + if !strings.Contains(routes[0].Tries[0].Error, "OAuth session expired") || !strings.Contains(routes[1].Tries[0].Error, "sign in again") { + t.Fatalf("errors told: %q, %q", routes[0].Tries[0].Error, routes[1].Tries[0].Error) + } +} + +// With every account refused, the agent is told to sign in again, not that +// nothing is ready. +func TestClaudeAllRefusedSaysSignInAgain(t *testing.T) { + claudeMadeFirst(t, false) + loginFile := filepath.Join(filepath.Dir(provider.Path()), "logins.json") + var logins []map[string]any + raw, _ := os.ReadFile(loginFile) + if err := json.Unmarshal(raw, &logins); err != nil { + t.Fatal(err) + } + for _, l := range logins { + l["on"] = false + } + if err := os.WriteFile(loginFile, mustJSON(logins), 0o600); err != nil { + t.Fatal(err) + } + claudeRefuses(t, filepath.Join(t.TempDir(), "calls")) + s := New() + t.Cleanup(s.subscription.abortAll) + askClaudeModel(s, "first") + if rec := askClaudeModel(s, "second"); rec.Code == 200 || !strings.Contains(rec.Body.String(), "sign in again") { + t.Fatalf("not told to sign in again: %d %s", rec.Code, rec.Body.String()) + } +} + +// Another vendor saying a Claude sign-in's words (an OpenCode plugin on +// Anthropic's OAuth: "OAuth access token has been revoked") isn't a Claude +// account Anthropic refused: it rests as any failure does, and isn't told +// as needing a sign-in that magpie would never try again (#716 review). +func TestSignInWordsFromAnotherVendorRest(t *testing.T) { + fresh(t) + revoked := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"error":{"message":"OAuth access token has been revoked"}}`) + }) + serveOn(t, "a", "ka", []string{"m"}, revoked) + serveOn(t, "b", "kb", []string{"m"}, &keyed{}) + if err := provider.SaveGroup(provider.Group{Name: "Two", Members: []string{"a/m", "b/m"}, Routing: provider.Ordered}); err != nil { + t.Fatal(err) + } + s := New() + if code, body := postAs(t, s, "", `{"model":"group/two","messages":[{"role":"user","content":"hi"}]}`); code != 200 || !strings.Contains(body, "from kb") { + t.Fatalf("%d %s", code, body) + } + if r := lastRoute(s); len(r.Tries) != 2 || r.Tries[0].Fail == failAuth || r.Tries[0].Rest == nil { + t.Fatalf("not rested as before: %+v", r.Tries) + } +} diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index 01ea7a2e3..a10306f34 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -130,6 +130,11 @@ type subscriptionRun struct { // --effort) or was told since (setEffort); "" is Claude Code's own effort string + // loginVersion is the credential the run's account had when it + // started (provider.ClaudeLoginVersion), which a refusal it reports is + // kept for + loginVersion string + // told is the conversation as the client had it in its last request // here (historyKey): tool results are the run's while the client's // conversation goes on from that one. @@ -325,6 +330,9 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, con } run := &subscriptionRun{bridge: b, token: token, model: model, cmd: cmd, tmp: tmp, schema: len(req.Schema) > 0, pending: map[string]chan mcpToolResult{}, stdin: stdin, owner: owner, effort: req.Effort} + if user, _ := ownerAccount(owner); user != "" { + run.loginVersion = provider.ClaudeLoginVersion(user) + } // A caller may abandon a turn after receiving tool_use. Do not leave the // parked Claude process and MCP request alive forever. run.timer = time.AfterFunc(30*time.Minute, run.abort) @@ -831,8 +839,8 @@ func (r *subscriptionRun) readOutput(rd io.Reader) { // a run in Claude Code's own home is on whatever account // Claude Code is signed in to by now: switched off the // owner's, what it says is another's (nil_1024) - if f := strings.Split(r.owner, "\x00"); len(f) > 1 && !(len(f) > 2 && f[2] == ownHome && provider.ClaudeCodeMovedOff(f[1])) { - provider.NoteClaudeLimits(f[1], claudeLimits(envelope.RateLimitInfo)) + if user, own := ownerAccount(r.owner); user != "" && !(own && provider.ClaudeCodeMovedOff(user)) { + provider.NoteClaudeLimits(user, claudeLimits(envelope.RateLimitInfo)) } continue } @@ -851,6 +859,14 @@ func (r *subscriptionRun) readOutput(rd io.Reader) { text += ": " + strings.Join(envelope.Errors, "; ") } } + // Anthropic refused the account's sign-in: kept on it, so + // it isn't run again on that one (provider/claude_auth.go) — + // not when Claude Code's own run went on as another account, + // which is read only for such an error + if user, own := ownerAccount(r.owner); user != "" && provider.ClaudeSignInRequired(text) && + !(own && provider.ClaudeCodeMovedOff(user)) { + provider.NoteClaudeSignInFailure(user, r.loginVersion, text) + } r.emit(Event{Kind: KError, Text: text, Status: envelope.APIErrorStatus, Code: errKind, RequestID: reqID}) r.endSegment() case waiting && len(envelope.StructuredOutput) > 0 && string(envelope.StructuredOutput) != "null": @@ -1483,6 +1499,16 @@ func (b *subscriptionBridge) removeRun(run *subscriptionRun) { // own home. const ownHome = "own" +// ownerAccount is the account a run's owner names, "" when it names none, +// and whether it is Claude Code's own sign-in (ownHome). +func ownerAccount(owner string) (user string, own bool) { + f := strings.Split(owner, "\x00") + if len(f) < 2 { + return "", false + } + return f[1], len(f) > 2 && f[2] == ownHome +} + func (s *Server) serveClaudeSubscription(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, usage *Usage) (int, string) { start := func(ctx context.Context, req *Request) (*subscriptionRun, <-chan Event, error) { ctx = p.Via(ctx) // the account's own proxy, its CLI run's too diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 976f029e4..86174ec13 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1649,11 +1649,24 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro skipped = append(skipped, c.label()+": "+call.Error) continue } + if !last && hw.failed() && failureOf(c, hw.code(), hw.errBody()) == failAuth { + // the account's sign-in is gone, refused by Anthropic: no rest + // brings it back, so none is told; it is passed over until it + // is signed in again (provider/claude_auth.go), and the next + // one is asked + if other == nil { + other = &Try{Status: call.Status, Error: call.Error} + } + try.Fail = failAuth + s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) + skipped = append(skipped, c.label()+": "+call.Error) + continue + } if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && !last && hw.failed() && spentAfter(cands[i+1:]) { // the others left are out of their allowance (Discord, waroy: a // Codex account run out, Grok busy a moment): this one is the // last that may answer, and is tried again as the last is - try.Fail, try.Again = failure(hw.code(), hw.errBody()), wait.Milliseconds() + try.Fail, try.Again = failureOf(c, hw.code(), hw.errBody()), wait.Milliseconds() s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) skipped = append(skipped, c.label()+": "+call.Error) again++ @@ -1678,7 +1691,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && hw.failed() { // nobody else is left: the same one again, after a moment - try.Fail, try.Again = failure(hw.code(), hw.errBody()), wait.Milliseconds() + try.Fail, try.Again = failureOf(c, hw.code(), hw.errBody()), wait.Milliseconds() s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) skipped = append(skipped, c.label()+": "+call.Error) again++ @@ -1747,7 +1760,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } else if hw.refused { try.Fail = failRefused } else { - try.Fail = failure(call.Status, []byte(call.Error)) + try.Fail = failureOf(c, call.Status, []byte(call.Error)) if try.Fail == failVerify && !held { // the last one left rests too, for the app to show and the // next requests to be held diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 27e8b7449..c9a8f0656 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -129,6 +129,7 @@ const ( failQuota = "quota" failRate = "rate" failOther = "other" + failAuth = "auth" // failCanceled: the agent went away before the answer came failCanceled = "canceled" // failForeign: the conversation's reasoning was sealed by another @@ -191,6 +192,17 @@ func failure(status int, body []byte) string { return failOther } +// failureOf says why c's reply failed: a Claude account's sign-in that +// Anthropic refused is told as one (provider/claude_auth.go), passed over +// till it is signed in again; another vendor saying the same words fails +// as failure says, and rests as before. +func failureOf(c candidate, status int, body []byte) string { + if status >= 400 && c.p.Account != nil && c.p.Account.Agent == "claude" && provider.ClaudeSignInRequired(string(body)) { + return failAuth + } + return failure(status, body) +} + // openRouterSharedPool says an OpenRouter free model was refused by the // provider's shared pool, rather than by OpenRouter's account-wide free tier. func openRouterSharedPool(body []byte) bool { @@ -307,7 +319,7 @@ func (s *Server) restAfter(c candidate, status int, header http.Header, body []b func (s *Server) restAfterMarked(c candidate, status int, header http.Header, body []byte, sharedPool bool) Rest { now := time.Now() d := fallbackCooldown - why := failure(status, body) + why := failureOf(c, status, body) r := Rest{Why: why, Status: status, By: "cooldown"} if why == failProxy { // the account is as good as it was; the proxy is the user's to start diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index fd8366ce6..7cf279dc2 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6311,7 +6311,8 @@ function renderEndpoints(p, src) { s.className = "res " + (x.ok ? "ok" : "bad"); s.replaceChildren(); s.append(svg(x.ok ? CHECK : "M4.5 4.5l7 7M11.5 4.5l-7 7", 10, 2)); - s.append(el("span", "", x.ok ? ledTook(x.ms) : x.status ? `${x.status} · ${x.error}` : x.error)); + const result = x.ok ? ledTook(x.ms) : x.status ? `${x.status} · ${x.error}` : x.error; + s.append(el("span", "", x.account ? t("Tested {user}: {result}", { user: x.account, result }) : result)); s.title = x.ok ? t("model {model}", { model: x.model }) : x.error; } } catch (e) { for (const s of Object.values(slots)) { s.className = "res"; s.textContent = ""; } status(e.message, "err"); } @@ -7857,7 +7858,11 @@ function renderAccounts(a, p) { const sub = subOf(a.agent); const list = el("div", "accts"); const ls = loginsInOrder(a, p); - const several = ls.filter((l) => (l.active && !l.paused) || l.on).length > 1; + // a Claude account whose sign-in Anthropic refused, or that has none, + // can't be used till it is signed in again; another subscription's lapse + // shows on its quota line only, as before + const unusable = (l) => a.agent === "claude" && !!l.lapsed; + const several = ls.filter((l) => !unusable(l) && ((l.active && !l.paused) || l.on)).length > 1; // kept signed in to one of the user's choosing (#524), the first is the // first in use in the order, which Make first sets without a sign-in const kept = keptLogin(p); @@ -7871,18 +7876,21 @@ function renderAccounts(a, p) { // the account Claude Code or Codex is signed in to can be paused while // another is on: the gateway passes over it, the agent staying signed in // to it (#263) - const pausable = (a.agent === "claude" || a.agent === "codex") && ls.some((l) => !l.active && l.on); + const pausable = (a.agent === "claude" || a.agent === "codex") && ls.some((l) => !unusable(l) && !l.active && l.on); const quota = loginUsageOf(a.agent); // the first, which magpie signed the agent out of while it was spent: // it is signed back in once it has room (#408) const back = ls.find((l) => l.returns && !l.active); for (const l of ls) { - const on = !l.paused && (l.active || l.on); + const on = !unusable(l) && !l.paused && (l.active || l.on); const row = el("div", "acc" + (on ? " in-use" : " off") + (l.user === justAdded ? " new" : "")); row.dataset.accountId = l.user; const dot = el("button", "dot tick"); if (on) dot.append(svg(CHECK, 10, 2.2)); - if (l.active && (pausable || l.paused)) { + if (unusable(l)) { + dot.disabled = true; + dot.title = t("Sign in again to use this account"); + } else if (l.active && (pausable || l.paused)) { dot.title = l.paused ? t("Resume: the gateway uses this account first again") : t("Pause: the gateway uses the other accounts, {agent} stays signed in to this one", { agent: a.agentName }); dot.onclick = () => accountAction("login/" + (l.paused ? "on" : "off"), { agent: a.agent, user: l.user }); } else if (l.active) { @@ -7897,7 +7905,18 @@ function renderAccounts(a, p) { const [amPill, amBox] = ls.length > 1 || accountModelsOf(p, l.user).length ? accountModels(p, l.user, false, l.user) : []; if (amPill) row.append(amPill); row.append(el("span", "grow")); - if (l.active && kept && l.user !== firstUser) { + if (unusable(l)) { + row.append(el("span", "using", t("Sign-in required"))); + const again = el("button", "text", t("Sign in again")); + again.onclick = () => startSignIn(a.agent); + row.append(again); + // the one Claude Code is signed in to can't be forgotten (ForgetLogin) + if (!l.active) { + const forget = el("button", "text quiet", t("Remove")); + forget.onclick = () => accountAction("login/forget", { agent: a.agent, user: l.user }, t("{user} removed", { user: l.user })); + row.append(forget); + } + } else if (l.active && kept && l.user !== firstUser) { // signed in to, kept so, and tried at its place in the order const signed = el("span", "using", l.paused ? t("Paused") : t("Signed in")); signed.title = t("{agent} is kept signed in to this account; requests through magpie go to the accounts in their order", { agent: a.agentName }); @@ -7935,7 +7954,7 @@ function renderAccounts(a, p) { row.append(forget, use); } } - row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user)); + row.append(accountQuota(l.lapsed ? { [l.user]: { error: t(l.lapsed) } } : quota, l.user)); row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220) if (amBox) row.append(amBox); list.append(row); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index cfb50e5cf..ea4961c58 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -928,6 +928,15 @@ const I18N = { "The agents given it use magpie's sign-in": "分配到它的 agent 都用 magpie 的登录", "Sign-in ran out": "登录已失效", "Sign in again": "重新登录", + "Sign-in required": "需要重新登录", + "sign-in required": "需要重新登录", + "Sign in again to use this account": "重新登录后才能使用此账号", + "Claude Code is no longer signed in; sign in again in magpie": "Claude Code 已没有有效登录,请在 magpie 中重新登录", + "its sign-in is gone; sign in again": "登录信息已不存在,请重新登录", + "Claude Code could not authenticate this account; sign in again in magpie": "Claude Code 无法认证此账号,请在 magpie 中重新登录", + "Tested {user}: {result}": "已测试 {user}:{result}", + "{who} could not authenticate. Sign in again in magpie; this login is not retried. The request went to {next}.": "{who} 认证失败。请在 magpie 中重新登录;当前登录不再重试。请求已转给 {next}。", + "{who} could not authenticate. Sign in again in magpie; this login is not retried. No other account could answer.": "{who} 认证失败。请在 magpie 中重新登录;当前登录不再重试。没有其他账号可以回答。", "Open it to sign in again": "打开它重新登录", "Signed in to {name} — the agents given it use magpie's sign-in": "已登录 {name}——分配到它的 agent 都用 magpie 的登录", "Signed out of {name} — the agents are given the server's own address again": "已退出 {name}——agent 重新使用服务器自己的地址", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index c2fa1bdb5..a3390f7fa 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -168,7 +168,7 @@ // (WorkBuddy's credits, #659): "355 / 500 credits · 71% used" const quota = (w, used, left, vars) => (w.limit > 0 ? quotaCount(w) + " · " : "") + t(quotaLeft ? left : used, { n: pct(share(w)), ...vars }); const fill = (w) => Math.max(0, Math.min(100, share(w))) + "%"; - const FAIL = { rate: "rate limited", credit: "out of credit", quota: "quota used up", other: "failed", canceled: "canceled", foreign: "another account's reasoning", floor: "reply too short", verify: "needs verification", refused: "refused (safety filter)", shape: "request not understood", proxy: "proxy not reachable", effort: "reasoning effort not in its plan", overflow: "too long for its model" }; + const FAIL = { rate: "rate limited", credit: "out of credit", quota: "quota used up", other: "failed", auth: "sign-in required", canceled: "canceled", foreign: "another account's reasoning", floor: "reply too short", verify: "needs verification", refused: "refused (safety filter)", shape: "request not understood", proxy: "proxy not reachable", effort: "reasoning effort not in its plan", overflow: "too long for its model" }; const failWord = (why) => t(FAIL[why] || "failed"); const API = { anthropic: "Anthropic", chat: "OpenAI", responses: "OpenAI Responses", gemini: "Gemini" }; const MODES = { @@ -551,6 +551,12 @@ { who: name, status: tr.status, account: tr.reset.who, agent }); if (tr.fail === "canceled") return t("{agent} canceled the request while {who} was answering: nobody failed, so nobody rests and nobody else is asked.", { who: name, agent }); + if (tr.fail === "auth") { + const next = r.tries[i + 1], nw = next && tried(r, next); + return next + ? t("{who} could not authenticate. Sign in again in magpie; this login is not retried. The request went to {next}.", { who: name, next: nw ? who(nw) : t("the next") }) + : t("{who} could not authenticate. Sign in again in magpie; this login is not retried. No other account could answer.", { who: name }); + } if (tr.fail === "foreign") return t("{who} couldn't read the reasoning another account wrote earlier in this conversation, so it is asked again without it, before any of the reply reaches {agent}.", { who: name, agent }); if (tr.fail === "floor") diff --git a/internal/gui/tests/claude-auth-status.test.cjs b/internal/gui/tests/claude-auth-status.test.cjs new file mode 100644 index 000000000..9d72f1068 --- /dev/null +++ b/internal/gui/tests/claude-auth-status.test.cjs @@ -0,0 +1,150 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const lapse = "Claude Code could not authenticate this account; sign in again in magpie"; +const error = "Failed to authenticate: OAuth session expired and could not be refreshed"; +const at = new Date().toISOString(); +const expired = { id: "claude@expired", provider: "claude", name: "Claude Code", kind: "account", who: "expired@example.com", model: "claude-opus-5-5" }; +const healthy = { ...expired, id: "claude", who: "healthy@example.com" }; +const routes = [ + { id: 103, seq: 103, time: at, agent: "codex", model: "claude/claude-opus-5-5", provider: "claude", + order: [expired, healthy], tries: [ + { id: expired.id, model: expired.model, done: true, status: 502, error, fail: "auth", ms: 300 }, + { id: healthy.id, model: healthy.model, done: true, status: 200, ms: 300 }, + ], done: true, status: 200 }, + { id: 102, seq: 102, time: at, agent: "codex", model: "claude/claude-opus-5-5", provider: "claude", + order: [expired], tries: [{ id: expired.id, model: expired.model, done: true, status: 502, error, fail: "auth", ms: 300 }], + done: true, status: 502, error }, +]; +const provider = { + id: "claude", name: "Claude Code", icon: "claudecode-color", anthropic: "https://api.example.test", + chat: "", responses: "", catalog: "", models: [{ id: "claude-opus-5-5", on: true }], + agents: [], fallback: [], headers: {}, keyList: [], proxy: "", + account: { agent: "claude", agentName: "Claude Code", user: healthy.who, plan: "max", logins: [ + { user: healthy.who, plan: "max", active: true, on: true }, + { user: expired.who, plan: "enterprise", on: true, lapsed: lapse }, + ] }, +}; +// Claude Code still signed in to the refused account: it can't be removed +const signedInRefused = { ...provider, account: { ...provider.account, user: expired.who, logins: [ + { user: expired.who, plan: "enterprise", active: true, on: true, lapsed: lapse }, + { user: healthy.who, plan: "max", on: true }, +] } }; +// another subscription's lapse keeps its row as it was +const codex = { + id: "codex", name: "Codex", icon: "codex-color", responses: "https://api.example.test", chat: "", anthropic: "", catalog: "", + models: [{ id: "gpt-6-luna", on: true }], agents: [], fallback: [], headers: {}, keyList: [], proxy: "", + account: { agent: "codex", agentName: "Codex", user: "a@example.com", plan: "plus", logins: [ + { user: "a@example.com", plan: "plus", active: true, on: true }, + { user: "b@example.com", plan: "plus", on: true, lapsed: "refresh token reused" }, + ] }, +}; + +function serve(lang, providers = [provider]) { + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs={lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [{ id: "codex", name: "Codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/providers") return json({ providers, presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/provider/test") return json({ results: [{ protocol: "anthropic", ok: true, ms: 300, model: healthy.model, account: healthy.who }], provider }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: at, seq: 103, totals: { requests: 2, errors: 1 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/login/usage") return json({}); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } + catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: Claude auth failures require sign-in instead of a timed retry`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" })).newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.setDefaultTimeout(5000); + await page.route("**/*", serve(lang)); + await page.goto("http://magpie.test/?view=routing"); + const steps = page.locator(".rt-steps"); + await steps.getByText(lang === "zh" ? /当前登录不再重试/ : /this login is not retried/).waitFor(); + let story = await steps.textContent(); + assert(story.includes(error), "the original CLI error remains visible"); + assert(story.includes(healthy.who), "the fallback account is named"); + assert(!/backoff|rests|休息/.test(story), "authentication is not a cooldown"); + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await steps.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-claude-auth-route.png`) }); + } + await page.locator(".rt-req").nth(1).click(); + await steps.getByText(lang === "zh" ? /没有其他账号可以回答/ : /No other account could answer/).waitFor(); + assert((await steps.textContent()).includes(error)); + + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Claude Code" }).first().click(); + const row = page.locator(`.editor .acc[data-account-id="${expired.who}"]`); + await row.waitFor(); + assert.equal(await row.locator(".dot").isDisabled(), true); + assert.equal(await row.locator(".dot svg").count(), 0, "an expired account is not shown in use"); + assert.equal(await row.locator(".using").textContent(), lang === "zh" ? "需要重新登录" : "Sign-in required"); + await row.getByRole("button", { name: lang === "zh" ? "重新登录" : "Sign in again", exact: true }).waitFor(); + assert.equal(await row.getByRole("button", { name: lang === "zh" ? "设为首选" : "Make first", exact: true }).count(), 0); + if (process.env.ARTIFACT_DIR) { + await page.locator(".editor .accts").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-claude-auth-accounts.png`) }); + } + await page.locator(".editor .eps").getByRole("button", { name: lang === "zh" ? "测试" : "Test", exact: true }).click(); + await page.locator(".editor .res").getByText(new RegExp(healthy.who.replaceAll(".", "\\."))).waitFor(); + assert(!(await page.locator(".editor .res").textContent()).includes(expired.who), "test success identifies only the account tested"); + assert.deepEqual(errors, []); + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-claude-auth.png`) }); + } + }); + } +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: only Claude rows need a new sign-in, and the signed-in one can't be removed`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" })).newPage(); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.setDefaultTimeout(5000); + await page.route("**/*", serve(lang, [signedInRefused, codex])); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Claude Code" }).first().click(); + const own = page.locator(`.editor .acc[data-account-id="${expired.who}"]`); + await own.waitFor(); + assert.equal(await own.locator(".dot").isDisabled(), true); + await own.getByRole("button", { name: lang === "zh" ? "重新登录" : "Sign in again", exact: true }).waitFor(); + assert.equal(await own.getByRole("button", { name: lang === "zh" ? "移除" : "Remove", exact: true }).count(), 0, "Claude Code's own account can't be forgotten"); + + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Codex" }).first().click(); + const other = page.locator('.editor .acc[data-account-id="b@example.com"]'); + await other.waitFor(); + assert.equal(await other.locator(".dot").isDisabled(), false, "a Codex account's dot still turns it off"); + assert.equal(await other.locator(".dot svg").count(), 1, "a Codex account on is shown on"); + assert.equal(await other.getByText(lang === "zh" ? "需要重新登录" : "Sign-in required", { exact: true }).count(), 0); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/claude_auth.go b/internal/provider/claude_auth.go new file mode 100644 index 000000000..a3d0dd067 --- /dev/null +++ b/internal/provider/claude_auth.go @@ -0,0 +1,126 @@ +package provider + +// A Claude sign-in Anthropic refused. Claude Code refreshes the sign-ins it +// runs on itself; when Anthropic refuses a refresh (invalid_grant), Claude +// Code empties the tokens where it keeps them (Claude Code 2.1.x), and each +// run on that sign-in fails with "OAuth session expired and could not be +// refreshed" until the account is signed in again. Waiting doesn't mend it, +// so magpie doesn't rest the account and try it again: it keeps the refusal +// on the saved login, for the credential it was made on (Refused), and +// passes over the login while it still has that credential. A credential +// changed since — Claude Code refreshed it after all, or the account was +// signed in again — is not the one refused. + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// claudeAuthLapse is why a saved Claude account whose credential Anthropic +// refused can't be used. +const claudeAuthLapse = "Claude Code could not authenticate this account; sign in again in magpie" + +// ClaudeSignInRequired says message is Claude Code's, or magpie's, word that +// a Claude sign-in is gone and has to be made again: not a failure of the +// moment, as a network error or another Claude Code holding the refresh is +// ("Failed to refresh OAuth token: another Claude Code process is +// refreshing it"). +func ClaudeSignInRequired(message string) bool { + for _, text := range []string{ + "OAuth session expired and could not be refreshed", // Claude Code: its refresh refused + "OAuth token revoked", // Claude Code: Anthropic revoked it + "OAuth access token has been revoked", // Anthropic's own 401 + claudeAuthLapse, claudeLogoutLapse, claudeGoneLapse, + } { + if strings.Contains(message, text) { + return true + } + } + return false +} + +// version tells a credential from the one it is refreshed to: a refresh +// replaces both tokens. +func (c claudeCredentials) version() string { + sum := sha256.Sum256([]byte(c.OAuth.AccessToken + "\x00" + c.OAuth.RefreshToken)) + return hex.EncodeToString(sum[:]) +} + +// cleared says c is a sign-in Claude Code emptied, as it does one whose +// refresh Anthropic refused: it reads an empty refresh token as no sign-in. +func (c claudeCredentials) cleared() bool { + o, _ := c.raw["claudeAiOauth"].(map[string]any) + rt, ok := o["refreshToken"].(string) + return ok && rt == "" && c.OAuth.AccessToken == "" +} + +func claudeLoginVersion(l savedLogin) string { + if c, ok := parseClaudeCredentials(l.Auth); ok { + return c.version() + } + return "" +} + +// ClaudeLoginVersion is the credential the saved Claude account user has +// now, which a run started on it goes by (NoteClaudeSignInFailure). +func ClaudeLoginVersion(user string) string { + loginsMu.Lock() + defer loginsMu.Unlock() + for _, l := range readLogins() { + if l.Agent == "claude" && strings.EqualFold(l.User, user) { + return claudeLoginVersion(l) + } + } + return "" +} + +// refuseClaudeLogin keeps on l that Anthropic refused its credential +// version, and says whether that changed l. +func refuseClaudeLogin(l *savedLogin, version string) bool { + if version == "" || l.Refused == version && l.Lapsed == claudeAuthLapse { + return false + } + l.Lapsed, l.Refused = claudeAuthLapse, version + return true +} + +// NoteClaudeSignInFailure keeps a run's refusal on the saved Claude account +// user, when the run started on the credential the account still has +// (version): one started on an older credential says nothing of this one. +// The run's own error stays in the request's trace. +func NoteClaudeSignInFailure(user, version, message string) { + if !ClaudeSignInRequired(message) { + return + } + loginsMu.Lock() + defer loginsMu.Unlock() + ls := readLogins() + for i := range ls { + if ls[i].Agent == "claude" && strings.EqualFold(ls[i].User, user) && claudeLoginVersion(ls[i]) == version { + if refuseClaudeLogin(&ls[i], version) { + _ = writeLogins(ls) + } + return + } + } +} + +// claudeOwnRefused is why the account Claude Code itself is signed in to, +// user, can't be used: Anthropic refused the credential Claude Code holds +// for it now; "" when it didn't. +func claudeOwnRefused(user string) string { + c, _, ok := claudeCredential() + if !ok { + return "" + } + v := c.version() + loginsMu.Lock() + defer loginsMu.Unlock() + for _, l := range readLogins() { + if l.Agent == "claude" && strings.EqualFold(l.User, user) && l.Refused == v && l.Lapsed != "" { + return l.Lapsed + } + } + return "" +} diff --git a/internal/provider/claude_auth_test.go b/internal/provider/claude_auth_test.go new file mode 100644 index 000000000..e7e6583d6 --- /dev/null +++ b/internal/provider/claude_auth_test.go @@ -0,0 +1,231 @@ +package provider + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func claudeAuthFixture(t *testing.T, lapse string) (Provider, savedLogin) { + t.Helper() + testHome := claudeHome(t) + noAnthropic(t) + t.Setenv("CLAUDE_CONFIG_DIR", "") + claudeSignIn(t, testHome, time.Now().Add(time.Hour)) + writeFile(t, filepath.Join(testHome, ".claude.json"), map[string]any{ + "oauthAccount": map[string]any{"emailAddress": "own@example.com"}, + }) + side := savedLogin{Agent: "claude", User: "side@example.com", On: true, Plan: "max", Seen: time.Now().Add(-time.Hour), Lapsed: lapse, + Auth: mustJSONRaw(t, map[string]any{"claudeAiOauth": map[string]any{ + "accessToken": "old-access", "refreshToken": "old-refresh", + "expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max", + }})} + writeFile(t, loginsPath(), []savedLogin{side}) + forgetAccountCaches() + p, ok := find(All(), "claude") + if !ok { + t.Fatal("own Claude account missing") + } + return p, side +} + +// sideToken is the saved account's turn at a request: the directory Claude +// Code runs on it in, or why it can't. +func sideToken(t *testing.T, p Provider, user string) (string, error) { + t.Helper() + for _, q := range p.AlsoOn() { + if q.Account.User == user { + dir, _, err := q.Account.Token(context.Background()) + return dir, err + } + } + t.Fatalf("%s is not among the accounts on", user) + return "", nil +} + +func claudeLapseOf(user string) string { + for _, l := range Logins("claude") { + if strings.EqualFold(l.User, user) { + return l.Lapsed + } + } + return "" +} + +func TestClaudeLegacyLapseIsNotRunOrBlamedOnLogout(t *testing.T) { + p, side := claudeAuthFixture(t, legacyClaudeLogoutLapse) + if _, err := sideToken(t, p, side.User); err == nil || !strings.Contains(err.Error(), "sign in again") { + t.Fatalf("lapsed login run: %v", err) + } + if _, err := os.Stat(claudeAccountDir(side.User)); !os.IsNotExist(err) { + t.Fatalf("a directory was made for the lapsed login: %v", err) + } + if got := claudeLapseOf(side.User); got != claudeLogoutLapse { + t.Fatalf("lapse shown as %q", got) + } +} + +// A refusal holds for the credential it was made on: Claude Code refreshing +// the account's sign-in in its directory afterwards, or a run started on +// the refused credential reporting late, leaves the account usable. +func TestClaudeRefusalHoldsForItsCredentialOnly(t *testing.T) { + p, side := claudeAuthFixture(t, "") + refused := ClaudeLoginVersion(side.User) + NoteClaudeSignInFailure(side.User, refused, "Failed to authenticate: OAuth session expired and could not be refreshed") + if _, err := sideToken(t, p, side.User); err == nil || !strings.Contains(err.Error(), "sign in again") { + t.Fatalf("refused login run: %v", err) + } + if err := SwitchLogin("claude", side.User); err == nil { + t.Fatal("Claude Code signed in to a refused login") + } + writeFile(t, filepath.Join(claudeAccountDir(side.User), ".credentials.json"), map[string]any{"claudeAiOauth": map[string]any{ + "accessToken": "new-access", "refreshToken": "new-refresh", + "expiresAt": time.Now().Add(8 * time.Hour).UnixMilli(), "subscriptionType": "max", + }}) + if _, err := sideToken(t, p, side.User); err != nil { + t.Fatalf("the sign-in Claude Code refreshed to is not used: %v", err) + } + NoteClaudeSignInFailure(side.User, refused, "Failed to authenticate: OAuth token revoked.") + if _, err := sideToken(t, p, side.User); err != nil || claudeLapseOf(side.User) != "" { + t.Fatalf("an old run's refusal holds for the new sign-in: %v", err) + } +} + +// Claude Code empties a sign-in Anthropic refused to refresh: the copy +// magpie kept of it is the refused one, never put back for Claude Code. +func TestClaudeEmptiedSignInIsNotPutBack(t *testing.T) { + p, side := claudeAuthFixture(t, "") + dir, err := sideToken(t, p, side.User) + if err != nil { + t.Fatal(err) + } + creds := filepath.Join(dir, ".credentials.json") + writeFile(t, creds, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "", "refreshToken": "", "expiresAt": 0}}) + if err := SwitchLogin("claude", side.User); err == nil { + t.Fatal("Claude Code signed in to the refused copy") + } + if _, err := sideToken(t, p, side.User); err == nil { + t.Fatal("ran on a sign-in Claude Code emptied") + } + if b, _ := os.ReadFile(creds); strings.Contains(string(b), "old-refresh") { + t.Fatal("the refused copy was put back") + } + if claudeLapseOf(side.User) != claudeAuthLapse { + t.Fatalf("lapse: %q", claudeLapseOf(side.User)) + } +} + +func TestClaudeClearedKeychainDoesNotFallBackToStaleFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a shell script stands in for the keychain") + } + p, side := claudeAuthFixture(t, "") + dir, err := sideToken(t, p, side.User) + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + cleared := filepath.Join(bin, "cleared.json") + writeFile(t, cleared, map[string]any{"claudeAiOauth": map[string]any{ + "accessToken": "", "refreshToken": "", "expiresAt": 0, "subscriptionType": "max", + }}) + if err := os.WriteFile(filepath.Join(bin, "security"), []byte("#!/bin/sh\ncat '"+cleared+"'\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + claudeKeychain = true + if _, err := sideToken(t, p, side.User); err == nil { + t.Fatal("cleared keychain item passed over for the file beside it") + } + if b, _ := os.ReadFile(filepath.Join(dir, ".credentials.json")); !strings.Contains(string(b), "old-refresh") { + t.Fatal("the file was rewritten") + } +} + +// The account Claude Code itself is signed in to: read again with the same +// sign-in after a moment it couldn't be read, it is not taken for gone; +// refused, it stays so until Claude Code holds another credential. +func TestClaudeOwnLoginLapsesOnlyForWhatHappened(t *testing.T) { + p, _ := claudeAuthFixture(t, "") + cred := filepath.Join(os.Getenv("HOME"), ".claude", ".credentials.json") + saved, err := os.ReadFile(cred) + if err != nil { + t.Fatal(err) + } + look := func() { + forgetClaudeCredential() + loginsMu.Lock() + loginsSeenAt = time.Time{} + loginsMu.Unlock() + rememberLogins(true) + } + look() + os.Remove(cred) + look() + if err := os.WriteFile(cred, saved, 0o600); err != nil { + t.Fatal(err) + } + look() + if got := claudeLapseOf("own@example.com"); got != "" { + t.Fatalf("read again unchanged, still lapsed: %q", got) + } + + NoteClaudeSignInFailure("own@example.com", ClaudeLoginVersion("own@example.com"), + "Failed to authenticate: OAuth session expired and could not be refreshed") + look() + if _, _, err := p.Account.Token(context.Background()); err == nil || claudeLapseOf("own@example.com") == "" { + t.Fatalf("refused own login still run: %v", err) + } + writeFile(t, cred, map[string]any{"claudeAiOauth": map[string]any{ + "accessToken": "signed-in-again", "refreshToken": "signed-in-again-refresh", + "expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max", + }}) + look() + if _, _, err := p.Account.Token(context.Background()); err != nil || claudeLapseOf("own@example.com") != "" { + t.Fatalf("signed in again, still refused: %v", err) + } +} + +func TestClaudeSignInRequiredDoesNotBlameTemporaryFailures(t *testing.T) { + for _, tc := range []struct { + message string + want bool + }{ + {"Failed to authenticate: OAuth session expired and could not be refreshed", true}, + {"Failed to authenticate: OAuth token revoked. Please log in again or contact your administrator.", true}, + {"OAuth access token has been revoked.", true}, + {"Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh", false}, + {"OAuth token refresh failed (HTTP 503)", false}, + {"Failed to authenticate: network connection timed out", false}, + {"You've hit your session limit", false}, + } { + if got := ClaudeSignInRequired(tc.message); got != tc.want { + t.Errorf("%q: got %v, want %v", tc.message, got, tc.want) + } + } +} + +func TestClaudeProbeReportsTheAccountAndMarksItsFailedLogin(t *testing.T) { + p, side := claudeAuthFixture(t, "") + old := claudeCLIProbe + t.Cleanup(func() { claudeCLIProbe = old }) + claudeCLIProbe = func(context.Context, string, string) error { + return errors.New("Failed to authenticate: OAuth session expired and could not be refreshed") + } + var saved Provider + for _, q := range p.AlsoOn() { + saved = q + } + result := saved.testClaude(context.Background(), "claude-sonnet-5") + if result.OK || result.Account != side.User || !strings.Contains(result.Error, "OAuth session expired") { + t.Fatalf("failed test lost the account or original error: %+v", result) + } + if _, err := sideToken(t, p, side.User); err == nil { + t.Fatal("the login that failed its test is still run") + } +} diff --git a/internal/provider/claude_dirs.go b/internal/provider/claude_dirs.go index 1cb3f162b..c9c7c4ddc 100644 --- a/internal/provider/claude_dirs.go +++ b/internal/provider/claude_dirs.go @@ -49,8 +49,10 @@ func readClaudeDir(dir string) (claudeCredentials, bool) { out, err := proc.Command("security", "find-generic-password", "-s", claudeDirService(dir), "-a", claudeKeychainAccount(), "-w").Output() if err == nil { b, _ := keychainText(bytes.TrimSpace(out)) - if c, ok := parseClaudeCredentials(b); ok { - return c, true + if c, ok := parseClaudeCredentials(b); c.raw != nil { + // the item there is the sign-in, emptied or not: Claude + // Code reads no file beside it + return c, ok } } } @@ -79,7 +81,8 @@ func forgetClaudeDir(user string) { // claudeSavedDir is the config directory to run Claude Code in for the // saved account user: its sign-in is put there from logins.json when there -// is none yet, and what Claude Code has made of it since is read back. +// is none yet, and what Claude Code has made of it since is read back. An +// account that can't be used (claudeSignedOut) gets none. func claudeSavedDir(user string) (string, error) { savedTokenMu.Lock() defer savedTokenMu.Unlock() @@ -96,16 +99,22 @@ func claudeSavedDir(user string) (string, error) { return "", fmt.Errorf("no saved claude account %q", user) } dir := claudeAccountDir(user) - if c, ok := readClaudeDir(dir); ok { - if changed, err := takeClaudeDir(&ls[i], c); err != nil || !changed { - return dir, err + has, changed, err := syncClaudeDir(&ls[i]) + if err != nil { + return "", err + } + if changed { + if err := writeLogins(ls); err != nil { + return "", err } - return dir, writeLogins(ls) } - c, ok := parseClaudeCredentials(ls[i].Auth) - if !ok { - return "", errors.New("the saved Claude sign-in of " + user + " is unreadable") + if why := claudeSignedOut(ls[i]); why != "" { + return "", errors.New(why) + } + if has { + return dir, nil } + c, _ := parseClaudeCredentials(ls[i].Auth) b, err := c.marshal() if err != nil { return "", err @@ -128,6 +137,23 @@ func claudeSavedDir(user string) (string, error) { return dir, nil } +// syncClaudeDir brings the saved Claude login l up to what Claude Code +// keeps in the account's config directory: the sign-in it refreshed to +// there, or, when it emptied that sign-in, Anthropic's refusal of the one +// l has. has says the directory holds a sign-in to run on; changed, that l +// changed. +func syncClaudeDir(l *savedLogin) (has, changed bool, err error) { + c, ok := readClaudeDir(claudeAccountDir(l.User)) + switch { + case ok: + changed, err = takeClaudeDir(l, c) + return true, changed, err + case c.cleared(): + return false, refuseClaudeLogin(l, claudeLoginVersion(*l)), nil + } + return false, false, nil +} + // takeClaudeDir puts the sign-in Claude Code keeps in an account's config // directory into its saved login, and says whether that changed it. func takeClaudeDir(l *savedLogin, c claudeCredentials) (bool, error) { @@ -144,7 +170,7 @@ func takeClaudeDir(l *savedLogin, c claudeCredentials) (bool, error) { l.Plan = c.OAuth.SubscriptionType } l.Renewed = time.Now().UTC().Truncate(time.Second) - l.Lapsed = "" + l.Lapsed, l.Refused = "", "" return true, nil } @@ -154,20 +180,20 @@ func takeClaudeDir(l *savedLogin, c claudeCredentials) (bool, error) { // magpie keeps the other accounts' sign-ins itself, each its own, 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. Never the one -// Claude Code was signed in to, whose sign-in its /logout revoked +// Claude Code was signed in to, whose sign-in went with it // (claudeLoggedOut, or still Held before rememberLogins next looks: it is -// asked only while Claude Code is signed out); of the others, one with a -// saved sign-in, on before off, then the one seen last; one whose saved -// sign-in is gone only when no other has one; "" when there is none. +// asked only while Claude Code is signed out); of the others, one that can +// be used, on before off, then the one seen last; one that can't — its +// sign-in gone, or refused — only when no other can; "" when there is none. func claudeStandIn(ls []savedLogin) string { user, best := "", -1 var seen time.Time for _, l := range ls { - if l.Agent != "claude" || l.Held || l.Lapsed == claudeLogoutLapse { + if l.Agent != "claude" || l.Held || l.Lapsed != "" && l.Refused == "" { continue } rank := 0 - if _, ok := parseClaudeCredentials(l.Auth); ok { + if claudeSignedOut(l) == "" { rank = 2 if l.On { rank++ @@ -181,15 +207,26 @@ func claudeStandIn(ls []savedLogin) string { } // claudeLogoutLapse is why the account Claude Code was signed in to can't -// be used once Claude Code logged out. -const claudeLogoutLapse = "Claude Code's /logout signed it out (it revokes the sign-in it holds); sign in again" +// be used once Claude Code let go of it: logged out, which revokes the +// sign-in it held, or emptied that sign-in once Anthropic refused it. +const claudeLogoutLapse = "Claude Code is no longer signed in; sign in again in magpie" + +// legacyClaudeLogoutLapse is claudeLogoutLapse as magpie wrote it before, +// which took every such lapse for a /logout. +const legacyClaudeLogoutLapse = "Claude Code's /logout signed it out (it revokes the sign-in it holds); sign in again" -// claudeLoggedOut marks the account Claude Code held, now that it is -// signed out, as lapsed: /logout revokes the refresh token Claude Code -// holds (POST /revoke, Claude Code 2.1.x's performLogout), and -// magpie's copy of that account is the same sign-in, so it is gone too. -// The accounts magpie keeps in config directories of their own are -// sign-ins of their own and stay. It says whether ls changed. +// claudeGoneLapse is why a saved Claude account with no sign-in kept can't +// be used. +const claudeGoneLapse = "its sign-in is gone; sign in again" + +// claudeLoggedOut marks the account Claude Code held, now that it holds no +// sign-in, as lapsed: magpie's copy of that account is the same sign-in, +// gone too — revoked by a /logout (POST /revoke, Claude Code +// 2.1.x's performLogout), or refused by Anthropic, as the sign-in Claude +// Code emptied was. Which it was can't be told from here. Signed in to it +// again, Claude Code holds it once more (rememberLogins). The accounts +// magpie keeps in config directories of their own are sign-ins of their own +// and stay. It says whether ls changed. func claudeLoggedOut(ls []savedLogin) bool { changed := false for i := range ls { @@ -197,20 +234,28 @@ func claudeLoggedOut(ls []savedLogin) bool { continue } ls[i].Held, changed = false, true - if _, ok := parseClaudeCredentials(ls[i].Auth); ok && ls[i].Lapsed == "" { - ls[i].Lapsed = claudeLogoutLapse + if claudeSignedOut(ls[i]) == "" { + ls[i].Lapsed, ls[i].Refused = claudeLogoutLapse, "" } } return changed } -// 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. +// claudeSignedOut is why a saved Claude account can't be used, "" when it +// can: its saved sign-in is gone, Claude Code let go of it +// (claudeLoggedOut), or Anthropic refused the credential it has now +// (claude_auth.go) — a refusal of an earlier one says nothing of it. func claudeSignedOut(l savedLogin) string { + if l.Lapsed != "" && (l.Refused == "" || l.Refused == claudeLoginVersion(l)) { + if l.Lapsed == legacyClaudeLogoutLapse { + return claudeLogoutLapse + } + return l.Lapsed + } if _, ok := parseClaudeCredentials(l.Auth); ok { return "" } - return "its sign-in is gone; sign in again" + return claudeGoneLapse } // claudeStandInAccount is the Claude Code provider while Claude Code is diff --git a/internal/provider/claude_probe.go b/internal/provider/claude_probe.go index 243fdf210..ccd1bc471 100644 --- a/internal/provider/claude_probe.go +++ b/internal/provider/claude_probe.go @@ -28,7 +28,7 @@ const claudeWait = time.Minute // testClaude is a Claude account's test of model, run by Claude Code. func (p Provider) testClaude(ctx context.Context, model string) Result { - r := Result{Protocol: Anthropic, Model: model} + r := Result{Protocol: Anthropic, Model: model, Account: p.Account.User} if model == "" { r.Error = "no model to try: expose one, or refresh the model list" return r @@ -45,9 +45,11 @@ func (p Provider) testClaude(ctx context.Context, model string) Result { return r } start := time.Now() + version := ClaudeLoginVersion(p.Account.User) err = claudeCLIProbe(ctx, dir, model) r.Millis = time.Since(start).Milliseconds() if err != nil { + NoteClaudeSignInFailure(p.Account.User, version, err.Error()) r.Error = err.Error() return r } diff --git a/internal/provider/logins.go b/internal/provider/logins.go index defbfe60d..eda57c729 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -83,6 +83,10 @@ type savedLogin struct { // Lapsed why the vendor last refused to (logins_on.go, keepalive.go). Renewed time.Time `json:"renewed,omitzero"` Lapsed string `json:"lapsed,omitempty"` + // Refused is the Claude credential Anthropic refused (its version, in + // claude_auth.go): Lapsed holds while the account has that one, and + // says nothing of the one it is refreshed or signed in to next. + Refused string `json:"refused,omitempty"` // Hidden is the agent's own sign-in removed in magpie, with the mark // of the sign-in it was (side_logins.go): it is listed and tried no // more until the agent signs in anew. The agent's files stay as they are. @@ -179,6 +183,11 @@ func writePrivate(path string, b []byte) error { func upsertLogin(ls []savedLogin, l savedLogin) []savedLogin { for i := range ls { if sameLogin(ls[i], l) { + // a refused Claude credential stays refused while it is the + // one the account has + if l.Agent == "claude" && l.Lapsed == "" && ls[i].Refused != "" && ls[i].Refused == claudeLoginVersion(l) { + l.Lapsed, l.Refused = ls[i].Lapsed, ls[i].Refused + } l.On = l.On || ls[i].On l.Paused = l.Paused || ls[i].Paused l.Order = ls[i].Order @@ -595,10 +604,12 @@ func Logins(agent string) []Login { 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 l.Agent == "claude" { + lg.Lapsed = claudeSignedOut(l) + } if !using { - lg.Lapsed = l.Lapsed - if lg.Lapsed == "" && l.Agent == "claude" { - lg.Lapsed = claudeSignedOut(l) + if l.Agent != "claude" { + lg.Lapsed = l.Lapsed } lg.Returns = l.On && strings.EqualFold(back[l.Agent], l.User) } @@ -767,11 +778,17 @@ func switchSavedLogin(agent, user string) (from string, _ error) { } if agent == "claude" { // as Claude Code keeps it, if it has run on the account beside the - // one it is signed in to - if c, ok := readClaudeDir(claudeAccountDir(target.User)); ok { - if _, err := takeClaudeDir(target, c); err != nil { - return "", err + // one it is signed in to; never one that can't be used, which + // Claude Code would only be refused on + _, changed, err := syncClaudeDir(target) + if err != nil { + return "", err + } + if why := claudeSignedOut(*target); why != "" { + if changed { + _ = writeLogins(ls) } + return "", fmt.Errorf("%s: %s", target.User, why) } } want := *target diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index befb75feb..efe86356f 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -197,9 +197,19 @@ func (p Provider) AlsoOn() []Provider { // Token is the access token of a saved account in use beside the agent's // own — for a Claude account, the config directory Claude Code runs on it -// in; ok is false for the agent's own, which the agent signs itself. +// in; ok is false for the agent's own, which the agent signs itself. A +// Claude account that can't be used, Anthropic having refused its sign-in, +// is an error, whichever it is (claude_auth.go). func (a *Account) Token(ctx context.Context) (tok string, ok bool, err error) { - if a == nil || a.token == nil { + if a == nil { + return "", false, nil + } + if a.token == nil { + if a.Agent == "claude" { + if why := claudeOwnRefused(a.User); why != "" { + return "", false, errors.New(why) + } + } return "", false, nil } tok, err = a.token(ctx) diff --git a/internal/provider/test.go b/internal/provider/test.go index 676687dcc..51c87373f 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -21,6 +21,7 @@ import ( // Result is what a probe of one endpoint came back with. type Result struct { + Account string `json:"account,omitempty"` Protocol Protocol `json:"protocol"` OK bool `json:"ok"` Status int `json:"status,omitempty"`