diff --git a/accounts_cli.go b/accounts_cli.go index 64817851..25448dc4 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -96,6 +96,12 @@ func accountsCmd(args []string) error { return nil } fmt.Println(green.Render("✓"), id, "is now signed in as", args[3], muted.Render("· sessions already running keep their account until restarted")) + if id == "codex" { + if was := provider.CodexDaemonStale(); was != "" { + fmt.Println(" ", "Codex's background service is still signed in as", was+"; restart it to use", args[3]+":", provider.CodexDaemonRestart) + fmt.Println(" ", muted.Render("running Codex sessions will be interrupted")) + } + } return nil } which, asJSON := "", false diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 3ee77e0a..c5dbc460 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3819,6 +3819,7 @@ function renderAccounts(a) { row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user)); list.append(row); } + if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon)); if (signing?.agent === a.agent) list.append(renderSigning(sub)); else { const add = el("button", "acc add"); @@ -3840,6 +3841,26 @@ function renderAccounts(a) { return list; } +// renderCodexDaemon: Codex's background app-server read the sign-in when it +// started, so after a switch the Codex sessions that attach to it are still +// on the account before (user) until it restarts. magpie doesn't restart it +// unasked: that ends the Codex sessions running on it. +function renderCodexDaemon(user) { + const box = el("div", "signing daemon"); + box.append(el("span", "mark", "!")); + const tt = el("span", "tt"); + tt.append(el("span", "n", t("Codex's background service is still signed in as {user}", { user })), + el("span", "s", t("Restart it to use the new account. Running Codex sessions will be interrupted."))); + box.append(tt); + const later = el("button", "text", t("Later")); + later.onclick = () => accountAction("codex/daemon/dismiss", {}); + const go = el("button", "text primary", t("Restart")); + go.title = "codex app-server daemon restart"; + go.onclick = () => { go.classList.add("busy"); accountAction("codex/daemon/restart", {}, t("Codex's background service restarted")); }; + box.append(later, go); + return box; +} + // Accounts brought in from another tool's export instead of signing in // again: the files' text (or what is pasted) goes to magpie, which checks // each account with the vendor before keeping it, and says what became of diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 490a26dd..4cedeccb 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -374,6 +374,11 @@ const I18N = { "magpie forgets this account's sign-in; the account itself is untouched": "magpie 忘掉这个账号的登录信息;账号本身不受影响", "Sign {agent} in to this account": "让 {agent} 登录到这个账号", "{agent} is now signed in as {user}": "{agent} 已切换为 {user}", + "Codex's background service is still signed in as {user}": "Codex 的后台服务仍登录着 {user}", + "Restart it to use the new account. Running Codex sessions will be interrupted.": "重启它才会用上新账号。正在运行的 Codex 会话会被中断。", + "Later": "稍后", + "Restart": "重启", + "Codex's background service restarted": "Codex 的后台服务已重启", "{agent}'s sign-in, read from its own files. Sign out there and this provider goes away.": "{agent} 的登录信息,读取自它自己的文件。在那里退出登录,此供应商即消失。", "Models": "模型", "Endpoints": "端点", diff --git a/internal/gui/codex_daemon_test.go b/internal/gui/codex_daemon_test.go new file mode 100644 index 00000000..0923796e --- /dev/null +++ b/internal/gui/codex_daemon_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// The Restart button runs `codex app-server daemon restart` with the codex +// magpie finds, for the Codex home it switches; a stand-in codex writes +// down how it was run, since the real one would restart the user's daemon. +func TestCodexDaemonRestartRoute(t *testing.T) { + h := sandboxHome(t) + bin := t.TempDir() + t.Setenv("PATH", bin) + t.Setenv("CODEX_HOME", "/somewhere/else") + rec := filepath.Join(bin, "ran") + script := "#!/bin/sh\necho \"$@|$CODEX_HOME\" > '" + rec + "'\n[ \"$FAIL\" = 1 ] && { echo 'Error: no daemon' >&2; exit 1; }\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "codex"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + providerRoutes(mux, nil) + post := func(action string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/codex/daemon/"+action, strings.NewReader("{}"))) + return w + } + w := post("restart") + if w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body) + } + var st providersJSON + if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil || st.CodexDaemon != "" { + t.Fatalf("state %v %q", err, st.CodexDaemon) + } + b, _ := os.ReadFile(rec) + if got, want := strings.TrimSpace(string(b)), "app-server daemon restart|"+filepath.Join(h, ".codex"); got != want { + t.Fatalf("ran %q, want %q", got, want) + } + t.Setenv("FAIL", "1") + if w := post("restart"); w.Code != 400 || !strings.Contains(w.Body.String(), "no daemon") { + t.Fatalf("failing codex: %d %s", w.Code, w.Body) + } + if w := post("dismiss"); w.Code != 200 { + t.Fatalf("dismiss: %d %s", w.Code, w.Body) + } + if w := post("nope"); w.Code != 404 { + t.Fatalf("unknown action: %d", w.Code) + } +} diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 3e398187..5070bfb3 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -134,6 +134,10 @@ type providersJSON struct { Presets []presetJSON `json:"presets"` Excluded []excludedJSON `json:"excluded"` // sign-ins magpie found but will not share Gateway gatewayJSON `json:"gateway"` + // CodexDaemon is the account Codex's background app-server is still + // signed in to after Codex was switched to another; "" when none is + // left behind (provider.CodexDaemonStale). + CodexDaemon string `json:"codexDaemon,omitempty"` } // agentModel is the model an agent is on, as magpie's catalog names it. @@ -328,6 +332,7 @@ func providersState() providersJSON { } else { s.Gateway.Running, s.Gateway.Window = gateway.Serving() } + s.CodexDaemon = provider.CodexDaemonStale() return s } @@ -655,6 +660,25 @@ func providerRoutes(mux *http.ServeMux, w Windows) { agent.SyncCatalog() writeJSON(rw, providersState()) }) + // Codex's background app-server, left on the account before a switch: + // restarting it (which ends the Codex sessions on it), or letting it be. + mux.HandleFunc("POST /api/codex/daemon/{action}", func(rw http.ResponseWriter, r *http.Request) { + switch r.PathValue("action") { + case "restart": + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + if err := provider.RestartCodexDaemon(ctx); err != nil { + fail(rw, err) + return + } + case "dismiss": + provider.DismissCodexDaemon() + default: + http.NotFound(rw, r) + return + } + writeJSON(rw, providersState()) + }) // A provider's several keys: add one, put one in use, name or remove it. mux.HandleFunc("POST /api/keys/{action}", func(rw http.ResponseWriter, r *http.Request) { var in struct { diff --git a/internal/proc/list.go b/internal/proc/list.go new file mode 100644 index 00000000..2a9937ca --- /dev/null +++ b/internal/proc/list.go @@ -0,0 +1,41 @@ +package proc + +import ( + "context" + "strconv" + "strings" +) + +// A Process is one running on this computer, as the system lists it: its +// id and its command line, arguments joined by spaces. +type Process struct { + PID int + Args string +} + +// List lists the running processes (Windows: only those named prefix*, +// since asking for all is slow there; elsewhere prefix is ignored). An +// error says the system couldn't be asked, not that nothing runs. +func List(ctx context.Context, prefix string) ([]Process, error) { + out, err := listCommand(ctx, prefix).Output() + if err != nil { + return nil, err + } + return parseList(string(out)), nil +} + +// parseList reads lines of "pid command line", as ps -o pid=,args= and the +// PowerShell listing print them; a line without a pid is skipped. +func parseList(out string) []Process { + var ps []Process + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + id, args, _ := strings.Cut(line, " ") + pid, err := strconv.Atoi(id) + if err != nil || pid <= 0 { + continue + } + ps = append(ps, Process{PID: pid, Args: strings.TrimSpace(args)}) + } + return ps +} diff --git a/internal/proc/list_other.go b/internal/proc/list_other.go new file mode 100644 index 00000000..577649fb --- /dev/null +++ b/internal/proc/list_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package proc + +import ( + "context" + "os/exec" +) + +// listCommand is ps, in its POSIX form, which macOS, Linux and the BSDs +// all take: every process, its id and its whole command line. +func listCommand(ctx context.Context, _ string) *exec.Cmd { + return CommandContext(ctx, "ps", "-A", "-o", "pid=", "-o", "args=") +} diff --git a/internal/proc/list_test.go b/internal/proc/list_test.go new file mode 100644 index 00000000..4f2ac36a --- /dev/null +++ b/internal/proc/list_test.go @@ -0,0 +1,23 @@ +package proc + +import ( + "reflect" + "testing" +) + +func TestParseList(t *testing.T) { + out := " PID ARGS\n" + + " 1 /sbin/launchd\n" + + " 812 /Users/me/.codex/packages/standalone/current/bin/codex app-server --managed-daemon --listen unix://\n" + + "\n" + + "9001\r\n" + + "x 1 junk\n" + want := []Process{ + {1, "/sbin/launchd"}, + {812, "/Users/me/.codex/packages/standalone/current/bin/codex app-server --managed-daemon --listen unix://"}, + {9001, ""}, + } + if got := parseList(out); !reflect.DeepEqual(got, want) { + t.Fatalf("parseList = %#v, want %#v", got, want) + } +} diff --git a/internal/proc/list_windows.go b/internal/proc/list_windows.go new file mode 100644 index 00000000..cb15dae1 --- /dev/null +++ b/internal/proc/list_windows.go @@ -0,0 +1,19 @@ +package proc + +import ( + "context" + "os/exec" + "strings" +) + +// listCommand asks WMI, through PowerShell, for the processes named +// prefix*, one "pid command line" line each; a process whose command line +// isn't readable (another user's) prints its id alone. +func listCommand(ctx context.Context, prefix string) *exec.Cmd { + filter := "" + if prefix != "" { + filter = ` -Filter "Name like '` + strings.ReplaceAll(prefix, "'", "''") + `%'"` + } + return CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", + `Get-CimInstance Win32_Process`+filter+` | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }`) +} diff --git a/internal/provider/codex_daemon.go b/internal/provider/codex_daemon.go new file mode 100644 index 00000000..e8916350 --- /dev/null +++ b/internal/provider/codex_daemon.go @@ -0,0 +1,252 @@ +package provider + +// Codex's background app-server. Codex CLI (0.156 on) can keep one running +// for its sessions to attach to — `codex app-server --managed-daemon`, +// started by the CLI and left running — and it reads the ChatGPT sign-in +// once, when it starts. So when magpie switches the account Codex is signed +// in to, a Codex session opened after that still runs on the account before +// until the daemon is restarted. magpie doesn't restart it on its own: +// that ends the sessions running on it. It says so instead, and restarts it +// when asked (RestartCodexDaemon). +// +// The daemon is told apart by --managed-daemon: the app-servers other apps +// run for themselves (the ChatGPT app's own codex, an editor's) are started +// without it, and they are none of magpie's business. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/proc" +) + +// CodexDaemonRestart is the command that restarts Codex's app-server, for +// the user to run themselves. +const CodexDaemonRestart = "codex app-server daemon restart" + +// listProcesses lists the processes that may be Codex's; a var so tests +// can fake it. +var listProcesses = func(ctx context.Context) ([]proc.Process, error) { + return proc.List(ctx, "codex") +} + +// processCodexHome is the CODEX_HOME a process was started with, when it +// can be read (Linux, a process of the user's): "" is unset. A var so tests +// can fake it. +var processCodexHome = func(pid int) (home string, ok bool) { + if runtime.GOOS != "linux" { + return "", false + } + b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ") + if err != nil { + return "", false + } + for _, kv := range bytes.Split(b, []byte{0}) { + if v, found := bytes.CutPrefix(kv, []byte("CODEX_HOME=")); found { + return string(v), true + } + } + return "", true +} + +// codexHome is the Codex home magpie signs Codex in through. +func codexHome() string { return filepath.Dir(codexAuthPath()) } + +// isManagedDaemon reports whether a command line is Codex's managed +// app-server: `codex [-c k=v…] app-server --managed-daemon …`. Its helpers +// (`codex app-server daemon pid-update-loop`), other apps' app-servers and +// other programs given those words aren't. +func isManagedDaemon(args string) bool { + f := strings.Fields(args) + i := slices.Index(f, "app-server") + if i < 1 || !slices.ContainsFunc(f[:i], func(a string) bool { + base := strings.ToLower(a[strings.LastIndexAny(a, `/\`)+1:]) + return base == "codex" || base == "codex.exe" + }) { + return false + } + return slices.ContainsFunc(f[i+1:], func(a string) bool { + return a == "--managed-daemon" || strings.HasPrefix(a, "--managed-daemon=") + }) +} + +// daemonPIDs are the ids Codex wrote down for the app-server it runs out of +// home: app-server-daemon/app-server.pid (daemon.pid before), a PidRecord +// ({"pid": …, …}) or a bare number. +func daemonPIDs(home string) []int { + var ids []int + for _, name := range []string{"app-server.pid", "daemon.pid"} { + b, err := os.ReadFile(filepath.Join(home, "app-server-daemon", name)) + if err != nil { + continue + } + if id, ok := parsePIDFile(b); ok { + ids = append(ids, id) + } + } + return ids +} + +func parsePIDFile(b []byte) (int, bool) { + b = bytes.TrimSpace(b) + if id, err := strconv.Atoi(string(b)); err == nil && id > 0 { + return id, true + } + var rec struct { + PID int `json:"pid"` + } + if json.Unmarshal(b, &rec) == nil && rec.PID > 0 { + return rec.PID, true + } + return 0, false +} + +// findCodexDaemon picks, among the running processes, Codex's managed +// app-server for home: 0 when none runs. One started for another +// CODEX_HOME is left out where that can be read; where it can't, the one +// home's pid file names is taken, else the first. +func findCodexDaemon(ps []proc.Process, home string) int { + var ids []int + for _, p := range ps { + if !isManagedDaemon(p.Args) { + continue + } + if h, ok := processCodexHome(p.PID); ok && h != "" && filepath.Clean(h) != filepath.Clean(home) { + continue + } + ids = append(ids, p.PID) + } + if len(ids) == 0 { + return 0 + } + for _, id := range daemonPIDs(home) { + if slices.Contains(ids, id) { + return id + } + } + return ids[0] +} + +// codexDaemon is the process id of Codex's managed app-server, 0 when none +// runs; known is false when the processes couldn't be listed. +func codexDaemon(ctx context.Context) (pid int, known bool) { + ps, err := listProcesses(ctx) + if err != nil { + return 0, false + } + return findCodexDaemon(ps, codexHome()), true +} + +// codexDaemonLeft is the app-server that was running when Codex's account +// last changed, and the account it was started on: it stays on that one +// until it restarts. +type leftDaemon struct { + pid int + user string +} + +var ( + codexDaemonMu sync.Mutex + codexDaemonLeft leftDaemon + codexDaemonChecked time.Time +) + +// codexDaemonRecheck is how long CodexDaemonStale trusts what it last saw. +const codexDaemonRecheck = 5 * time.Second + +// noteCodexSwitch remembers, when Codex's account changes from one to +// another, the app-server left running on the one before. +func noteCodexSwitch(from, to string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pid, known := codexDaemon(ctx) + codexDaemonMu.Lock() + defer codexDaemonMu.Unlock() + codexDaemonChecked = time.Now() + switch { + case !known: + case pid == 0: + codexDaemonLeft = leftDaemon{} + case pid == codexDaemonLeft.pid: + // the daemon is on the account it started with, whatever came between + if strings.EqualFold(to, codexDaemonLeft.user) { + codexDaemonLeft = leftDaemon{} + } + default: + codexDaemonLeft = leftDaemon{pid, from} + } +} + +// CodexDaemonStale answers the account Codex's app-server is still signed +// in to when Codex has been switched to another since it started, "" when +// it isn't running or is on the account Codex is. +func CodexDaemonStale() string { + codexDaemonMu.Lock() + defer codexDaemonMu.Unlock() + if codexDaemonLeft.pid == 0 { + return "" + } + if time.Since(codexDaemonChecked) > codexDaemonRecheck { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + pid, known := codexDaemon(ctx) + cancel() + codexDaemonChecked = time.Now() + if known && pid != codexDaemonLeft.pid { + // restarted, or stopped, since: it reads the sign-in afresh + codexDaemonLeft = leftDaemon{} + } + } + return codexDaemonLeft.user +} + +// DismissCodexDaemon stops saying the app-server is on another account. +func DismissCodexDaemon() { + codexDaemonMu.Lock() + defer codexDaemonMu.Unlock() + codexDaemonLeft = leftDaemon{} +} + +// RestartCodexDaemon has Codex restart its app-server, which reads the +// sign-in again: `codex app-server daemon restart`, run with the codex CLI +// magpie finds and the Codex home it signs Codex in through. The Codex +// sessions attached to it are ended. +func RestartCodexDaemon(ctx context.Context) error { + cmd, err := codexDaemonCommand(ctx, "restart") + if err != nil { + return err + } + if out, err := cmd.CombinedOutput(); err != nil { + if msg := lastLine(out); msg != "" { + return errors.New("codex app-server daemon restart: " + msg) + } + return errors.New("codex app-server daemon restart: " + err.Error()) + } + DismissCodexDaemon() + return nil +} + +// codexDaemonCommand is `codex app-server daemon `, with CODEX_HOME +// set to the home magpie switches, and nothing to read on stdin (Codex +// waits on a stdin left open). +func codexDaemonCommand(ctx context.Context, verb string) (*exec.Cmd, error) { + exe := codexExecutable() + if exe == "" { + return nil, errors.New("the codex CLI isn't installed, or magpie can't find it") + } + cmd := proc.CommandContext(ctx, exe, "app-server", "daemon", verb) + env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "CODEX_HOME=") }) + cmd.Env = append(env, "CODEX_HOME="+codexHome()) + cmd.Stdin = nil // os/exec reads it from the null device + return cmd, nil +} diff --git a/internal/provider/codex_daemon_test.go b/internal/provider/codex_daemon_test.go new file mode 100644 index 00000000..e30505e8 --- /dev/null +++ b/internal/provider/codex_daemon_test.go @@ -0,0 +1,261 @@ +package provider + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/proc" +) + +func TestIsManagedDaemon(t *testing.T) { + for args, want := range map[string]bool{ + "/Users/me/.codex/packages/standalone/current/bin/codex app-server --managed-daemon --listen unix://": true, + `C:\Users\me\.codex\bin\codex.exe app-server --managed-daemon=true`: true, + "codex -c features.x=true app-server --managed-daemon": true, + // the ChatGPT app's own codex, and other apps' app-servers + "/Applications/ChatGPT.app/Contents/Resources/codex -c features.code_mode_host=true app-server --analytics-default-enabled": false, + "/Users/me/Library/Application Support/Cindy/codex-package/0.156.0/bin/codex app-server --disable plugins": false, + // the daemon's helpers, and the commands that drive it + "/Users/me/.codex/packages/standalone/current/bin/codex app-server daemon pid-update-loop": false, + "codex app-server daemon restart": false, + "codex --managed-daemon": false, + "codex --managed-daemon app-server": false, + "app-server --managed-daemon": false, + "node /x/server.js app-server --managed-daemon": false, + "/x/codex-helper app-server --managed-daemon": false, + "": false, + } { + if got := isManagedDaemon(args); got != want { + t.Errorf("isManagedDaemon(%q) = %v, want %v", args, got, want) + } + } +} + +func TestParsePIDFile(t *testing.T) { + for in, want := range map[string]int{ + "4242\n": 4242, + `{"pid":812,"processStartTime":123,"processIdentity":"x","executableIdentity":{"digest":"d"}}`: 812, + "": 0, + "{}": 0, + "not a pid": 0, + `{"pid":-3}`: 0, + "-1": 0, + } { + if got, _ := parsePIDFile([]byte(in)); got != want { + t.Errorf("parsePIDFile(%q) = %d, want %d", in, got, want) + } + } +} + +func TestFindCodexDaemon(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + old := processCodexHome + t.Cleanup(func() { processCodexHome = old }) + homes := map[int]string{} + processCodexHome = func(pid int) (string, bool) { + h, ok := homes[pid] + return h, ok + } + chatgpt := proc.Process{PID: 10, Args: "/Applications/ChatGPT.app/Contents/Resources/codex app-server --analytics-default-enabled"} + helper := proc.Process{PID: 11, Args: "/x/codex app-server daemon pid-update-loop"} + ps := []proc.Process{chatgpt, helper} + if got := findCodexDaemon(ps, home); got != 0 { + t.Fatalf("no managed daemon: got %d", got) + } + ps = append(ps, proc.Process{PID: 20, Args: "/x/codex app-server --managed-daemon"}, + proc.Process{PID: 30, Args: "/y/codex app-server --managed-daemon"}) + if got := findCodexDaemon(ps, home); got != 20 { + t.Fatalf("no pid file: got %d, want the first, 20", got) + } + // the pid file of this home names the one that is its + os.MkdirAll(filepath.Join(home, "app-server-daemon"), 0o700) + os.WriteFile(filepath.Join(home, "app-server-daemon", "app-server.pid"), []byte(`{"pid":30}`), 0o600) + if got := findCodexDaemon(ps, home); got != 30 { + t.Fatalf("pid file: got %d, want 30", got) + } + // one known to run for another CODEX_HOME isn't this home's + homes[30] = "/elsewhere/.codex" + homes[20] = home + "/" + if got := findCodexDaemon(ps, home); got != 20 { + t.Fatalf("other home: got %d, want 20", got) + } + homes[20] = "/elsewhere/too" + if got := findCodexDaemon(ps, home); got != 0 { + t.Fatalf("all for other homes: got %d", got) + } +} + +// fakeDaemon has the process list show the managed daemon as pid (none +// when 0), or fail when pid is -1. +func fakeDaemon(t *testing.T) *int { + t.Helper() + t.Setenv("HOME", t.TempDir()) + pid := new(int) + old, oldHome := listProcesses, processCodexHome + t.Cleanup(func() { + listProcesses, processCodexHome = old, oldHome + DismissCodexDaemon() + }) + processCodexHome = func(int) (string, bool) { return "", false } + listProcesses = func(context.Context) ([]proc.Process, error) { + switch *pid { + case -1: + return nil, errors.New("no ps") + case 0: + return []proc.Process{{PID: 7, Args: "codex app-server"}}, nil + } + return []proc.Process{{PID: 7, Args: "codex app-server"}, {PID: *pid, Args: "codex app-server --managed-daemon"}}, nil + } + DismissCodexDaemon() + return pid +} + +func TestCodexDaemonStale(t *testing.T) { + pid := fakeDaemon(t) + recheck := func() { codexDaemonMu.Lock(); codexDaemonChecked = time.Time{}; codexDaemonMu.Unlock() } + + // no daemon running: nothing to say + noteCodexSwitch("a@x", "b@x") + if got := CodexDaemonStale(); got != "" { + t.Fatalf("no daemon: %q", got) + } + // one running when the account changes stays on the one before + *pid = 50 + noteCodexSwitch("a@x", "b@x") + if got := CodexDaemonStale(); got != "a@x" { + t.Fatalf("got %q, want a@x", got) + } + // and still after another switch, to a third + noteCodexSwitch("b@x", "c@x") + if got := CodexDaemonStale(); got != "a@x" { + t.Fatalf("after a third: got %q, want a@x", got) + } + // switched back to the one it started on, it is right again + noteCodexSwitch("c@x", "A@x") + if got := CodexDaemonStale(); got != "" { + t.Fatalf("back on its own: %q", got) + } + // restarted since: a new process, which read the new sign-in + noteCodexSwitch("a@x", "b@x") + *pid = 51 + recheck() + if got := CodexDaemonStale(); got != "" { + t.Fatalf("restarted: %q", got) + } + // the list failing says nothing about it: what was known stays + noteCodexSwitch("b@x", "a@x") + *pid = -1 + recheck() + if got := CodexDaemonStale(); got != "b@x" { + t.Fatalf("list failing: got %q, want b@x", got) + } + DismissCodexDaemon() + if got := CodexDaemonStale(); got != "" { + t.Fatalf("dismissed: %q", got) + } +} + +// fakeCodex puts a codex that writes down how it was run in place of the +// real one, which is never run: it would restart the user's daemon. +func fakeCodex(t *testing.T, exit int) (out string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("a shell script stands in for codex") + } + dir := t.TempDir() + out = filepath.Join(dir, "out") + script := "#!/bin/sh\n" + + "echo \"$@\" > '" + out + ".args'\n" + + "echo \"$CODEX_HOME\" > '" + out + ".home'\n" + + "if [ -p /dev/stdin ]; then echo pipe; else echo other; fi > '" + out + ".stdin'\n" + + "echo 'starting…'\n" + if exit != 0 { + script += "echo 'Error: background server socket is stale or unreachable' >&2\nexit " + string(rune('0'+exit)) + "\n" + } + exe := filepath.Join(dir, "codex") + if err := os.WriteFile(exe, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + old := codexExecutable + t.Cleanup(func() { codexExecutable = old }) + codexExecutable = func() string { return exe } + return out +} + +func TestRestartCodexDaemon(t *testing.T) { + pid := fakeDaemon(t) + t.Setenv("CODEX_HOME", "/somewhere/else") + out := fakeCodex(t, 0) + *pid = 50 + noteCodexSwitch("a@x", "b@x") + if err := RestartCodexDaemon(context.Background()); err != nil { + t.Fatal(err) + } + read := func(ext string) string { + b, _ := os.ReadFile(out + ext) + return strings.TrimSpace(string(b)) + } + if got := read(".args"); got != "app-server daemon restart" { + t.Errorf("args %q", got) + } + home, _ := os.UserHomeDir() + if got, want := read(".home"), filepath.Join(home, ".codex"); got != want { + t.Errorf("CODEX_HOME %q, want %q (where magpie writes auth.json)", got, want) + } + if got := read(".stdin"); got != "other" { + t.Errorf("stdin is a %s, want the null device", got) + } + codexDaemonMu.Lock() + left := codexDaemonLeft + codexDaemonMu.Unlock() + if left != (leftDaemon{}) { + t.Errorf("still noted after a restart: %+v", left) + } +} + +func TestRestartCodexDaemonFails(t *testing.T) { + pid := fakeDaemon(t) + fakeCodex(t, 2) + *pid = 50 + noteCodexSwitch("a@x", "b@x") + err := RestartCodexDaemon(context.Background()) + if err == nil || !strings.Contains(err.Error(), "stale or unreachable") { + t.Fatalf("err %v", err) + } + if got := CodexDaemonStale(); got != "a@x" { + t.Fatalf("a failed restart forgot the daemon: %q", got) + } + codexExecutable = func() string { return "" } + if err := RestartCodexDaemon(context.Background()); err == nil { + t.Fatal("no codex: no error") + } +} + +// Switching Codex's account notes the daemon left on the one before; the +// switch to the account Codex is on already changes nothing. +func TestSwitchLoginNotesCodexDaemon(t *testing.T) { + pid := fakeDaemon(t) + home := signIn(t) + rememberLogins(true) + codexSignIn(t, home, "work@example.com", "r-work") + rememberLogins(true) + *pid = 50 + if err := SwitchLogin("codex", "work@example.com"); err != nil { + t.Fatal(err) + } + if got := CodexDaemonStale(); got != "" { + t.Fatalf("no switch, yet %q", got) + } + if err := SwitchLogin("codex", "me@example.com"); err != nil { + t.Fatal(err) + } + if got := CodexDaemonStale(); got != "work@example.com" { + t.Fatalf("got %q, want work@example.com", got) + } +} diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 32a3f5a9..87845bde 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -477,7 +477,8 @@ func Logins(agent string) []Login { // SwitchLogin signs an agent in to a remembered account. Sessions of the // agent that are already running keep the account they started with until -// they restart. +// they restart; so does Codex's background app-server, which new Codex +// sessions attach to (CodexDaemonStale says when it is). func SwitchLogin(agent, user string) error { switch agent { case "grok": @@ -493,6 +494,17 @@ func SwitchLogin(agent, user string) error { case "gemini", "antigravity": return switchGoogleLogin(agent, user) } + from, err := switchSavedLogin(agent, user) + if err == nil && agent == "codex" && from != "" { + noteCodexSwitch(from, user) + } + return err +} + +// switchSavedLogin puts a Codex or Claude Code account magpie saved into +// the agent's own store, and answers the account it replaced: "" when there +// was none, or the agent was on that one already. +func switchSavedLogin(agent, user string) (from string, _ error) { // not while a saved account is being refreshed: the agent would be // given the refresh token that refresh is spending savedTokenMu.Lock() @@ -507,13 +519,14 @@ func SwitchLogin(agent, user string) error { } } if target == nil { - return fmt.Errorf("no saved %s account %q", agent, user) + return "", fmt.Errorf("no saved %s account %q", agent, user) } want := *target if live, ok := liveLogin(agent); ok { if strings.EqualFold(live.User, want.User) { - return nil + return "", nil } + from = live.User // the credentials being replaced, as fresh as the agent has them; // in use still if the one taking over was: it is next in line now live.Seen = time.Now().UTC().Truncate(time.Second) @@ -524,7 +537,7 @@ func SwitchLogin(agent, user string) error { } } if err := writeLogins(ls); err != nil { - return err + return "", err } } var err error @@ -537,11 +550,11 @@ func SwitchLogin(agent, user string) error { err = fmt.Errorf("%s accounts can't be switched", agent) } if err != nil { - return err + return "", err } loginsSeenAt = time.Time{} forgetAccountCaches() - return nil + return from, nil } func putClaudeLogin(l savedLogin) error {