provider, gui: Codex's only account can be signed out from its row, as codex logout does, and removing the account Codex is on while another is saved says what to do, in the reader's language (mamba on Discord) — removing Codex's only account failed with "codex is signed in to <user> now; switch to another account first", with no other account to switch to, and Providers › Codex › Sign out hit the same error, as it forgets the account in use last. ForgetLogin on the account Codex is signed in to, with no other Codex account saved, now signs Codex out as codex logout does: ~/.codex/auth.json is deleted, magpie forgets the account and drops what it served (magpie only knows file-stored Codex sign-ins, liveLogin reads auth.json, and the keychain is never touched); one signed in but not saved yet is signed out the same way. With another saved it is still refused, now as a SignedInError ("Codex is signed in to <user> now: sign it in to another of its accounts first (Use on that account), then remove this one"), which /api answers with code signed_in, the agent and the user, and app.js says in en/zh/ja/de. The only Codex account's row in its editor has Sign out (退出登录), asked in the app's own dialog, whose Cancel, Escape and backdrop go back to the editor and send nothing (closeConfirmAsk follows a dialog's back), then posts login/forget; the removed-provider Sign out dialog for Codex says Codex itself is signed out too. Claude Code keeps the refusal: its sign-in may be in the keychain, which magpie never touches, and magpie serves its saved accounts while it is signed out (claudeStandIn) and treats its /logout apart (claudeLoggedOut). TestCodexSignOutLastAccount (refused with another saved, auth.json kept; signed out when the last; an unsaved sign-in signed out; an unknown account still an error), TestForgetAccountSignsCodexOut (fails without the change with mamba's error) and TestClaudeInUseStillRefused are new; codex-signout.test.cjs (WebKit, en and zh: the button and its title, the dialog, Cancel and Escape post nothing and go back to the editor, the post and its status, no Sign out with two accounts, the translated refusal, no select, no left border, no scroll) fails without the change. The account-*, own-signin-remove, saved-signedout-remove, reset-reminder and click-scroll tests pass in WebKit; Chromium wouldn't launch on this machine. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
1 parent
528d6c12dc
commit
43a6595035
7 files changed
+369
-6
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user