From 90239141e6788809a78c54b6252c312d21dd4388 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Fri, 30 Oct 2026 17:58:49 -0400 Subject: [PATCH 1/6] fix(usage): show Codex 403s, lapsed tokens and signed-out homes as their own states Every 401 and 403 from the usage endpoint became token_expired, so the card said "Sign-in expired" and offered `codex login`. A 403 is a refusal that a new sign-in does not clear, and an access token that has only passed its exp is renewed from the refresh token the next time Codex runs. Current Codex releases log out and revoke the stored login before `codex login` signs in again, so following that advice ended a login that still worked. - A 403 is now unavailable and names its status code. A 401 stays token_expired. The OpenCode reader shares this path. - Before any request, the exp claim of the access token is read (not verified). A lapsed token with a refresh token is token_stale ("Refreshes when Codex next runs."); without one it is token_expired. Neither calls the endpoint. A token with no readable exp is left to the endpoint, as before. - A ~/.codex* home that holds the profile ledger, which Superset writes when it adds a home or makes it the default, stays listed after its auth.json is gone or holds no login. It gets a signed_out card with the detail text of a signed-out Claude profile, so it can be signed in again or removed. Dirs without the ledger stay hidden. An unmarked API key, or an auth.json that does not parse (it can be mid-write), does not count as signed out, and the system-default home never does. Co-Authored-By: Claude Opus 5.5 --- .../usage/components/UsageView/UsageView.tsx | 7 +- .../UsageView/utils/staleTokenDetail/index.ts | 1 + .../staleTokenDetail/staleTokenDetail.test.ts | 17 ++ .../staleTokenDetail/staleTokenDetail.ts | 14 ++ packages/agent-setup/src/index.ts | 1 + .../src/trpc/router/usage/claude.ts | 4 +- .../src/trpc/router/usage/codex.test.ts | 176 +++++++++++++++++- .../src/trpc/router/usage/codex.ts | 110 +++++++++-- .../src/trpc/router/usage/profiles.test.ts | 80 ++++++++ .../src/trpc/router/usage/profiles.ts | 82 ++++++-- .../src/trpc/router/usage/types.ts | 7 +- packages/i18n/locales/cs/messages.po | 3 + packages/i18n/locales/de/messages.po | 3 + packages/i18n/locales/en/messages.po | 3 + packages/i18n/locales/es/messages.po | 3 + packages/i18n/locales/fr/messages.po | 3 + packages/i18n/locales/id/messages.po | 3 + packages/i18n/locales/it/messages.po | 3 + packages/i18n/locales/ja/messages.po | 3 + packages/i18n/locales/ko/messages.po | 3 + packages/i18n/locales/nl/messages.po | 3 + packages/i18n/locales/pl/messages.po | 3 + packages/i18n/locales/pt-BR/messages.po | 3 + packages/i18n/locales/ru/messages.po | 3 + packages/i18n/locales/tr/messages.po | 3 + packages/i18n/locales/vi/messages.po | 3 + packages/i18n/locales/zh-CN/messages.po | 3 + packages/i18n/locales/zh-TW/messages.po | 3 + 28 files changed, 510 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/UsageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/UsageView.tsx index 36590fc9655..0d7b5fabfd7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/UsageView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/UsageView.tsx @@ -49,6 +49,7 @@ import { RemoveAccountDialog } from "./components/RemoveAccountDialog"; import type { RestartSessionsPrompt } from "./components/RestartSessionsDialog"; import { RestartSessionsDialog } from "./components/RestartSessionsDialog"; import { API_BILLING_LINKS } from "./utils/apiBilling"; +import { staleTokenDetail } from "./utils/staleTokenDetail"; import { switchSignInCommand } from "./utils/switchSignInCommand"; import type { ManagedAgent, QuotaAgent } from "./utils/visibleQuotaAgents"; import { isManagedAgent, visibleQuotaAgents } from "./utils/visibleQuotaAgents"; @@ -284,11 +285,7 @@ function AccountCard({ ) : account.status === "token_stale" ? (
- {account.agent === "opencode" ? ( - Refreshes when OpenCode next runs. - ) : ( - Refreshes when Claude Code next runs. - )} + {staleTokenDetail(account.agent)}
) : expiredCommand !== null ? (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/index.ts new file mode 100644 index 00000000000..6a32caa7d76 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/index.ts @@ -0,0 +1 @@ +export { staleTokenDetail } from "./staleTokenDetail"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.test.ts new file mode 100644 index 00000000000..3115ec3e539 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test"; +import { initI18n } from "@superset/i18n"; +import { staleTokenDetail } from "./staleTokenDetail"; + +initI18n(); + +describe("staleTokenDetail", () => { + it("names the CLI that renews the lapsed token", () => { + expect(staleTokenDetail("codex")).toBe("Refreshes when Codex next runs."); + expect(staleTokenDetail("claude")).toBe( + "Refreshes when Claude Code next runs.", + ); + expect(staleTokenDetail("opencode")).toBe( + "Refreshes when OpenCode next runs.", + ); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.ts new file mode 100644 index 00000000000..1c3183ec16f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/usage/components/UsageView/utils/staleTokenDetail/staleTokenDetail.ts @@ -0,0 +1,14 @@ +import { msg } from "@lingui/core/macro"; +import { i18n } from "@superset/i18n"; +import type { QuotaAgent } from "../visibleQuotaAgents"; + +/** A lapsed access token renews when the CLI that owns it runs again. */ +export function staleTokenDetail(agent: QuotaAgent): string { + if (agent === "codex") { + return i18n._(msg({ message: "Refreshes when Codex next runs." })); + } + if (agent === "opencode") { + return i18n._(msg({ message: "Refreshes when OpenCode next runs." })); + } + return i18n._(msg({ message: "Refreshes when Claude Code next runs." })); +} diff --git a/packages/agent-setup/src/index.ts b/packages/agent-setup/src/index.ts index b09606d8709..ab46445c002 100644 --- a/packages/agent-setup/src/index.ts +++ b/packages/agent-setup/src/index.ts @@ -119,6 +119,7 @@ export { writePluginTree, } from "./plugin-cache"; export { mcpHeadersHelperCommand } from "./plugin-connections"; +export { PROFILE_LEDGER_NAME } from "./profile-sharing"; export { type McpReconcileReport, reconcileMcpServers, diff --git a/packages/host-service/src/trpc/router/usage/claude.ts b/packages/host-service/src/trpc/router/usage/claude.ts index 64d8d42debe..412e563f7d4 100644 --- a/packages/host-service/src/trpc/router/usage/claude.ts +++ b/packages/host-service/src/trpc/router/usage/claude.ts @@ -18,6 +18,7 @@ import { discoverClaudeProfiles, keychainServicesForConfigDir, readKeychainSecrets, + SIGNED_OUT_PROFILE_DETAIL, } from "./profiles"; import type { UsageAccount, @@ -579,8 +580,7 @@ export async function fetchClaudeAccounts(): Promise { email: profile.email, plan: null, status: "signed_out", - statusDetail: - "Signed out — use Switch sign-in to reconnect, or Remove to delete this profile.", + statusDetail: SIGNED_OUT_PROFILE_DETAIL, windows: [], creditsBalance: null, extraUsage: null, diff --git a/packages/host-service/src/trpc/router/usage/codex.test.ts b/packages/host-service/src/trpc/router/usage/codex.test.ts index 4f97f2c2a1d..44a663f52e9 100644 --- a/packages/host-service/src/trpc/router/usage/codex.test.ts +++ b/packages/host-service/src/trpc/router/usage/codex.test.ts @@ -1,7 +1,46 @@ -import { describe, expect, it } from "bun:test"; -import { dedupeCodexAccounts } from "./codex"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + classifyCodexToken, + dedupeCodexAccounts, + fetchCodexAccounts, + fetchCodexSubscriptionQuota, +} from "./codex"; import type { UsageAccount } from "./types"; +const NOW = Date.parse("2026-09-11T12:00:00.000Z"); +const HOUR_SECONDS = 60 * 60; + +function accessToken(payload: Record): string { + const part = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${part({ alg: "RS256", typ: "JWT" })}.${part(payload)}.signature`; +} + +const roots: string[] = []; + +/** A fake home dir holding the given `~/.codex*` dirs and their files. */ +function tempHome(dirs: Record>): string { + const home = mkdtempSync(join(tmpdir(), "superset-usage-codex-")); + roots.push(home); + for (const [dir, files] of Object.entries(dirs)) { + mkdirSync(join(home, dir)); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(home, dir, name), content); + } + } + return home; +} + +afterEach(() => { + mock.restore(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + function codexAccount(accountKey: string, email: string | null): UsageAccount { return { agent: "codex", @@ -89,3 +128,136 @@ describe("dedupeCodexAccounts", () => { ).toEqual([first, second]); }); }); + +describe("classifyCodexToken", () => { + const lapsed = accessToken({ exp: NOW / 1000 - HOUR_SECONDS }); + + it("reads a lapsed access token as stale while a refresh token remains", () => { + expect( + classifyCodexToken({ access_token: lapsed, refresh_token: "rt" }, NOW), + ).toBe("token_stale"); + }); + + it("reads a lapsed access token with no refresh token as expired", () => { + expect(classifyCodexToken({ access_token: lapsed }, NOW)).toBe( + "token_expired", + ); + expect( + classifyCodexToken({ access_token: lapsed, refresh_token: "" }, NOW), + ).toBe("token_expired"); + }); + + it("keeps an access token that has not reached its exp live", () => { + expect( + classifyCodexToken( + { + access_token: accessToken({ exp: NOW / 1000 + HOUR_SECONDS }), + refresh_token: "rt", + }, + NOW, + ), + ).toBe("live"); + }); + + it("leaves a token without a readable exp to the usage endpoint", () => { + for (const token of [ + "opaque-token", + "header.not-json.signature", + accessToken({ sub: "user" }), + accessToken({ exp: "soon" }), + ]) { + expect(classifyCodexToken({ access_token: token }, NOW)).toBe("live"); + } + }); +}); + +describe("fetchCodexSubscriptionQuota", () => { + function respondWith(response: Response) { + return spyOn(globalThis, "fetch").mockResolvedValue(response); + } + + it("reads a 401 as an expired sign-in", async () => { + respondWith(new Response(null, { status: 401 })); + expect((await fetchCodexSubscriptionQuota("token")).status).toBe( + "token_expired", + ); + }); + + it("reads a 403 as unavailable, not as an expired sign-in", async () => { + respondWith(new Response(null, { status: 403 })); + const quota = await fetchCodexSubscriptionQuota("token"); + expect(quota.status).toBe("unavailable"); + expect(quota.statusDetail).toContain("403"); + }); + + it("reads the quota windows from a 200", async () => { + respondWith( + Response.json({ + email: "dev@example.com", + plan_type: "plus", + rate_limit: { + primary_window: { + used_percent: 12, + limit_window_seconds: 5 * HOUR_SECONDS, + reset_at: NOW / 1000 + HOUR_SECONDS, + }, + }, + }), + ); + expect(await fetchCodexSubscriptionQuota("token")).toMatchObject({ + status: "ok", + email: "dev@example.com", + plan: "plus", + windows: [{ id: "primary", label: "Session (5h)", usedPercent: 12 }], + }); + }); +}); + +describe("fetchCodexAccounts", () => { + it("reports a lapsed token Codex can renew as stale without calling the endpoint", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 401 }), + ); + const home = tempHome({ + ".codex": { + "auth.json": JSON.stringify({ + tokens: { + access_token: accessToken({ + exp: Math.floor(Date.now() / 1000) - HOUR_SECONDS, + }), + refresh_token: "rt", + account_id: "account", + }, + }), + }, + }); + + const accounts = await fetchCodexAccounts(home); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + status: "token_stale", + statusDetail: "Refreshes when Codex next runs.", + selection: null, + }); + }); + + it("shows a home Superset set up whose login is gone as signed out", async () => { + const home = tempHome({ + ".codex-work": { ".superset-profile.json": "{}" }, + }); + + const accounts = await fetchCodexAccounts(home); + + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + agent: "codex", + credentialKind: "subscription", + status: "signed_out", + statusDetail: + "Signed out — use Switch sign-in to reconnect, or Remove to delete this profile.", + selection: join(home, ".codex-work"), + }); + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/codex.ts b/packages/host-service/src/trpc/router/usage/codex.ts index f054d7bb427..c7345f371e5 100644 --- a/packages/host-service/src/trpc/router/usage/codex.ts +++ b/packages/host-service/src/trpc/router/usage/codex.ts @@ -1,13 +1,18 @@ /** * Codex subscription quota from the ChatGPT backend `wham/usage` endpoint, * using the OAuth token the Codex CLI already stores in `$CODEX_HOME/auth.json`. - * Read-only: we never refresh the token (see claude.ts for why). + * Read-only: we never refresh the token (see claude.ts for why). A lapsed + * access token is reported as `token_stale` while a refresh token remains. */ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { type CodexHome, discoverCodexHomes } from "./profiles"; +import { + type CodexHome, + discoverCodexHomes, + SIGNED_OUT_PROFILE_DETAIL, +} from "./profiles"; import type { UsageAccount, UsageAccountStatus, @@ -17,11 +22,14 @@ import type { const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; const FETCH_TIMEOUT_MS = 10_000; +interface CodexTokens { + access_token?: string; + refresh_token?: string; + account_id?: string; +} + interface CodexAuthFile { - tokens?: { - access_token?: string; - account_id?: string; - }; + tokens?: CodexTokens | null; } interface CodexRateLimitWindow { @@ -102,8 +110,11 @@ function mapWindows(usage: CodexUsageResponse): UsageQuotaWindow[] { return windows; } -export async function fetchCodexAccounts(): Promise { - const homes = await discoverCodexHomes(); +/** `homeDir` overrides the home dir for tests. */ +export async function fetchCodexAccounts( + homeDir?: string, +): Promise { + const homes = await discoverCodexHomes(homeDir); // The first discovered home is what codex uses with no CODEX_HOME override // (see discoverCodexHomes) — running on it needs no env injection. const defaultHome = homes[0]?.home ?? null; @@ -148,8 +159,36 @@ export interface CodexSubscriptionQuota { creditsBalance: number | null; } +export const CODEX_STALE_TOKEN_DETAIL = "Refreshes when Codex next runs."; export const CODEX_EXPIRED_TOKEN_DETAIL = - "Codex token expired — run `codex` to refresh it."; + "Sign-in expired — run `codex login`."; + +/** The `exp` claim of a JWT access token in ms, or null when it is unreadable. */ +function accessTokenExpiresAt(accessToken: string): number | null { + try { + const payload = accessToken.split(".")[1] ?? ""; + const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString()); + return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null; + } catch { + return null; + } +} + +/** + * Codex renews a lapsed access token from its refresh token the next time it + * runs, so only a lapsed token without a refresh token means the login is + * gone. A token whose expiry cannot be read is left to the usage endpoint. + */ +export function classifyCodexToken( + tokens: CodexTokens, + now = Date.now(), +): "live" | "token_stale" | "token_expired" { + const expiresAt = tokens.access_token + ? accessTokenExpiresAt(tokens.access_token) + : null; + if (expiresAt === null || expiresAt > now) return "live"; + return tokens.refresh_token ? "token_stale" : "token_expired"; +} /** * Reads the subscription quota behind a ChatGPT OAuth access token. The @@ -170,7 +209,8 @@ export async function fetchCodexSubscriptionQuota( signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); - if (response.status === 401 || response.status === 403) { + // A 403 is a refusal that a new sign-in does not clear. + if (response.status === 401) { return { ...empty, status: "token_expired", @@ -245,23 +285,61 @@ async function fetchCodexAccountForHome( ]; } - let auth: CodexAuthFile; + let auth: CodexAuthFile | null = null; try { auth = JSON.parse(await readFile(authPath, "utf-8")); } catch { - return []; + // No readable login; only a signed-out home still gets a card. + } + const tokens = auth?.tokens; + const accessToken = tokens?.access_token; + if (!tokens || !accessToken) { + if (!home.signedOut) return []; + return [ + { + account: { + ...base, + email: null, + plan: null, + status: "signed_out", + statusDetail: SIGNED_OUT_PROFILE_DETAIL, + windows: [], + creditsBalance: null, + }, + chatgptAccountId: null, + }, + ]; + } + + const lapsed = classifyCodexToken(tokens); + if (lapsed !== "live") { + return [ + { + account: { + ...base, + email: null, + plan: null, + status: lapsed, + statusDetail: + lapsed === "token_stale" + ? CODEX_STALE_TOKEN_DETAIL + : CODEX_EXPIRED_TOKEN_DETAIL, + windows: [], + creditsBalance: null, + }, + chatgptAccountId: tokens.account_id ?? null, + }, + ]; } - const accessToken = auth.tokens?.access_token; - if (!accessToken) return []; const quota = await fetchCodexSubscriptionQuota( accessToken, - auth.tokens?.account_id, + tokens.account_id, ); return [ { account: { ...base, ...quota }, - chatgptAccountId: auth.tokens?.account_id ?? null, + chatgptAccountId: tokens.account_id ?? null, }, ]; } diff --git a/packages/host-service/src/trpc/router/usage/profiles.test.ts b/packages/host-service/src/trpc/router/usage/profiles.test.ts index c10eba510f2..ecfe03c3d45 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.test.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.test.ts @@ -12,6 +12,7 @@ import { API_BILLING_MARKER, claudeKeychainAccounts, discoverClaudeProfiles, + discoverCodexHomes, readCodexProfileKind, } from "./profiles"; @@ -158,3 +159,82 @@ describe("discoverClaudeProfiles", () => { }); }); }); + +describe("discoverCodexHomes", () => { + const PROFILE_LEDGER = ".superset-profile.json"; + + function codexDir(home: string, name: string, files: Record) { + mkdirSync(join(home, name)); + for (const [file, content] of Object.entries(files)) { + writeFileSync(join(home, name, file), content); + } + return join(home, name); + } + + it("keeps a home Superset set up after its login is gone, as signed out", async () => { + const home = tempProfile(); + const signedOut = codexDir(home, ".codex-a", { [PROFILE_LEDGER]: "{}" }); + const emptyLogin = codexDir(home, ".codex-b", { + [PROFILE_LEDGER]: "{}", + "auth.json": JSON.stringify({ OPENAI_API_KEY: null, tokens: null }), + }); + const homes = await discoverCodexHomes(home); + expect(homes.find((entry) => entry.home === signedOut)).toMatchObject({ + credentialKind: "subscription", + loginFingerprint: null, + signedOut: true, + }); + expect(homes.find((entry) => entry.home === emptyLogin)).toMatchObject({ + signedOut: true, + }); + }); + + it("keeps a home with a token as a live subscription", async () => { + const home = tempProfile(); + const live = codexDir(home, ".codex-c", { + "auth.json": JSON.stringify({ tokens: { access_token: "test-token" } }), + }); + expect( + (await discoverCodexHomes(home)).find((entry) => entry.home === live), + ).toEqual({ + home: live, + sourceLabel: expect.any(String), + credentialKind: "subscription", + loginFingerprint: null, + signedOut: false, + }); + }); + + it("skips dirs without the ledger, and logins it cannot classify", async () => { + const home = tempProfile(); + codexDir(home, ".codex-b", {}); + // An unmarked API key and a half-written auth.json are logins, not sign-outs. + codexDir(home, ".codex-key", { + [PROFILE_LEDGER]: "{}", + "auth.json": JSON.stringify({ OPENAI_API_KEY: "sk-test" }), + }); + codexDir(home, ".codex-partial", { + [PROFILE_LEDGER]: "{}", + "auth.json": '{"tokens": {"access_tok', + }); + expect((await discoverCodexHomes(home)).map((entry) => entry.home)).toEqual( + [join(home, ".codex")], + ); + }); + + it("lists the default home first, without a login and never as signed out", async () => { + const home = tempProfile(); + codexDir(home, ".codex", { [PROFILE_LEDGER]: "{}" }); + codexDir(home, ".codex-a", { [PROFILE_LEDGER]: "{}" }); + const homes = await discoverCodexHomes(home); + expect(homes[0]).toMatchObject({ + home: join(home, ".codex"), + credentialKind: "subscription", + signedOut: false, + }); + expect(homes.map((entry) => entry.home)).toEqual([ + join(home, ".codex"), + join(home, ".codex-a"), + ]); + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/profiles.ts b/packages/host-service/src/trpc/router/usage/profiles.ts index a7c19384a54..d8f0b2b39ee 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.ts @@ -27,7 +27,10 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { homedir, platform, userInfo } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { resolveAmbientCodexHome } from "@superset/agent-setup"; +import { + PROFILE_LEDGER_NAME, + resolveAmbientCodexHome, +} from "@superset/agent-setup"; const execFileAsync = promisify(execFile); @@ -46,6 +49,9 @@ const MAX_MARKER_BYTES = 64; export type ProfileCredentialKind = "subscription" | "api_key"; +export const SIGNED_OUT_PROFILE_DETAIL = + "Signed out — use Switch sign-in to reconnect, or Remove to delete this profile."; + export interface ClaudeProfile { /** Absolute config dir path (the CLAUDE_CONFIG_DIR value's expansion). */ configDir: string; @@ -66,6 +72,8 @@ export interface CodexHome { credentialKind: ProfileCredentialKind; /** API profiles only; derived from the marker, never from auth.json. */ loginFingerprint: string | null; + /** A home Superset set up (it holds the profile ledger) whose login is gone. */ + signedOut: boolean; } function tildeLabel(path: string): string { @@ -253,7 +261,8 @@ export async function readKeychainSecrets(service: string): Promise { } interface CodexAuthShape { - tokens?: { access_token?: string }; + OPENAI_API_KEY?: unknown; + tokens?: { access_token?: string } | null; } /** @@ -281,20 +290,58 @@ export async function readCodexProfileKind( } } +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +/** + * True when Superset set the home up (it holds the profile ledger) and its + * auth.json is gone or holds neither a token nor a key. An auth.json that + * does not parse may be mid-write, so it does not count as signed out. + */ +async function isSignedOutCodexHome(codexHome: string): Promise { + if (!(await isFile(join(codexHome, PROFILE_LEDGER_NAME)))) return false; + let raw: string; + try { + raw = await readFile(join(codexHome, "auth.json"), "utf-8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } + try { + const parsed: CodexAuthShape | null = JSON.parse(raw); + return !parsed?.tokens?.access_token && !parsed?.OPENAI_API_KEY; + } catch { + return false; + } +} + /** * Codex homes: the ambient home (`~/.codex`, or a `CODEX_HOME` the user set * themselves — see resolveAmbientCodexHome for why Superset's own injected * value is ignored) plus any `~/.codex*` dot-dir carrying an `auth.json` with * a token or the API-billing marker. The common multi-account convention is - * one CODEX_HOME dir per account. + * one CODEX_HOME dir per account. A dot-dir Superset set up stays listed, + * flagged `signedOut`, after its login is gone, so it can be re-signed or + * removed. * * The first entry is the system default, and `fetchCodexAccounts` gives it * `selection: null`. It is listed even without an `auth.json` so the * add-account poller has a baseline to compare a fresh `codex login` against. + * `homeDir` overrides the home dir for tests. */ -export async function discoverCodexHomes(): Promise { - const home = homedir(); - const defaultHome = resolveAmbientCodexHome(home); +export async function discoverCodexHomes( + homeDir?: string, +): Promise { + const home = homeDir ?? homedir(); + // An injected home must not resolve to the machine's own CODEX_HOME. + const defaultHome = + homeDir === undefined + ? resolveAmbientCodexHome(home) + : join(home, ".codex"); const defaultKind = (await readCodexProfileKind(defaultHome)) ?? { credentialKind: "subscription" as const, loginFingerprint: null, @@ -306,6 +353,7 @@ export async function discoverCodexHomes(): Promise { home: defaultHome, sourceLabel: tildeLabel(defaultHome), ...defaultKind, + signedOut: false, }, ], ]); @@ -316,12 +364,22 @@ export async function discoverCodexHomes(): Promise { for (const candidate of candidates) { if (homes.has(candidate)) continue; const kind = await readCodexProfileKind(candidate); - if (!kind) continue; - homes.set(candidate, { - home: candidate, - sourceLabel: tildeLabel(candidate), - ...kind, - }); + if (kind) { + homes.set(candidate, { + home: candidate, + sourceLabel: tildeLabel(candidate), + ...kind, + signedOut: false, + }); + } else if (await isSignedOutCodexHome(candidate)) { + homes.set(candidate, { + home: candidate, + sourceLabel: tildeLabel(candidate), + credentialKind: "subscription", + loginFingerprint: null, + signedOut: true, + }); + } } return [...homes.values()]; } diff --git a/packages/host-service/src/trpc/router/usage/types.ts b/packages/host-service/src/trpc/router/usage/types.ts index 25c4f954772..12abb480722 100644 --- a/packages/host-service/src/trpc/router/usage/types.ts +++ b/packages/host-service/src/trpc/router/usage/types.ts @@ -61,9 +61,10 @@ export type UsageAccountStatus = /** Credentials exist but the provider returned no usable quota data * (org-managed/education plans, endpoint changes, transient errors). */ | "unavailable" - /** A profile dir with an identity but no readable credential (logged out - * or a half-finished login). Surfaced so it can be re-signed or removed; - * a signed-out Codex home is undetectable (its auth.json is gone). */ + /** A profile dir with no readable credential (logged out or a + * half-finished login): a Claude profile that still names an identity, or + * a Codex home Superset set up (it holds the profile ledger). Surfaced so + * it can be re-signed or removed. */ | "signed_out"; export interface UsageQuotaWindow { diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 86f9ca9094c..9ff5be03028 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -15211,6 +15211,9 @@ msgstr "Obnovit pull requesty" msgid "Refreshes when Claude Code next runs." msgstr "Obnoví se při příštím spuštění Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Obnoví se při příštím spuštění Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Obnoví se při příštím spuštění OpenCode." diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index eb6b351a33e..c173774d91b 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -15211,6 +15211,9 @@ msgstr "Pull Requests aktualisieren" msgid "Refreshes when Claude Code next runs." msgstr "Wird beim nächsten Start von Claude Code aktualisiert." +msgid "Refreshes when Codex next runs." +msgstr "Wird beim nächsten Start von Codex aktualisiert." + msgid "Refreshes when OpenCode next runs." msgstr "Wird beim nächsten Start von OpenCode aktualisiert." diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 94b911fdd70..589253bea5c 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -15211,6 +15211,9 @@ msgstr "Refresh pull requests" msgid "Refreshes when Claude Code next runs." msgstr "Refreshes when Claude Code next runs." +msgid "Refreshes when Codex next runs." +msgstr "Refreshes when Codex next runs." + msgid "Refreshes when OpenCode next runs." msgstr "Refreshes when OpenCode next runs." diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 0af4acd31af..20b8c44da2a 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -15211,6 +15211,9 @@ msgstr "Actualizar las pull requests" msgid "Refreshes when Claude Code next runs." msgstr "Se actualiza la próxima vez que se ejecute Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Se actualiza la próxima vez que se ejecute Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Se actualiza la próxima vez que se ejecute OpenCode." diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 55b40436c4b..69e45809a96 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -15211,6 +15211,9 @@ msgstr "Actualiser les pull requests" msgid "Refreshes when Claude Code next runs." msgstr "Actualisé au prochain lancement de Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Actualisé au prochain lancement de Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Actualisé au prochain lancement de OpenCode." diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 662535d37a0..ee5d2d52beb 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -15211,6 +15211,9 @@ msgstr "Segarkan pull request" msgid "Refreshes when Claude Code next runs." msgstr "Diperbarui saat Claude Code berikutnya dijalankan." +msgid "Refreshes when Codex next runs." +msgstr "Diperbarui saat Codex berikutnya dijalankan." + msgid "Refreshes when OpenCode next runs." msgstr "Diperbarui saat OpenCode berikutnya dijalankan." diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 7819af55741..1850b0e9dce 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -15211,6 +15211,9 @@ msgstr "Aggiorna le pull request" msgid "Refreshes when Claude Code next runs." msgstr "Si aggiorna alla prossima esecuzione di Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Si aggiorna alla prossima esecuzione di Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Si aggiorna alla prossima esecuzione di OpenCode." diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 9ec8a442bc1..7c79eb3d967 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -15211,6 +15211,9 @@ msgstr "プルリクエストを更新" msgid "Refreshes when Claude Code next runs." msgstr "次に Claude Code を実行すると更新されます。" +msgid "Refreshes when Codex next runs." +msgstr "次に Codex を実行すると更新されます。" + msgid "Refreshes when OpenCode next runs." msgstr "次に OpenCode を実行すると更新されます。" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 6dc58ba3798..295c01658e5 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -15211,6 +15211,9 @@ msgstr "풀 리퀘스트 새로 고침" msgid "Refreshes when Claude Code next runs." msgstr "다음에 Claude Code를 실행하면 갱신됩니다." +msgid "Refreshes when Codex next runs." +msgstr "다음에 Codex를 실행하면 갱신됩니다." + msgid "Refreshes when OpenCode next runs." msgstr "다음에 OpenCode를 실행하면 갱신됩니다." diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 04eafd8ea5b..302414642b2 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -15211,6 +15211,9 @@ msgstr "Pull requests vernieuwen" msgid "Refreshes when Claude Code next runs." msgstr "Wordt vernieuwd zodra Claude Code opnieuw draait." +msgid "Refreshes when Codex next runs." +msgstr "Wordt vernieuwd zodra Codex opnieuw draait." + msgid "Refreshes when OpenCode next runs." msgstr "Wordt vernieuwd zodra OpenCode opnieuw draait." diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 15832a244e1..a9af9a8c801 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -15211,6 +15211,9 @@ msgstr "Odśwież pull requesty" msgid "Refreshes when Claude Code next runs." msgstr "Odświeży się przy następnym uruchomieniu Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Odświeży się przy następnym uruchomieniu Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Odświeży się przy następnym uruchomieniu OpenCode." diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 19a00af3f69..beda306ba93 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -15211,6 +15211,9 @@ msgstr "Atualizar os pull requests" msgid "Refreshes when Claude Code next runs." msgstr "Atualiza na próxima execução do Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Atualiza na próxima execução do Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Atualiza na próxima execução do OpenCode." diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 94cf8074db5..e28645bcc0b 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -15211,6 +15211,9 @@ msgstr "Обновить пул-реквесты" msgid "Refreshes when Claude Code next runs." msgstr "Обновится при следующем запуске Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Обновится при следующем запуске Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Обновится при следующем запуске OpenCode." diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 75d2af45488..956efe01a5b 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -15211,6 +15211,9 @@ msgstr "Pull request'leri yenile" msgid "Refreshes when Claude Code next runs." msgstr "Claude Code bir sonraki çalıştığında yenilenir." +msgid "Refreshes when Codex next runs." +msgstr "Codex bir sonraki çalıştığında yenilenir." + msgid "Refreshes when OpenCode next runs." msgstr "OpenCode bir sonraki çalıştığında yenilenir." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 496fef4f577..94308bf105b 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -15211,6 +15211,9 @@ msgstr "Làm mới pull request" msgid "Refreshes when Claude Code next runs." msgstr "Làm mới khi Claude Code chạy lần tiếp theo." +msgid "Refreshes when Codex next runs." +msgstr "Làm mới khi Codex chạy lần tiếp theo." + msgid "Refreshes when OpenCode next runs." msgstr "Làm mới khi OpenCode chạy lần tiếp theo." diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index a89934a1feb..dc0900eba3c 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -15211,6 +15211,9 @@ msgstr "刷新拉取请求" msgid "Refreshes when Claude Code next runs." msgstr "下次运行 Claude Code 时刷新。" +msgid "Refreshes when Codex next runs." +msgstr "下次运行 Codex 时刷新。" + msgid "Refreshes when OpenCode next runs." msgstr "下次运行 OpenCode 时刷新。" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index cf3c9fd67bf..1a36fcb54d3 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -15211,6 +15211,9 @@ msgstr "重新整理拉取請求" msgid "Refreshes when Claude Code next runs." msgstr "下次執行 Claude Code 時重新整理。" +msgid "Refreshes when Codex next runs." +msgstr "下次執行 Codex 時重新整理。" + msgid "Refreshes when OpenCode next runs." msgstr "下次執行 OpenCode 時重新整理。" From 3bce0a1e9c66e726b59953126d7b22d50de455b4 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 01:31:41 -0400 Subject: [PATCH 2/6] fix(usage): list a Codex home as signed out only if Paradev created it The signed-out listing keyed on the profile ledger, but provisioning writes that ledger into any home it touches: Make default, prepareAccount and host boot reach homes that the user or another tool created. Such a home whose login was logged out, or only moved aside for a moment, got a "Signed out" card. That card offers Remove, which deletes the whole dir, and Switch sign-in, which runs `codex login`, and `codex login` revokes the login it finds before it signs in. The test for "no login" also missed most of what Codex stores in auth.json, and logins kept in the OS keyring, for which Codex deletes auth.json. A ~/.codex* home is now listed as signed out only when all of these hold: - It carries Paradev's creation marker, .superset-created naming codex. The marker reader is shared with the API-billing marker, as in #9182, which adds the marker to the add-account command. - It holds the profile ledger, which provisioning writes once a sign-in there has completed. A home whose first sign-in is still running, or was cancelled, stays hidden. - auth.json is missing (lstat gives ENOENT), or it is a regular file that parses to no credential. The credential fields are tokens (access and refresh token), OPENAI_API_KEY, personal_access_token, agent_identity, bedrock_api_key and bedrock_access_keys, and all must be null or empty. Any other field that is set, except auth_mode and last_refresh, counts as a login. - The home's config.toml does not set cli_auth_credentials_store to anything but "file". An auth.json that is a symlink, is not a file or does not parse still leaves the home hidden. Co-Authored-By: Claude Opus 5.5 --- .../src/trpc/router/usage/codex.test.ts | 23 ++- .../src/trpc/router/usage/profiles.test.ts | 192 +++++++++++++++--- .../src/trpc/router/usage/profiles.ts | 131 ++++++++++-- .../src/trpc/router/usage/types.ts | 4 +- 4 files changed, 302 insertions(+), 48 deletions(-) diff --git a/packages/host-service/src/trpc/router/usage/codex.test.ts b/packages/host-service/src/trpc/router/usage/codex.test.ts index 44a663f52e9..a4ba5771d05 100644 --- a/packages/host-service/src/trpc/router/usage/codex.test.ts +++ b/packages/host-service/src/trpc/router/usage/codex.test.ts @@ -8,6 +8,7 @@ import { fetchCodexAccounts, fetchCodexSubscriptionQuota, } from "./codex"; +import { PROFILE_CREATED_MARKER } from "./profiles"; import type { UsageAccount } from "./types"; const NOW = Date.parse("2026-09-11T12:00:00.000Z"); @@ -187,7 +188,7 @@ describe("fetchCodexSubscriptionQuota", () => { respondWith(new Response(null, { status: 403 })); const quota = await fetchCodexSubscriptionQuota("token"); expect(quota.status).toBe("unavailable"); - expect(quota.statusDetail).toContain("403"); + expect(quota.statusDetail).toBe("Usage endpoint returned 403."); }); it("reads the quota windows from a 200", async () => { @@ -243,13 +244,20 @@ describe("fetchCodexAccounts", () => { }); }); - it("shows a home Superset set up whose login is gone as signed out", async () => { + it("shows a home Paradev created whose login is gone as signed out", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 500 }), + ); const home = tempHome({ - ".codex-work": { ".superset-profile.json": "{}" }, + ".codex-work": { + [PROFILE_CREATED_MARKER]: "codex", + ".superset-profile.json": "{}", + }, }); const accounts = await fetchCodexAccounts(home); + expect(fetchSpy).not.toHaveBeenCalled(); expect(accounts).toHaveLength(1); expect(accounts[0]).toMatchObject({ agent: "codex", @@ -260,4 +268,13 @@ describe("fetchCodexAccounts", () => { selection: join(home, ".codex-work"), }); }); + + // Its card would offer Remove, which deletes the dir, and Switch sign-in. + it("gives no card to a provisioned home Paradev did not create once its login is gone", async () => { + const home = tempHome({ + ".codex-work": { ".superset-profile.json": "{}" }, + }); + + expect(await fetchCodexAccounts(home)).toEqual([]); + }); }); diff --git a/packages/host-service/src/trpc/router/usage/profiles.test.ts b/packages/host-service/src/trpc/router/usage/profiles.test.ts index ecfe03c3d45..c761b8d3918 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.test.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.test.ts @@ -1,18 +1,23 @@ import { afterEach, describe, expect, it } from "bun:test"; import { + existsSync, mkdirSync, mkdtempSync, + renameSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { provisionCodexProfile } from "@superset/agent-setup"; import { API_BILLING_MARKER, claudeKeychainAccounts, discoverClaudeProfiles, discoverCodexHomes, + PROFILE_CREATED_MARKER, readCodexProfileKind, } from "./profiles"; @@ -161,32 +166,60 @@ describe("discoverClaudeProfiles", () => { }); describe("discoverCodexHomes", () => { + // Provisioning (Make default, prepareAccount, host boot) writes this into + // any home it touches, including homes Paradev did not create. const PROFILE_LEDGER = ".superset-profile.json"; + // A home the add-account command created, provisioned after its sign-in. + const PARADEV_HOME = { + [PROFILE_CREATED_MARKER]: "codex", + [PROFILE_LEDGER]: "{}", + }; - function codexDir(home: string, name: string, files: Record) { - mkdirSync(join(home, name)); + function codexDir( + home: string, + name: string, + files: Record, + ): string { + const dir = join(home, name); + mkdirSync(dir); for (const [file, content] of Object.entries(files)) { - writeFileSync(join(home, name, file), content); + writeFileSync(join(dir, file), content); } - return join(home, name); + return dir; } - it("keeps a home Superset set up after its login is gone, as signed out", async () => { + async function listed(home: string, dir: string) { + return (await discoverCodexHomes(home)).find((entry) => entry.home === dir); + } + + it("keeps a home Paradev created after its login is gone, as signed out", async () => { const home = tempProfile(); - const signedOut = codexDir(home, ".codex-a", { [PROFILE_LEDGER]: "{}" }); + const loggedOut = codexDir(home, ".codex-a", PARADEV_HOME); + // Codex writes OPENAI_API_KEY even when it is null. const emptyLogin = codexDir(home, ".codex-b", { - [PROFILE_LEDGER]: "{}", - "auth.json": JSON.stringify({ OPENAI_API_KEY: null, tokens: null }), + ...PARADEV_HOME, + "auth.json": JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { id_token: "id", access_token: "", refresh_token: "" }, + last_refresh: "2026-09-11T12:00:00Z", + }), }); - const homes = await discoverCodexHomes(home); - expect(homes.find((entry) => entry.home === signedOut)).toMatchObject({ + expect(await listed(home, loggedOut)).toMatchObject({ credentialKind: "subscription", loginFingerprint: null, signedOut: true, }); - expect(homes.find((entry) => entry.home === emptyLogin)).toMatchObject({ - signedOut: true, - }); + expect(await listed(home, emptyLogin)).toMatchObject({ signedOut: true }); + }); + + // The add-account command writes the marker before the login runs. + it("keeps a home hidden while its first sign-in has not completed", async () => { + const home = tempProfile(); + codexDir(home, ".codex-new", { [PROFILE_CREATED_MARKER]: "codex\n" }); + expect((await discoverCodexHomes(home)).map((entry) => entry.home)).toEqual( + [join(home, ".codex")], + ); }); it("keeps a home with a token as a live subscription", async () => { @@ -194,9 +227,7 @@ describe("discoverCodexHomes", () => { const live = codexDir(home, ".codex-c", { "auth.json": JSON.stringify({ tokens: { access_token: "test-token" } }), }); - expect( - (await discoverCodexHomes(home)).find((entry) => entry.home === live), - ).toEqual({ + expect(await listed(home, live)).toEqual({ home: live, sourceLabel: expect.any(String), credentialKind: "subscription", @@ -205,16 +236,129 @@ describe("discoverCodexHomes", () => { }); }); - it("skips dirs without the ledger, and logins it cannot classify", async () => { + // Remove deletes the whole dir, so only a dir Paradev made may be offered. + it("never lists a home Paradev did not create as signed out, even once it provisioned it", async () => { const home = tempProfile(); - codexDir(home, ".codex-b", {}); - // An unmarked API key and a half-written auth.json are logins, not sign-outs. - codexDir(home, ".codex-key", { + codexDir(home, ".codex", { "config.toml": 'model = "m"\n' }); + const external = codexDir(home, ".codex-ext", { + "auth.json": JSON.stringify({ + tokens: { access_token: "test-token", refresh_token: "rt" }, + }), + }); + await provisionCodexProfile(external, { homeDir: home }); + expect(existsSync(join(external, PROFILE_LEDGER))).toBe(true); + // The tool that keeps this login moves it aside while it rotates it. + renameSync(join(external, "auth.json"), join(home, "parked-auth.json")); + codexDir(home, ".codex-ledger", { [PROFILE_LEDGER]: "{}" }); + codexDir(home, ".codex-claude", { + ...PARADEV_HOME, + [PROFILE_CREATED_MARKER]: "claude", + }); + const markerDir = codexDir(home, ".codex-marker-dir", { [PROFILE_LEDGER]: "{}", - "auth.json": JSON.stringify({ OPENAI_API_KEY: "sk-test" }), }); + mkdirSync(join(markerDir, PROFILE_CREATED_MARKER)); + + expect((await discoverCodexHomes(home)).map((entry) => entry.home)).toEqual( + [join(home, ".codex")], + ); + }); + + describe("does not read a login stored outside the access token as signed out", () => { + // auth.json as Codex writes each kind of login. + const logins: Array<[string, Record]> = [ + ["an API key", { auth_mode: "apikey", OPENAI_API_KEY: "sk-test" }], + [ + "a personal access token", + { OPENAI_API_KEY: null, personal_access_token: "pat-test" }, + ], + [ + "an agent identity", + { + auth_mode: "agentIdentity", + OPENAI_API_KEY: null, + agent_identity: "header.payload.signature", + }, + ], + [ + "an agent identity record", + { + OPENAI_API_KEY: null, + agent_identity: { agent_runtime_id: "rt-1", agent_private_key: "k" }, + }, + ], + [ + "a Bedrock API key", + { + OPENAI_API_KEY: null, + bedrock_api_key: { api_key: "key", region: "us-east-1" }, + }, + ], + [ + "Bedrock access keys", + { + OPENAI_API_KEY: null, + bedrock_access_keys: { + access_key_id: "id", + secret_access_key: "secret", + }, + }, + ], + [ + "a refresh token without an access token", + { + OPENAI_API_KEY: null, + tokens: { access_token: "", refresh_token: "rt" }, + }, + ], + [ + "a field this reader does not know", + { OPENAI_API_KEY: null, future_login: { token: "t" } }, + ], + ]; + for (const [kind, auth] of logins) { + it(kind, async () => { + const home = tempProfile(); + const dir = codexDir(home, ".codex-a", { + ...PARADEV_HOME, + "auth.json": JSON.stringify(auth), + }); + expect((await listed(home, dir))?.signedOut ?? false).toBe(false); + }); + } + }); + + it("does not read a home whose login is kept in the OS keyring as signed out", async () => { + const home = tempProfile(); + // Codex then deletes auth.json. Provisioning copies config.toml from + // the default home, so the setting can reach every profile. + const keyring = codexDir(home, ".codex-keyring", { + ...PARADEV_HOME, + "config.toml": 'model = "m"\ncli_auth_credentials_store = "keyring"\n', + }); + const auto = codexDir(home, ".codex-auto", { + ...PARADEV_HOME, + "config.toml": "cli_auth_credentials_store = 'auto' # keyring if any\n", + }); + const file = codexDir(home, ".codex-file", { + ...PARADEV_HOME, + "config.toml": + '# cli_auth_credentials_store = "keyring"\ncli_auth_credentials_store = "file"\n', + }); + expect((await listed(home, keyring))?.signedOut ?? false).toBe(false); + expect((await listed(home, auto))?.signedOut ?? false).toBe(false); + expect(await listed(home, file)).toMatchObject({ signedOut: true }); + }); + + it("leaves an auth.json it cannot read as a file alone", async () => { + const home = tempProfile(); + const link = codexDir(home, ".codex-link", PARADEV_HOME); + symlinkSync(join(home, "store", "auth.json"), join(link, "auth.json")); + const directory = codexDir(home, ".codex-dir", PARADEV_HOME); + mkdirSync(join(directory, "auth.json")); + // A half-written auth.json is a login being saved. codexDir(home, ".codex-partial", { - [PROFILE_LEDGER]: "{}", + ...PARADEV_HOME, "auth.json": '{"tokens": {"access_tok', }); expect((await discoverCodexHomes(home)).map((entry) => entry.home)).toEqual( @@ -224,8 +368,8 @@ describe("discoverCodexHomes", () => { it("lists the default home first, without a login and never as signed out", async () => { const home = tempProfile(); - codexDir(home, ".codex", { [PROFILE_LEDGER]: "{}" }); - codexDir(home, ".codex-a", { [PROFILE_LEDGER]: "{}" }); + codexDir(home, ".codex", PARADEV_HOME); + codexDir(home, ".codex-a", PARADEV_HOME); const homes = await discoverCodexHomes(home); expect(homes[0]).toMatchObject({ home: join(home, ".codex"), diff --git a/packages/host-service/src/trpc/router/usage/profiles.ts b/packages/host-service/src/trpc/router/usage/profiles.ts index d8f0b2b39ee..bf974449fe0 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.ts @@ -23,7 +23,8 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { readdir, readFile, stat } from "node:fs/promises"; +import type { Stats } from "node:fs"; +import { lstat, readdir, readFile, stat } from "node:fs/promises"; import { homedir, platform, userInfo } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -45,6 +46,12 @@ const MAX_STATE_FILE_BYTES = 50 * 1024 * 1024; * mtime is the login fingerprint, since a re-login rewrites it. */ export const API_BILLING_MARKER = ".superset-api-billing"; +/** + * Marks a profile dir that Paradev created, holding the agent id. A Codex + * home is listed as signed out only when it carries one: that card offers + * Remove, which deletes the whole dir. + */ +export const PROFILE_CREATED_MARKER = ".superset-created"; const MAX_MARKER_BYTES = 64; export type ProfileCredentialKind = "subscription" | "api_key"; @@ -72,7 +79,7 @@ export interface CodexHome { credentialKind: ProfileCredentialKind; /** API profiles only; derived from the marker, never from auth.json. */ loginFingerprint: string | null; - /** A home Superset set up (it holds the profile ledger) whose login is gone. */ + /** A home Paradev created and set up that holds no login. */ signedOut: boolean; } @@ -144,22 +151,41 @@ async function readClaudeIdentity( } } -/** The marker's mtime, or null when the dir is not API-billed for `agent`. */ -export async function readApiBillingFingerprint( +/** A marker file's stats, or null unless it exists and names `agent`. */ +async function readAgentMarker( profileDir: string, + marker: string, agent: "claude" | "codex", -): Promise { - const markerPath = join(profileDir, API_BILLING_MARKER); +): Promise { + const markerPath = join(profileDir, marker); try { const info = await stat(markerPath); if (!info.isFile() || info.size > MAX_MARKER_BYTES) return null; const content = (await readFile(markerPath, "utf-8")).trim(); - return content === agent ? `${info.mtimeMs}` : null; + return content === agent ? info : null; } catch { return null; } } +/** The marker's mtime, or null when the dir is not API-billed for `agent`. */ +export async function readApiBillingFingerprint( + profileDir: string, + agent: "claude" | "codex", +): Promise { + const marker = await readAgentMarker(profileDir, API_BILLING_MARKER, agent); + return marker ? `${marker.mtimeMs}` : null; +} + +export async function hasProfileCreatedMarker( + profileDir: string, + agent: "claude" | "codex", +): Promise { + return ( + (await readAgentMarker(profileDir, PROFILE_CREATED_MARKER, agent)) !== null + ); +} + /** * Extra Claude profile dirs beyond the defaults. Default homes are excluded — * callers already cover `~/.claude` and `~/.config/claude`. `candidates` @@ -261,8 +287,7 @@ export async function readKeychainSecrets(service: string): Promise { } interface CodexAuthShape { - OPENAI_API_KEY?: unknown; - tokens?: { access_token?: string } | null; + tokens?: { access_token?: string }; } /** @@ -298,22 +323,90 @@ async function isFile(path: string): Promise { } } +/** auth.json fields that describe a login without being a credential. */ +const CODEX_AUTH_METADATA = new Set(["auth_mode", "last_refresh"]); + +function isBlank(value: unknown): boolean { + return value === null || value === undefined || value === ""; +} + +/** + * Whether a parsed auth.json holds anything Codex could sign in with. Codex + * keeps a login in `tokens` (a refresh token alone renews the access token), + * `OPENAI_API_KEY`, `personal_access_token`, `agent_identity`, + * `bedrock_api_key` or `bedrock_access_keys`. Any other field that is set + * counts as a login too, so a kind added later is never read as signed out. + */ +function holdsCodexLogin(auth: unknown): boolean { + if (typeof auth !== "object" || auth === null || Array.isArray(auth)) { + return true; + } + return Object.entries(auth).some(([field, value]) => { + if (CODEX_AUTH_METADATA.has(field) || isBlank(value)) return false; + if ( + field !== "tokens" || + typeof value !== "object" || + Array.isArray(value) + ) { + return true; + } + const tokens = value as { access_token?: unknown; refresh_token?: unknown }; + return !isBlank(tokens.access_token) || !isBlank(tokens.refresh_token); + }); +} + +/** `cli_auth_credentials_store = …` in a Codex config.toml. */ +const CREDENTIALS_STORE_SETTING = + /^\s*["']?cli_auth_credentials_store["']?\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]*))/gm; + +/** + * True unless the home's config.toml leaves the login in auth.json. With + * `cli_auth_credentials_store` set to `keyring`, or to `auto` where a keyring + * exists, Codex saves the login in the OS keyring and deletes auth.json. + * Provisioning copies config.toml from the default home, so that setting can + * reach every profile. A config.toml that cannot be read is not trusted. + */ +async function mayKeepLoginOutsideAuthFile( + codexHome: string, +): Promise { + let config: string; + try { + config = await readFile(join(codexHome, "config.toml"), "utf-8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } + for (const match of config.matchAll(CREDENTIALS_STORE_SETTING)) { + if ((match[1] ?? match[2] ?? match[3]) !== "file") return true; + } + return false; +} + /** - * True when Superset set the home up (it holds the profile ledger) and its - * auth.json is gone or holds neither a token nor a key. An auth.json that - * does not parse may be mid-write, so it does not count as signed out. + * True when Paradev created the home and set it up, and it holds no login. + * + * - Created: it carries the creation marker. Provisioning (Make default, + * host boot) also writes the profile ledger into homes Paradev did not + * create, so the ledger alone does not count. + * - Set up: it holds the ledger, which provisioning writes once a sign-in + * there has completed. A home whose first sign-in is still running, or + * was cancelled, stays hidden. + * - No login: auth.json is missing, or is a file that parses to no + * credential, and config.toml keeps logins in that file. An auth.json that + * is a symlink, is not a file or does not parse (it can be mid-write) is + * left alone. */ async function isSignedOutCodexHome(codexHome: string): Promise { + if (!(await hasProfileCreatedMarker(codexHome, "codex"))) return false; if (!(await isFile(join(codexHome, PROFILE_LEDGER_NAME)))) return false; - let raw: string; + if (await mayKeepLoginOutsideAuthFile(codexHome)) return false; + const authPath = join(codexHome, "auth.json"); try { - raw = await readFile(join(codexHome, "auth.json"), "utf-8"); + if (!(await lstat(authPath)).isFile()) return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } try { - const parsed: CodexAuthShape | null = JSON.parse(raw); - return !parsed?.tokens?.access_token && !parsed?.OPENAI_API_KEY; + return !holdsCodexLogin(JSON.parse(await readFile(authPath, "utf-8"))); } catch { return false; } @@ -324,9 +417,9 @@ async function isSignedOutCodexHome(codexHome: string): Promise { * themselves — see resolveAmbientCodexHome for why Superset's own injected * value is ignored) plus any `~/.codex*` dot-dir carrying an `auth.json` with * a token or the API-billing marker. The common multi-account convention is - * one CODEX_HOME dir per account. A dot-dir Superset set up stays listed, - * flagged `signedOut`, after its login is gone, so it can be re-signed or - * removed. + * one CODEX_HOME dir per account. A dot-dir Paradev created and set up stays + * listed, flagged `signedOut`, once it holds no login, so it can be signed in + * again or removed. * * The first entry is the system default, and `fetchCodexAccounts` gives it * `selection: null`. It is listed even without an `auth.json` so the diff --git a/packages/host-service/src/trpc/router/usage/types.ts b/packages/host-service/src/trpc/router/usage/types.ts index 12abb480722..af239564dc6 100644 --- a/packages/host-service/src/trpc/router/usage/types.ts +++ b/packages/host-service/src/trpc/router/usage/types.ts @@ -63,8 +63,8 @@ export type UsageAccountStatus = | "unavailable" /** A profile dir with no readable credential (logged out or a * half-finished login): a Claude profile that still names an identity, or - * a Codex home Superset set up (it holds the profile ledger). Surfaced so - * it can be re-signed or removed. */ + * a Codex home Paradev created and set up. Surfaced so it can be + * re-signed or removed. */ | "signed_out"; export interface UsageQuotaWindow { From 6a2246583db19a14da69d17f8624c469c31e8d19 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 01:31:41 -0400 Subject: [PATCH 3/6] fix(usage): read a Codex home again before Remove deletes it removeAccount checked the selection against the cached quota list, which can be five minutes old, and then deleted the dir. A login that came back after the list was read was deleted with it. A Codex home is now discovered again first. It must still be a secondary home, and if its card said signed out, it must still hold no login. Otherwise nothing is deleted and the error says why. Co-Authored-By: Claude Opus 5.5 --- .../trpc/router/usage/profile-remove.test.ts | 73 +++++++++++++++++++ .../src/trpc/router/usage/profile-remove.ts | 32 +++++++- .../src/trpc/router/usage/usage.ts | 14 +++- 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts index c750430c4cd..c8a4c00d58b 100644 --- a/packages/host-service/src/trpc/router/usage/profile-remove.test.ts +++ b/packages/host-service/src/trpc/router/usage/profile-remove.test.ts @@ -10,10 +10,12 @@ import { import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { + assertCodexHomeStillRemovable, assertRemovableProfileDir, removeClaudeProfile, removeCodexHome, } from "./profile-remove"; +import { PROFILE_CREATED_MARKER } from "./profiles"; // These guard a recursive delete — every rejection here is load-bearing. describe("assertRemovableProfileDir", () => { @@ -151,3 +153,74 @@ describe("removeClaudeProfile with shared-config links", () => { } }); }); + +// Remove acts on a card from the cached quota list, which can be minutes old, +// so the home is read again before anything is deleted. +describe("assertCodexHomeStillRemovable", () => { + const roots: string[] = []; + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + /** A fake home dir holding a signed-out Codex home that Paradev created. */ + function signedOutProfile(): { home: string; profile: string } { + const home = mkdtempSync(join(tmpdir(), "superset-profile-recheck-")); + roots.push(home); + const profile = join(home, ".codex-work"); + mkdirSync(profile); + writeFileSync(join(profile, PROFILE_CREATED_MARKER), "codex"); + writeFileSync(join(profile, ".superset-profile.json"), "{}"); + return { home, profile }; + } + + function signIn(codexHome: string): void { + mkdirSync(codexHome, { recursive: true }); + writeFileSync( + join(codexHome, "auth.json"), + JSON.stringify({ tokens: { access_token: "test-token" } }), + ); + } + + it("accepts a home shown as signed out that still holds no login", async () => { + const { home, profile } = signedOutProfile(); + await expect( + assertCodexHomeStillRemovable(profile, "signed_out", home), + ).resolves.toBeUndefined(); + }); + + it("keeps a home shown as signed out once a login is back", async () => { + const { home, profile } = signedOutProfile(); + signIn(profile); + await expect( + assertCodexHomeStillRemovable(profile, "signed_out", home), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: expect.stringContaining(profile), + }); + }); + + it("accepts a signed-in home shown as signed in", async () => { + const { home, profile } = signedOutProfile(); + signIn(profile); + await expect( + assertCodexHomeStillRemovable(profile, "ok", home), + ).resolves.toBeUndefined(); + }); + + it("refuses a home no longer discovered, and the default home", async () => { + const { home } = signedOutProfile(); + // Another tool's home that it provisioned, with its login moved aside. + const external = join(home, ".codex-ext"); + mkdirSync(external); + writeFileSync(join(external, ".superset-profile.json"), "{}"); + const defaultHome = join(home, ".codex"); + signIn(defaultHome); + for (const dir of [external, defaultHome, join(home, ".codex-gone")]) { + await expect( + assertCodexHomeStillRemovable(dir, "ok", home), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + } + }); +}); diff --git a/packages/host-service/src/trpc/router/usage/profile-remove.ts b/packages/host-service/src/trpc/router/usage/profile-remove.ts index 9736dcb50a3..54822ff24fb 100644 --- a/packages/host-service/src/trpc/router/usage/profile-remove.ts +++ b/packages/host-service/src/trpc/router/usage/profile-remove.ts @@ -11,7 +11,9 @@ import { homedir, platform } from "node:os"; import { join, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { resolveAmbientCodexHome } from "@superset/agent-setup"; -import { keychainServicesForConfigDir } from "./profiles"; +import { TRPCError } from "@trpc/server"; +import { discoverCodexHomes, keychainServicesForConfigDir } from "./profiles"; +import type { UsageAccountStatus } from "./types"; const execFileAsync = promisify(execFile); @@ -64,3 +66,31 @@ export async function removeCodexHome(home: string): Promise { const dir = assertRemovableProfileDir(home); await rm(dir, { recursive: true, force: true }); } + +/** + * Reads a Codex home again right before Remove deletes it. The card the user + * acted on comes from the cached quota list, which can be minutes old: the + * home must still be a discovered secondary home, and one that card showed + * as signed out must still hold no login. `homeDir` overrides the home dir + * for tests. + */ +export async function assertCodexHomeStillRemovable( + home: string, + listedStatus: UsageAccountStatus, + homeDir?: string, +): Promise { + const [, ...secondary] = await discoverCodexHomes(homeDir); + const current = secondary.find((entry) => entry.home === home); + if (!current) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No removable codex profile at ${home}.`, + }); + } + if (listedStatus === "signed_out" && !current.signedOut) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `${home} holds a login again, so nothing was deleted. Refresh usage and try again.`, + }); + } +} diff --git a/packages/host-service/src/trpc/router/usage/usage.ts b/packages/host-service/src/trpc/router/usage/usage.ts index 4e4debac5df..4ad39511ac4 100644 --- a/packages/host-service/src/trpc/router/usage/usage.ts +++ b/packages/host-service/src/trpc/router/usage/usage.ts @@ -30,7 +30,11 @@ import { import { fetchGrokAccounts } from "./grok-quota"; import { countAgentPrsByDay } from "./history/agent-prs"; import { fetchOpencodeAccounts } from "./opencode-quota"; -import { removeClaudeProfile, removeCodexHome } from "./profile-remove"; +import { + assertCodexHomeStillRemovable, + removeClaudeProfile, + removeCodexHome, +} from "./profile-remove"; import { discoverClaudeProfiles, discoverCodexHomes, @@ -243,7 +247,8 @@ export const usageRouter = router({ /** * Deletes a secondary profile: its dir plus, for Claude on macOS, its * scoped keychain items. The system default (selection null) is never - * removable, and only currently discovered profiles are accepted. A + * removable, and only currently discovered profiles are accepted. A Codex + * home is read again first, since the quota list can be minutes old. A * default pointer at the removed profile is cleared so agents fall back * to the system login instead of a dead dir. */ @@ -256,12 +261,12 @@ export const usageRouter = router({ ) .mutation(async ({ ctx, input }) => { const accounts = await getQuota(false); - const known = accounts.some( + const listed = accounts.find( (account) => account.agent === input.agent && account.selection === input.selection, ); - if (!known) { + if (!listed) { throw new TRPCError({ code: "BAD_REQUEST", message: `No removable ${input.agent} profile at ${input.selection}.`, @@ -270,6 +275,7 @@ export const usageRouter = router({ if (input.agent === "claude") { await removeClaudeProfile(input.selection); } else { + await assertCodexHomeStillRemovable(input.selection, listed.status); await removeCodexHome(input.selection); } const defaults = getDefaultAccountSelections(ctx.db); From c49d5e21da0a20f9975e3df9d08f1b3dd369dc2f Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 01:31:41 -0400 Subject: [PATCH 4/6] fix(chat): don't offer signed-out logins in the usage-limit account menu "Continue this chat on another login" listed every login of the agent and did not say which ones were signed out. Picking a signed-out one made it the default for new agents and restarted the chat on a home with no login, so the chat could not continue. Signed-out rows are now disabled and read "Signed out". This covers signed-out Claude profiles too. Co-Authored-By: Claude Opus 5.5 --- .../UsageLimitNotice.test.tsx | 45 ++++++++++++++++++- .../UsageLimitNotice/UsageLimitNotice.tsx | 28 +++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.test.tsx index 220981093ea..7ffa9e36e9c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.test.tsx @@ -7,7 +7,7 @@ import { HOST_USAGE_QUOTA_QUERY_KEY, type UsageAccount, } from "renderer/hooks/host-service/useHostUsageQuota"; -import { UsageLimitNotice } from "./UsageLimitNotice"; +import { type SwitchAccount, UsageLimitNotice } from "./UsageLimitNotice"; afterEach(cleanup); @@ -175,3 +175,46 @@ test("an unknown session account is not inferred from the host default", () => { ); expect(view.getByLabelText("Switch account").textContent).toBe("Account"); }); + +test("a signed-out login is listed but cannot take the chat over", async () => { + const onSwitchAccount = mock(async () => undefined); + const codex = (selection: string | null, email: string | null) => ({ + ...account(selection, false), + agent: "codex" as const, + email, + }); + const signedOut: UsageAccount = { + ...codex("/home/dev/.codex-work", null), + sourceLabel: "~/.codex-work", + status: "signed_out", + }; + const view = renderNotice( + { + limit: { hitAtMs: 1 }, + onRespond: () => undefined, + agent: "codex", + hostUrl: null, + sessionAccount: { + agent: "codex", + selection: null, + credentialKind: "subscription", + identity: "captured", + email: "running@example.com", + }, + onSwitchAccount, + }, + [ + codex(null, "running@example.com"), + codex("/home/dev/.codex-other", "other@example.com"), + signedOut, + ], + ); + fireEvent.keyDown(view.getByLabelText("Switch account"), { key: "Enter" }); + const row = await view.findByRole("menuitem", { name: /~\/\.codex-work/ }); + expect(row.textContent).toContain("Signed out"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(row); + expect(onSwitchAccount).not.toHaveBeenCalled(); + const other = view.getByRole("menuitem", { name: "other@example.com" }); + expect(other.getAttribute("aria-disabled")).not.toBe("true"); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.tsx index fa0c718d353..719d9fd5328 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatSession/components/SessionView/components/UsageLimitNotice/UsageLimitNotice.tsx @@ -270,11 +270,15 @@ function AccountMenu({ )} {accounts.map((account) => { const isCurrent = account === current; + // A login with no credential cannot take the chat over. + const signedOut = account.status === "signed_out"; const window = tightest(account); return ( { void onSwitch?.(() => @@ -293,16 +297,18 @@ function AccountMenu({ {account.email ?? account.sourceLabel} - {[ - account.plan, - window - ? t({ - message: `${window.label} · ${formatPercent(window.usedPercent / 100)} used`, - }) - : undefined, - ] - .filter(Boolean) - .join(" · ")} + {signedOut + ? t({ message: "Signed out" }) + : [ + account.plan, + window + ? t({ + message: `${window.label} · ${formatPercent(window.usedPercent / 100)} used`, + }) + : undefined, + ] + .filter(Boolean) + .join(" · ")} From 7e819a3ed4103c908402a0049c50c7084888b33e Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 01:31:41 -0400 Subject: [PATCH 5/6] fix(i18n): use the Czech genitive for Codex in the stale-token text Co-Authored-By: Claude Opus 5.5 --- packages/i18n/locales/cs/messages.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 9ff5be03028..6c9421e1fe3 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -15212,7 +15212,7 @@ msgid "Refreshes when Claude Code next runs." msgstr "Obnoví se při příštím spuštění Claude Code." msgid "Refreshes when Codex next runs." -msgstr "Obnoví se při příštím spuštění Codex." +msgstr "Obnoví se při příštím spuštění Codexu." msgid "Refreshes when OpenCode next runs." msgstr "Obnoví se při příštím spuštění OpenCode." From 106be509eef4e345df360f15f85eb26e39ea4057 Mon Sep 17 00:00:00 2001 From: Tsavo Knott Date: Sat, 31 Oct 2026 04:12:13 -0400 Subject: [PATCH 6/6] fix(i18n): add the Ukrainian text for the Codex refresh hint #9190 added the Ukrainian catalog after this branch added "Refreshes when Codex next runs." to the other catalogs, so `lingui compile --strict` failed for uk. The wording matches the catalog's entries for Claude Code and OpenCode. Co-Authored-By: Claude Opus 5.5 --- packages/i18n/locales/uk/messages.po | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/i18n/locales/uk/messages.po b/packages/i18n/locales/uk/messages.po index 59c8f9eaf30..0e59d554704 100644 --- a/packages/i18n/locales/uk/messages.po +++ b/packages/i18n/locales/uk/messages.po @@ -15196,6 +15196,9 @@ msgstr "Оновити пул-реквести" msgid "Refreshes when Claude Code next runs." msgstr "Оновиться під час наступного запуску Claude Code." +msgid "Refreshes when Codex next runs." +msgstr "Оновиться під час наступного запуску Codex." + msgid "Refreshes when OpenCode next runs." msgstr "Оновиться під час наступного запуску OpenCode."