diff --git a/accounts_cli.go b/accounts_cli.go index 9dded9ef..11c3d622 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -253,6 +253,16 @@ func addAccount(agentID string) error { if err != nil { return err } + if st.State == "installing" { + fmt.Printf("Installing %s, which %s is used through…\n", st.Installing, agentID) + for st.State == "installing" { + time.Sleep(500 * time.Millisecond) + st, _ = provider.SignInStatus(st.ID) + } + if st.State != "waiting" { + return fmt.Errorf("sign-in didn't finish: %s", st.Error) + } + } fmt.Println("Finish signing in in your browser. If it didn't open, go to:") fmt.Println(faint.Render(st.URL)) openInBrowser(st.URL) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f572a342..37cbac96 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2715,11 +2715,12 @@ const SUBS = [ { agent: "antigravity", name: "Antigravity", icon: "antigravity-color", plans: "Google AI Pro · Ultra · free", risk: true }, ]; const subOf = (agent) => SUBS.find((x) => x.agent === agent); -let signing = null; // the sign-in under way: { id, agent, url, state, error } +let signing = null; // the sign-in under way: { id, agent, url, state, installing, error } +const signingOpen = () => signing?.state === "waiting" || signing?.state === "installing"; let justAdded = ""; // the account that just came in, to greet it async function startSignIn(agent, risky) { - if (signing?.state === "waiting") api("signin/" + signing.id + "/cancel", {}).catch(() => {}); + if (signingOpen()) api("signin/" + signing.id + "/cancel", {}).catch(() => {}); // an account Google may suspend is added only once that is said if (subOf(agent)?.risk && !risky) { signing = { agent, state: "risk" }; @@ -2739,11 +2740,17 @@ async function startSignIn(agent, risky) { } async function followSignIn(id) { - while (signing?.id === id && signing.state === "waiting") { + while (signing?.id === id && signingOpen()) { await new Promise((r) => setTimeout(r, 800)); let st; try { st = await api("signin/" + id); } catch { continue; } - if (signing?.id !== id || st.state === "waiting") continue; + if (signing?.id !== id) continue; + if (st.state === "waiting" || st.state === "installing") { + // the CLI it needed is in: now the vendor's page can open + if (signing.state === "installing" && st.state === "waiting" && st.url) api("open", { url: st.url }).catch(() => {}); + if (signing.state !== st.state || signing.url !== st.url) { signing = st; renderProviders(); } + continue; + } if (st.state === "done") { signing = null; justAdded = st.user; @@ -2797,6 +2804,15 @@ function renderSigning(sub) { return box; } box.append(el("span", "spinner")); + if (signing.state === "installing") { + tt.append(el("span", "n", t("Installing {cli}…", { cli: signing.installing })), + el("span", "s", t("{name} is used through its own CLI, which isn't on this computer yet. magpie is installing it with the official installer; the sign-in page opens as soon as it's done.", { name: sub.name }))); + box.append(tt); + const x = el("button", "text", t("Cancel")); + x.onclick = cancelSignIn; + box.append(x); + return box; + } tt.append(el("span", "n", t("Finish signing in to {name} in your browser", { name: sub.name })), el("span", "s", 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/gui/assets/i18n.js b/internal/gui/assets/i18n.js index eafa566a..88d2b481 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -214,6 +214,8 @@ const I18N = { "{name} accounts can be suspended": "{name} 账号可能被封禁", "Google may suspend an Antigravity account it sees used outside Antigravity. Use one you can afford to lose.": "Google 发现 Antigravity 账号在 Antigravity 之外使用时,可能会封禁该账号。请用一个丢了也无妨的账号。", "Sign in anyway": "仍然登录", + "Installing {cli}…": "正在安装 {cli}…", + "{name} is used through its own CLI, which isn't on this computer yet. magpie is installing it with the official installer; the sign-in page opens as soon as it's done.": "{name} 需要通过它自己的 CLI 使用,这台电脑上还没有安装。magpie 正在用官方安装脚本安装,装好后会立即打开登录页面。", "magpie opened the sign-in page. The account shows up here as soon as you're done.": "magpie 已打开登录页面,完成后账号会立即出现在这里。", "Open again": "重新打开", "Copy link": "复制链接", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 90520a0d..93bfca9a 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -523,7 +523,11 @@ func providerRoutes(mux *http.ServeMux, w Windows, gw *gateway.Server) { fail(rw, err) return } - w.OpenURL(st.URL) + if st.URL != "" { + // one that installs a CLI first has no URL yet: the window + // opens it once there is one + w.OpenURL(st.URL) + } writeJSON(rw, st) }) mux.HandleFunc("GET /api/signin/{id}", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/provider/account_test.go b/internal/provider/account_test.go index b68fcced..05b3e280 100644 --- a/internal/provider/account_test.go +++ b/internal/provider/account_test.go @@ -66,16 +66,20 @@ func signIn(t *testing.T) string { func isolate(t *testing.T) { t.Helper() oldKeychain, oldURL, oldBase, oldExe := claudeKeychain, claudeTokenURL, claudeBase, claudeExecutable - oldCursor := cursorKeychain + oldCursor, oldDevin := cursorKeychain, DevinExecutable claudeKeychain, cursorKeychain = false, false claudeExecutable = func() string { return "" } + // the machine's own devin, if it has one, is no test's + DevinExecutable = func() string { return "" } forgetClaudeCredential() forgetClaudeStatus() + forgetDevinStatus() t.Cleanup(func() { claudeKeychain, claudeTokenURL, claudeBase, claudeExecutable = oldKeychain, oldURL, oldBase, oldExe - cursorKeychain = oldCursor + cursorKeychain, DevinExecutable = oldCursor, oldDevin forgetClaudeCredential() forgetClaudeStatus() + forgetDevinStatus() }) } diff --git a/internal/provider/devin.go b/internal/provider/devin.go index b1c2c4d7..827a81fe 100644 --- a/internal/provider/devin.go +++ b/internal/provider/devin.go @@ -307,7 +307,13 @@ func devinExchange(ctx context.Context, code, verifier, redirect string) (savedL return savedLogin{}, err } forgetDevinStatus() - user, plan, _ := askDevinIdentity() + forgetAccountCaches() + user, plan, ok := askDevinIdentity() + if !ok { + // the account is only ever seen through the CLI: without it + // answering, magpie has nothing to show and nothing to run + return savedLogin{}, errors.New("signed in, but `devin auth status` doesn't show the account; run it in a terminal to see why") + } return savedLogin{Agent: "devin", User: user, Plan: plan}, nil } diff --git a/internal/provider/grok.go b/internal/provider/grok.go index 0c9c8a00..605db514 100644 --- a/internal/provider/grok.go +++ b/internal/provider/grok.go @@ -262,7 +262,9 @@ func runCLISignIn(s *signInFlow, what string, env []string, using bool, failed f cancel() return err } + s.mu.Lock() s.stop = cancel + s.mu.Unlock() got := make(chan string, 1) go func() { sc := bufio.NewScanner(out) diff --git a/internal/provider/install.go b/internal/provider/install.go new file mode 100644 index 00000000..50c49dc8 --- /dev/null +++ b/internal/provider/install.go @@ -0,0 +1,116 @@ +package provider + +// Some subscriptions are used through the vendor's own CLI — Cursor's, +// Grok's, Devin's — and signed in with it too. One that isn't installed is +// installed with the vendor's own installer when its sign-in starts, as the +// vendor's page tells a person to, rather than leaving the sign-in to fail +// or, as Devin's did, to finish with nothing to show for it. + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/yetone/magpie/internal/netproxy" + "github.com/yetone/magpie/internal/proc" +) + +// agentCLI is a CLI a subscription runs through: how to find it, and the +// vendor's install one-liners for a POSIX shell and for PowerShell. +type agentCLI struct { + Name string + find func() string + sh string + ps string +} + +func cliFor(agent string) (agentCLI, bool) { + switch agent { + case "devin": + return agentCLI{"Devin CLI", func() string { return DevinExecutable() }, + "curl -fsSL https://cli.devin.ai/install.sh | bash", + "irm https://static.devin.ai/cli/setup.ps1 | iex"}, true + case "cursor": + return agentCLI{"Cursor CLI", func() string { return CursorExecutable() }, + "curl https://cursor.com/install -fsS | bash", + "irm 'https://cursor.com/install?win32=true' | iex"}, true + case "grok": + return agentCLI{"Grok CLI", func() string { return GrokExecutable() }, + "curl -fsSL https://x.ai/cli/install.sh | bash", + "irm https://x.ai/cli/install.ps1 | iex"}, true + } + return agentCLI{}, false +} + +// missingCLI is the CLI an agent's sign-in needs and this machine lacks. +func missingCLI(agent string) (agentCLI, bool) { + c, ok := cliFor(agent) + if !ok || c.find() != "" { + return agentCLI{}, false + } + return c, true +} + +// installTimeout bounds an installer: they download a few hundred MB at most. +var installTimeout = 10 * time.Minute + +// runInstaller runs a vendor's installer; a var so tests can fake it. +var runInstaller = func(ctx context.Context, c agentCLI) ([]byte, error) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = proc.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", c.ps) + } else { + cmd = proc.CommandContext(ctx, "bash", "-c", c.sh) + } + cmd.Dir, _ = os.UserHomeDir() + cmd.Env = netproxy.Env(nil) + // no stdin: an installer that would ask something takes its default + return cmd.CombinedOutput() +} + +// installCLI installs c with its vendor's installer and makes sure magpie +// finds it afterwards. +func installCLI(ctx context.Context, c agentCLI) error { + ctx, cancel := context.WithTimeout(ctx, installTimeout) + defer cancel() + out, err := runInstaller(ctx, c) + // an installer adds its directory to the user's PATH, which a running + // program never sees + refreshPath() + manual := c.sh + if runtime.GOOS == "windows" { + manual = c.ps + } + if ctx.Err() == context.Canceled { + return ctx.Err() + } + // what counts is the CLI being there: an installer may end in a step + // that wants a terminal — Devin's runs `devin setup`, whose sign-in + // gives up without one — and fail after it installed everything + if c.find() != "" { + return nil + } + if err != nil { + msg := lastLine(out) + if msg == "" { + msg = err.Error() + } + return errorf("installing %s failed (%s); install it yourself with `%s`, then sign in again", c.Name, msg, manual) + } + return errorf("the %s installer finished, but magpie can't find it; install it yourself with `%s`, then sign in again", c.Name, manual) +} + +// lastLine is the last line of a command's output with something on it, +// without its colours. +func lastLine(out []byte) string { + lines := strings.Split(ansi.ReplaceAllString(string(out), ""), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if l := strings.TrimSpace(lines[i]); l != "" { + return l + } + } + return "" +} diff --git a/internal/provider/install_other.go b/internal/provider/install_other.go new file mode 100644 index 00000000..8d0f987b --- /dev/null +++ b/internal/provider/install_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package provider + +// refreshPath has nothing to do: magpie looks for the CLIs where their +// installers put them. +func refreshPath() {} diff --git a/internal/provider/install_test.go b/internal/provider/install_test.go new file mode 100644 index 00000000..8d96c88e --- /dev/null +++ b/internal/provider/install_test.go @@ -0,0 +1,160 @@ +package provider + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// a sign-in that needs a CLI this machine lacks installs it, then goes on +func TestSignInInstallsMissingCLI(t *testing.T) { + if os.PathSeparator != '/' { + t.Skip("the fake devin is a shell script") + } + home := claudeHome(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + exe := filepath.Join(home, "bin", "devin") + oldExe := DevinExecutable + DevinExecutable = func() string { + if isFile(exe) { + return exe + } + return "" + } + t.Cleanup(func() { DevinExecutable = oldExe }) + release := make(chan struct{}) + var ran string + oldRun := runInstaller + runInstaller = func(ctx context.Context, c agentCLI) ([]byte, error) { + ran = c.sh + <-release + os.MkdirAll(filepath.Dir(exe), 0o755) + os.WriteFile(exe, []byte("#!/bin/sh\nprintf 'Logged in (via Devin).\\n Email: dev@example.com\\n Tier: Devin Pro\\n'\n"), 0o755) + // as Devin's does: `devin setup` gives up on its sign-in without a terminal + return []byte("Installed devin\nError: Login canceled\n"), errors.New("exit status 1") + } + t.Cleanup(func() { runInstaller = oldRun }) + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"sessionToken":"devin-session-token$sk-test"}`)) + })) + defer fake.Close() + oldTok := devinExchangeURL + devinExchangeURL = fake.URL + t.Cleanup(func() { devinExchangeURL = oldTok }) + + st, err := StartSignIn("devin") + if err != nil { + t.Fatal(err) + } + if st.State != "installing" || st.Installing != "Devin CLI" || st.URL != "" { + t.Fatalf("started %+v", st) + } + close(release) + st = waitPast(t, st.ID, "installing") + if st.State != "waiting" || !strings.HasPrefix(st.URL, devinAuthorizeURL) || st.Installing != "" { + t.Fatalf("after install %+v", st) + } + if !strings.Contains(ran, "cli.devin.ai/install.sh") { + t.Fatalf("installer %q", ran) + } + finishInBrowser(t, st, "dv-code") + if st = waitDone(t, st.ID); st.State != "done" || st.User != "dev@example.com" { + t.Fatalf("state %+v", st) + } +} + +// an installer that fails says so, and how to install it by hand +func TestSignInInstallFails(t *testing.T) { + claudeHome(t) + oldExe := DevinExecutable + DevinExecutable = func() string { return "" } + t.Cleanup(func() { DevinExecutable = oldExe }) + oldRun := runInstaller + runInstaller = func(ctx context.Context, c agentCLI) ([]byte, error) { + return []byte("downloading…\n\x1b[0;31mError: Failed to download\x1b[0m\n"), errors.New("exit status 1") + } + t.Cleanup(func() { runInstaller = oldRun }) + + st, err := StartSignIn("devin") + if err != nil { + t.Fatal(err) + } + st = waitPast(t, st.ID, "installing") + if st.State != "failed" || !strings.Contains(st.Error, "Error: Failed to download") || !strings.Contains(st.Error, "install.") { + t.Fatalf("state %+v", st) + } +} + +// canceled while installing: the sign-in never starts +func TestSignInCanceledWhileInstalling(t *testing.T) { + claudeHome(t) + oldExe := DevinExecutable + DevinExecutable = func() string { return "" } + t.Cleanup(func() { DevinExecutable = oldExe }) + stopped := make(chan struct{}) + oldRun := runInstaller + runInstaller = func(ctx context.Context, c agentCLI) ([]byte, error) { + <-ctx.Done() + close(stopped) + return nil, ctx.Err() + } + t.Cleanup(func() { runInstaller = oldRun }) + + st, _ := StartSignIn("devin") + CancelSignIn(st.ID) + select { + case <-stopped: + case <-time.After(5 * time.Second): + t.Fatal("the installer went on") + } + if st, _ = SignInStatus(st.ID); st.State != "canceled" { + t.Fatalf("state %+v", st) + } +} + +// signed in, but the CLI can't see it: a failure, not "done" with no one +func TestDevinSignInUnseen(t *testing.T) { + if os.PathSeparator != '/' { + t.Skip("the fake devin is a shell script") + } + home := claudeHome(t) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + exe := filepath.Join(home, "devin") + os.WriteFile(exe, []byte("#!/bin/sh\necho 'Not logged in.'\n"), 0o755) + oldExe := DevinExecutable + DevinExecutable = func() string { return exe } + t.Cleanup(func() { DevinExecutable = oldExe }) + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"sessionToken":"devin-session-token$sk-test"}`)) + })) + defer fake.Close() + oldTok := devinExchangeURL + devinExchangeURL = fake.URL + t.Cleanup(func() { devinExchangeURL = oldTok }) + + st, err := StartSignIn("devin") + if err != nil { + t.Fatal(err) + } + finishInBrowser(t, st, "dv-code") + if st = waitDone(t, st.ID); st.State != "failed" || !strings.Contains(st.Error, "devin auth status") { + t.Fatalf("state %+v", st) + } +} + +func waitPast(t *testing.T, id, state string) SignInState { + t.Helper() + for end := time.Now().Add(5 * time.Second); time.Now().Before(end); time.Sleep(20 * time.Millisecond) { + if st, _ := SignInStatus(id); st.State != state { + return st + } + } + t.Fatalf("still %s", state) + return SignInState{} +} diff --git a/internal/provider/install_windows.go b/internal/provider/install_windows.go new file mode 100644 index 00000000..4ad4852a --- /dev/null +++ b/internal/provider/install_windows.go @@ -0,0 +1,43 @@ +package provider + +import ( + "os" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// refreshPath takes up the PATH an installer just wrote to the registry, +// which this process, started before it, doesn't have. +func refreshPath() { + var dirs []string + for _, k := range []struct { + root registry.Key + path string + }{ + {registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`}, + {registry.CURRENT_USER, `Environment`}, + } { + key, err := registry.OpenKey(k.root, k.path, registry.QUERY_VALUE) + if err != nil { + continue + } + if v, _, err := key.GetStringValue("Path"); err == nil { + for _, d := range strings.Split(v, ";") { + if d = strings.TrimSpace(os.ExpandEnv(strings.ReplaceAll(d, "%", "$"))); d != "" { + dirs = append(dirs, d) + } + } + } + key.Close() + } + seen := map[string]bool{} + var path []string + for _, d := range append(strings.Split(os.Getenv("PATH"), ";"), dirs...) { + if l := strings.ToLower(d); d != "" && !seen[l] { + seen[l] = true + path = append(path, d) + } + } + os.Setenv("PATH", strings.Join(path, ";")) +} diff --git a/internal/provider/signin.go b/internal/provider/signin.go index 8eb23f6c..0f4191e6 100644 --- a/internal/provider/signin.go +++ b/internal/provider/signin.go @@ -48,13 +48,15 @@ var signInTimeout = 10 * time.Minute type SignInState struct { ID string `json:"id"` Agent string `json:"agent"` - URL string `json:"url"` // the vendor's page, to open or copy - Code string `json:"code,omitempty"` // what to type there, for a device code - State string `json:"state"` // waiting, done, failed or canceled - User string `json:"user,omitempty"` // the account, once done - Plan string `json:"plan,omitempty"` // - Using bool `json:"using,omitempty"` // the agent was signed in to it too - Error string `json:"error,omitempty"` + URL string `json:"url"` // the vendor's page, to open or copy + Code string `json:"code,omitempty"` // what to type there, for a device code + State string `json:"state"` // installing, waiting, done, failed or canceled + // Installing is the CLI being installed before the sign-in can start + Installing string `json:"installing,omitempty"` + User string `json:"user,omitempty"` // the account, once done + Plan string `json:"plan,omitempty"` // + Using bool `json:"using,omitempty"` // the agent was signed in to it too + Error string `json:"error,omitempty"` } type signInFlow struct { @@ -81,9 +83,84 @@ func randomToken(n int) string { // StartSignIn begins adding an account to an agent: open the returned URL // in a browser and the rest happens on its own; SignInStatus follows it. +// +// An agent signed in with a CLI that isn't installed has it installed first: +// the sign-in is then "installing", and gets its URL when that is done. func StartSignIn(agent string) (SignInState, error) { s := &signInFlow{verifier: randomToken(48), state: randomToken(24), done: make(chan struct{})} s.st = SignInState{ID: randomToken(9), Agent: agent, State: "waiting"} + cli, install := missingCLI(agent) + var installing context.Context + if install { + s.st.State, s.st.Installing = "installing", cli.Name + // canceling the sign-in stops the installer + installing, s.stop = context.WithCancel(context.Background()) + } else if err := s.begin(); err != nil { + return SignInState{}, err + } + timeout := signInTimeout + if install { + timeout += installTimeout + } + go func() { + select { + case <-s.done: + case <-time.After(timeout): + s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"}) + } + }() + + signIns.Lock() + // only one sign-in per agent at a time: a newer one replaces the older + for id, o := range signIns.m { + if o.st.Agent == agent { + o.finish(SignInState{State: "canceled"}) + delete(signIns.m, id) + } + } + signIns.m[s.st.ID] = s + signIns.Unlock() + if install { + go s.installThenBegin(installing, cli) + } + return s.status(), nil +} + +// installThenBegin installs the CLI the sign-in needs, then starts it. +func (s *signInFlow) installThenBegin(ctx context.Context, cli agentCLI) { + err := installCLI(ctx, cli) + s.mu.Lock() + s.stop = nil + s.mu.Unlock() + if err == nil { + forgetAccountCaches() + err = s.begin() + } + if err != nil { + s.finish(SignInState{State: "failed", Error: err.Error()}) + return + } + s.mu.Lock() + if s.st.State == "installing" { + s.st.State, s.st.Installing = "waiting", "" + s.mu.Unlock() + return + } + // canceled while it began: let go of what it started + stop, srv := s.stop, s.srv + s.mu.Unlock() + if stop != nil { + stop() + } + if srv != nil { + _ = srv.Close() + } +} + +// begin starts the vendor's sign-in: the URL to open, and whatever waits +// for the browser to come back. +func (s *signInFlow) begin() error { + agent := s.st.Agent sum := sha256.Sum256([]byte(s.verifier)) challenge := base64.RawURLEncoding.EncodeToString(sum[:]) @@ -93,7 +170,7 @@ func StartSignIn(agent string) (SignInState, error) { switch agent { case "claude": if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil { - return SignInState{}, err + return err } s.redirect = fmt.Sprintf("http://localhost:%d/callback", ln.Addr().(*net.TCPAddr).Port) q.Set("code", "true") @@ -107,7 +184,7 @@ func StartSignIn(agent string) (SignInState, error) { s.st.URL = claudeAuthorizeURL + "?" + q.Encode() case "codex": if ln, err = listenCodexCallback(); err != nil { - return SignInState{}, err + return err } _, port, _ := net.SplitHostPort(codexCallbackAddr) s.redirect = "http://localhost:" + port + "/auth/callback" @@ -125,18 +202,18 @@ func StartSignIn(agent string) (SignInState, error) { case "cursor": // Cursor has no sign-in of its own to borrow: its CLI signs in if err := startCursorSignIn(s); err != nil { - return SignInState{}, err + return err } case "grok": // so is Grok: its CLI signs in with a device code if err := startGrokSignIn(s); err != nil { - return SignInState{}, err + return err } case "devin": // `devin auth login` is a TUI over this same PKCE + localhost // callback, so magpie runs the round itself if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil { - return SignInState{}, err + return err } s.redirect = fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port) q.Set("redirect_uri", s.redirect) @@ -145,48 +222,37 @@ func StartSignIn(agent string) (SignInState, error) { q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") q.Set("cli_pkce_marker", "1") + // set with the lock: the window follows a sign-in that installed + // devin first while this runs + s.mu.Lock() s.st.URL = devinAuthorizeURL + "?" + q.Encode() + s.mu.Unlock() case "copilot": // GitHub's device code, as Copilot's editors sign in if err := startCopilotSignIn(s); err != nil { - return SignInState{}, err + return err } case "gemini", "antigravity": // Google's sign-in, under the app's own OAuth client app, _ := googleAppOf(agent) if ln, err = net.Listen("tcp", "127.0.0.1:0"); err != nil { - return SignInState{}, err + return err } startGoogleSignIn(s, app, ln.Addr().(*net.TCPAddr).Port, challenge) default: - return SignInState{}, fmt.Errorf("magpie can't sign in to %s accounts", agent) + return fmt.Errorf("magpie can't sign in to %s accounts", agent) } if ln != nil { mux := http.NewServeMux() mux.HandleFunc("/", s.callback) - s.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} - go func() { _ = s.srv.Serve(ln) }() + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + s.mu.Lock() + s.srv = srv + s.mu.Unlock() + go func() { _ = srv.Serve(ln) }() } - go func() { - select { - case <-s.done: - case <-time.After(signInTimeout): - s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"}) - } - }() - - signIns.Lock() - // only one sign-in per agent at a time: a newer one replaces the older - for id, o := range signIns.m { - if o.st.Agent == agent { - o.finish(SignInState{State: "canceled"}) - delete(signIns.m, id) - } - } - signIns.m[s.st.ID] = s - signIns.Unlock() - return s.status(), nil + return nil } // listenCodexCallback takes Codex's callback port. A `codex login` left @@ -255,22 +321,23 @@ func (s *signInFlow) status() SignInState { // finish records the outcome once and lets the callback server go. func (s *signInFlow) finish(out SignInState) bool { s.mu.Lock() - if s.st.State != "waiting" { + if s.st.State != "waiting" && s.st.State != "installing" { s.mu.Unlock() return false } out.ID, out.Agent, out.URL, out.Code = s.st.ID, s.st.Agent, s.st.URL, s.st.Code s.st = out + stop, srv := s.stop, s.srv s.mu.Unlock() if out.State == "done" { // signing in again brings back an account removed from magpie _ = ShowAccount(out.Agent) } close(s.done) - if s.stop != nil { - s.stop() + if stop != nil { + stop() } - if s.srv == nil { + if srv == nil { return true } go func() { @@ -278,7 +345,7 @@ func (s *signInFlow) finish(out SignInState) bool { time.Sleep(time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _ = s.srv.Shutdown(ctx) + _ = srv.Shutdown(ctx) }() return true }