claude: adding a Claude account is Claude Code's own claude auth login, and magpie never refreshes a Claude sign-in nor asks Anthropic anything with one (the user: 把 magpie 自己的 Claude 登录改成直接用 claude /login,只要体验上没什么差别就改) — magpie traded the sign-in page's code for tokens with Claude Code's OAuth client, asked Anthropic's profile and model list with them, refreshed saved accounts itself (daily keepalive included) and handed a saved account to Claude Code as CLAUDE_CODE_OAUTH_TOKEN. Now Add account runs claude auth login --claudeai in a config directory of magpie's own: the page Claude Code would open comes to magpie's window (Claude Code is given magpie as its BROWSER, which writes the page down and exits), the browser comes back to Claude Code's own listener, and when the browser can't reach this machine the address it ended on, or the code Claude's page shows, is pasted as before and handed to Claude Code. Who signed in is what Claude Code kept (.claude.json, auth status). A saved account in use beside the signed-in one runs Claude Code with CLAUDE_CONFIG_DIR set to its own directory (claude-accounts/ beside magpie's config), its sign-in put there once from logins.json and read back as Claude Code refreshes it; switching to it moves it into Claude Code's own store and removes the directory, as forgetting it does. Claude's models are the catalog's (models.dev), not asked of Anthropic; an imported Claude login is kept as the file has it, and Claude Code refreshes it on first use. Claude Code is required to add a Claude account. TestClaudeSignInByPaste, TestClaudeSignInByPastedAddress, TestClaudeSavedAccountDir (a fake claude CLI, and a server standing in for Anthropic that fails the test if asked) and the reworked sign-in, import and gateway tests fail without the change. With the real Claude Code 2.1.286 in a sandbox directory, its sign-in page (localhost callback) reached magpie's binary and the sign-in waited for the browser; nothing was left behind once it was canceled. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; claude-risk, plugin-signin, signin-callback, signin-link, signin-paste-codex, routing-signin, own-signin-remove, click-scroll, old-webkit pass.

This commit is contained in:
yetone committed 2026-10-01 16:37:07 +08:00
1 parent b7037c101e
commit e698e7dddd
27 files changed
+904 -562

No files matched your search

+6 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+24 -7
View File
@@ -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)
}
+7 -7
View File
@@ -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()
+8 -6
View File
@@ -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)
}
}
+3 -2
View File
@@ -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":"<synthetic>","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"}'
+3 -2
View File
@@ -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":""}}}'
+1
View File
@@ -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) {
+6 -159
View File
@@ -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)
+23 -60
View File
@@ -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
+148
View File
@@ -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
}
+7 -6
View File
@@ -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()
+312
View File
@@ -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")
}
+8 -8
View File
@@ -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 })
+13 -19
View File
@@ -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
}
+3 -9
View File
@@ -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},
} {
+15 -13
View File
@@ -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 {
+12 -31
View File
@@ -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
}
+16 -1
View File
@@ -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)
}
+7 -16
View File
@@ -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
+20 -125
View File
@@ -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
+235
View File
@@ -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" <<EOF
{"claudeAiOauth":{"accessToken":"sk-ant-oat01-x","refreshToken":"%s","expiresAt":%d,"scopes":["user:inference","user:profile"],"subscriptionType":"%s"}}
EOF
cat > "$CLAUDE_CONFIG_DIR/.claude.json" <<EOF
{"oauthAccount":{"emailAddress":"%s","organizationUuid":"%s","organizationName":"%s","displayName":"%s"}}
EOF
echo '{"loggedIn":true,"email":"%[8]s","subscriptionType":"%[7]s"}' > "$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)
}
}
+2 -2
View File
@@ -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)
+5 -29
View File
@@ -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")
+2 -19
View File
@@ -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{
+11 -37
View File
@@ -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)
}
+6 -1
View File
@@ -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 <agent> sign in to one more Claude, ChatGPT or Google (Gemini CLI, Antigravity) subscription
magpie accounts switch <agent> <email> 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 <gemini|antigravity> <email> <project> the Google Cloud project a Google account's requests go to
magpie plugin [add <package>|rm|update|on|off|login <provider>|logout <provider>]
@@ -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