Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<SwitchAccount>(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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DropdownMenuItem
className={cn(MENU_ROW_CLASS, "h-auto items-center py-1.5")}
disabled={isCurrent || setDefault.isPending || !onSwitch}
disabled={
isCurrent || signedOut || setDefault.isPending || !onSwitch
}
key={account.accountKey}
onSelect={() => {
void onSwitch?.(() =>
Expand All @@ -293,16 +297,18 @@ function AccountMenu({
{account.email ?? account.sourceLabel}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{[
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(" · ")}
</span>
</span>
</DropdownMenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -284,11 +285,7 @@ function AccountCard({
</div>
) : account.status === "token_stale" ? (
<div className="mt-1.5 text-[11px] text-muted-foreground">
{account.agent === "opencode" ? (
<Trans>Refreshes when OpenCode next runs.</Trans>
) : (
<Trans>Refreshes when Claude Code next runs.</Trans>
)}
{staleTokenDetail(account.agent)}
</div>
) : expiredCommand !== null ? (
<div className="mt-1.5 flex flex-wrap items-center gap-x-1 gap-y-1 text-[11px] text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { staleTokenDetail } from "./staleTokenDetail";
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
Original file line number Diff line number Diff line change
@@ -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." }));
}
1 change: 1 addition & 0 deletions packages/agent-setup/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/host-service/src/trpc/router/usage/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
discoverClaudeProfiles,
keychainServicesForConfigDir,
readKeychainSecrets,
SIGNED_OUT_PROFILE_DETAIL,
} from "./profiles";
import type {
UsageAccount,
Expand Down Expand Up @@ -579,8 +580,7 @@ export async function fetchClaudeAccounts(): Promise<UsageAccount[]> {
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,
Expand Down
193 changes: 191 additions & 2 deletions packages/host-service/src/trpc/router/usage/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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, Record<string, string>>): 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",
Expand Down Expand Up @@ -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([]);
});
});
Loading
Loading