fix(cc-config): read the live account's quota from the CLI, and ask for every account at once

The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
This commit is contained in:
2026-09-17 23:17:06 +07:00
parent 470f9909b4
commit 1aee080fca
8 changed files with 324 additions and 26 deletions
+30 -7
View File
@@ -4234,11 +4234,18 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`. Empty until asked for: reading it
* refreshes that account's stored OAuth token, so it is never fetched with
* the list — the user asks per profile, one profile at a time. */
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives — the whole point of the panel is comparing accounts, and one
* button per row made the user do that by hand. The cost is bounded: the
* live account is read from the CLI and never refreshed, and any other
* profile is refreshed only when its token has actually expired, which
* leaves it valid for hours of further opens. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const load = useCallback(async () => {
try {
@@ -4284,7 +4291,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
);
const checkUsage = useCallback(async (name: string) => {
setBusy(`usage:${name}`);
setUsageBusy((prev) => ({ ...prev, [name]: true }));
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
@@ -4299,10 +4306,26 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setBusy(null);
setUsageBusy((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}
}, []);
// Every profile's quota, as soon as there is a list to ask about. Once per
// profile per opening: a retry is the row's own button, so a failed account
// is not re-asked on every render.
useEffect(() => {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -4471,8 +4494,8 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={busy === `usage:${p.name}`}
disabled={busy !== null}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
@@ -0,0 +1,146 @@
/**
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
* it asks for EVERY saved account's quota, not one row at a time.
*
* The one-at-a-time version made the user click through their own accounts to
* answer the only question the panel exists for — which account still has room.
* The other half is that each row fails on its own: an account whose saved
* login has been superseded must not stop its neighbours from reporting.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
const window = (utilization: number) => ({
utilization,
resetsAt: "2026-09-18T08:00:00Z",
});
return {
window,
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcp: r({ mcp: [] }),
hooks: r({ hooks: [] }),
settings: r({ sources: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
// The modal's own list: two saved accounts, one of them live.
profiles: r({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
}),
profileUsage: vi.fn().mockImplementation(async (name: string) => {
if (name === "spare") {
throw new Error("this profile's saved login has been superseded");
}
return {
name,
refreshed: false,
fiveHour: window(8),
sevenDay: window(70),
checkedAt: "2026-09-17T16:00:00Z",
};
}),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function openProfiles() {
render(<CcConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await settle();
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — auth profile quotas", () => {
it("asks for every saved account's quota when the modal opens", async () => {
await openProfiles();
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText("70%")).toBeInTheDocument();
});
it("reports the account that failed without costing the others their numbers", async () => {
await openProfiles();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
expect(screen.getByText("8%")).toBeInTheDocument();
});
it("asks once per profile, however many times the panel re-renders", async () => {
await openProfiles();
await settle();
await settle();
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
expect(asked).toHaveLength(1);
});
});