diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 9bda81c0..51f61a84 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -10078,6 +10078,13 @@ function renderAccounts(a, p) { out.title = t("Signs Codex out of this account, as codex logout does"); out.onclick = () => askSignOutLogin(a, l); row.append(out); + } else if (a.agent === "codex") { + // Codex is signed in to another of its accounts first, then this + // one goes (ForgetLogin), rather than Codex signed out + const forget = el("button", "text quiet", t("Remove")); + forget.title = t("Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched"); + forget.onclick = () => accountAction("login/forget", { agent: a.agent, user: l.user }, t("{user} removed", { user: l.user })); + row.append(forget); } } else { const forget = el("button", "text quiet", t("Remove")); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index cb5dcffc..3f42c05a 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -620,6 +620,7 @@ const I18N = { "{name} signed out": "已退出 {name}", "{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} 当前登录的是 {user}:请先在另一个账号上点「使用」把 {agent} 切过去,再移除这个账号", "Signs Codex out of this account, as codex logout does": "让 Codex 退出这个账号,与 codex logout 相同", + "Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched": "Codex 会先登录到它的另一个账号,magpie 再忘掉这个账号;账号本身不受影响", "Codex itself is signed out too, as codex logout does.": "Codex 本身也会退出登录,与 codex logout 相同。", "Sign {agent} out of {user}?": "让 {agent} 退出 {user}?", "{agent} is signed out, as codex logout does, and magpie forgets the account. Sign in again to use it; an open {agent} may need quitting and opening again. The account itself is left as it is.": "{agent} 会退出登录(与 codex logout 相同),magpie 也不再保存这个账号。要再用需重新登录;已打开的 {agent} 可能要退出后重新打开。账号本身不受影响。", @@ -4007,6 +4008,7 @@ const I18N = { "{name} signed out": "{name} からサインアウトしました", "{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} は現在 {user} でサインインしています。先に別のアカウントの「使用」で {agent} を切り替えてから、このアカウントを削除してください", "Signs Codex out of this account, as codex logout does": "codex logout と同じく、Codex をこのアカウントからサインアウトします", + "Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched": "Codex を別のアカウントにサインインし直してから、magpie はこのアカウントを忘れます。アカウント自体はそのままです", "Codex itself is signed out too, as codex logout does.": "codex logout と同じく、Codex 自体もサインアウトします。", "Sign {agent} out of {user}?": "{agent} を {user} からサインアウトしますか?", "{agent} is signed out, as codex logout does, and magpie forgets the account. Sign in again to use it; an open {agent} may need quitting and opening again. The account itself is left as it is.": "codex logout と同じく {agent} はサインアウトし、magpie はこのアカウントを削除します。再び使うにはサインインし直してください。開いている {agent} は終了して開き直す必要があるかもしれません。アカウント自体はそのままです。", @@ -7380,6 +7382,7 @@ const I18N = { "{name} signed out": "{name} abgemeldet", "{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} ist gerade bei {user} angemeldet: Melden Sie {agent} zuerst bei einem anderen seiner Konten an (dort „Verwenden“) und entfernen Sie dann dieses", "Signs Codex out of this account, as codex logout does": "Meldet Codex von diesem Konto ab, wie codex logout", + "Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched": "Codex wird bei einem anderen seiner Konten angemeldet, und magpie vergisst dieses; das Konto selbst bleibt unberührt", "Codex itself is signed out too, as codex logout does.": "Codex selbst wird ebenfalls abgemeldet, wie mit codex logout.", "Sign {agent} out of {user}?": "{agent} von {user} abmelden?", "{agent} is signed out, as codex logout does, and magpie forgets the account. Sign in again to use it; an open {agent} may need quitting and opening again. The account itself is left as it is.": "{agent} wird abgemeldet, wie mit codex logout, und magpie vergisst das Konto. Zum erneuten Verwenden neu anmelden; ein geöffnetes {agent} muss eventuell beendet und neu geöffnet werden. Das Konto selbst bleibt unverändert.", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index e7d3a886..dc1874cd 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -681,8 +681,9 @@ login/forget once per account and the line goes; in English and Chinese. couldn't be removed while Codex was signed in to it): its row has Sign out, asked in the app's own dialog; Cancel and Escape post nothing and go back to the editor, Sign out posts login/forget and says so. With another account -saved the one in use has no Sign out, and a signed_in refusal is said in the -reader's language. No select, no left border, the page doesn't move; in +saved the one in use has no Sign out but Remove, which posts login/forget +(vincentzhang1_55530 on Discord: magpie signs Codex in to another account +first), and a signed_in refusal is said in the reader's language. No select, no left border, the page doesn't move; in English and Chinese. `nokey-editor.test.cjs` opens two local Ollama providers saved with no key, diff --git a/internal/gui/tests/codex-signout.test.cjs b/internal/gui/tests/codex-signout.test.cjs index 579c1e70..3e2fc9ef 100644 --- a/internal/gui/tests/codex-signout.test.cjs +++ b/internal/gui/tests/codex-signout.test.cjs @@ -48,8 +48,8 @@ function serve(lang, logins, posts, refuse) { } const words = { - en: { out: "Sign out", cancel: "Cancel", ask: "Sign Codex out of me@example.com?", text: /Codex is signed out, as codex logout does/, done: "Codex signed out of me@example.com", refused: /Codex is signed in to me@example\.com now: sign it in to another of its accounts first/ }, - zh: { out: "退出登录", cancel: "取消", ask: "让 Codex 退出 me@example.com?", text: /codex logout/, done: "Codex 已退出 me@example.com", refused: /Codex 当前登录的是 me@example\.com:请先在另一个账号上点「使用」/ }, + en: { out: "Sign out", remove: "Remove", cancel: "Cancel", ask: "Sign Codex out of me@example.com?", text: /Codex is signed out, as codex logout does/, done: "Codex signed out of me@example.com", refused: /Codex is signed in to me@example\.com now: sign it in to another of its accounts first/ }, + zh: { out: "退出登录", remove: "移除", cancel: "取消", ask: "让 Codex 退出 me@example.com?", text: /codex logout/, done: "Codex 已退出 me@example.com", refused: /Codex 当前登录的是 me@example\.com:请先在另一个账号上点「使用」/ }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -112,12 +112,23 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(errors, []); }); - test(`${engine} ${lang}: with another account saved, the one in use has no Sign out`, async (t) => { - const { page, errors } = await open(t, two); + // vincentzhang1_55530 on Discord: the one in use is removed as the + // others are, and Codex is signed in to another first (ForgetLogin) + test(`${engine} ${lang}: with another account saved, the one in use has Remove, not Sign out`, async (t) => { + const { page, errors, posts } = await open(t, two); for (const user of ["me@example.com", "work@example.com"]) { const row = page.locator(".editor .acc", { hasText: user }); assert.equal(await row.getByRole("button", { name: w.out, exact: true }).count(), 0, user); } + const remove = page.locator(".editor .acc", { hasText: "me@example.com" }).getByRole("button", { name: w.remove, exact: true }); + assert.equal(await remove.count(), 1, "the one in use has Remove"); + assert.ok(await remove.getAttribute("title")); + await remove.click(); + await page.waitForFunction(() => document.querySelector("#status")?.textContent.includes("me@example.com")); + assert.deepEqual(posts, [{ agent: "codex", user: "me@example.com" }]); + const missing = await page.evaluate(() => ["Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched"] + .filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k])); + assert.deepEqual(missing, [], "every string has its zh, ja and de"); assert.deepEqual(errors, []); }); diff --git a/internal/provider/codex_signout_test.go b/internal/provider/codex_signout_test.go index 7a9b7030..6bdb20e4 100644 --- a/internal/provider/codex_signout_test.go +++ b/internal/provider/codex_signout_test.go @@ -10,34 +10,37 @@ import ( ) // Codex's only account is signed out when it is removed, as codex logout -// does (mamba on Discord); one with another saved is still refused, with a -// SignedInError the GUI says in the reader's language; and ForgetAccount, -// which takes the one in use last, signs Codex out too. +// does (mamba on Discord); one with another saved signs Codex in to that +// one first (vincentzhang1_55530 on Discord: a Team account removed beside +// a Plus one left Codex at its sign-in screen); and ForgetAccount, which +// takes the one in use last, signs Codex out too. func TestCodexSignOutLastAccount(t *testing.T) { home := signIn(t) auth := filepath.Join(home, ".codex", "auth.json") rememberLogins(true) + var signedIn *SignedInError - // another saved: the one signed in to is refused, as before + // another saved: the one signed in to goes, and Codex is signed in to + // the other, its own sign-in back in auth.json codexSignIn(t, home, "work@example.com", "r-work") rememberLogins(true) - err := ForgetLogin("codex", "work@example.com") - var signedIn *SignedInError - if !errors.As(err, &signedIn) || signedIn.Agent != "codex" || signedIn.User != "work@example.com" { + if err := ForgetLogin("codex", "work@example.com"); err != nil { t.Fatalf("the one in use with another saved: %v", err) } - if !strings.Contains(err.Error(), "Codex is signed in to work@example.com now") { - t.Fatalf("message: %v", err) + live, ok := liveLogin("codex") + if !ok || live.User != "me@example.com" { + t.Fatalf("Codex signed in to %q (%v), want me@example.com", live.User, ok) } - if _, err := os.Stat(auth); err != nil { - t.Fatalf("auth.json went on a refusal: %v", err) + if b, _ := os.ReadFile(auth); strings.Contains(string(b), "r-work") { + t.Fatalf("auth.json still the removed one's: %s", b) + } + rememberLogins(true) + if ls := Logins("codex"); len(ls) != 1 || ls[0].User != "me@example.com" || !ls[0].Active { + t.Fatalf("listed: %+v", ls) } - // the other forgotten, the one left is signed out + // the one left is signed out if err := ForgetLogin("codex", "me@example.com"); err != nil { - t.Fatal(err) - } - if err := ForgetLogin("codex", "work@example.com"); err != nil { t.Fatalf("the only account: %v", err) } if _, err := os.Stat(auth); !os.IsNotExist(err) { diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 12655e82..639ebe12 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -946,7 +946,8 @@ func putClaudeLogin(l savedLogin) error { // ForgetLogin drops a remembered account. The one an agent is signed in to // now can't be forgotten while it has another to be signed in to; it would -// only be remembered again (SignedInError). Codex's last one is signed out +// only be remembered again (SignedInError). Codex is signed in to another +// of its accounts first (nextOnForget), and its last one is signed out // instead, as `codex logout` does (mamba on Discord: a single Codex account // couldn't be removed at all). func ForgetLogin(agent, user string) error { @@ -979,6 +980,18 @@ func ForgetLogin(agent, user string) error { case "gemini", "antigravity": return forgetGoogleLogin(agent, user) } + if agent == "codex" { + // the account Codex is signed in to, with another saved: Codex is + // signed in to that one first, as its Use would, and this one is + // forgotten then, rather than Codex left signed out or the removal + // refused (vincentzhang1_55530 on Discord: a Team account removed + // beside a Plus one, and Codex was at its sign-in screen) + if next := nextOnForget(agent, user); next != "" { + if err := SwitchLogin(agent, next); err != nil { + return err + } + } + } signedOut, err := forgetLogin(agent, user) if signedOut { // gone from the agent too: nothing of it is served any more @@ -987,6 +1000,29 @@ func ForgetLogin(agent, user string) error { return err } +// nextOnForget is the account an agent is signed in to in place of user, +// which is being removed: "" when it isn't signed in to user, or has no +// other saved. The first other in the order that is on, else one whose +// sign-in still holds, else any other. +func nextOnForget(agent, user string) string { + ls := Logins(agent) + if !slices.ContainsFunc(ls, func(l Login) bool { return l.Active && strings.EqualFold(l.User, user) }) { + return "" + } + for _, fit := range []func(Login) bool{ + func(l Login) bool { return l.On && !l.Paused && l.Lapsed == "" }, + func(l Login) bool { return l.Lapsed == "" }, + func(Login) bool { return true }, + } { + for _, l := range ls { + if !strings.EqualFold(l.User, user) && fit(l) { + return l.User + } + } + } + return "" +} + func forgetLogin(agent, user string) (signedOut bool, err error) { loginsMu.Lock() defer loginsMu.Unlock() diff --git a/internal/provider/logins_test.go b/internal/provider/logins_test.go index 309c22aa..8d053e6a 100644 --- a/internal/provider/logins_test.go +++ b/internal/provider/logins_test.go @@ -66,14 +66,12 @@ func TestCodexLogins(t *testing.T) { } } - if err := ForgetLogin("codex", "me@example.com"); err == nil { - t.Fatal("forgot the active login") - } - if err := ForgetLogin("codex", "work@example.com"); err != nil { + // the active one forgotten: Codex is signed in to the other first + if err := ForgetLogin("codex", "me@example.com"); err != nil { t.Fatal(err) } - if users, _ := loginUsers(Logins("codex")); strings.Join(users, ",") != "me@example.com" { - t.Fatalf("after forget: %v", users) + if users, active := loginUsers(Logins("codex")); strings.Join(users, ",") != "work@example.com" || active != "work@example.com" { + t.Fatalf("after forget: %v, active %q", users, active) } if err := SwitchLogin("codex", "nobody@example.com"); err == nil { t.Fatal("switched to an unknown login")