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:
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v1.6.9 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -1700,7 +1700,11 @@ Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/`
|
||||
|
||||
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
|
||||
|
||||
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path. **Nothing calls this on a timer** — it is one profile per user request, which is what keeps credential rewrites down to the moments the user asked for. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
|
||||
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
|
||||
|
||||
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, 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` (409) — using Claude Code once makes the CLI refresh it.
|
||||
|
||||
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
|
||||
|
||||
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
|
||||
|
||||
|
||||
@@ -195,6 +195,68 @@ describe("profileUsage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the CLI's own token for the account that is logged in, not the saved copy", async () => {
|
||||
// The saved copy of the LIVE account is the one set of credentials certain
|
||||
// to fail: the CLI has refreshed since, and that retired the refresh token
|
||||
// sitting in the profile file. Refreshing it here would also rotate the
|
||||
// token the user's terminal is using.
|
||||
const account = { accountUuid: "uuid-live", emailAddress: "a@b.c" };
|
||||
fs.mkdirSync(profiles.profilesDir(), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(profiles.profilesDir(), "mine.json"),
|
||||
JSON.stringify({
|
||||
name: "mine",
|
||||
account,
|
||||
credentials: { accessToken: "retired", refreshToken: "retired-r", expiresAt: 1 },
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(TMP, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-token",
|
||||
refreshToken: "live-r",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(path.join(TMP, ".claude.json"), JSON.stringify({ oauthAccount: account }));
|
||||
stubFetch();
|
||||
|
||||
const result = await usage.profileUsage("mine");
|
||||
|
||||
assert.equal(result.refreshed, false);
|
||||
assert.match(calls[0].init.headers.Authorization, /live-token/);
|
||||
assert.equal(
|
||||
calls.some((c) => c.url.includes("/v1/oauth/token")),
|
||||
false,
|
||||
"refreshing the live account would log the user's own terminal out"
|
||||
);
|
||||
fs.rmSync(path.join(TMP, ".credentials.json"));
|
||||
fs.rmSync(path.join(TMP, ".claude.json"));
|
||||
});
|
||||
|
||||
it("names a spent refresh token instead of echoing the upstream 400", async () => {
|
||||
writeProfileFile("superseded", {
|
||||
accessToken: "expired",
|
||||
refreshToken: "already-rotated",
|
||||
expiresAt: Date.now() - 1000,
|
||||
});
|
||||
global.fetch = async (url) => {
|
||||
calls.push({ url: String(url) });
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '{"error": "invalid_grant", "error_description": "not found"}',
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => usage.profileUsage("superseded"),
|
||||
(e) => e.code === "ESTALE" && /save the profile again/.test(e.message)
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unknown profile as ENOTFOUND", async () => {
|
||||
stubFetch();
|
||||
await assert.rejects(
|
||||
|
||||
@@ -156,6 +156,29 @@ function freeName(base) {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The LIVE credentials when `stored` is the account logged in right now, else
|
||||
* null.
|
||||
*
|
||||
* A saved profile is a copy, and an OAuth refresh token is single use: every
|
||||
* time Claude Code refreshes the account it is logged into, the copy sitting in
|
||||
* the profile file is retired. So for the active account the saved credentials
|
||||
* are the one set guaranteed NOT to work, and `.credentials.json` — which the
|
||||
* CLI keeps current — is the only usable source. Nothing here writes: rotating
|
||||
* the token the live session is holding would log the user out of their own
|
||||
* terminal.
|
||||
*
|
||||
* @param stored a profile as returned by `readProfile`
|
||||
* @returns the live `claudeAiOauth` block, or null when this is not that account
|
||||
*/
|
||||
function liveCredentialsFor(stored) {
|
||||
const live = readActiveAuth();
|
||||
if (!live.oauth) return null;
|
||||
const mine = identityOf(live).accountUuid;
|
||||
const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid;
|
||||
return mine && mine === theirs ? live.oauth : null;
|
||||
}
|
||||
|
||||
/** Read one stored profile, or null when it does not exist. */
|
||||
function readProfile(name) {
|
||||
const stored = readJson(profileFile(name));
|
||||
@@ -388,6 +411,7 @@ module.exports = {
|
||||
// Token-bearing reads/writes: for cc-usage.js, never for a route.
|
||||
readProfile,
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
+47
-14
@@ -17,16 +17,24 @@
|
||||
* returned, logged or echoed. What leaves this module is two percentages
|
||||
* and two reset timestamps.
|
||||
*
|
||||
* - **Nothing here runs on a poll.** A saved profile's access token is short
|
||||
* lived — every profile that is not the active one is normally expired — so
|
||||
* reading its usage means refreshing it first, and a refresh ROTATES the
|
||||
* stored refresh token. Doing that on a page load would rewrite the user's
|
||||
* credentials for every account they have, repeatedly, for a number on a
|
||||
* screen. It is therefore driven by an explicit per-profile request, and the
|
||||
* rotated tokens are written straight back (`cc-profiles.writeProfile`) —
|
||||
* a refresh whose result is dropped leaves the profile holding a refresh
|
||||
* token Anthropic has already retired, i.e. an account the user must log in
|
||||
* to again.
|
||||
* - **The active account is read from the CLI, never from its saved copy.**
|
||||
* A refresh token is single use, so every refresh Claude Code performs on
|
||||
* the account it is logged into retires the copy stored in that profile:
|
||||
* the saved credentials of the LIVE account are the one set certain to be
|
||||
* rejected. `cc-profiles.liveCredentialsFor` hands back what the CLI is
|
||||
* actually holding, and that token is never refreshed here — rotating it
|
||||
* would log the user out of their own terminal.
|
||||
*
|
||||
* - **Nothing here runs on a poll, and no refresh happens that a token's own
|
||||
* expiry did not force.** Opening the Profiles modal asks for every saved
|
||||
* account at once — comparing them is the point — so the guard against
|
||||
* rewriting a user's credentials over and over is not "one row at a time",
|
||||
* it is here: the live account never refreshes at all, and any other profile
|
||||
* refreshes only once its token has actually expired, which then leaves it
|
||||
* valid for hours of further opens. What a refresh does return is written
|
||||
* straight back (`cc-profiles.writeProfile`), because Anthropic retires the
|
||||
* refresh token it was handed and dropping the replacement would leave an
|
||||
* account the user has to log into again.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -60,7 +68,18 @@ async function postJson(url, body, headers) {
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
||||
if (!res.ok) {
|
||||
// The one upstream failure a user can act on: this profile's refresh token
|
||||
// has already been spent, which happens whenever a NEWER login or refresh
|
||||
// for the same account rotated it. Saying so beats echoing a raw 400.
|
||||
if (text.includes("invalid_grant")) {
|
||||
throw err(
|
||||
"ESTALE",
|
||||
"this profile's saved login has been superseded - activate the account once and save the profile again"
|
||||
);
|
||||
}
|
||||
throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
@@ -146,8 +165,21 @@ async function fetchUsage(accessToken) {
|
||||
async function profileUsage(name) {
|
||||
const stored = profiles.readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
|
||||
const creds = stored.credentials || {};
|
||||
|
||||
// For the account that is logged in RIGHT NOW the saved copy is the one set
|
||||
// of credentials guaranteed to fail — the CLI has rotated its refresh token
|
||||
// since, retiring the profile's. Use what the CLI holds, and never refresh
|
||||
// it: that would retire the token the user's own terminal is using, and this
|
||||
// module does not write `.credentials.json` back.
|
||||
const live = profiles.liveCredentialsFor(stored);
|
||||
const creds = live || stored.credentials || {};
|
||||
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
if (live && !fresh) {
|
||||
throw err(
|
||||
"ELIVEEXPIRED",
|
||||
"the active account's token has expired - use Claude Code once so it refreshes, then check again"
|
||||
);
|
||||
}
|
||||
|
||||
let token = fresh ? creds.accessToken : await refreshProfile(name);
|
||||
let refreshed = !fresh;
|
||||
@@ -158,8 +190,9 @@ async function profileUsage(name) {
|
||||
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
|
||||
} catch (e) {
|
||||
// A token that looked fresh but was revoked server-side is worth one
|
||||
// retry through the refresh path; a second 401 is a real answer.
|
||||
if (e.code !== "EUNAUTHORIZED" || refreshed) throw e;
|
||||
// retry through the refresh path; a second 401 is a real answer. Not for
|
||||
// the live account, for the same reason its token is never refreshed here.
|
||||
if (e.code !== "EUNAUTHORIZED" || refreshed || live) throw e;
|
||||
token = await refreshProfile(name);
|
||||
refreshed = true;
|
||||
const usage = await fetchUsage(token);
|
||||
|
||||
@@ -596,9 +596,15 @@ router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
||||
res.json(await ccUsage.profileUsage(name));
|
||||
} catch (e) {
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADNAME: 400, ENOREFRESH: 409, ENOAUTH: 409, EUNAUTHORIZED: 401 }[
|
||||
e.code
|
||||
] || 502;
|
||||
{
|
||||
ENOTFOUND: 404,
|
||||
EBADNAME: 400,
|
||||
ENOREFRESH: 409,
|
||||
ENOAUTH: 409,
|
||||
ESTALE: 409,
|
||||
ELIVEEXPIRED: 409,
|
||||
EUNAUTHORIZED: 401,
|
||||
}[e.code] || 502;
|
||||
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user