8fdda66893
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_` for every non-ASCII character, and a macOS LaunchAgent starts the dashboard with no locale at all — Vietnamese text and Claude Code's logo came out as underscores in every browser terminal. - shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS, C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and Windows are left alone - RunSetup.tsx: the `/` list now offers Claude Code's bundled skills (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and every enabled plugin's commands and skills as /<plugin>:<name> - Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached per token), not by .claude.json: a running session can leave the credentials store holding one account's token under another account's name. The Profiles modal, its Check quota and the status-bar quota now show the account requests are really billed to and warn on the mismatch; a switch saves the outgoing token into its OWNER's profile (credentials only) instead of overwriting the profile .claude.json names; saving a profile while they disagree is refused (EMISMATCH); a saved profile holding another account's token reports EWRONGACCOUNT instead of that account's usage - Running Claude Code sessions follow a switch on their own (they re-read the credentials store when it changes), so the "close your sessions first" warning is gone
551 lines
20 KiB
JavaScript
551 lines
20 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` — or, on macOS,
|
|
* out of the login Keychain item the CLI actually reads there
|
|
* - `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 os = require("node:os");
|
|
const path = require("node:path");
|
|
const cp = require("node:child_process");
|
|
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
/**
|
|
* On macOS the `claude` CLI keeps `claudeAiOauth` in the login Keychain, NOT in
|
|
* `<CLAUDE_HOME>/.credentials.json` — a file there is absent or a stale leftover
|
|
* from an older build. Reading/writing only the file on a Mac is why a switch
|
|
* reported success while the CLI kept running as the previous account.
|
|
*/
|
|
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
const KEYCHAIN_LABEL = `macOS Keychain (${KEYCHAIN_SERVICE})`;
|
|
|
|
/** Off with AGENTHUB_CC_KEYCHAIN=0 — tests, and anyone pinning the file. */
|
|
function keychainEnabled() {
|
|
return process.platform === "darwin" && process.env.AGENTHUB_CC_KEYCHAIN !== "0";
|
|
}
|
|
|
|
function keychainArgs(verb) {
|
|
return [verb, "-s", KEYCHAIN_SERVICE, "-a", os.userInfo().username];
|
|
}
|
|
|
|
/** The keychain item's JSON, or null when there is none / it is unreadable. */
|
|
function readKeychain() {
|
|
try {
|
|
const out = cp.execFileSync(
|
|
"/usr/bin/security",
|
|
[...keychainArgs("find-generic-password"), "-w"],
|
|
{
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
}
|
|
);
|
|
const parsed = JSON.parse(out.trim());
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeKeychain(value) {
|
|
// ponytail: the secret rides in argv (`security` has no stdin mode), so it is
|
|
// briefly visible in `ps` on a shared machine. Upgrade path is a native
|
|
// keychain binding if that ever matters.
|
|
cp.execFileSync(
|
|
"/usr/bin/security",
|
|
[...keychainArgs("add-generic-password"), "-U", "-w", JSON.stringify(value)],
|
|
{ stdio: "ignore" }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 live credentials blob and where it came from. The Keychain wins on macOS
|
|
* when it holds an item; everything else (and a Mac whose CLI still uses the
|
|
* file) falls back to `.credentials.json`.
|
|
*/
|
|
function readCredentials() {
|
|
if (keychainEnabled()) {
|
|
const kc = readKeychain();
|
|
if (kc) return { creds: kc, keychain: true };
|
|
}
|
|
return { creds: readJson(credentialsPath()), keychain: false };
|
|
}
|
|
|
|
/** Write credentials back to the store they were read from. Returns what was written. */
|
|
function writeCredentials(creds, keychain) {
|
|
if (!keychain) {
|
|
writeJson(credentialsPath(), creds);
|
|
return [credentialsPath()];
|
|
}
|
|
writeKeychain(creds);
|
|
// A stale file beside the Keychain would still name the old account; keep it
|
|
// in step when it exists, never create one.
|
|
if (fs.existsSync(credentialsPath())) {
|
|
writeJson(credentialsPath(), creds);
|
|
return [KEYCHAIN_LABEL, credentialsPath()];
|
|
}
|
|
return [KEYCHAIN_LABEL];
|
|
}
|
|
|
|
/** The account identity currently on disk. `oauth`/`account` may be null. */
|
|
function readActiveAuth() {
|
|
const { creds, keychain } = readCredentials();
|
|
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, keychain };
|
|
}
|
|
|
|
/** 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`
|
|
* @param opts.owner the live token's owner (`cc-usage.liveOwner`), when known
|
|
* @returns the live `claudeAiOauth` block, or null when this is not that account
|
|
*/
|
|
function liveCredentialsFor(stored, { owner = null } = {}) {
|
|
const live = readActiveAuth();
|
|
if (!live.oauth) return null;
|
|
// The token's owner when known: `.claude.json` can name another account.
|
|
const mine = owner?.uuid || 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({ owner = null } = {}) {
|
|
const live = readActiveAuth();
|
|
const configured = identityOf(live);
|
|
// The token is what Claude Code runs as; `.claude.json` is only a label, and
|
|
// a running session can leave it naming a different account.
|
|
const mismatch =
|
|
owner?.uuid && configured.accountUuid && owner.uuid !== configured.accountUuid
|
|
? { configEmail: configured.email, configUuid: configured.accountUuid }
|
|
: null;
|
|
const active = mismatch
|
|
? {
|
|
...configured,
|
|
accountUuid: owner.uuid,
|
|
email: owner.email || null,
|
|
displayName: null,
|
|
organization: null,
|
|
seatTier: null,
|
|
}
|
|
: configured;
|
|
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));
|
|
// The owner's own saved identity reads better than the bare uuid + email.
|
|
const mine = mismatch && items.find((p) => p.active);
|
|
const shown = mine
|
|
? {
|
|
...active,
|
|
displayName: mine.displayName,
|
|
organization: mine.organization,
|
|
seatTier: mine.seatTier,
|
|
}
|
|
: active;
|
|
return { items, active: shown.accountUuid || shown.email ? shown : null, mismatch };
|
|
}
|
|
|
|
/**
|
|
* 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, { owner = null } = {}) {
|
|
const auth = readActiveAuth();
|
|
if (!auth.oauth) {
|
|
throw err(
|
|
"ENOAUTH",
|
|
"no Claude account is logged in on this machine - run `claude auth login` first"
|
|
);
|
|
}
|
|
const configUuid = auth.account && auth.account.accountUuid;
|
|
if (owner?.uuid && configUuid && owner.uuid !== configUuid) {
|
|
throw err(
|
|
"EMISMATCH",
|
|
`the token logged in now belongs to ${owner.email || "another account"}, not ${
|
|
auth.account.emailAddress || configUuid
|
|
} - switch to that account's profile, or run \`claude auth login\` again`
|
|
);
|
|
}
|
|
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, { owner = null } = {}) {
|
|
if (!readProfile(name)?.credentials) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
|
|
const current = readActiveAuth();
|
|
let savedOutgoing = null;
|
|
if (current.oauth) {
|
|
const { items, mismatch } = listProfiles({ owner });
|
|
const mine = 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 (mismatch) {
|
|
// The live token belongs to `mine`, but `.claude.json` names someone
|
|
// else: keep that profile's own identity and take only the token — the
|
|
// newest one that account has (a refresh retired the saved copy). Done
|
|
// even when `mine` IS the target, so the switch writes that newest token.
|
|
writeProfile(mine.name, {
|
|
...readProfile(mine.name),
|
|
credentials: current.oauth,
|
|
savedAt: Date.now(),
|
|
});
|
|
savedOutgoing = mine.name;
|
|
} else if (mine.name !== name) {
|
|
savedOutgoing = saveProfile(mine.name).name;
|
|
}
|
|
}
|
|
const target = readProfile(name);
|
|
|
|
const written = [];
|
|
const { creds: live, keychain } = readCredentials();
|
|
const creds = live || {};
|
|
// 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;
|
|
written.push(...writeCredentials(creds, keychain));
|
|
|
|
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,
|
|
readActiveAuth,
|
|
// exported for tests
|
|
profilesDir,
|
|
configPaths,
|
|
};
|