1aee080fca
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.
419 lines
15 KiB
JavaScript
419 lines
15 KiB
JavaScript
/**
|
|
* @file cc-profiles.js
|
|
* @description Named Claude Code auth profiles — swap which Anthropic account
|
|
* (and therefore which quota) the local `claude` CLI runs as, without touching
|
|
* anything else in the Claude home.
|
|
*
|
|
* A profile is exactly the two identity artifacts and nothing more:
|
|
* - `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`
|
|
* - `oauthAccount` + `userID` out of the `.claude.json` config file(s)
|
|
* Skills, subagents, commands, memory, plugins, marketplaces, MCP servers,
|
|
* settings and session history all live elsewhere under CLAUDE_HOME and are
|
|
* deliberately never read or written here — that is what makes a switch keep
|
|
* the whole workspace and change only the account.
|
|
*
|
|
* Profiles are stored one JSON file per profile under
|
|
* `<CLAUDE_HOME>/agenthub-profiles/` (dir 0700, files 0600). They hold live OAuth
|
|
* tokens, so **no route ever returns token material** — `listProfiles` returns
|
|
* identity metadata only. This module cannot mint credentials: a new profile is
|
|
* captured by logging in with `claude auth login` and snapshotting what the CLI
|
|
* wrote.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
/**
|
|
* A profile's `name` is its filename, so the charset is deliberately narrow.
|
|
* It is an internal identifier: what the user reads and edits is `label`, which
|
|
* accepts spaces and diacritics precisely because it never touches a path.
|
|
*/
|
|
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
|
|
|
const LABEL_MAX = 60;
|
|
|
|
function err(code, message) {
|
|
const e = new Error(message);
|
|
e.code = code;
|
|
return e;
|
|
}
|
|
|
|
function profilesDir() {
|
|
return path.join(getClaudeHome(), "agenthub-profiles");
|
|
}
|
|
|
|
function credentialsPath() {
|
|
return path.join(getClaudeHome(), ".credentials.json");
|
|
}
|
|
|
|
/**
|
|
* Every `.claude.json` that could carry the account identity. Claude Code has
|
|
* shipped both locations (`~/.claude.json` beside the home, and one inside it);
|
|
* a stale copy left behind with the OLD account's `oauthAccount` is exactly the
|
|
* kind of contradiction that makes a switch look like it half-worked, so both
|
|
* are rewritten when they exist.
|
|
*/
|
|
function configPaths() {
|
|
const home = getClaudeHome();
|
|
return [`${home}.json`, path.join(home, ".claude.json")];
|
|
}
|
|
|
|
function readJson(file) {
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
|
}
|
|
}
|
|
|
|
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
|
function writeJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.agenthub-tmp`;
|
|
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
/** The account identity currently on disk. `oauth`/`account` may be null. */
|
|
function readActiveAuth() {
|
|
const creds = readJson(credentialsPath());
|
|
let account = null;
|
|
let userID = null;
|
|
for (const file of configPaths()) {
|
|
const cfg = readJson(file);
|
|
if (cfg && cfg.oauthAccount) {
|
|
account = cfg.oauthAccount;
|
|
userID = typeof cfg.userID === "string" ? cfg.userID : null;
|
|
break;
|
|
}
|
|
}
|
|
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID };
|
|
}
|
|
|
|
/** Token-free description of an account, safe to send to the browser. */
|
|
function identityOf({ oauth, account }) {
|
|
return {
|
|
accountUuid: (account && account.accountUuid) || null,
|
|
email: (account && account.emailAddress) || null,
|
|
displayName: (account && account.displayName) || null,
|
|
organization: (account && account.organizationName) || null,
|
|
seatTier: (account && account.seatTier) || null,
|
|
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
|
/** When the stored access token expires; a switch still works after that
|
|
* (the refresh token is stored too), it just refreshes on first use. */
|
|
expiresAt: (oauth && oauth.expiresAt) || null,
|
|
};
|
|
}
|
|
|
|
function profileFile(name) {
|
|
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
|
}
|
|
return path.join(profilesDir(), `${name}.json`);
|
|
}
|
|
|
|
/**
|
|
* Validate a user-supplied display label. Anything printable is allowed —
|
|
* spaces and diacritics included — because a label is never used as a path.
|
|
* Control characters are not: they would corrupt both the stored JSON line and
|
|
* the row rendered in the UI.
|
|
*/
|
|
function normalizeLabel(label) {
|
|
const clean = typeof label === "string" ? label.trim() : "";
|
|
if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
|
throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`);
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
|
|
function slugify(text) {
|
|
const base = String(text || "")
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
|
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 32);
|
|
return NAME_RE.test(base) ? base : "profile";
|
|
}
|
|
|
|
/** `base`, or `base-2`, `base-3`… — the first that is not already a file. */
|
|
function freeName(base) {
|
|
let name = base;
|
|
for (let n = 2; fs.existsSync(path.join(profilesDir(), `${name}.json`)); n += 1) {
|
|
name = `${base}-${n}`;
|
|
}
|
|
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));
|
|
return stored && typeof stored === "object" ? stored : null;
|
|
}
|
|
|
|
/**
|
|
* Every stored profile plus the account that is live right now.
|
|
* @returns `{ items, active }` — `items[].active` marks the one whose
|
|
* `accountUuid` matches the live config. Tokens are never included.
|
|
*/
|
|
function listProfiles() {
|
|
const active = identityOf(readActiveAuth());
|
|
let names = [];
|
|
try {
|
|
names = fs
|
|
.readdirSync(profilesDir())
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.slice(0, -5));
|
|
} catch {
|
|
/* no profiles saved yet */
|
|
}
|
|
const items = [];
|
|
for (const name of names) {
|
|
if (!NAME_RE.test(name)) continue;
|
|
let stored;
|
|
try {
|
|
stored = readProfile(name);
|
|
} catch {
|
|
continue; // a corrupt profile file is skipped, not fatal for the list
|
|
}
|
|
if (!stored) continue;
|
|
const identity = identityOf({ oauth: stored.credentials, account: stored.account });
|
|
items.push({
|
|
name,
|
|
// Profiles saved before labels existed fall back to their identifier, so
|
|
// the UI always has something to show and to put in the rename field.
|
|
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
|
|
savedAt: stored.savedAt || null,
|
|
active: Boolean(identity.accountUuid) && identity.accountUuid === active.accountUuid,
|
|
...identity,
|
|
});
|
|
}
|
|
// Label first (that is what the UI shows), name as the tiebreaker so two
|
|
// profiles sharing a label still come back in a stable order.
|
|
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
|
return { items, active: active.accountUuid || active.email ? active : null };
|
|
}
|
|
|
|
/**
|
|
* Snapshot the currently logged-in account into a profile.
|
|
*
|
|
* One account is one profile: saving an account that some other profile
|
|
* already holds is refused (`EDUPLICATE`) rather than silently making a second
|
|
* copy, because two profiles of one account both report themselves active and
|
|
* there is then nothing to switch between. Re-saving into the account's OWN
|
|
* profile is the refresh path and stays allowed.
|
|
*
|
|
* @param name Optional identifier/filename. Omitted, one is derived from the
|
|
* account, which is what the UI does — the user renames the `label`
|
|
* afterwards instead of having to invent a filename up front.
|
|
* @returns `{ ok, name, label, ...identity }`
|
|
*/
|
|
function saveProfile(name) {
|
|
const auth = readActiveAuth();
|
|
if (!auth.oauth) {
|
|
throw err(
|
|
"ENOAUTH",
|
|
"no Claude account is logged in on this machine - run `claude auth login` first"
|
|
);
|
|
}
|
|
const identity = identityOf(auth);
|
|
const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile";
|
|
// An explicit name is both identifier and initial label; a derived one keeps
|
|
// the readable form as the label and only the filename gets flattened.
|
|
const explicit = name !== undefined && name !== null;
|
|
let target;
|
|
if (explicit) {
|
|
profileFile(name); // validates the charset, throwing EBADNAME
|
|
target = name;
|
|
} else {
|
|
target = freeName(slugify(readable));
|
|
}
|
|
|
|
const clash = listProfiles().items.find(
|
|
(p) => p.accountUuid && p.accountUuid === identity.accountUuid && p.name !== target
|
|
);
|
|
if (clash) {
|
|
throw err(
|
|
"EDUPLICATE",
|
|
`this account is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
|
|
);
|
|
}
|
|
|
|
// A re-save must not clobber a label the user has since edited.
|
|
const existing = readProfile(target);
|
|
const label = (existing && existing.label) || (explicit ? target : readable);
|
|
|
|
fs.mkdirSync(profilesDir(), { recursive: true, mode: 0o700 });
|
|
writeJson(profileFile(target), {
|
|
name: target,
|
|
label,
|
|
savedAt: Date.now(),
|
|
credentials: auth.oauth,
|
|
account: auth.account,
|
|
userID: auth.userID,
|
|
});
|
|
return { ok: true, name: target, label, ...identity };
|
|
}
|
|
|
|
/**
|
|
* Change a profile's display label. The `name` (its filename, and the id every
|
|
* other route takes) deliberately does not move: renaming is a cosmetic edit,
|
|
* not a re-keying, so it cannot fail halfway and leave a profile unreachable.
|
|
*
|
|
* @param name Profile to rename.
|
|
* @param label New display label; spaces and diacritics are fine.
|
|
* @returns `{ ok, name, label }`
|
|
*/
|
|
function renameProfile(name, label) {
|
|
const file = profileFile(name);
|
|
const stored = readProfile(name);
|
|
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const clean = normalizeLabel(label);
|
|
|
|
const taken = listProfiles().items.find(
|
|
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
|
|
);
|
|
if (taken) {
|
|
throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
|
|
}
|
|
|
|
writeJson(file, { ...stored, label: clean });
|
|
return { ok: true, name, label: clean };
|
|
}
|
|
|
|
/**
|
|
* Make a stored profile the live account.
|
|
*
|
|
* The outgoing account is snapshotted back into whichever profile it came from
|
|
* first, so its refreshed tokens are not lost — and a switch away from an
|
|
* account that was never saved is refused rather than silently discarding the
|
|
* only copy of its credentials.
|
|
*
|
|
* @param name Profile to activate.
|
|
* @returns `{ ok, name, savedOutgoing, written }`
|
|
*/
|
|
function activateProfile(name) {
|
|
const target = readProfile(name);
|
|
if (!target || !target.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
|
|
const current = readActiveAuth();
|
|
let savedOutgoing = null;
|
|
if (current.oauth) {
|
|
const mine = listProfiles().items.find((p) => p.active);
|
|
if (!mine) {
|
|
throw err(
|
|
"EUNSAVED",
|
|
"the account logged in right now is not saved as a profile - save it first, or its credentials are lost on switch"
|
|
);
|
|
}
|
|
if (mine.name !== name) savedOutgoing = saveProfile(mine.name).name;
|
|
}
|
|
|
|
const written = [];
|
|
const creds = readJson(credentialsPath()) || {};
|
|
// Only the Claude account slot moves; `mcpOAuth` holds this machine's MCP
|
|
// server tokens, which are not part of the account identity.
|
|
creds.claudeAiOauth = target.credentials;
|
|
writeJson(credentialsPath(), creds);
|
|
written.push(credentialsPath());
|
|
|
|
for (const file of configPaths()) {
|
|
const cfg = readJson(file);
|
|
if (!cfg) continue;
|
|
if (target.account) cfg.oauthAccount = target.account;
|
|
else delete cfg.oauthAccount;
|
|
if (target.userID) cfg.userID = target.userID;
|
|
writeJson(file, cfg);
|
|
written.push(file);
|
|
}
|
|
|
|
return { ok: true, name, savedOutgoing, written };
|
|
}
|
|
|
|
/**
|
|
* Delete a stored profile.
|
|
*
|
|
* Refused only when it holds the LAST saved copy of the live account: that copy
|
|
* is the credentials' only home outside the files a switch is about to
|
|
* overwrite. A redundant copy of the live account is deletable, which is what
|
|
* makes an accidental duplicate cleanable — with a blanket active-guard, two
|
|
* profiles of one account would both be undeletable forever.
|
|
*/
|
|
function deleteProfile(name) {
|
|
const file = profileFile(name);
|
|
if (!readProfile(name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const items = listProfiles().items;
|
|
const entry = items.find((p) => p.name === name);
|
|
if (entry && entry.active) {
|
|
const otherCopy = items.some((p) => p.name !== name && p.accountUuid === entry.accountUuid);
|
|
if (!otherCopy) {
|
|
throw err(
|
|
"EACTIVE",
|
|
"cannot delete the only saved copy of the account that is logged in right now - switch away first"
|
|
);
|
|
}
|
|
}
|
|
fs.rmSync(file, { force: true });
|
|
return { ok: true, name };
|
|
}
|
|
|
|
/**
|
|
* Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which
|
|
* has to write a rotated OAuth token pair straight back: the exchange retires
|
|
* the refresh token it was given, so a profile whose refreshed credentials are
|
|
* not persisted is a profile that can no longer be activated.
|
|
*/
|
|
function writeProfile(name, value) {
|
|
writeJson(profileFile(name), value);
|
|
return { ok: true, name };
|
|
}
|
|
|
|
module.exports = {
|
|
listProfiles,
|
|
saveProfile,
|
|
renameProfile,
|
|
activateProfile,
|
|
deleteProfile,
|
|
// Token-bearing reads/writes: for cc-usage.js, never for a route.
|
|
readProfile,
|
|
writeProfile,
|
|
liveCredentialsFor,
|
|
// exported for tests
|
|
profilesDir,
|
|
configPaths,
|
|
};
|