provider: a subscription used through its vendor's CLI — Devin's, Cursor's, Grok's — installs that CLI when its sign-in starts, if this machine lacks it, rather than leaving the sign-in to fail or, as Devin's did, to say "signed in" and show nothing: Devin's sign-in wrote credentials.toml, but magpie sees the account only through devin auth status, so with no devin CLI no provider ever came and nothing said why. The sign-in is now "installing" while the vendor's own installer runs (curl … | bash, or its PowerShell one on Windows, with magpie's proxy), then opens the vendor's page as before; the window says what it is installing and why, can cancel it, and a failed install says the installer's last line and the command to run by hand. What counts is the CLI being there afterwards — Devin's installer ends in devin setup, whose sign-in gives up without a terminal and fails the script after it installed everything — and on Windows the PATH the installer wrote is taken up so the running app finds it. A Devin sign-in the CLI then can't see fails, where it said done with no one; the tests no longer see this machine's own devin
This commit is contained in:
12 files changed
+486
-49
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "复制链接",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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, ";"))
|
||||
}
|
||||
+108
-41
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user