diff --git a/accounts_cli.go b/accounts_cli.go index 4a485d0f..ef29c8c5 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -302,8 +302,12 @@ func addAccount(agentID string) error { openInBrowser(st.URL) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - if st.PasteCallback { - fmt.Println("If the page the browser ends on won't load (magpie on a server or in Docker), paste its whole address here and press Enter:") + if st.PasteCallback || st.PasteCode { + if st.PasteCode { + fmt.Println("Paste the code the page shows here and press Enter:") + } else { + fmt.Println("If the page the browser ends on won't load (magpie on a server or in Docker), paste its whole address here and press Enter:") + } id := st.ID go func() { lines := bufio.NewScanner(os.Stdin) diff --git a/go.mod b/go.mod index 3b660bb8..8f4e15ef 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/wailsapp/wails/v3 v3.0.0-beta.24 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 + golang.org/x/text v0.39.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 ) @@ -50,7 +51,6 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/text v0.39.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index 4be4b34d..4ddf0da6 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -244,7 +244,7 @@ func callbackBaseURL() string { return "http://" + host + ":" + port } -func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, oauth, owner string) (*subscriptionRun, <-chan Event, error) { +func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, configDir, owner string) (*subscriptionRun, <-chan Event, error) { binary, err := claudeBinary() if err != nil { return nil, nil, err @@ -277,10 +277,7 @@ func (b *subscriptionBridge) start(ctx context.Context, req *Request, model, oau cmd := proc.CommandContext(context.Background(), binary, args...) cmd.Dir = tmp cmd.Env = netproxy.EnvWith(claudeProxy(ctx), cleanClaudeEnv(os.Environ())) - if oauth != "" { - // a saved account in use beside the one Claude Code is signed in to - cmd.Env = append(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN="+oauth) - } + cmd.Env = inClaudeDir(cmd.Env, configDir) stdin, err := cmd.StdinPipe() if err != nil { cleanup() @@ -588,6 +585,26 @@ func claudeCLIArgs(model, mcpConfig, effort string, web bool) []string { return args } +// inClaudeDir is env for a Claude Code run on a saved account in use beside +// the one it is signed in to: in the account's config directory +// (provider's claude_dirs.go), where Claude Code keeps the sign-in fresh +// itself. configDir "" is the account Claude Code is signed in to, and env +// is kept as it is. +func inClaudeDir(env []string, configDir string) []string { + if configDir == "" { + return env + } + out := make([]string, 0, len(env)+1) + for _, e := range env { + switch k, _, _ := strings.Cut(e, "="); k { + case "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR": + continue + } + out = append(out, e) + } + return append(out, "CLAUDE_CONFIG_DIR="+configDir) +} + func cleanClaudeEnv(env []string) []string { blocked := map[string]bool{ "ANTHROPIC_BASE_URL": true, "ANTHROPIC_API_KEY": true, "ANTHROPIC_AUTH_TOKEN": true, @@ -1321,11 +1338,11 @@ func (s *Server) serveClaudeSubscription(w http.ResponseWriter, r *http.Request, // effort can think past that (#250) req.Effort = "low" } - token, _, err := p.Account.Token(ctx) + dir, _, err := p.Account.Token(ctx) if err != nil { return nil, nil, err } - return s.subscription.start(ctx, req, model, token, owner) + return s.subscription.start(ctx, req, model, dir, owner) } return s.serveSubscription(w, r, from, "Claude Code", model, body, usage, start) } diff --git a/internal/gateway/claude_warmup.go b/internal/gateway/claude_warmup.go index 89bb7ea6..8d86beae 100644 --- a/internal/gateway/claude_warmup.go +++ b/internal/gateway/claude_warmup.go @@ -26,14 +26,16 @@ func claudeProxy(ctx context.Context) string { // warmClaude sends a Claude account one "hi" through Claude Code, as the // bridge runs it (claudeCLIArgs): none of its tools, settings or MCP // servers, nothing kept on disk, at Haiku, the least an account's window -// is started by. oauth is a saved account's sign-in, "" for the one -// Claude Code is signed in to. -func warmClaude(ctx context.Context, oauth string) error { return askClaude(ctx, oauth, "haiku") } +// is started by. configDir is a saved account's config directory, "" for +// the one Claude Code is signed in to. +func warmClaude(ctx context.Context, configDir string) error { + return askClaude(ctx, configDir, "haiku") +} // askClaude has Claude Code answer one "hi" at model, as warmClaude does; // it is how a Claude account's model test runs (provider.ProbeClaudeVia), // so the test asks Anthropic as Claude Code does, through Claude Code. -func askClaude(ctx context.Context, oauth, model string) error { +func askClaude(ctx context.Context, configDir, model string) error { binary, err := claudeBinary() if err != nil { return err @@ -47,9 +49,7 @@ func askClaude(ctx context.Context, oauth, model string) error { cmd.Dir = tmp cmd.Stdin = strings.NewReader("hi") cmd.Env = netproxy.EnvWith(claudeProxy(ctx), cleanClaudeEnv(os.Environ())) - if oauth != "" { - cmd.Env = append(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN="+oauth) - } + cmd.Env = inClaudeDir(cmd.Env, configDir) var out, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &stderr runErr := cmd.Run() diff --git a/internal/gateway/claude_warmup_test.go b/internal/gateway/claude_warmup_test.go index 1857da52..6517e6c8 100644 --- a/internal/gateway/claude_warmup_test.go +++ b/internal/gateway/claude_warmup_test.go @@ -19,7 +19,7 @@ func fakeWarmClaude(t *testing.T, out string, code int) string { log := filepath.Join(dir, "log") script := "#!/bin/sh\n" + "{ printf 'args:'; for a in \"$@\"; do printf '[%s]' \"$a\"; done; echo; " + - "echo \"token:$CLAUDE_CODE_OAUTH_TOKEN\"; echo \"base:$ANTHROPIC_BASE_URL\"; echo \"pwd:$(pwd)\"; printf 'stdin:'; cat; echo; } > " + log + "\n" + + "echo \"dir:$CLAUDE_CONFIG_DIR\"; echo \"base:$ANTHROPIC_BASE_URL\"; echo \"pwd:$(pwd)\"; printf 'stdin:'; cat; echo; } > " + log + "\n" + "echo '" + out + "'\nexit " + string(rune('0'+code)) + "\n" if err := os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0o755); err != nil { t.Fatal(err) @@ -31,13 +31,14 @@ func fakeWarmClaude(t *testing.T, out string, code int) string { func TestWarmClaudeAsksClaudeCodeOnce(t *testing.T) { log := fakeWarmClaude(t, `{"type":"result","is_error":false,"result":"Hi!"}`, 0) - if err := warmClaude(context.Background(), "saved-token"); err != nil { + t.Setenv("CLAUDE_CONFIG_DIR", "/the/users/own") + if err := warmClaude(context.Background(), "/saved/account"); err != nil { t.Fatal(err) } b, _ := os.ReadFile(log) got := string(b) for _, want := range []string{"[-p]", "[--model][haiku]", "[--tools][]", "[--setting-sources][]", "[--no-session-persistence]", - "token:saved-token", "base:\n", "stdin:hi", "magpie-claude-"} { + "dir:/saved/account\n", "base:\n", "stdin:hi", "magpie-claude-"} { if !strings.Contains(got, want) { t.Errorf("run lacks %q:\n%s", want, got) } @@ -50,12 +51,13 @@ func TestWarmClaudeAsksClaudeCodeOnce(t *testing.T) { } } } - // the account Claude Code is signed in to is its own + // the account Claude Code is signed in to is its own, in its own + // config directory if err := warmClaude(context.Background(), ""); err != nil { t.Fatal(err) } - if b, _ := os.ReadFile(log); !strings.Contains(string(b), "token:\n") { - t.Errorf("a token given for Claude Code's own account:\n%s", b) + if b, _ := os.ReadFile(log); !strings.Contains(string(b), "dir:/the/users/own\n") { + t.Errorf("another config directory given for Claude Code's own account:\n%s", b) } } diff --git a/internal/gateway/fallback_test.go b/internal/gateway/fallback_test.go index a80322e0..095e46e3 100644 --- a/internal/gateway/fallback_test.go +++ b/internal/gateway/fallback_test.go @@ -253,9 +253,10 @@ func TestClaudeAccountsTakeOver(t *testing.T) { // out of quota, the CLI says so in a result and waits on its next input script := `#!/bin/sh if [ "$1" = auth ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}'; exit 0; fi -echo "${CLAUDE_CODE_OAUTH_TOKEN:-own}" >> ` + log + ` +TOK=; [ -n "$CLAUDE_CONFIG_DIR" ] && TOK=$(sed -n 's/.*"accessToken": *"\([^"]*\)".*/\1/p' "$CLAUDE_CONFIG_DIR/.credentials.json") +echo "${TOK:-own}" >> ` + log + ` while read -r line; do - if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] || [ "$CLAUDE_CODE_OAUTH_TOKEN" = tok-me ]; then + if [ -z "$TOK" ] || [ "$TOK" = tok-me ]; then echo '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1790700000}}' echo '{"type":"assistant","message":{"id":"x","model":"","role":"assistant","content":[{"type":"text","text":"You'"'"'ve hit your limit · resets 3am"}]},"error":"rate_limit"}' echo '{"type":"result","subtype":"success","is_error":true,"result":"You'"'"'ve hit your limit · resets 3am"}' diff --git a/internal/gateway/refusal_accounts_test.go b/internal/gateway/refusal_accounts_test.go index 952833a7..79eb2909 100644 --- a/internal/gateway/refusal_accounts_test.go +++ b/internal/gateway/refusal_accounts_test.go @@ -242,9 +242,10 @@ func TestClaudeSubscriptionRefusalMovesToNextAccount(t *testing.T) { log := filepath.Join(dir, "log") script := `#!/bin/sh if [ "$1" = auth ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}'; exit 0; fi -echo "${CLAUDE_CODE_OAUTH_TOKEN:-own}" >> ` + log + ` +TOK=; [ -n "$CLAUDE_CONFIG_DIR" ] && TOK=$(sed -n 's/.*"accessToken": *"\([^"]*\)".*/\1/p' "$CLAUDE_CONFIG_DIR/.credentials.json") +echo "${TOK:-own}" >> ` + log + ` while read -r line; do - if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] || [ "$CLAUDE_CODE_OAUTH_TOKEN" = tok-me ]; then + if [ -z "$TOK" ] || [ "$TOK" = tok-me ]; then 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_start","index":0,"content_block":{"type":"text","text":""}}}' echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}}' diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1b9a0d4a..cb258108 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -5662,6 +5662,7 @@ function renderSigning(sub) { tt.append(el("span", "n", t("Finish signing in to {name} in your browser", { name: sub.name })), el("span", "s", signing.state === "starting" ? t("Starting the sign-in…") : sub.plugin ? (signing.instructions || (signing.pasteCode ? t("magpie opened the sign-in page. Paste the code it shows below.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done."))) + : signing.pasteCode ? t("magpie opened the sign-in page. Paste the code it shows below.") : signing.code && sub.agent === "factory" ? t("magpie opened Factory's sign-in page. Check it shows this code and confirm it; the account shows up here as soon as you're done.") : signing.code ? t("magpie opened GitHub's device page. Enter this code there; the account shows up here as soon as you're done.") : t("magpie opened the sign-in page. The account shows up here as soon as you're done."))); if (signing.code) { diff --git a/internal/provider/account.go b/internal/provider/account.go index 8065d8a9..3abe5bf2 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -230,22 +230,15 @@ func Excluded() []Exclusion { return append(out, savedButSignedOut()...) } -const claudeClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - -// claudeBase is Anthropic's API root. A var so tests can point it elsewhere. +// claudeBase is Anthropic's API root, which Claude Code asks; magpie never +// does with a Claude sign-in. var claudeBase = "https://api.anthropic.com" -// claudeTokenURL is where a Claude subscription's OAuth token is refreshed; -// a var so tests can point it elsewhere. -var claudeTokenURL = "https://platform.claude.com/v1/oauth/token" - // claudeKeychain reads credentials from the macOS Keychain; a var so tests // never touch the machine's own login. var claudeKeychain = runtime.GOOS == "darwin" var ( - claudeMu sync.Mutex // serializes refresh; a rotated token is written back once - claudeStatusMu sync.Mutex claudeStatusAt time.Time claudeStatusUser string @@ -323,14 +316,6 @@ func (c claudeCredentials) marshal() ([]byte, error) { return json.MarshalIndent(raw, "", " ") } -// claudeExpiry reads expiresAt whether a version stored seconds or ms. -func claudeExpiry(v int64) time.Time { - if v < 1e12 { - return time.Unix(v, 0) - } - return time.UnixMilli(v) -} - type claudeCredentialLocation struct { path string account string @@ -602,118 +587,16 @@ func claudeAccount() (Provider, bool) { // one that would go straight to the API with its sign-in is refused acct.sign = func(context.Context, *http.Request, []byte) error { return errClaudeViaCLI } acct.models = func() []catalog.Model { return catalog.Provider("anthropic") } - acct.fetch = func(ctx context.Context) ([]catalog.Model, error) { - ms, err := claudeModels(ctx) - if err != nil { - return nil, err - } + // Claude's models are the ones magpie knows: listing them would ask + // Anthropic with the account's sign-in, which magpie never does + acct.fetch = func(context.Context) ([]catalog.Model, error) { + ms := catalog.Provider("anthropic") return ms, catalog.SaveLive("claude", claudeBase, ms) } return Provider{ID: "claude", Name: "Claude Code", Icon: "claudecode-color", Anthropic: claudeBase, Catalog: "anthropic", Website: "https://claude.ai", Account: acct}, true } -// claudeModels asks Anthropic's Models API, authenticated with the account's -// own OAuth token, so the picker follows the vendor instead of a snapshot. -// Nothing here is compiled in: a new model shows up the moment Anthropic -// lists it (which is what the refresh button runs). -func claudeModels(ctx context.Context) ([]catalog.Model, error) { - tok, err := claudeToken(ctx) - if err != nil { - return nil, err - } - var out []catalog.Model - after := "" - for { - u := claudeBase + "/v1/models?limit=1000" - if after != "" { - u += "&after_id=" + url.QueryEscape(after) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+tok) - req.Header.Set("anthropic-version", "2023-06-01") - req.Header.Set("anthropic-beta", "oauth-2025-04-20") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "magpie") - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, errors.New("Claude model list: " + err.Error()) - } - b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) - res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Claude model list: %s", res.Status) - } - var page struct { - Data []struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - } `json:"data"` - HasMore bool `json:"has_more"` - LastID string `json:"last_id"` - } - if json.Unmarshal(b, &page) != nil { - return nil, errors.New("Claude model list: unexpected payload") - } - for _, m := range page.Data { - if m.ID == "" { - continue - } - name := m.DisplayName - if name == "" { - name = m.ID - } - out = append(out, catalog.Model{ID: m.ID, Name: name, Provider: "anthropic"}) - } - if !page.HasMore || page.LastID == "" || page.LastID == after { - break - } - after = page.LastID - } - if len(out) == 0 { - return nil, errors.New("Claude listed no models") - } - return out, nil -} - -// claudeFresh reports whether a token is usable for the next few minutes. -func claudeFresh(c claudeCredentials) bool { - return c.OAuth.AccessToken != "" && - (c.OAuth.ExpiresAt == 0 || time.Until(claudeExpiry(c.OAuth.ExpiresAt)) > 5*time.Minute) -} - -// claudeToken returns a usable access token, refreshing it through Anthropic -// when it is about to expire. A refresh rotates the refresh token, so the new -// pair goes back where Claude Code will look for it. -func claudeToken(ctx context.Context) (string, error) { - claudeMu.Lock() - defer claudeMu.Unlock() - c, loc, ok := claudeCredential() - if !ok { - return "", errors.New("Claude Code is signed out; run claude auth login") - } - if !claudeFresh(c) { - // Claude Code itself may have rotated the token since the cache was - // filled; a stale refresh token would be rejected, so look again. - if latest, latestLoc, ok := readClaudeCredential(); ok { - c, loc = latest, latestLoc - } - } - if claudeFresh(c) { - return c.OAuth.AccessToken, nil - } - if err := claudeRefresh(ctx, &c); err != nil { - return "", err - } - if err := saveClaudeCredential(loc, c); err != nil { - return "", err - } - return c.OAuth.AccessToken, nil -} - // refreshRefused is a refresh the vendor answered and turned down: the // sign-in is gone, where a refresh that got no answer may yet go through. type refreshRefused string @@ -730,42 +613,6 @@ func refreshFailed(status int, agent, msg string) error { return fmt.Errorf("%s token refresh failed (HTTP %d)", agent, status) } -// claudeRefresh trades a sign-in's refresh token for a new pair. -func claudeRefresh(ctx context.Context, c *claudeCredentials) error { - if c.OAuth.RefreshToken == "" { - return refreshRefused("Claude Code OAuth token expired; run claude auth login") - } - body, _ := json.Marshal(map[string]string{"grant_type": "refresh_token", "refresh_token": c.OAuth.RefreshToken, - "client_id": claudeClientID}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, claudeTokenURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - res, err := http.DefaultClient.Do(req) - if err != nil { - return errors.New("Claude Code token refresh: " + err.Error()) - } - defer res.Body.Close() - b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) - var fresh struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - } - if res.StatusCode != http.StatusOK || json.Unmarshal(b, &fresh) != nil || fresh.AccessToken == "" { - return refreshFailed(res.StatusCode, "Claude Code", "Claude Code is signed out (token refresh failed); run claude auth login") - } - c.OAuth.AccessToken = fresh.AccessToken - if fresh.RefreshToken != "" { - c.OAuth.RefreshToken = fresh.RefreshToken - } - if fresh.ExpiresIn > 0 { - c.OAuth.ExpiresAt = time.Now().Add(time.Duration(fresh.ExpiresIn) * time.Second).UnixMilli() - } - return nil -} - // Accounts lists the signed-in agents as providers. func Accounts() []Provider { rememberLogins(false) diff --git a/internal/provider/account_test.go b/internal/provider/account_test.go index 0530ddda..5401ec6e 100644 --- a/internal/provider/account_test.go +++ b/internal/provider/account_test.go @@ -15,6 +15,8 @@ import ( "sync" "testing" "time" + + "github.com/yetone/magpie/internal/catalog" ) // fakeJWT is a token whose payload is the given claims; nobody checks the @@ -67,7 +69,7 @@ func signIn(t *testing.T) string { // logins and forgets anything a previous test cached. func isolate(t *testing.T) { t.Helper() - oldKeychain, oldURL, oldBase, oldExe := claudeKeychain, claudeTokenURL, claudeBase, claudeExecutable + oldKeychain, oldBase, oldExe := claudeKeychain, claudeBase, claudeExecutable oldCursor, oldDevin := cursorKeychain, DevinExecutable claudeKeychain, cursorKeychain = false, false claudeExecutable = func() string { return "" } @@ -77,7 +79,7 @@ func isolate(t *testing.T) { forgetClaudeStatus() forgetDevinStatus() t.Cleanup(func() { - claudeKeychain, claudeTokenURL, claudeBase, claudeExecutable = oldKeychain, oldURL, oldBase, oldExe + claudeKeychain, claudeBase, claudeExecutable = oldKeychain, oldBase, oldExe cursorKeychain, DevinExecutable = oldCursor, oldDevin forgetClaudeCredential() forgetClaudeStatus() @@ -234,19 +236,23 @@ func TestClaudeAccountIsProvider(t *testing.T) { home := claudeHome(t) creds := claudeSignIn(t, home, time.Now().Add(time.Hour)) - // Anthropic lists its models live; magpie trusts that answer, not the binary. - models := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/models" || r.Header.Get("Authorization") != "Bearer sk-ant-oat01-old" { - w.WriteHeader(401) - return - } - json.NewEncoder(w).Encode(map[string]any{"has_more": false, "data": []any{ - map[string]any{"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5"}, - map[string]any{"id": "claude-opus-5-5", "display_name": "Claude Opus 5.5"}, - }}) + // Claude's models are the catalog's: magpie asks Anthropic nothing with + // the account's sign-in, not even its model list + asked := false + anthropic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked = true + w.WriteHeader(500) })) - defer models.Close() - claudeBase = models.URL + defer anthropic.Close() + claudeBase = anthropic.URL + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + if err := os.WriteFile(catalog.CachePath(), []byte(`{"anthropic": {"models": { + "claude-sonnet-5": {"id":"claude-sonnet-5","name":"Claude Sonnet 5","modalities":{"input":["text"],"output":["text"]}}, + "claude-opus-5-5": {"id":"claude-opus-5-5","name":"Claude Opus 5.5","modalities":{"input":["text"],"output":["text"]}}}}}`), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) p, ok := find(All(), "claude") if !ok || p.Account == nil || p.Account.User != "Claude Max" || p.Account.Plan != "max" || !p.Ready() { @@ -256,13 +262,11 @@ func TestClaudeAccountIsProvider(t *testing.T) { t.Fatalf("endpoints: %+v", p) } - // nothing is compiled in: before a fetch there is nothing to show, and - // afterwards exactly what the vendor listed, "Opus 5.5" included - if got := len(p.Available()); got != 0 { + if got := len(p.Available()); got != 2 { t.Fatalf("available before a fetch: %d", got) } - if ms, err := p.Fetch(context.Background()); err != nil || len(ms) != 2 { - t.Fatalf("fetch: %v %v", ms, err) + if ms, err := p.Fetch(context.Background()); err != nil || len(ms) != 2 || asked { + t.Fatalf("fetch: %v %v asked Anthropic: %v", ms, err, asked) } p, _ = find(All(), "claude") if at, ok := p.Fetched(); !ok || time.Since(at) > time.Minute { @@ -311,47 +315,6 @@ func TestClaudeAccountIsProvider(t *testing.T) { } } -func TestClaudeRefreshesToken(t *testing.T) { - home := claudeHome(t) - creds := claudeSignIn(t, home, time.Now().Add(-time.Minute)) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["grant_type"] != "refresh_token" || body["client_id"] != claudeClientID || body["refresh_token"] != "sk-ant-ort01-old" { - w.WriteHeader(400) - return - } - json.NewEncoder(w).Encode(map[string]any{"access_token": "sk-ant-oat01-new", "refresh_token": "sk-ant-ort01-new", "expires_in": 3600}) - })) - defer srv.Close() - claudeTokenURL = srv.URL - - if tok, err := claudeToken(context.Background()); err != nil || tok != "sk-ant-oat01-new" { - t.Fatalf("token: %q %v", tok, err) - } - - // the rotated pair is written back where Claude Code will find it, and - // everything else in the file survives - var raw map[string]any - if !readJSON(creds, &raw) { - t.Fatalf("credentials unreadable: %s", creds) - } - oauth, _ := raw["claudeAiOauth"].(map[string]any) - if oauth["accessToken"] != "sk-ant-oat01-new" || oauth["refreshToken"] != "sk-ant-ort01-new" || oauth["subscriptionType"] != "max" { - t.Fatalf("oauth: %v", oauth) - } - if exp, _ := oauth["expiresAt"].(float64); exp <= float64(time.Now().UnixMilli()) { - t.Fatalf("expiry not advanced: %v", oauth["expiresAt"]) - } - if mcp, _ := raw["mcpOAuth"].(map[string]any); mcp == nil { - t.Fatalf("mcpOAuth lost: %v", raw) - } - if b, _ := os.ReadFile(creds); strings.Contains(string(b), "sk-ant-ort01-old") { - t.Fatalf("old refresh token kept: %s", b) - } -} - func TestCopilotSignAndModels(t *testing.T) { signIn(t) var mu sync.Mutex diff --git a/internal/provider/claude_dirs.go b/internal/provider/claude_dirs.go new file mode 100644 index 00000000..91877a91 --- /dev/null +++ b/internal/provider/claude_dirs.go @@ -0,0 +1,148 @@ +package provider + +// A saved Claude account in use beside the one Claude Code is signed in to +// runs Claude Code in a config directory of its own (CLAUDE_CONFIG_DIR): its +// sign-in is put there once, from logins.json, and from then on Claude Code +// keeps it, refreshing it as it does its own. magpie never refreshes a +// Claude sign-in nor asks Anthropic anything with one; it only reads back +// what Claude Code left, so logins.json follows. + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/yetone/magpie/internal/proc" + "golang.org/x/text/unicode/norm" +) + +// claudeDirsRoot holds the saved Claude accounts' config directories, and +// the sign-ins being made. +func claudeDirsRoot() string { return filepath.Join(filepath.Dir(Path()), "claude-accounts") } + +// claudeAccountDir is the config directory a saved Claude account runs +// Claude Code in. +func claudeAccountDir(user string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(user)))) + return filepath.Join(claudeDirsRoot(), hex.EncodeToString(sum[:])[:16]) +} + +// claudeDirService is the keychain item Claude Code keeps its sign-in in +// when CLAUDE_CONFIG_DIR is dir: its own name, told apart by the directory. +func claudeDirService(dir string) string { + sum := sha256.Sum256([]byte(norm.NFC.String(dir))) + return "Claude Code-credentials-" + hex.EncodeToString(sum[:])[:8] +} + +// readClaudeDir is the sign-in Claude Code keeps in config directory dir, +// where it looks first: the keychain on a Mac, then the file. +func readClaudeDir(dir string) (claudeCredentials, bool) { + if claudeKeychain { + 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 + } + } + } + b, err := os.ReadFile(filepath.Join(dir, ".credentials.json")) + if err != nil { + return claudeCredentials{}, false + } + return parseClaudeCredentials(b) +} + +// clearClaudeDir takes the sign-in out of config directory dir, so the one +// it was moved to is its only holder. +func clearClaudeDir(dir string) { + if claudeKeychain { + _ = proc.Command("security", "delete-generic-password", "-s", claudeDirService(dir), "-a", claudeKeychainAccount()).Run() + } + _ = os.Remove(filepath.Join(dir, ".credentials.json")) +} + +// forgetClaudeDir removes a saved account's config directory altogether. +func forgetClaudeDir(user string) { + dir := claudeAccountDir(user) + clearClaudeDir(dir) + _ = os.RemoveAll(dir) +} + +// 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. +func claudeSavedDir(user string) (string, error) { + savedTokenMu.Lock() + defer savedTokenMu.Unlock() + loginsMu.Lock() + defer loginsMu.Unlock() + ls := readLogins() + i := -1 + for j := range ls { + if ls[j].Agent == "claude" && strings.EqualFold(ls[j].User, user) { + i = j + } + } + if i < 0 { + 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 + } + return dir, writeLogins(ls) + } + c, ok := parseClaudeCredentials(ls[i].Auth) + if !ok { + return "", errors.New("the saved Claude sign-in of " + user + " is unreadable") + } + b, err := c.marshal() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + if err := writePrivate(filepath.Join(dir, ".credentials.json"), append(b, '\n')); err != nil { + return "", err + } + // who it is, as Claude Code keeps it beside a sign-in it made + profile := filepath.Join(dir, ".claude.json") + if _, err := os.Stat(profile); os.IsNotExist(err) && len(ls[i].Profile) > 0 { + var acct map[string]any + if json.Unmarshal(ls[i].Profile, &acct) == nil { + pb, _ := json.MarshalIndent(map[string]any{"oauthAccount": acct, "hasCompletedOnboarding": true}, "", " ") + _ = writePrivate(profile, append(pb, '\n')) + } + } + return dir, 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) { + if old, ok := parseClaudeCredentials(l.Auth); ok && old.OAuth.AccessToken == c.OAuth.AccessToken && + old.OAuth.RefreshToken == c.OAuth.RefreshToken { + return false, nil + } + b, err := c.marshal() + if err != nil { + return false, err + } + l.Auth = b + if c.OAuth.SubscriptionType != "" { + l.Plan = c.OAuth.SubscriptionType + } + l.Renewed = time.Now().UTC().Truncate(time.Second) + l.Lapsed = "" + return true, nil +} diff --git a/internal/provider/claude_probe.go b/internal/provider/claude_probe.go index 917b9b8d..243fdf21 100644 --- a/internal/provider/claude_probe.go +++ b/internal/provider/claude_probe.go @@ -13,13 +13,14 @@ import ( // Claude account comes to: such requests run Claude Code instead. var errClaudeViaCLI = errors.New("a Claude account is used through Claude Code, not by requests to Anthropic's API") -// claudeCLIProbe has Claude Code answer one "hi" at model, with oauth the -// sign-in of a saved account, "" for the one Claude Code is signed in to. +// claudeCLIProbe has Claude Code answer one "hi" at model, with configDir +// the config directory of a saved account (claude_dirs.go), "" for the one +// Claude Code is signed in to. // The gateway, which runs Claude Code, sets it (ProbeClaudeVia). -var claudeCLIProbe func(ctx context.Context, oauth, model string) error +var claudeCLIProbe func(ctx context.Context, configDir, model string) error // ProbeClaudeVia sets how a Claude account's test runs Claude Code. -func ProbeClaudeVia(f func(ctx context.Context, oauth, model string) error) { claudeCLIProbe = f } +func ProbeClaudeVia(f func(ctx context.Context, configDir, model string) error) { claudeCLIProbe = f } // claudeWait is how long a test waits on Claude Code, which takes a few // seconds to start before it asks. @@ -38,13 +39,13 @@ func (p Provider) testClaude(ctx context.Context, model string) Result { } ctx, cancel := context.WithTimeout(ctx, claudeWait) defer cancel() - oauth, _, err := p.Account.Token(ctx) + dir, _, err := p.Account.Token(ctx) if err != nil { r.Error = err.Error() return r } start := time.Now() - err = claudeCLIProbe(ctx, oauth, model) + err = claudeCLIProbe(ctx, dir, model) r.Millis = time.Since(start).Milliseconds() if err != nil { r.Error = err.Error() diff --git a/internal/provider/claude_signin.go b/internal/provider/claude_signin.go new file mode 100644 index 00000000..8d6637d6 --- /dev/null +++ b/internal/provider/claude_signin.go @@ -0,0 +1,312 @@ +package provider + +// Adding a Claude account is Claude Code's own `claude auth login`, run by +// magpie in a config directory of its own: the sign-in page, the tokens and +// who signed in are all Claude Code's, and magpie asks Anthropic nothing. +// magpie takes the link Claude Code would open (it is the browser Claude +// Code is given), hands it to its window, and keeps the account Claude Code +// signed in to once it is done. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/proc" +) + +// claudeCLISignIn is a `claude auth login` magpie is running. +type claudeCLISignIn struct { + mu sync.Mutex + in io.WriteCloser // where the code its page shows is typed + port string // where it waits for the browser, from the link it opened +} + +// openedURLEnv names the file the browser Claude Code is given (magpie +// itself, TookOpenedURL) writes the page it was asked to open to. +const openedURLEnv = "MAGPIE_OPENED_URL" + +// claudeURLOpener is the browser `claude auth login` is given; a var so +// tests can give another. +var claudeURLOpener = func() string { + exe, _ := os.Executable() + return exe +} + +// TookOpenedURL is magpie run as the browser of a `claude auth login` it +// started: the page is written down for magpie's window to open, and +// nothing else runs. +func TookOpenedURL(args []string) bool { + f := os.Getenv(openedURLEnv) + if f == "" || len(args) != 1 || !strings.HasPrefix(args[0], "https://") { + return false + } + _ = os.WriteFile(f, []byte(args[0]), 0o600) + return true +} + +// claudeSignInEnv is env for a Claude Code that signs in to config +// directory dir: none of the wiring that would point it elsewhere. +func claudeSignInEnv(env []string, dir string) []string { + out := make([]string, 0, len(env)+2) + for _, kv := range withoutClaudeWiring(env) { + switch k, _, _ := strings.Cut(kv, "="); k { + case "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "BROWSER", openedURLEnv: + continue + } + out = append(out, kv) + } + return append(out, "CLAUDE_CONFIG_DIR="+dir) +} + +// startClaudeSignIn runs `claude auth login` and gives the window the page +// to open: the one Claude Code would have opened, which comes back to it on +// this machine, or, when it opened none, the one it prints, whose page +// shows a code to paste. +func startClaudeSignIn(s *signInFlow) error { + path := claudeExecutable() + if path == "" { + return errors.New("install Claude Code first: magpie signs in to Claude through it") + } + if err := os.MkdirAll(claudeDirsRoot(), 0o700); err != nil { + return err + } + dir, err := os.MkdirTemp(claudeDirsRoot(), "signin-") + if err != nil { + return err + } + cleanup := func() { + clearClaudeDir(dir) + _ = os.RemoveAll(dir) + } + opened := filepath.Join(dir, ".opened-url") + env := append(claudeSignInEnv(os.Environ(), dir), "BROWSER="+claudeURLOpener(), openedURLEnv+"="+opened) + ctx, cancel := context.WithCancel(context.Background()) + cmd := proc.CommandContext(ctx, path, "auth", "login", "--claudeai") + cmd.Dir = dir + cmd.Env = netproxy.Env(env) + in, err := cmd.StdinPipe() + if err != nil { + cancel() + cleanup() + return err + } + out, err := cmd.StdoutPipe() + if err != nil { + cancel() + cleanup() + return err + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + cancel() + cleanup() + return err + } + cl := &claudeCLISignIn{in: in} + s.mu.Lock() + s.stop, s.claude = cancel, cl + s.mu.Unlock() + + printed := make(chan string, 1) + exited := make(chan string, 1) + go func() { + sc := bufio.NewScanner(out) + var tail []string + for sc.Scan() { + line := strings.TrimSpace(ansi.ReplaceAllString(sc.Text(), "")) + if u := cursorLoginURL.FindString(line); u != "" { + select { + case printed <- u: + default: + } + continue + } + if line = strings.TrimSpace(strings.TrimPrefix(line, "Paste code here if prompted >")); line != "" { + tail = append(tail, line) + } + } + err := cmd.Wait() + cancel() + forgetAccountCaches() + msg := "claude auth login didn't finish" + if n := len(tail); n > 0 { + msg = tail[n-1] + } + if err == nil { + l, lerr := claudeSignedIn(path, dir) + if lerr == nil { + var using bool + if using, lerr = addLogin(l); lerr == nil { + cleanup() + s.finish(SignInState{State: "done", User: l.User, Plan: l.Plan, Using: using}) + return + } + } + msg = lerr.Error() + } + cleanup() + exited <- msg + s.finish(SignInState{State: "failed", Error: msg}) + }() + + var link string + grace := time.After(linkWait) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for { + if b, err := os.ReadFile(opened); err == nil && len(b) > 0 { + u := strings.TrimSpace(string(b)) + cl.mu.Lock() + cl.port = claudeCallbackPort(u) + cl.mu.Unlock() + s.mu.Lock() + s.st.URL, s.st.PasteCallback, s.st.PasteCode = u, true, false + s.mu.Unlock() + return nil + } + select { + case u := <-printed: + // the page it opens, if it opens one, is on its way + link = u + grace = time.After(3 * time.Second) + case msg := <-exited: + return errors.New(msg) + case <-grace: + if link == "" { + cancel() + return errors.New("claude auth login gave no link to open") + } + s.mu.Lock() + s.st.URL, s.st.PasteCode = link, true + s.mu.Unlock() + return nil + case <-tick.C: + } + } +} + +// claudeCallbackPort is the port a Claude sign-in page sends the browser +// back to on this machine, "" when it sends it elsewhere. +func claudeCallbackPort(link string) string { + u, err := url.Parse(link) + if err != nil { + return "" + } + r, err := url.Parse(u.Query().Get("redirect_uri")) + if err != nil || !isLoopback(r.Hostname()) { + return "" + } + return r.Port() +} + +func isLoopback(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + } + return false +} + +// claudeSignedIn is the account `claude auth login` signed in to in config +// directory dir, as magpie keeps it: the sign-in Claude Code kept, and who +// it says that is. +func claudeSignedIn(path, dir string) (savedLogin, error) { + c, ok := readClaudeDir(dir) + if !ok { + return savedLogin{}, errors.New("Claude Code signed in, but kept no sign-in magpie can read") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := proc.CommandContext(ctx, path, "auth", "status", "--json") + cmd.Dir = dir + cmd.Env = claudeSignInEnv(os.Environ(), dir) + b, _ := cmd.Output() + var status struct { + Email string `json:"email"` + OrgName string `json:"orgName"` + SubscriptionType string `json:"subscriptionType"` + } + _ = json.Unmarshal(b, &status) + acct := map[string]any{} + if pb, err := os.ReadFile(filepath.Join(dir, ".claude.json")); err == nil { + var profile struct { + OAuthAccount map[string]any `json:"oauthAccount"` + } + if json.Unmarshal(pb, &profile) == nil && profile.OAuthAccount != nil { + acct = profile.OAuthAccount + } + } + if _, ok := acct["emailAddress"].(string); !ok && status.Email != "" { + acct["emailAddress"] = status.Email + } + if _, ok := acct["organizationName"].(string); !ok && status.OrgName != "" { + acct["organizationName"] = status.OrgName + } + if c.OAuth.SubscriptionType == "" { + c.OAuth.SubscriptionType = status.SubscriptionType + } + return claudeLogin(c, acct) +} + +// claudePaste finishes a Claude sign-in from what its page left: the code +// Claude's page shows, typed into `claude auth login` as one types it, or +// the address a browser that couldn't reach this machine ended on, handed +// to the `claude auth login` waiting for it. +func (s *signInFlow) claudePaste(raw string) error { + raw = strings.TrimSpace(raw) + s.mu.Lock() + st, cl := s.st, s.claude + s.mu.Unlock() + if st.State != "waiting" { + return errors.New("this sign-in is over; start it again") + } + cl.mu.Lock() + port, in := cl.port, cl.in + cl.mu.Unlock() + if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") { + if port == "" || u.Port() != port || !isLoopback(u.Hostname()) || u.Query().Get("code") == "" { + return errors.New("that address isn't from this sign-in: paste the one its browser tab ended on") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:"+port+u.RequestURI(), nil) + if err != nil { + return err + } + c := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + res, err := c.Do(req) + if err != nil { + return errors.New("Claude Code didn't take that address: " + err.Error()) + } + res.Body.Close() + } else if code, state, ok := strings.Cut(raw, "#"); ok && code != "" && state != "" && !strings.ContainsAny(raw, " \n") { + if _, err := io.WriteString(in, raw+"\n"); err != nil { + return errors.New("the sign-in is over; start it again") + } + } else { + return errors.New("paste the code Claude's page shows, or the whole address the browser ended on") + } + select { + case <-s.done: + case <-time.After(time.Minute): + return errors.New("the sign-in is still finishing; magpie shows the account when it's done") + } + switch st := s.status(); st.State { + case "done": + return nil + case "failed": + return errors.New(st.Error) + } + return errors.New("the sign-in was canceled") +} diff --git a/internal/provider/claude_warmup.go b/internal/provider/claude_warmup.go index c8ce0f25..199635c3 100644 --- a/internal/provider/claude_warmup.go +++ b/internal/provider/claude_warmup.go @@ -33,9 +33,9 @@ func claudeWarmUsage(ctx context.Context) map[string]SubscriptionQuota { } // warmClaudeLogin is a warm-up's send for the Claude accounts: send asks -// Claude Code, with oauth the sign-in of a saved account, "" for the one -// Claude Code is signed in to. -func warmClaudeLogin(send func(ctx context.Context, oauth string) error) func(context.Context, string) error { +// Claude Code, with configDir the config directory of a saved account +// (claude_dirs.go), "" for the one Claude Code is signed in to. +func warmClaudeLogin(send func(ctx context.Context, configDir string) error) func(context.Context, string) error { return func(ctx context.Context, user string) error { // the account's own proxy, which send's Claude Code runs with too ctx = ViaLogin(ctx, "claude", user) @@ -44,15 +44,15 @@ func warmClaudeLogin(send func(ctx context.Context, oauth string) error) func(co if i < 0 { return fmt.Errorf("no Claude account %q", user) } - oauth := "" + dir := "" if !ls[i].Active { - tok, _, err := savedLoginToken(ctx, "claude", ls[i].User) + d, err := claudeSavedDir(ls[i].User) if err != nil { return err } - oauth = tok + dir = d } - return send(ctx, oauth) + return send(ctx, dir) } } @@ -81,7 +81,7 @@ func ClaudeWarmed() map[string]time.Time { return codexWarmedIn(claudeWarmPath() // KeepClaudeWindowsWarm starts the Claude accounts' windows as they reset, // while settings say to, each with one request send makes. -func KeepClaudeWindowsWarm(ctx context.Context, send func(ctx context.Context, oauth string) error) { +func KeepClaudeWindowsWarm(ctx context.Context, send func(ctx context.Context, configDir string) error) { w := codexWarmer{path: claudeWarmPath(), now: time.Now, usage: claudeWarmUsage, send: warmClaudeLogin(send), expect: claudeWindowsExpected, warmed: claudeWarmedNow} keepWarm(ctx, "claude", w, func() (string, string) { s := settings.Load(); return s.ClaudeWarmup, s.ClaudeWarmAt }) diff --git a/internal/provider/keepalive.go b/internal/provider/keepalive.go index b178fe8f..77cac3b9 100644 --- a/internal/provider/keepalive.go +++ b/internal/provider/keepalive.go @@ -1,13 +1,15 @@ package provider -// Keeping saved accounts signed in. A Claude or ChatGPT account saved in -// magpie but not the one the agent is signed in to is refreshed only when -// it is used or its allowance looked at — and one waiting its turn (smart +// Keeping saved accounts signed in. A ChatGPT account saved in magpie but +// not the one Codex is signed in to is refreshed only when it is used or +// its allowance looked at — and one waiting its turn (smart // routing leaves the accounts whose week resets later for days) could sit // unused past its refresh token's life, and be found signed out just when // it's needed. So whichever magpie runs the gateway renews each such // sign-in about once a day. The account the agent is signed in to is the -// agent's own to refresh, and is left to it. +// agent's own to refresh, and is left to it. A Claude account is never +// refreshed by magpie: Claude Code refreshes it, whenever it runs on it +// (claude_dirs.go). import ( "context" @@ -32,7 +34,7 @@ type Renewal struct { Lapsed bool `json:"lapsed,omitempty"` } -// RenewLogins renews the saved Claude and ChatGPT sign-ins not in the +// RenewLogins renews the saved ChatGPT sign-ins not in the // agent's hands that have gone every or longer without it; every 0 renews // all of them. func RenewLogins(ctx context.Context, every time.Duration) []Renewal { @@ -48,7 +50,7 @@ func RenewLogins(ctx context.Context, every time.Duration) []Renewal { loginsMu.Unlock() out := []Renewal{} for _, l := range ls { - if (l.Agent != "claude" && l.Agent != "codex") || strings.EqualFold(active[l.Agent], l.User) || accountRemoved(l.Agent) { + if l.Agent != "codex" || strings.EqualFold(active[l.Agent], l.User) || accountRemoved(l.Agent) { continue } r := Renewal{Agent: l.Agent, User: l.User} @@ -76,19 +78,11 @@ func renewalDue(l savedLogin, every time.Duration, now time.Time) bool { if l.Seen.After(last) { last = l.Seen } - switch l.Agent { - case "claude": - if c, ok := parseClaudeCredentials(l.Auth); ok && c.OAuth.RefreshExpiresAt > 0 && - claudeExpiry(c.OAuth.RefreshExpiresAt).Sub(now) < 2*every { - return true - } - case "codex": - var a struct { - LastRefresh time.Time `json:"last_refresh"` - } - if json.Unmarshal(l.Auth, &a) == nil && a.LastRefresh.After(last) { - last = a.LastRefresh - } + var a struct { + LastRefresh time.Time `json:"last_refresh"` + } + if json.Unmarshal(l.Auth, &a) == nil && a.LastRefresh.After(last) { + last = a.LastRefresh } return now.Sub(last) >= every } diff --git a/internal/provider/keepalive_test.go b/internal/provider/keepalive_test.go index 7bdc2201..59e3a135 100644 --- a/internal/provider/keepalive_test.go +++ b/internal/provider/keepalive_test.go @@ -116,11 +116,6 @@ func TestRenewLogins(t *testing.T) { func TestRenewalDue(t *testing.T) { now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) - claude := func(refreshEnds time.Time) []byte { - b, _ := json.Marshal(map[string]any{"claudeAiOauth": map[string]any{"accessToken": "a", "refreshToken": "r", - "refreshTokenExpiresAt": refreshEnds.UnixMilli()}}) - return b - } codex := func(last time.Time) []byte { b, _ := json.Marshal(map[string]any{"tokens": map[string]any{"access_token": "a"}, "last_refresh": last.Format(time.RFC3339Nano)}) return b @@ -130,10 +125,9 @@ func TestRenewalDue(t *testing.T) { l savedLogin due bool }{ - {"never renewed, seen long ago", savedLogin{Agent: "claude", Seen: now.Add(-48 * time.Hour), Auth: claude(now.Add(30 * 24 * time.Hour))}, true}, - {"seen in the agent's hands today", savedLogin{Agent: "claude", Seen: now.Add(-time.Hour), Auth: claude(now.Add(30 * 24 * time.Hour))}, false}, - {"renewed today", savedLogin{Agent: "claude", Seen: now.Add(-72 * time.Hour), Renewed: now.Add(-time.Hour), Auth: claude(now.Add(30 * 24 * time.Hour))}, false}, - {"refresh token nearly out", savedLogin{Agent: "claude", Renewed: now.Add(-time.Hour), Auth: claude(now.Add(30 * time.Hour))}, true}, + {"never renewed, seen long ago", savedLogin{Agent: "codex", Seen: now.Add(-48 * time.Hour)}, true}, + {"seen in the agent's hands today", savedLogin{Agent: "codex", Seen: now.Add(-time.Hour)}, false}, + {"renewed today", savedLogin{Agent: "codex", Seen: now.Add(-72 * time.Hour), Renewed: now.Add(-time.Hour)}, false}, {"codex refreshed itself lately", savedLogin{Agent: "codex", Seen: now.Add(-72 * time.Hour), Auth: codex(now.Add(-2 * time.Hour))}, false}, {"codex a day since", savedLogin{Agent: "codex", Seen: now.Add(-72 * time.Hour), Auth: codex(now.Add(-25 * time.Hour))}, true}, } { diff --git a/internal/provider/login_import.go b/internal/provider/login_import.go index 38f38358..1601f7d5 100644 --- a/internal/provider/login_import.go +++ b/internal/provider/login_import.go @@ -8,11 +8,12 @@ package provider // ({claudeAiOauth: {…}}); a bare refresh token a line is taken too. They // all sign in with the agents' own OAuth clients, the ones magpie uses. // -// Each is checked the way a sign-in is finished: its refresh token traded -// for new tokens and the account asked for. That rotates the token, so the -// file it came from stops working: the account is magpie's from then on, -// as a sign-in in magpie would make it. Tokens are never logged or sent -// back. +// A ChatGPT one is checked the way a sign-in is finished: its refresh token +// traded for new tokens and the account asked for. That rotates the token, +// so the file it came from stops working: the account is magpie's from then +// on, as a sign-in in magpie would make it. A Claude one is kept as it +// came, magpie asking Anthropic nothing; Claude Code refreshes it the first +// time it runs on it. Tokens are never logged or sent back. import ( "context" @@ -260,15 +261,16 @@ func importLogin(ctx context.Context, agent string, e loginImport, name string, if len(scopes) == 0 { scopes = claudeImportScopes } - c := claudeCredentials{raw: map[string]any{}, OAuth: claudeAuth{RefreshToken: e.refreshToken, Scopes: scopes}} - if err := claudeRefresh(ctx, &c); err != nil { - return refused("Claude", err) + // kept as it came, not tried: trying it would ask Anthropic, which + // magpie never does with a Claude sign-in. Claude Code refreshes it + // the first time it runs on the account, its access token taken + // for spent. + c := claudeCredentials{raw: map[string]any{}, OAuth: claudeAuth{AccessToken: e.accessToken, RefreshToken: e.refreshToken, + ExpiresAt: 1, Scopes: scopes}} + if e.email == "" { + return fail("the file doesn't say which Claude account this is; sign in to it from magpie instead") } - acct := map[string]any{} - if e.email != "" { - acct["emailAddress"] = e.email - } - claudeProfileInto(ctx, &c, acct) + acct := map[string]any{"emailAddress": e.email} l, err = claudeLogin(c, acct) } if err != nil { diff --git a/internal/provider/login_import_test.go b/internal/provider/login_import_test.go index d6b243f0..df634789 100644 --- a/internal/provider/login_import_test.go +++ b/internal/provider/login_import_test.go @@ -111,37 +111,19 @@ func TestImportCodexLogins(t *testing.T) { } } -// With no Claude sign-in, the first imported account becomes Claude Code's. +// With no Claude sign-in, the first imported account becomes Claude Code's, +// kept as the file has it: nothing is asked of Anthropic to try it. func TestImportClaudeLogins(t *testing.T) { - home := claudeHome(t) + claudeHome(t) + asked := false fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/oauth/token": - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["client_id"] != claudeClientID || body["refresh_token"] != "sk-ant-ort01-cpa" { - w.WriteHeader(400) - json.NewEncoder(w).Encode(map[string]any{"error": "invalid_grant"}) - return - } - json.NewEncoder(w).Encode(map[string]any{"access_token": "sk-ant-oat01-fresh", "refresh_token": "sk-ant-ort01-fresh", "expires_in": 3600}) - case "/api/oauth/profile": - if r.Header.Get("Authorization") != "Bearer sk-ant-oat01-fresh" { - w.WriteHeader(401) - return - } - json.NewEncoder(w).Encode(map[string]any{ - "account": map[string]any{"uuid": "u-1", "email": "max@example.com", "display_name": "Max"}, - "organization": map[string]any{"uuid": "o-1", "name": "max@example.com's Organization", "organization_type": "claude_max", "rate_limit_tier": "default_claude_max_20x"}, - }) - default: - w.WriteHeader(404) - } + asked = true + w.WriteHeader(500) })) defer fake.Close() - oldTok, oldBase := claudeTokenURL, claudeBase - t.Cleanup(func() { claudeTokenURL, claudeBase = oldTok, oldBase }) - claudeTokenURL, claudeBase = fake.URL+"/v1/oauth/token", fake.URL + oldBase := claudeBase + t.Cleanup(func() { claudeBase = oldBase }) + claudeBase = fake.URL cpa := `{"type":"claude","access_token":"sk-ant-oat01-old","refresh_token":"sk-ant-ort01-cpa","email":"max@example.com","expired":"2026-01-01T00:00:00Z"}` creds := `{"claudeAiOauth":{"accessToken":"a","refreshToken":"sk-ant-ort01-gone","expiresAt":1}}` @@ -149,15 +131,14 @@ func TestImportClaudeLogins(t *testing.T) { if err != nil { t.Fatal(err) } - if got := importStatuses(rs); got != "max@example.com:added,#2:failed,#3:failed" || rs[0].Plan != "max" { - t.Fatalf("got %s %+v", got, rs) + if got := importStatuses(rs); got != "max@example.com:added,#2:failed,#3:failed" || asked { + t.Fatalf("got %s %+v asked Anthropic: %v", got, rs, asked) } c, _, ok := claudeCredential() - if !ok || c.OAuth.RefreshToken != "sk-ant-ort01-fresh" || c.OAuth.SubscriptionType != "max" || len(c.OAuth.Scopes) == 0 { + if !ok || c.OAuth.RefreshToken != "sk-ant-ort01-cpa" || c.OAuth.AccessToken != "sk-ant-oat01-old" || len(c.OAuth.Scopes) == 0 { t.Fatalf("Claude Code's credentials %+v", c.OAuth) } if _, active := loginUsers(Logins("claude")); active != "max@example.com" { t.Fatalf("active %q", active) } - _ = home } diff --git a/internal/provider/logins.go b/internal/provider/logins.go index cc5f4c9c..d74b2815 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -658,6 +658,15 @@ func switchSavedLogin(agent, user string) (from string, _ error) { if target == nil { return "", fmt.Errorf("no saved %s account %q", agent, user) } + 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 + } + } + } want := *target if live, ok := liveLogin(agent); ok { if strings.EqualFold(live.User, want.User) { @@ -682,7 +691,10 @@ func switchSavedLogin(agent, user string) (from string, _ error) { case "codex": err = writePrivate(codexAuthPath(), append(bytes.TrimSpace(want.Auth), '\n')) case "claude": - err = putClaudeLogin(want) + if err = putClaudeLogin(want); err == nil { + // Claude Code's own now: its only holder + forgetClaudeDir(want.User) + } default: err = fmt.Errorf("%s accounts can't be switched", agent) } @@ -792,6 +804,9 @@ func ForgetLogin(agent, user string) error { if !found { return fmt.Errorf("no saved %s account %q", agent, user) } + if agent == "claude" { + forgetClaudeDir(user) + } return writeLogins(out) } diff --git a/internal/provider/logins_on.go b/internal/provider/logins_on.go index 24b91c4f..7b507084 100644 --- a/internal/provider/logins_on.go +++ b/internal/provider/logins_on.go @@ -173,6 +173,11 @@ func (p Provider) AlsoOn() []Provider { tok, _, err := savedLoginToken(ctx, agent, user) return tok, err } + if agent == "claude" { + // Claude Code runs on it in a config directory of its own + // (claude_dirs.go), and keeps the sign-in there itself + a.token = func(context.Context) (string, error) { return claudeSavedDir(user) } + } if agent == "codex" { a.sign = codexSign(func(ctx context.Context) (string, string, error) { return savedLoginToken(ctx, agent, user) }) } @@ -184,7 +189,8 @@ func (p Provider) AlsoOn() []Provider { } // Token is the access token of a saved account in use beside the agent's -// own; ok is false for the agent's own, which the agent signs itself. +// 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. func (a *Account) Token(ctx context.Context) (tok string, ok bool, err error) { if a == nil || a.token == nil { return "", false, nil @@ -221,21 +227,6 @@ func renewSavedLogin(ctx context.Context, agent, user string, force bool) (tok, } var auth []byte switch agent { - case "claude": - c, ok := parseClaudeCredentials(l.Auth) - if !ok { - return "", "", errors.New("the saved Claude sign-in of " + user + " is unreadable") - } - if !force && claudeFresh(c) { - return c.OAuth.AccessToken, "", nil - } - if err := claudeRefresh(ctx, &c); err != nil { - return "", "", savedRefreshFailed(agent, user, err) - } - tok = c.OAuth.AccessToken - if auth, err = c.marshal(); err != nil { - return "", "", err - } case "codex": var raw map[string]any var a codexAuth diff --git a/internal/provider/signin.go b/internal/provider/signin.go index 3d43e98b..8953ae6e 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -1,9 +1,10 @@ package provider // Adding a subscription from magpie itself. magpie runs the vendor's own -// browser sign-in — the one Claude Code's /login and `codex login` run, with -// their OAuth clients — takes the tokens at a callback on this machine, and -// keeps the account with the others in logins.json. An agent that has no +// browser sign-in — the one `codex login` runs, with its OAuth client — +// takes the tokens at a callback on this machine, and keeps the account +// with the others in logins.json. A Claude account is signed in by Claude +// Code itself (claude_signin.go). An agent that has no // account yet is signed in to it straight away; otherwise it stays one // click away, beside the rest. @@ -29,17 +30,13 @@ import ( // The vendors' sign-in pages; vars so tests can point them elsewhere. var ( - claudeAuthorizeURL = "https://claude.com/cai/oauth/authorize" - codexAuthorizeURL = "https://auth.openai.com/oauth/authorize" - devinAuthorizeURL = "https://app.devin.ai/auth/cli/continue" + codexAuthorizeURL = "https://auth.openai.com/oauth/authorize" + devinAuthorizeURL = "https://app.devin.ai/auth/cli/continue" // codexCallbackAddr is fixed: OpenAI only sends Codex's client back to // port 1455. codexCallbackAddr = "127.0.0.1:1455" ) -// claudeScopes are what Claude Code asks for at /login. -const claudeScopes = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload user:plugins" - const codexScopes = "openid profile email offline_access api.connectors.read api.connectors.invoke" // signInTimeout is how long a sign-in waits for the browser. @@ -73,8 +70,9 @@ type signInFlow struct { srv *http.Server stop func() // ends an agent's own login command, when that is the sign-in kiro *kiroFlow - site string // where to sign in, for an agent with more than one (ZCode: "zai" or "bigmodel") - plugin string // a plugin's sign-in session, finished with the code pasted back + site string // where to sign in, for an agent with more than one (ZCode: "zai" or "bigmodel") + plugin string // a plugin's sign-in session, finished with the code pasted back + claude *claudeCLISignIn // Claude Code's own sign-in, run by magpie // claimed is a callback being traded for the account: the browser's own // or a pasted address, whichever came first claimed bool @@ -193,19 +191,10 @@ func (s *signInFlow) begin() error { q := url.Values{} switch agent { case "claude": - if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil { + // Claude Code's own `claude auth login`: magpie makes no part of it + if err := startClaudeSignIn(s); err != nil { return err } - s.redirect = fmt.Sprintf("http://localhost:%d/callback", ln.Addr().(*net.TCPAddr).Port) - q.Set("code", "true") - q.Set("client_id", claudeClientID) - q.Set("response_type", "code") - q.Set("redirect_uri", s.redirect) - q.Set("scope", claudeScopes) - q.Set("code_challenge", challenge) - q.Set("code_challenge_method", "S256") - q.Set("state", s.state) - s.st.URL = claudeAuthorizeURL + "?" + q.Encode() case "codex": if ln, err = listenCodexCallback(); err != nil { return err @@ -377,6 +366,9 @@ func SubmitSignInCallback(id, raw string) error { if s.plugin != "" { return s.pluginCode(raw) } + if s.claude != nil { + return s.claudePaste(raw) + } return s.pastedCallback(raw) } @@ -651,10 +643,8 @@ func (s *signInFlow) exchange(ctx context.Context, code string) (savedLogin, err switch s.st.Agent { case "codex": return codexExchange(ctx, code, s.verifier, s.redirect) - case "devin": - return devinExchange(ctx, code, s.verifier, s.redirect) } - return claudeExchange(ctx, code, s.verifier, s.redirect, s.state) + return devinExchange(ctx, code, s.verifier, s.redirect) } func postToken(ctx context.Context, tokenURL, ctype string, body []byte, out any) error { @@ -700,68 +690,6 @@ func postToken(ctx context.Context, tokenURL, ctype string, body []byte, out any return json.Unmarshal(b, out) } -func claudeExchange(ctx context.Context, code, verifier, redirect, state string) (savedLogin, error) { - body, _ := json.Marshal(map[string]string{"grant_type": "authorization_code", "code": code, - "redirect_uri": redirect, "client_id": claudeClientID, "code_verifier": verifier, "state": state}) - var tok struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` - Scope string `json:"scope"` - Account struct { - UUID string `json:"uuid"` - Email string `json:"email_address"` - } `json:"account"` - Organization struct { - UUID string `json:"uuid"` - Name string `json:"name"` - } `json:"organization"` - } - if err := postToken(ctx, claudeTokenURL, "application/json", body, &tok); err != nil { - return savedLogin{}, err - } - if tok.AccessToken == "" || tok.RefreshToken == "" { - return savedLogin{}, errors.New("Claude sent back no token") - } - acct := map[string]any{"accountUuid": tok.Account.UUID, "emailAddress": tok.Account.Email, - "organizationUuid": tok.Organization.UUID} - if tok.Organization.Name != "" { - acct["organizationName"] = tok.Organization.Name - } - c := claudeCredentials{raw: map[string]any{}, OAuth: claudeAuth{ - AccessToken: tok.AccessToken, RefreshToken: tok.RefreshToken, - ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).UnixMilli(), - Scopes: strings.Fields(tok.Scope), - }} - claudeProfileInto(ctx, &c, acct) - return claudeLogin(c, acct) -} - -// claudeProfileInto asks Claude for the account's profile and puts it in -// acct (Claude Code's oauthAccount) and its plan in c; the plan comes with -// the profile, as it does for Claude Code. It says whether Claude answered. -func claudeProfileInto(ctx context.Context, c *claudeCredentials, acct map[string]any) bool { - p, err := claudeProfile(ctx, c.OAuth.AccessToken) - if err != nil { - return false - } - c.OAuth.SubscriptionType = claudePlans[p.Organization.Type] - c.OAuth.RateLimitTier = p.Organization.RateLimitTier - set := func(k, v string) { - if v != "" { - acct[k] = v - } - } - set("accountUuid", p.Account.UUID) - set("emailAddress", p.Account.Email) - set("displayName", p.Account.DisplayName) - set("organizationUuid", p.Organization.UUID) - set("organizationName", p.Organization.Name) - set("billingType", p.Organization.BillingType) - acct["hasExtraUsageEnabled"] = p.Organization.ExtraUsage - return true -} - // claudeLogin is a Claude sign-in as magpie keeps it. func claudeLogin(c claudeCredentials, acct map[string]any) (savedLogin, error) { email, _ := acct["emailAddress"].(string) @@ -776,44 +704,6 @@ func claudeLogin(c claudeCredentials, acct map[string]any) (savedLogin, error) { return savedLogin{Agent: "claude", User: claudeUser(email, c.OAuth.SubscriptionType, acct), Plan: c.OAuth.SubscriptionType, Auth: auth, Profile: profile}, nil } -// claudePlans names Claude's organization types the way Claude Code does. -var claudePlans = map[string]string{"claude_max": "max", "claude_pro": "pro", "claude_enterprise": "enterprise", "claude_team": "team"} - -type claudeProfileInfo struct { - Account struct { - UUID string `json:"uuid"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - } `json:"account"` - Organization struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Type string `json:"organization_type"` - RateLimitTier string `json:"rate_limit_tier"` - BillingType string `json:"billing_type"` - ExtraUsage bool `json:"has_extra_usage_enabled"` - } `json:"organization"` -} - -func claudeProfile(ctx context.Context, token string) (claudeProfileInfo, error) { - var p claudeProfileInfo - req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeBase+"/api/oauth/profile", nil) - if err != nil { - return p, err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return p, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return p, fmt.Errorf("profile: %s", resp.Status) - } - return p, json.NewDecoder(resp.Body).Decode(&p) -} - func codexExchange(ctx context.Context, code, verifier, redirect string) (savedLogin, error) { form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirect}, "client_id": {codexClientID}, "code_verifier": {verifier}} @@ -887,6 +777,11 @@ func addLogin(l savedLogin) (using bool, err error) { return false, err } } + if l.Agent == "claude" { + // signed in afresh: what Claude Code kept for it beside the + // agent's own is an older sign-in, and gives way + forgetClaudeDir(l.User) + } loginsSeenAt = time.Time{} forgetAccountCaches() return using, nil diff --git a/internal/provider/signin_claude_test.go b/internal/provider/signin_claude_test.go new file mode 100644 index 00000000..fd5a7593 --- /dev/null +++ b/internal/provider/signin_claude_test.go @@ -0,0 +1,235 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeClaudeAccount is who a fake `claude auth login` signs in to. +type fakeClaudeAccount struct { + email, org, orgUUID, plan, refresh, name string +} + +// fakeClaudeLogin points claudeExecutable at a `claude` whose `auth login` +// signs in to a: as Claude Code does, it opens its page through $BROWSER, +// which comes back to a listener of its own on this machine (the test's +// callback server here), prints the page whose code is pasted into it, and +// keeps the sign-in in $CLAUDE_CONFIG_DIR. own is the script that answers +// `auth status` with no CLAUDE_CONFIG_DIR, "" for no answer. opens says +// whether the browser it's given opens anything; magpie's own is replaced +// with a script that writes the page down as magpie would. +func fakeClaudeLogin(t *testing.T, a fakeClaudeAccount, own string, opens bool) string { + t.Helper() + shellFakes(t) + dir := t.TempDir() + goFile := filepath.Join(dir, "go") + cb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" || r.URL.Query().Get("code") != "the-code" || r.URL.Query().Get("state") != "st" { + w.WriteHeader(400) + return + } + os.WriteFile(goFile, nil, 0o600) + w.Write([]byte("signed in")) + })) + t.Cleanup(cb.Close) + port := cb.URL[strings.LastIndex(cb.URL, ":")+1:] + opened := "https://claude.com/cai/oauth/authorize?code=true&" + url.Values{"redirect_uri": {"http://localhost:" + port + "/callback"}, "state": {"st"}}.Encode() + printed := "https://claude.com/cai/oauth/authorize?code=true&" + url.Values{"redirect_uri": {"https://platform.claude.com/oauth/code/callback"}, "state": {"st"}}.Encode() + if own == "" { + own = "exit 1" + } + exe := filepath.Join(dir, "claude") + script := fmt.Sprintf(`#!/bin/sh +if [ "$1 $2" = "auth status" ]; then + if [ -n "$CLAUDE_CONFIG_DIR" ]; then cat "$CLAUDE_CONFIG_DIR/status.json"; exit 0; fi + %s +fi +[ "$1 $2" = "auth login" ] || exit 2 +echo "Opening browser to sign in…" +"$BROWSER" '%s' +echo "If the browser didn't open, visit: %s" +printf 'Paste code here if prompted > ' +exec 3<&0 +( while read -r line; do echo "$line" >> "$CLAUDE_CONFIG_DIR/.pasted"; done ) <&3 >/dev/null 2>&1 & +reader=$! +while :; do + [ -f '%s' ] && break + grep -qx 'the-code#st' "$CLAUDE_CONFIG_DIR/.pasted" 2>/dev/null && break + sleep 0.05 +done +kill $reader 2>/dev/null +rm -f '%[4]s' "$CLAUDE_CONFIG_DIR/.pasted" +cat > "$CLAUDE_CONFIG_DIR/.credentials.json" < "$CLAUDE_CONFIG_DIR/.claude.json" < "$CLAUDE_CONFIG_DIR/status.json" +echo "Login successful." +`, own, opened, printed, goFile, a.refresh, time.Now().Add(time.Hour).UnixMilli(), a.plan, a.email, a.orgUUID, a.org, a.name) + if err := os.WriteFile(exe, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + browser := filepath.Join(dir, "browser") + body := "#!/bin/sh\nexit 1\n" + if opens { + body = "#!/bin/sh\nprintf %s \"$1\" > \"$" + openedURLEnv + "\"\n" + } + os.WriteFile(browser, []byte(body), 0o755) + oldExe, oldOpener := claudeExecutable, claudeURLOpener + claudeExecutable = func() string { return exe } + claudeURLOpener = func() string { return browser } + forgetClaudeStatus() + t.Cleanup(func() { + claudeExecutable, claudeURLOpener = oldExe, oldOpener + forgetClaudeStatus() + }) + return exe +} + +// noAnthropic fails the test if anything is asked of claudeBase. +func noAnthropic(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("asked Anthropic: %s %s", r.Method, r.URL) + w.WriteHeader(500) + })) + t.Cleanup(srv.Close) + old := claudeBase + claudeBase = srv.URL + t.Cleanup(func() { claudeBase = old }) +} + +// A browser that can't come back to this machine: the page Claude Code +// prints shows a code, pasted into magpie, which types it into Claude Code; +// or the address the browser ended on, handed to Claude Code's listener. +func TestClaudeSignInByPaste(t *testing.T) { + claudeHome(t) + noAnthropic(t) + fakeClaudeLogin(t, fakeClaudeAccount{email: "paste@example.com", plan: "pro", refresh: "sk-ant-ort01-paste"}, "", false) + + st, err := StartSignIn("claude") + if err != nil { + t.Fatal(err) + } + if !st.PasteCode || !strings.Contains(st.URL, "platform.claude.com") { + t.Fatalf("state %+v", st) + } + if err := SubmitSignInCallback(st.ID, "not a code"); err == nil { + t.Fatal("took garbage") + } + if err := SubmitSignInCallback(st.ID, "the-code#st"); err != nil { + t.Fatal(err) + } + if st = waitDone(t, st.ID); st.State != "done" || st.User != "paste@example.com" || st.Plan != "pro" || !st.Using { + t.Fatalf("state %+v", st) + } + c, _, ok := claudeCredential() + if !ok || c.OAuth.RefreshToken != "sk-ant-ort01-paste" { + t.Fatalf("Claude Code's credentials %+v", c.OAuth) + } + // the sign-in's own directory is gone + if es, _ := os.ReadDir(claudeDirsRoot()); len(es) != 0 { + t.Fatalf("left behind: %v", es) + } +} + +func TestClaudeSignInByPastedAddress(t *testing.T) { + claudeHome(t) + noAnthropic(t) + fakeClaudeLogin(t, fakeClaudeAccount{email: "addr@example.com", plan: "max", refresh: "sk-ant-ort01-addr"}, "", true) + + st, err := StartSignIn("claude") + if err != nil { + t.Fatal(err) + } + if !st.PasteCallback || !strings.Contains(st.URL, "localhost") { + t.Fatalf("state %+v", st) + } + u, _ := url.Parse(st.URL) + back, _ := url.Parse(u.Query().Get("redirect_uri")) + if err := SubmitSignInCallback(st.ID, "http://localhost:1/callback?code=the-code&state=st"); err == nil { + t.Fatal("took another port's address") + } + if err := SubmitSignInCallback(st.ID, back.String()+"?code=the-code&state=st"); err != nil { + t.Fatal(err) + } + if st = waitDone(t, st.ID); st.State != "done" || st.User != "addr@example.com" { + t.Fatalf("state %+v", st) + } +} + +// A saved account in use beside Claude Code's own runs Claude Code in a +// config directory of its own: its sign-in is put there once, what Claude +// Code makes of it is read back, and switching to it moves it out again. +func TestClaudeSavedAccountDir(t *testing.T) { + home := claudeHome(t) + noAnthropic(t) + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) + writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "old@example.com"}}) + fakeClaudeLogin(t, fakeClaudeAccount{email: "side@example.com", name: "Side", plan: "max", refresh: "sk-ant-ort01-side"}, "", true) + + st, err := StartSignIn("claude") + if err != nil { + t.Fatal(err) + } + finishInBrowser(t, st, "the-code") + if st = waitDone(t, st.ID); st.State != "done" || st.Using { + t.Fatalf("state %+v", st) + } + if err := SetLoginOn("claude", "side@example.com", true); err != nil { + t.Fatal(err) + } + p, ok := find(All(), "claude") + if !ok || len(p.AlsoOn()) != 1 { + t.Fatalf("also on: %v %+v", ok, p.AlsoOn()) + } + dir, own, err := p.AlsoOn()[0].Account.Token(context.Background()) + if err != nil || !own || dir != claudeAccountDir("side@example.com") { + t.Fatalf("dir %q %v %v", dir, own, err) + } + c, ok := readClaudeDir(dir) + if !ok || c.OAuth.RefreshToken != "sk-ant-ort01-side" { + t.Fatalf("dir credentials %+v", c.OAuth) + } + if b, _ := os.ReadFile(filepath.Join(dir, ".claude.json")); !strings.Contains(string(b), "side@example.com") { + t.Fatalf(".claude.json %s", b) + } + + // Claude Code refreshes it there; magpie keeps what it made of it + c.OAuth.RefreshToken = "sk-ant-ort01-side2" + b, _ := c.marshal() + os.WriteFile(filepath.Join(dir, ".credentials.json"), b, 0o600) + if _, _, err := p.AlsoOn()[0].Account.Token(context.Background()); err != nil { + t.Fatal(err) + } + for _, l := range readLogins() { + if l.User == "side@example.com" { + if lc, _ := parseClaudeCredentials(l.Auth); lc.OAuth.RefreshToken != "sk-ant-ort01-side2" { + t.Fatalf("saved %+v", lc.OAuth) + } + } + } + + // switched to, it is Claude Code's own, and its directory is gone + if err := SwitchLogin("claude", "side@example.com"); err != nil { + t.Fatal(err) + } + var cc map[string]map[string]any + readJSON(cred, &cc) + if cc["claudeAiOauth"]["refreshToken"] != "sk-ant-ort01-side2" { + t.Fatalf("credentials %v", cc["claudeAiOauth"]) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("directory kept: %v", err) + } +} diff --git a/internal/provider/signin_paste_test.go b/internal/provider/signin_paste_test.go index 2861965a..ea99ac85 100644 --- a/internal/provider/signin_paste_test.go +++ b/internal/provider/signin_paste_test.go @@ -169,11 +169,11 @@ func TestPastedAddressAndCallbackFinishOnce(t *testing.T) { // Every built-in sign-in that comes back to a port here takes the address // pasted instead; one that doesn't come back through the browser's address -// bar doesn't offer it. +// bar doesn't offer it. Claude's is Claude Code's own (signin_claude_test.go). func TestLoopbackSignInsTakePastedAddress(t *testing.T) { claudeHome(t) fakeCodexTokens(t, nil) - for _, agent := range []string{"claude", "codex", "gemini", "antigravity", "kiro", "zed"} { + for _, agent := range []string{"codex", "gemini", "antigravity", "kiro", "zed"} { st, err := StartSignIn(agent) if err != nil { t.Fatalf("%s: %v", agent, err) diff --git a/internal/provider/signin_stale_test.go b/internal/provider/signin_stale_test.go index 1eeaffcf..e1357616 100644 --- a/internal/provider/signin_stale_test.go +++ b/internal/provider/signin_stale_test.go @@ -1,10 +1,6 @@ package provider import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" "path/filepath" "testing" "time" @@ -16,35 +12,15 @@ import ( func TestClaudeSignInOverStaleCredentials(t *testing.T) { shellFakes(t) home := claudeHome(t) - fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/oauth/token": - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "sk-ant-oat01-new", "refresh_token": "sk-ant-ort01-new", "expires_in": 3600, - "account": map[string]any{"uuid": "u", "email_address": "new@example.com"}, - "organization": map[string]any{"uuid": "o-new", "name": "new@example.com's Organization"}, - }) - case "/api/oauth/profile": - json.NewEncoder(w).Encode(map[string]any{ - "account": map[string]any{"email": "new@example.com"}, - "organization": map[string]any{"organization_type": "claude_max"}, - }) - default: - w.WriteHeader(404) - } - })) - defer fake.Close() - claudeTokenURL, claudeBase = fake.URL+"/v1/oauth/token", fake.URL - + noAnthropic(t) cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{ "emailAddress": "old@example.com", "organizationUuid": "o-old"}}) // Claude Code is signed out until the new account's credentials are there - exe := filepath.Join(home, "claude") - os.WriteFile(exe, []byte("#!/bin/sh\nif grep -q sk-ant-ort01-new "+cred+"; then\n"+ - `echo '{"loggedIn": true, "email": "new@example.com", "subscriptionType": "max"}'`+"\nelse\n"+ - `echo '{"loggedIn": false, "authMethod": "none"}'`+"\nfi\n"), 0o755) - claudeExecutable = func() string { return exe } + fakeClaudeLogin(t, fakeClaudeAccount{email: "new@example.com", org: "new@example.com's Organization", orgUUID: "o-new", plan: "max", refresh: "sk-ant-ort01-new"}, + "if grep -q sk-ant-ort01-new "+cred+"; then\n"+ + `echo '{"loggedIn": true, "email": "new@example.com", "subscriptionType": "max"}'`+"\nelse\n"+ + `echo '{"loggedIn": false, "authMethod": "none"}'`+"\nfi\nexit 0", true) forgetClaudeStatus() if _, ok := find(All(), "claude"); ok { t.Fatal("signed-out account listed") diff --git a/internal/provider/signin_team_test.go b/internal/provider/signin_team_test.go index 2bf0f54c..6cb52f26 100644 --- a/internal/provider/signin_team_test.go +++ b/internal/provider/signin_team_test.go @@ -16,25 +16,8 @@ import ( // brings back a Claude account that had been removed from magpie. func TestClaudeTeamSeatBesidePersonal(t *testing.T) { home := claudeHome(t) - fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/oauth/token": - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "sk-ant-oat01-team", "refresh_token": "sk-ant-ort01-team", "expires_in": 3600, - "account": map[string]any{"uuid": "u", "email_address": "same@example.com"}, - "organization": map[string]any{"uuid": "o-team", "name": "Acme"}, - }) - case "/api/oauth/profile": - json.NewEncoder(w).Encode(map[string]any{ - "account": map[string]any{"email": "same@example.com"}, - "organization": map[string]any{"organization_type": "claude_team"}, - }) - default: - w.WriteHeader(404) - } - })) - defer fake.Close() - claudeTokenURL, claudeBase = fake.URL+"/v1/oauth/token", fake.URL + noAnthropic(t) + fakeClaudeLogin(t, fakeClaudeAccount{email: "same@example.com", org: "Acme", orgUUID: "o-team", plan: "team", refresh: "sk-ant-ort01-team"}, "", true) cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{ diff --git a/internal/provider/signin_test.go b/internal/provider/signin_test.go index 5167e47a..21087671 100644 --- a/internal/provider/signin_test.go +++ b/internal/provider/signin_test.go @@ -48,37 +48,8 @@ func waitDone(t *testing.T, id string) SignInState { func TestClaudeSignInAddsAnAccount(t *testing.T) { home := claudeHome(t) - var gotVerifier string - fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/oauth/token": - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["code"] != "the-code" || body["client_id"] != claudeClientID || body["grant_type"] != "authorization_code" { - w.WriteHeader(400) - return - } - gotVerifier = body["code_verifier"] - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "sk-ant-oat01-new", "refresh_token": "sk-ant-ort01-new", "expires_in": 3600, - "scope": "user:inference user:profile", - "account": map[string]any{"uuid": "u-new", "email_address": "new@example.com"}, - }) - case "/api/oauth/profile": - if r.Header.Get("Authorization") != "Bearer sk-ant-oat01-new" { - w.WriteHeader(401) - return - } - json.NewEncoder(w).Encode(map[string]any{ - "account": map[string]any{"email": "new@example.com", "display_name": "New"}, - "organization": map[string]any{"organization_type": "claude_max", "rate_limit_tier": "default_claude_max_20x"}, - }) - default: - w.WriteHeader(404) - } - })) - defer fake.Close() - claudeTokenURL, claudeBase = fake.URL+"/v1/oauth/token", fake.URL + noAnthropic(t) + fakeClaudeLogin(t, fakeClaudeAccount{email: "new@example.com", name: "New", plan: "max", refresh: "sk-ant-ort01-new"}, "", true) // signed in already, to someone else: the new account is added beside it cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) @@ -88,8 +59,9 @@ func TestClaudeSignInAddsAnAccount(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.HasPrefix(st.URL, claudeAuthorizeURL+"?") || !strings.Contains(st.URL, "code_challenge_method=S256") { - t.Fatalf("url %s", st.URL) + // the page Claude Code itself opens, coming back to it + if !strings.HasPrefix(st.URL, "https://claude.com/cai/oauth/authorize?") || !st.PasteCallback { + t.Fatalf("state %+v", st) } page := finishInBrowser(t, st, "the-code") if !strings.Contains(page, "signed in") { @@ -99,9 +71,6 @@ func TestClaudeSignInAddsAnAccount(t *testing.T) { if st.State != "done" || st.User != "new@example.com" || st.Plan != "max" || st.Using { t.Fatalf("state %+v", st) } - if gotVerifier == "" { - t.Fatal("no PKCE verifier sent") - } users, active := loginUsers(Logins("claude")) if strings.Join(users, ",") != "new@example.com,old@example.com" || active != "old@example.com" { t.Fatalf("logins %v, active %q", users, active) @@ -176,7 +145,12 @@ func TestCodexSignInSignsInWhenSignedOut(t *testing.T) { func TestSignInIgnoresAForeignCallback(t *testing.T) { claudeHome(t) - st, err := StartSignIn("claude") + oldAddr := codexCallbackAddr + t.Cleanup(func() { codexCallbackAddr = oldAddr }) + ln, _ := net.Listen("tcp", "127.0.0.1:0") + codexCallbackAddr = ln.Addr().String() + ln.Close() + st, err := StartSignIn("codex") if err != nil { t.Fatal(err) } diff --git a/main.go b/main.go index e6f18bf0..97c4b979 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/yetone/magpie/internal/imagemcp" "github.com/yetone/magpie/internal/netproxy" "github.com/yetone/magpie/internal/profile" + "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/sessions" "github.com/yetone/magpie/internal/settings" "github.com/yetone/magpie/internal/tui" @@ -72,7 +73,7 @@ const usage = `magpie — one place to pick every agent's model magpie accounts [agent] [--json] every subscription magpie knows, with each one's allowance used and when it resets magpie accounts add sign in to one more Claude, ChatGPT or Google (Gemini CLI, Antigravity) subscription magpie accounts switch sign the agent in to another of them - magpie accounts refresh renew the saved Claude and ChatGPT sign-ins now (the gateway does it daily) + magpie accounts refresh renew the saved ChatGPT sign-ins now (the gateway does it daily) magpie accounts checkin WorkBuddy's daily check-in (签到) for each WorkBuddy account, now (Settings can do it daily) magpie accounts project the Google Cloud project a Google account's requests go to magpie plugin [add |rm|update|on|off|login |logout ] @@ -104,6 +105,10 @@ var ( ) func main() { + if provider.TookOpenedURL(os.Args[1:]) { + // Claude Code, signing in for magpie, handed over the page to open + return + } gateway.Version = version netproxy.Install() update.GUI = hasGUI