修正 Claude 登录失效后的账号判断与提示 (#716)
* Handle Claude sign-in failures without timed retries * Judge a Claude login by the credential it has now A refusal is kept for the credential it was made on (savedLogin.Refused) and holds only while the account still has that credential. One check, claudeSignedOut, decides whether a saved Claude login can be used, after syncClaudeDir has read what Claude Code keeps in the account's directory: a credential it refreshed to there is taken, and one it emptied (as Claude Code 2.1.x does after Anthropic answers invalid_grant) is refused. Token is the only gate: a saved account's directory, or the credential Claude Code itself holds for its own account. Lapsed accounts stay candidates, so the trace shows each one passed over and the agent is told to sign in again when none is left. This fixes four paths of the first version: a moment the own sign-in could not be read left it lapsed after it came back unchanged; a credential refreshed in the account's directory after a refusal was ignored; a switch could restore a copy Claude Code had emptied; and with every account refused the agent got "none of ... is ready" (404). * Limit sign-in lapses to Claude accounts Address the review of #716: - A refused sign-in is failAuth only for a Claude account (failureOf); another vendor with the same words rests as before. - The account list's sign-in-required row is Claude's only, and the account Claude Code is signed in to offers no Remove. - Add the missing zh string for "its sign-in is gone; sign in again". - readOutput checks the error before reading Claude Code's sign-in, and the owner is parsed in one place (ownerAccount). TestSignInWordsFromAnotherVendorRest and the new browser case in claude-auth-status.test.cjs fail on the previous commit and pass.
This commit is contained in:
1 parent
03421f9f42
commit
dec9e70660
15 files changed
+854
-54
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1652,11 +1652,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++
|
||||
@@ -1681,7 +1694,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++
|
||||
@@ -1750,7 +1763,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6891,7 +6891,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"); }
|
||||
@@ -8461,7 +8462,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);
|
||||
@@ -8475,18 +8480,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) {
|
||||
@@ -8501,7 +8509,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 });
|
||||
@@ -8539,7 +8558,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);
|
||||
|
||||
@@ -1001,6 +1001,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 重新使用服务器自己的地址",
|
||||
|
||||
@@ -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 = {
|
||||
@@ -553,6 +553,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")
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
// claudeLoggedOut marks the account Claude Code held, now that it is
|
||||
// signed out, as lapsed: /logout revokes the refresh token Claude Code
|
||||
// holds (POST <token URL>/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.
|
||||
// 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"
|
||||
|
||||
// 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 <token URL>/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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user