accounts: after a Codex switch, magpie says when Codex's background service is still on the account before, and restarts it when asked (vincentzhang on Discord) — Codex CLI can keep an app-server running (codex app-server --managed-daemon) that reads the sign-in once, when it starts, so sessions opened after a switch still ran on the account before. When a switch changes the account while such a daemon runs, Codex's account list shows that it is still signed in as the one before, with Later and Restart; Restart runs codex app-server daemon restart for the Codex home magpie signs in through, which ends the Codex sessions on it, so magpie never does it unasked. magpie accounts switch codex prints the same hint and the command. The notice goes when the daemon restarts or stops, or Codex is switched back. Only a codex program's app-server with --managed-daemon counts: the ChatGPT app's codex and other apps' app-servers don't. Tested with a fake codex and a fake daemon in a sandbox magpie web; the real codex 0.155 has the restart command
This commit is contained in:
12 files changed
+742
-6
No files matched your search
@@ -96,6 +96,12 @@ func accountsCmd(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Println(green.Render("✓"), id, "is now signed in as", args[3], muted.Render("· sessions already running keep their account until restarted"))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
which, asJSON := "", false
|
which, asJSON := "", false
|
||||||
|
|||||||
@@ -3819,6 +3819,7 @@ function renderAccounts(a) {
|
|||||||
row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user));
|
row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user));
|
||||||
list.append(row);
|
list.append(row);
|
||||||
}
|
}
|
||||||
|
if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon));
|
||||||
if (signing?.agent === a.agent) list.append(renderSigning(sub));
|
if (signing?.agent === a.agent) list.append(renderSigning(sub));
|
||||||
else {
|
else {
|
||||||
const add = el("button", "acc add");
|
const add = el("button", "acc add");
|
||||||
@@ -3840,6 +3841,26 @@ function renderAccounts(a) {
|
|||||||
return list;
|
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
|
// 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
|
// 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
|
// each account with the vendor before keeping it, and says what became of
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ const I18N = {
|
|||||||
"magpie forgets this account's sign-in; the account itself is untouched": "magpie 忘掉这个账号的登录信息;账号本身不受影响",
|
"magpie forgets this account's sign-in; the account itself is untouched": "magpie 忘掉这个账号的登录信息;账号本身不受影响",
|
||||||
"Sign {agent} in to this account": "让 {agent} 登录到这个账号",
|
"Sign {agent} in to this account": "让 {agent} 登录到这个账号",
|
||||||
"{agent} is now signed in as {user}": "{agent} 已切换为 {user}",
|
"{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} 的登录信息,读取自它自己的文件。在那里退出登录,此供应商即消失。",
|
"{agent}'s sign-in, read from its own files. Sign out there and this provider goes away.": "{agent} 的登录信息,读取自它自己的文件。在那里退出登录,此供应商即消失。",
|
||||||
"Models": "模型",
|
"Models": "模型",
|
||||||
"Endpoints": "端点",
|
"Endpoints": "端点",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,10 @@ type providersJSON struct {
|
|||||||
Presets []presetJSON `json:"presets"`
|
Presets []presetJSON `json:"presets"`
|
||||||
Excluded []excludedJSON `json:"excluded"` // sign-ins magpie found but will not share
|
Excluded []excludedJSON `json:"excluded"` // sign-ins magpie found but will not share
|
||||||
Gateway gatewayJSON `json:"gateway"`
|
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.
|
// agentModel is the model an agent is on, as magpie's catalog names it.
|
||||||
@@ -328,6 +332,7 @@ func providersState() providersJSON {
|
|||||||
} else {
|
} else {
|
||||||
s.Gateway.Running, s.Gateway.Window = gateway.Serving()
|
s.Gateway.Running, s.Gateway.Window = gateway.Serving()
|
||||||
}
|
}
|
||||||
|
s.CodexDaemon = provider.CodexDaemonStale()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,6 +660,25 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
|||||||
agent.SyncCatalog()
|
agent.SyncCatalog()
|
||||||
writeJSON(rw, providersState())
|
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.
|
// 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) {
|
mux.HandleFunc("POST /api/keys/{action}", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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=")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)" }`)
|
||||||
|
}
|
||||||
@@ -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 <verb>`, 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -477,7 +477,8 @@ func Logins(agent string) []Login {
|
|||||||
|
|
||||||
// SwitchLogin signs an agent in to a remembered account. Sessions of the
|
// SwitchLogin signs an agent in to a remembered account. Sessions of the
|
||||||
// agent that are already running keep the account they started with until
|
// 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 {
|
func SwitchLogin(agent, user string) error {
|
||||||
switch agent {
|
switch agent {
|
||||||
case "grok":
|
case "grok":
|
||||||
@@ -493,6 +494,17 @@ func SwitchLogin(agent, user string) error {
|
|||||||
case "gemini", "antigravity":
|
case "gemini", "antigravity":
|
||||||
return switchGoogleLogin(agent, user)
|
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
|
// not while a saved account is being refreshed: the agent would be
|
||||||
// given the refresh token that refresh is spending
|
// given the refresh token that refresh is spending
|
||||||
savedTokenMu.Lock()
|
savedTokenMu.Lock()
|
||||||
@@ -507,13 +519,14 @@ func SwitchLogin(agent, user string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if target == nil {
|
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
|
want := *target
|
||||||
if live, ok := liveLogin(agent); ok {
|
if live, ok := liveLogin(agent); ok {
|
||||||
if strings.EqualFold(live.User, want.User) {
|
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;
|
// 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
|
// in use still if the one taking over was: it is next in line now
|
||||||
live.Seen = time.Now().UTC().Truncate(time.Second)
|
live.Seen = time.Now().UTC().Truncate(time.Second)
|
||||||
@@ -524,7 +537,7 @@ func SwitchLogin(agent, user string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writeLogins(ls); err != nil {
|
if err := writeLogins(ls); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
@@ -537,11 +550,11 @@ func SwitchLogin(agent, user string) error {
|
|||||||
err = fmt.Errorf("%s accounts can't be switched", agent)
|
err = fmt.Errorf("%s accounts can't be switched", agent)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
loginsSeenAt = time.Time{}
|
loginsSeenAt = time.Time{}
|
||||||
forgetAccountCaches()
|
forgetAccountCaches()
|
||||||
return nil
|
return from, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func putClaudeLogin(l savedLogin) error {
|
func putClaudeLogin(l savedLogin) error {
|
||||||
|
|||||||
Reference in new issue
Block a user