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(" · ")} 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..a4ba5771d05 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,47 @@ -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 { PROFILE_CREATED_MARKER } from "./profiles"; 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 +129,152 @@ 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).toBe("Usage endpoint returned 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 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": { + [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", + 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"), + }); + }); + + // 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/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/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/profiles.test.ts b/packages/host-service/src/trpc/router/usage/profiles.test.ts index c10eba510f2..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,17 +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"; @@ -158,3 +164,221 @@ 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, + ): string { + const dir = join(home, name); + mkdirSync(dir); + for (const [file, content] of Object.entries(files)) { + writeFileSync(join(dir, file), content); + } + return dir; + } + + 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 loggedOut = codexDir(home, ".codex-a", PARADEV_HOME); + // Codex writes OPENAI_API_KEY even when it is null. + const emptyLogin = codexDir(home, ".codex-b", { + ...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", + }), + }); + expect(await listed(home, loggedOut)).toMatchObject({ + credentialKind: "subscription", + loginFingerprint: null, + 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 () => { + const home = tempProfile(); + const live = codexDir(home, ".codex-c", { + "auth.json": JSON.stringify({ tokens: { access_token: "test-token" } }), + }); + expect(await listed(home, live)).toEqual({ + home: live, + sourceLabel: expect.any(String), + credentialKind: "subscription", + loginFingerprint: null, + signedOut: false, + }); + }); + + // 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", { "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]: "{}", + }); + 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", { + ...PARADEV_HOME, + "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", PARADEV_HOME); + codexDir(home, ".codex-a", PARADEV_HOME); + 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..bf974449fe0 100644 --- a/packages/host-service/src/trpc/router/usage/profiles.ts +++ b/packages/host-service/src/trpc/router/usage/profiles.ts @@ -23,11 +23,15 @@ 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"; -import { resolveAmbientCodexHome } from "@superset/agent-setup"; +import { + PROFILE_LEDGER_NAME, + resolveAmbientCodexHome, +} from "@superset/agent-setup"; const execFileAsync = promisify(execFile); @@ -42,10 +46,19 @@ 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"; +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 +79,8 @@ export interface CodexHome { credentialKind: ProfileCredentialKind; /** API profiles only; derived from the marker, never from auth.json. */ loginFingerprint: string | null; + /** A home Paradev created and set up that holds no login. */ + signedOut: boolean; } function tildeLabel(path: string): string { @@ -136,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` @@ -281,20 +315,126 @@ export async function readCodexProfileKind( } } +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +/** 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 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; + if (await mayKeepLoginOutsideAuthFile(codexHome)) return false; + const authPath = join(codexHome, "auth.json"); + try { + if (!(await lstat(authPath)).isFile()) return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } + try { + return !holdsCodexLogin(JSON.parse(await readFile(authPath, "utf-8"))); + } 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 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 * 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 +446,7 @@ export async function discoverCodexHomes(): Promise { home: defaultHome, sourceLabel: tildeLabel(defaultHome), ...defaultKind, + signedOut: false, }, ], ]); @@ -316,12 +457,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..af239564dc6 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 Paradev created and set up. Surfaced so it can be + * re-signed or removed. */ | "signed_out"; export interface UsageQuotaWindow { 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); diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index a85f7eeb436..bd4e8be8d9d 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -15202,6 +15202,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í Codexu." + 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 e92234af47e..a37a409ac37 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -15202,6 +15202,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 cd37e6aef16..33487949bf0 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -15202,6 +15202,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 4bc5ef73473..21adf104eb7 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -15202,6 +15202,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 d59f4aa2713..4b66240d475 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -15202,6 +15202,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 e2aaeeb77b9..aa467a8d2f5 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -15202,6 +15202,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 d62548e5be3..a41c960eb78 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -15202,6 +15202,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 23bef315913..b56897fd58a 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -15202,6 +15202,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 b82f7a47f43..1249fc853c7 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -15202,6 +15202,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 33fd2b834b5..ddb0ceb01db 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -15202,6 +15202,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 7d25e4adb84..43865c40805 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -15202,6 +15202,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 60dfe270340..74a3043f0e4 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -15202,6 +15202,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 d52426804af..e795fb63af6 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -15202,6 +15202,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 817093be75a..073ab586374 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -15202,6 +15202,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/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." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 3fc3650a49e..a9b1960f35a 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -15202,6 +15202,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 b31c3d70d62..32e1caa576f 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -15202,6 +15202,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 5ecee72c0ec..59483977105 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -15202,6 +15202,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 時重新整理。"