Files
AgentHub/server/lib/cc-profiles.js
T
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00

498 lines
17 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`
* @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: 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,
// exported for tests
profilesDir,
configPaths,
};