From 43a65950350db5c228687da5b7ec4bf4acb96df5 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 21:22:50 +0800 Subject: [PATCH] =?UTF-8?q?provider,=20gui:=20Codex's=20only=20account=20c?= =?UTF-8?q?an=20be=20signed=20out=20from=20its=20row,=20as=20codex=20logou?= =?UTF-8?q?t=20does,=20and=20removing=20the=20account=20Codex=20is=20on=20?= =?UTF-8?q?while=20another=20is=20saved=20says=20what=20to=20do,=20in=20th?= =?UTF-8?q?e=20reader's=20language=20(mamba=20on=20Discord)=20=E2=80=94=20?= =?UTF-8?q?removing=20Codex's=20only=20account=20failed=20with=20"codex=20?= =?UTF-8?q?is=20signed=20in=20to=20=20now;=20switch=20to=20another?= =?UTF-8?q?=20account=20first",=20with=20no=20other=20account=20to=20switc?= =?UTF-8?q?h=20to,=20and=20Providers=20=E2=80=BA=20Codex=20=E2=80=BA=20Sig?= =?UTF-8?q?n=20out=20hit=20the=20same=20error,=20as=20it=20forgets=20the?= =?UTF-8?q?=20account=20in=20use=20last.=20ForgetLogin=20on=20the=20accoun?= =?UTF-8?q?t=20Codex=20is=20signed=20in=20to,=20with=20no=20other=20Codex?= =?UTF-8?q?=20account=20saved,=20now=20signs=20Codex=20out=20as=20`codex?= =?UTF-8?q?=20logout`=20does:=20~/.codex/auth.json=20is=20deleted,=20magpi?= =?UTF-8?q?e=20forgets=20the=20account=20and=20drops=20what=20it=20served?= =?UTF-8?q?=20(magpie=20only=20knows=20file-stored=20Codex=20sign-ins,=20l?= =?UTF-8?q?iveLogin=20reads=20auth.json,=20and=20the=20keychain=20is=20nev?= =?UTF-8?q?er=20touched);=20one=20signed=20in=20but=20not=20saved=20yet=20?= =?UTF-8?q?is=20signed=20out=20the=20same=20way.=20With=20another=20saved?= =?UTF-8?q?=20it=20is=20still=20refused,=20now=20as=20a=20SignedInError=20?= =?UTF-8?q?("Codex=20is=20signed=20in=20to=20=20now:=20sign=20it=20i?= =?UTF-8?q?n=20to=20another=20of=20its=20accounts=20first=20(Use=20on=20th?= =?UTF-8?q?at=20account),=20then=20remove=20this=20one"),=20which=20/api?= =?UTF-8?q?=20answers=20with=20code=20signed=5Fin,=20the=20agent=20and=20t?= =?UTF-8?q?he=20user,=20and=20app.js=20says=20in=20en/zh/ja/de.=20The=20on?= =?UTF-8?q?ly=20Codex=20account's=20row=20in=20its=20editor=20has=20Sign?= =?UTF-8?q?=20out=20(=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95),=20asked=20in?= =?UTF-8?q?=20the=20app's=20own=20dialog,=20whose=20Cancel,=20Escape=20and?= =?UTF-8?q?=20backdrop=20go=20back=20to=20the=20editor=20and=20send=20noth?= =?UTF-8?q?ing=20(closeConfirmAsk=20follows=20a=20dialog's=20back),=20then?= =?UTF-8?q?=20posts=20login/forget;=20the=20removed-provider=20Sign=20out?= =?UTF-8?q?=20dialog=20for=20Codex=20says=20Codex=20itself=20is=20signed?= =?UTF-8?q?=20out=20too.=20Claude=20Code=20keeps=20the=20refusal:=20its=20?= =?UTF-8?q?sign-in=20may=20be=20in=20the=20keychain,=20which=20magpie=20ne?= =?UTF-8?q?ver=20touches,=20and=20magpie=20serves=20its=20saved=20accounts?= =?UTF-8?q?=20while=20it=20is=20signed=20out=20(claudeStandIn)=20and=20tre?= =?UTF-8?q?ats=20its=20/logout=20apart=20(claudeLoggedOut).=20TestCodexSig?= =?UTF-8?q?nOutLastAccount=20(refused=20with=20another=20saved,=20auth.jso?= =?UTF-8?q?n=20kept;=20signed=20out=20when=20the=20last;=20an=20unsaved=20?= =?UTF-8?q?sign-in=20signed=20out;=20an=20unknown=20account=20still=20an?= =?UTF-8?q?=20error),=20TestForgetAccountSignsCodexOut=20(fails=20without?= =?UTF-8?q?=20the=20change=20with=20mamba's=20error)=20and=20TestClaudeInU?= =?UTF-8?q?seStillRefused=20are=20new;=20codex-signout.test.cjs=20(WebKit,?= =?UTF-8?q?=20en=20and=20zh:=20the=20button=20and=20its=20title,=20the=20d?= =?UTF-8?q?ialog,=20Cancel=20and=20Escape=20post=20nothing=20and=20go=20ba?= =?UTF-8?q?ck=20to=20the=20editor,=20the=20post=20and=20its=20status,=20no?= =?UTF-8?q?=20Sign=20out=20with=20two=20accounts,=20the=20translated=20ref?= =?UTF-8?q?usal,=20no=20select,=20no=20left=20border,=20no=20scroll)=20fai?= =?UTF-8?q?ls=20without=20the=20change.=20The=20account-*,=20own-signin-re?= =?UTF-8?q?move,=20saved-signedout-remove,=20reset-reminder=20and=20click-?= =?UTF-8?q?scroll=20tests=20pass=20in=20WebKit;=20Chromium=20wouldn't=20la?= =?UTF-8?q?unch=20on=20this=20machine.=20go=20vet,=20go=20build=20(darwin,?= =?UTF-8?q?=20GOOS=3Dlinux=20nogui,=20GOOS=3Dwindows)=20and=20go=20test=20?= =?UTF-8?q?-tags=20nogui=20./...=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 4 + internal/gui/assets/app.js | 48 ++++++++ internal/gui/assets/i18n.js | 18 +++ internal/gui/tests/README.md | 8 ++ internal/gui/tests/codex-signout.test.cjs | 135 ++++++++++++++++++++++ internal/provider/codex_signout_test.go | 106 +++++++++++++++++ internal/provider/logins.go | 56 ++++++++- 7 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 internal/gui/tests/codex-signout.test.cjs create mode 100644 internal/provider/codex_signout_test.go diff --git a/internal/gui/api.go b/internal/gui/api.go index 987c2f62..bd19a45c 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -1553,6 +1553,10 @@ func fail(rw http.ResponseWriter, err error) { out["code"] = "no_models" out["agent"] = noModels.Agent } + var signedIn *provider.SignedInError + if errors.As(err, &signedIn) { + out["code"], out["agent"], out["user"] = "signed_in", signedIn.Agent, signedIn.User + } _ = json.NewEncoder(rw).Encode(out) } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 8646c5a7..134f0994 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -108,6 +108,12 @@ async function api(path, body) { // reader's language if (data?.code === "no_models") err.message = t("Add a provider or subscription in magpie first, then connect {agent}", { agent: data.agent }); if (data?.why) err.why = data.why; // a failed move's reason, said in the reader's language + // an account the agent is signed in to, refused removal while it has + // another: said in the reader's language + if (data?.code === "signed_in") { + err.code = data.code; + err.message = t("{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: { codex: "Codex", claude: "Claude Code" }[data.agent] || data.agent, user: data.user }); + } throw err; } // the accounts it names, for Hide accounts to know them (#568); a @@ -4698,6 +4704,8 @@ function askForgetAccount(x) { head.append(icon(x.agentIcon), el("b", "", t("Sign {name} out of magpie?", { name: x.agentName }))); ed.append(head); ed.append(el("p", "lib-confirm", t("magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.", { name: x.agentName }))); + // the one Codex is signed in to goes last, and Codex with it (ForgetLogin) + if (x.agent === "codex") ed.append(el("p", "lib-confirm", t("Codex itself is signed out too, as codex logout does."))); const bar = el("div", "bar"); const go = el("button", "text primary danger-fill", t("Sign out")); go.onclick = async (e) => { @@ -4719,6 +4727,36 @@ function askForgetAccount(x) { cancel.focus({ preventScroll: true }); } +// askSignOutLogin asks before Codex is signed out of its only account, as +// codex logout does: its sign-in file goes and magpie forgets the account. +// It is the reset's dialog, so the backdrop and Escape close it the same way, +// back to the provider's editor it was asked from. +function askSignOutLogin(a, l) { + const ed = el("div", "editor forget-ask"); + const head = el("div", "ehead"); + head.append(icon(a.agentIcon), el("b", "", t("Sign {agent} out of {user}?", { agent: a.agentName, user: l.user }))); + ed.append(head); + ed.append(el("p", "lib-confirm", t("{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: a.agentName }))); + const bar = el("div", "bar"); + const go = el("button", "text primary danger-fill", t("Sign out")); + go.onclick = async (e) => { + e.stopPropagation(); + go.disabled = true; + go.classList.add("busy"); + if (await accountAction("login/forget", { agent: a.agent, user: l.user }, t("{agent} signed out of {user}", { agent: a.agentName, user: l.user }))) closeConfirmAsk(); + else go.disabled = false; + }; + const cancel = el("button", "text", t("Cancel")); + cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); }; + bar.append(el("span", "grow"), cancel, go); + ed.append(bar); + ed.back = renderProviders; // the provider's editor it was asked from + confirmAsk = ed; + openModal(ed); + $("#modal").classList.add("lib"); + cancel.focus({ preventScroll: true }); +} + // accountPlan is the account's chip: its vendor and plan, the plan alone // when it already names the vendor (a plugin's own "Zed Pro"). function accountPlan(a) { @@ -9848,6 +9886,13 @@ function renderAccounts(a, p) { if (l.own) forget.title = forgetOwnTitle(a); forget.onclick = () => accountAction("login/forget", { agent: a.agent, user: l.user }, t("{user} removed", { user: l.user })); row.append(forget); + } else if (a.agent === "codex" && ls.length === 1) { + // Codex's only account: no other to sign it in to first, so it is + // signed out, as codex logout does (ForgetLogin) + const out = el("button", "text quiet", t("Sign out")); + out.title = t("Signs Codex out of this account, as codex logout does"); + out.onclick = () => askSignOutLogin(a, l); + row.append(out); } } else { const forget = el("button", "text quiet", t("Remove")); @@ -12853,7 +12898,10 @@ function askReset(q) { } function closeConfirmAsk() { if (!confirmAsk) return; + // one asked from an editor goes back to it rather than close it too + const back = confirmAsk.back; confirmAsk = null; + if (back) { $("#modal").classList.remove("lib"); back(); return; } closeModal().then(() => { if (!confirmAsk) $("#modal").classList.remove("lib"); }); } // the dialog is the providers page's: while this asks, its backdrop and diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f90186ad..ff19a15b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -618,6 +618,12 @@ const I18N = { "magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.": "退出并移除为 {name} 保存的账号;再次添加 {name} 需重新登录。账号本身不受影响。", "magpie signs out the accounts it keeps for {name}; adding it again signs in afresh": "退出为 {name} 保存的账号;再次添加需重新登录", "{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 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} 可能要退出后重新打开。账号本身不受影响。", + "{agent} signed out of {user}": "{agent} 已退出 {user}", "auth": "认证", "agent default": "Agent 默认值", "every magpie model as a provider in T3 Code, on Claude Code": "magpie 的全部模型作为 T3 Code 里的一个供应商(经 Claude Code 运行)", @@ -3953,6 +3959,12 @@ const I18N = { "magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.": "magpie は {name} 用に保存しているアカウントからサインアウトし、削除します。{name} を再度追加するときは改めてサインインします。アカウント自体はそのままです。", "magpie signs out the accounts it keeps for {name}; adding it again signs in afresh": "magpie は {name} 用に保存しているアカウントからサインアウトします。再度追加するときは改めてサインインします", "{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 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} は終了して開き直す必要があるかもしれません。アカウント自体はそのままです。", + "{agent} signed out of {user}": "{agent} は {user} からサインアウトしました", "auth": "認証", "agent default": "エージェントのデフォルト", "every magpie model as a provider in T3 Code, on Claude Code": "magpie のすべてのモデルを T3 Code のプロバイダとして(Claude Code 上で)", @@ -7274,6 +7286,12 @@ const I18N = { "magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.": "magpie meldet die für {name} gespeicherten Konten ab und vergisst sie; beim erneuten Hinzufügen von {name} neu anmelden. Das Konto selbst bleibt unverändert.", "magpie signs out the accounts it keeps for {name}; adding it again signs in afresh": "magpie meldet die für {name} gespeicherten Konten ab; beim erneuten Hinzufügen neu anmelden", "{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 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.", + "{agent} signed out of {user}": "{agent} von {user} abgemeldet", "auth": "Auth", "agent default": "Standard des Agenten", "every magpie model as a provider in T3 Code, on Claude Code": "alle magpie-Modelle als Anbieter in T3 Code, über Claude Code", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 7d79317d..a4d4d50a 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -666,6 +666,14 @@ accounts saved in magpie: the Providers line naming them has Remove, whose dialog lists them; Cancel and Escape post nothing, Remove posts login/forget once per account and the line goes; in English and Chinese. +`codex-signout.test.cjs` opens Codex with one account (mamba on Discord: it +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 +English and Chinese. + `nokey-editor.test.cjs` opens two local Ollama providers saved with no key, one from the preset and one custom, as /api/providers gives them: a click on each row opens its editor (the key box saying one is optional) and diff --git a/internal/gui/tests/codex-signout.test.cjs b/internal/gui/tests/codex-signout.test.cjs new file mode 100644 index 00000000..579c1e70 --- /dev/null +++ b/internal/gui/tests/codex-signout.test.cjs @@ -0,0 +1,135 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// mamba on Discord: Codex's only account couldn't be removed ("codex is +// signed in to … now; switch to another account first"). Its row now has +// Sign out, asked in the app's own dialog: Cancel and Escape send 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 refusal says what to do in the reader's +// language. No select, no left border, the page doesn't move. In English +// and Chinese, Chromium and WebKit. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const codex = (logins) => ({ + id: "codex", name: "Codex", icon: "codex-color", chat: "", responses: "", anthropic: "", catalog: "", routing: "smart", + models: [{ id: "gpt-6", name: "GPT-6", on: true }], agents: [], fallback: [], headers: {}, keyList: [], + account: { agent: "codex", agentName: "Codex", agentIcon: "codex-color", user: logins[0].user, plan: "PLUS", logins }, +}); +const one = [{ user: "me@example.com", plan: "PLUS", active: true, on: true }]; +const two = [...one, { user: "work@example.com", plan: "PRO", on: true }]; + +function serve(lang, logins, posts, refuse) { + const providers = { providers: [codex(logins)], presets: [], excluded: [], gateway: { running: true, window: true } }; + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + return async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/login/forget") { + posts.push(req.postDataJSON()); + if (refuse) return route.fulfill({ status: 400, json: { error: "codex is signed in to me@example.com now", code: "signed_in", agent: "codex", user: "me@example.com" } }); + return json({ ...providers, providers: [] }); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +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:请先在另一个账号上点「使用」/ }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + const open = async (t, logins, refuse = false) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = []; + const posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, logins, posts, refuse)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Codex" }).first().click(); + await page.locator(".editor .acc").first().waitFor(); + return { page, errors, posts }; + }; + + test(`${engine} ${lang}: Codex's only account signs out, asked first`, async (t) => { + const { page, errors, posts } = await open(t, one); + const top = await page.evaluate(() => document.scrollingElement.scrollTop); + const row = page.locator(".editor .acc", { hasText: "me@example.com" }); + const out = row.getByRole("button", { name: w.out, exact: true }); + assert.equal(await out.count(), 1, "the only account has Sign out"); + assert.ok(await out.getAttribute("title")); + + const ask = page.locator("#modal .forget-ask"); + await out.click(); + await ask.waitFor(); + assert.equal(await ask.locator(".ehead b").textContent(), w.ask); + assert.match(await ask.locator(".lib-confirm").textContent(), w.text); + assert.equal(await page.locator("select").count(), 0, "no native select"); + const stripe = await ask.evaluate((n) => [n, ...n.querySelectorAll("*")].some((x) => parseFloat(getComputedStyle(x).borderLeftWidth) > 1 && getComputedStyle(x).borderLeftStyle !== "none" && getComputedStyle(x).borderLeftWidth !== getComputedStyle(x).borderRightWidth)); + assert.equal(stripe, false, "no left-border accent"); + await ask.getByRole("button", { name: w.cancel, exact: true }).click(); + await ask.waitFor({ state: "detached" }); + await row.waitFor(); // back in the editor it was asked from + await out.click(); + await ask.waitFor(); + await page.keyboard.press("Escape"); + await ask.waitFor({ state: "detached" }); + await row.waitFor(); + assert.deepEqual(posts, [], "Cancel and Escape send nothing"); + + await out.click(); + await ask.getByRole("button", { name: w.out, exact: true }).click(); + await ask.waitFor({ state: "detached" }); + assert.deepEqual(posts, [{ agent: "codex", user: "me@example.com" }]); + await page.waitForFunction((s) => document.querySelector("#status")?.textContent.includes(s), w.done); + assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), top, "the page doesn't move"); + const missing = await page.evaluate(() => [ + "Sign out", "Signs Codex out of this account, as codex logout does", "Sign {agent} out of {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} signed out of {user}", "Codex itself is signed out too, as codex logout does.", + "{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one", + ].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, []); + }); + + test(`${engine} ${lang}: with another account saved, the one in use has no Sign out`, async (t) => { + const { page, errors } = 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); + } + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: a refusal says what to do, in the reader's language`, async (t) => { + const { page, errors } = await open(t, one, true); + await page.locator(".editor .acc", { hasText: "me@example.com" }).getByRole("button", { name: w.out, exact: true }).click(); + const ask = page.locator("#modal .forget-ask"); + await ask.getByRole("button", { name: w.out, exact: true }).click(); + await page.waitForFunction(() => /me@example\.com/.test([...document.querySelectorAll(".editor-error, #status")].map((n) => n.textContent).join(" "))); + const said = await page.evaluate(() => [...document.querySelectorAll(".editor-error, #status")].map((n) => n.textContent).join(" ")); + assert.match(said, w.refused); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/codex_signout_test.go b/internal/provider/codex_signout_test.go new file mode 100644 index 00000000..7a9b7030 --- /dev/null +++ b/internal/provider/codex_signout_test.go @@ -0,0 +1,106 @@ +package provider + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 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. +func TestCodexSignOutLastAccount(t *testing.T) { + home := signIn(t) + auth := filepath.Join(home, ".codex", "auth.json") + rememberLogins(true) + + // another saved: the one signed in to is refused, as before + 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" { + 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) + } + if _, err := os.Stat(auth); err != nil { + t.Fatalf("auth.json went on a refusal: %v", err) + } + + // the other forgotten, 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) { + t.Fatalf("auth.json still there: %v", err) + } + if ls := Logins("codex"); len(ls) != 0 { + t.Fatalf("still listed: %+v", ls) + } + rememberLogins(true) + if ls := Logins("codex"); len(ls) != 0 { + t.Fatalf("remembered again: %+v", ls) + } + + // signed in but not saved yet: signed out all the same + codexSignIn(t, home, "new@example.com", "r-new") + if err := ForgetLogin("codex", "new@example.com"); err != nil { + t.Fatalf("unsaved: %v", err) + } + if _, err := os.Stat(auth); !os.IsNotExist(err) { + t.Fatalf("auth.json still there: %v", err) + } + + // an account not signed in to and not saved is still unknown + if err := ForgetLogin("codex", "nobody@example.com"); err == nil || errors.As(err, &signedIn) { + t.Fatalf("unknown: %v", err) + } +} + +func TestForgetAccountSignsCodexOut(t *testing.T) { + home := signIn(t) + rememberLogins(true) + codexSignIn(t, home, "work@example.com", "r-work") + rememberLogins(true) + if err := ForgetAccount("codex"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".codex", "auth.json")); !os.IsNotExist(err) { + t.Fatalf("auth.json still there: %v", err) + } + if ls := Logins("codex"); len(ls) != 0 { + t.Fatalf("saved still: %+v", ls) + } +} + +// Claude Code's account in use is never signed out by a removal, even its +// only one: its sign-in may be in the keychain, and magpie serves its +// saved accounts while it is signed out (claudeStandIn). +func TestClaudeInUseStillRefused(t *testing.T) { + home := claudeHome(t) + t.Setenv("CLAUDE_CONFIG_DIR", "") + shellFakes(t) + creds := claudeSignIn(t, home, time.Now().Add(time.Hour)) + writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "cc@example.com"}}) + rememberLogins(true) + live, ok := liveLogin("claude") + if !ok { + t.Fatal("no Claude Code sign-in") + } + var signedIn *SignedInError + if err := ForgetLogin("claude", live.User); !errors.As(err, &signedIn) { + t.Fatalf("claude in use: %v", err) + } + if _, err := os.Stat(creds); err != nil { + t.Fatalf("credentials went: %v", err) + } +} diff --git a/internal/provider/logins.go b/internal/provider/logins.go index bbe5fbfe..17e60cca 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -889,7 +889,10 @@ func putClaudeLogin(l savedLogin) error { } // ForgetLogin drops a remembered account. The one an agent is signed in to -// now can't be forgotten; it would only be remembered again. +// 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 +// instead, as `codex logout` does (mamba on Discord: a single Codex account +// couldn't be removed at all). func ForgetLogin(agent, user string) error { if pp, ok := pluginOfAgent(agent); ok { return forgetPluginLogin(pp, user) @@ -920,12 +923,29 @@ func ForgetLogin(agent, user string) error { case "gemini", "antigravity": return forgetGoogleLogin(agent, user) } + signedOut, err := forgetLogin(agent, user) + if signedOut { + // gone from the agent too: nothing of it is served any more + ForgetAccounts() + } + return err +} + +func forgetLogin(agent, user string) (signedOut bool, err error) { loginsMu.Lock() defer loginsMu.Unlock() - if live, ok := liveLogin(agent); ok && strings.EqualFold(live.User, user) { - return fmt.Errorf("%s is signed in to %s now; switch to another account first", agent, user) - } ls := readLogins() + if live, ok := liveLogin(agent); ok && strings.EqualFold(live.User, user) { + other := slices.ContainsFunc(ls, func(l savedLogin) bool { return l.Agent == agent && !strings.EqualFold(l.User, user) }) + if agent != "codex" || other { + return false, &SignedInError{Agent: agent, User: user} + } + // as `codex logout` does: the sign-in's file goes, all of it + if err := os.Remove(codexAuthPath()); err != nil && !os.IsNotExist(err) { + return false, err + } + signedOut = true + } out := ls[:0] found := false for _, l := range ls { @@ -936,12 +956,36 @@ func ForgetLogin(agent, user string) error { out = append(out, l) } if !found { - return fmt.Errorf("no saved %s account %q", agent, user) + if signedOut { + return true, nil // signed in, not saved yet + } + return false, fmt.Errorf("no saved %s account %q", agent, user) } if agent == "claude" { forgetClaudeDir(user) } - return writeLogins(out) + return signedOut, writeLogins(out) +} + +// SignedInError is ForgetLogin's refusal of the account an agent is +// signed in to now while it has another: that one is signed in to first +// (its Use), and this one removed then. The GUI says it in the reader's +// language (code signed_in). +type SignedInError struct{ Agent, User string } + +func (e *SignedInError) Error() string { + return fmt.Sprintf("%s is signed in to %s now: sign it in to another of its accounts first (Use on that account), then remove this one", loginAgentName(e.Agent), e.User) +} + +// loginAgentName is a switchable agent's name as its accounts list says it. +func loginAgentName(agent string) string { + switch agent { + case "codex": + return "Codex" + case "claude": + return "Claude Code" + } + return agent } // ForgetAccounts makes the next look at the accounts read them afresh, for