3113479cd2
Settings → AI tools → Profiles holds one tab per tool: Claude Code (the cc-profiles panel, moved out of the Claude Code page's modal — its button now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi (`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential tables are emptied first because import merges, and a failed import puts the outgoing set back). Antigravity is listed as not switchable: agy keeps its login in the OS keyring. server/lib/tool-profiles.js + /api/tool-profiles keep cc-profiles' rules (one login = one profile, the outgoing login is re-saved before a switch, an unsaved one refuses it, the live login's profile is not deletable) and never return a token; /tool-profiles joins ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES, writes are same-origin only. The account pool takes a saved Codex profile too (moved in like a Claude one, GET /api/accounts/profiles?tool=codex). Each Claude/Codex profile has a "Move to pool" link (/admin?poolAdd=&poolProfile= opens the pool's Add dialog on it), Settings shows the pool's size, and the pool card links back.
455 lines
15 KiB
JavaScript
455 lines
15 KiB
JavaScript
/**
|
|
* @file tool-profiles.js
|
|
* @description Named login profiles for the AI tools other than Claude Code —
|
|
* the same idea as cc-profiles.js (which stays Claude's, with its Keychain and
|
|
* `.claude.json` rules): snapshot the login a tool's CLI holds right now, and
|
|
* swap it back in later, touching nothing but that login.
|
|
*
|
|
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
|
* key). Config, sessions, skills and history stay where they are.
|
|
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
|
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
|
* Import MERGES, so a switch first empties omp's own credential tables in
|
|
* `~/.omp/agent/agent.db`; a failed import puts the outgoing set back.
|
|
* - `antigravity`: not switchable — agy keeps its login in the OS keyring
|
|
* (Secret Service), which AgentHub does not read or write.
|
|
*
|
|
* Profiles live one JSON file each under
|
|
* `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (dir 0700, files 0600). They hold
|
|
* live tokens, so `listProfiles` returns identity metadata only; the
|
|
* token-bearing `readProfile` is for the account pool's move-in, never a route.
|
|
* The rules are cc-profiles.js's: one account is one profile (`EDUPLICATE`), a
|
|
* switch saves the outgoing login back into its own profile first and refuses
|
|
* one that was never saved (`EUNSAVED`), and the only saved copy of the live
|
|
* login is not deletable (`EACTIVE`).
|
|
* @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 crypto = require("node:crypto");
|
|
const cp = require("node:child_process");
|
|
|
|
const { getClaudeHome } = require("./claude-home");
|
|
const { getCodexHome } = require("./codex-home");
|
|
|
|
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
|
const LABEL_MAX = 60;
|
|
|
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
|
const sha = (text) => crypto.createHash("sha256").update(String(text)).digest("hex");
|
|
|
|
/** 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, mode: 0o700 });
|
|
const tmp = `${file}.agenthub-tmp`;
|
|
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
// ── Codex ──────────────────────────────────────────────────────────────
|
|
|
|
const codexAuthFile = () => path.join(getCodexHome(), "auth.json");
|
|
|
|
/** The id_token's claims, or null. Not signature-checked: a label, not a proof. */
|
|
function jwtClaims(token) {
|
|
try {
|
|
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const codex = {
|
|
label: "Codex",
|
|
read() {
|
|
const auth = readJson(codexAuthFile());
|
|
return auth && typeof auth === "object" && (auth.tokens || auth.OPENAI_API_KEY) ? auth : null;
|
|
},
|
|
write(secret) {
|
|
writeJson(codexAuthFile(), secret);
|
|
return [codexAuthFile()];
|
|
},
|
|
identify(auth) {
|
|
if (auth.tokens && auth.tokens.id_token) {
|
|
const c = jwtClaims(auth.tokens.id_token) || {};
|
|
const claim = c["https://api.openai.com/auth"] || {};
|
|
const account = auth.tokens.account_id || claim.chatgpt_account_id || null;
|
|
return {
|
|
key: `chatgpt:${account || c.email || sha(auth.tokens.id_token).slice(0, 16)}`,
|
|
kind: "chatgpt",
|
|
email: c.email || null,
|
|
detail: claim.chatgpt_plan_type || null,
|
|
};
|
|
}
|
|
const key = String(auth.OPENAI_API_KEY || "");
|
|
return {
|
|
key: `apikey:${sha(key).slice(0, 16)}`,
|
|
kind: "apikey",
|
|
email: null,
|
|
detail: key ? `…${key.slice(-4)}` : null,
|
|
};
|
|
},
|
|
};
|
|
|
|
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
|
|
|
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
|
|
|
function ompBin() {
|
|
const bin = process.env.AGENTHUB_OMP_BIN || require("./ai-tools").detect("omp").path;
|
|
if (!bin) throw err("ENOTINSTALLED", "Oh My Pi (omp) is not installed on this machine");
|
|
return bin;
|
|
}
|
|
|
|
function ompRun(args, input) {
|
|
return cp.execFileSync(ompBin(), args, {
|
|
encoding: "utf8",
|
|
input,
|
|
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
timeout: 20000,
|
|
maxBuffer: 8 << 20,
|
|
windowsHide: true,
|
|
});
|
|
}
|
|
|
|
/** One credential's stable part: who it is, never the rotating token. */
|
|
function ompCredentialId(c) {
|
|
const cred = (c && c.credential) || {};
|
|
const who =
|
|
cred.email ||
|
|
cred.accountId ||
|
|
(cred.type === "oauth" ? "oauth" : sha(JSON.stringify(cred)).slice(0, 12));
|
|
return `${c.provider}:${who}`;
|
|
}
|
|
|
|
/**
|
|
* Empty omp's credential store: the credentials and what hangs off them (rate
|
|
* blocks, refresh leases). omp's own triggers bump `auth_change_revision`, so
|
|
* a running omp re-reads. ponytail: this writes omp's schema directly (it has
|
|
* no logout command); a schema change there surfaces as an error here, and the
|
|
* caller puts the outgoing set back.
|
|
*/
|
|
function ompClear() {
|
|
const file = path.join(ompDir(), "agent", "agent.db");
|
|
if (!fs.existsSync(file)) return;
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const db = new DatabaseSync(file);
|
|
try {
|
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
db.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
for (const t of [
|
|
"auth_credential_blocks",
|
|
"auth_credential_refresh_leases",
|
|
"auth_credentials",
|
|
]) {
|
|
db.exec(`DELETE FROM ${t}`);
|
|
}
|
|
db.exec("COMMIT");
|
|
} catch (e) {
|
|
db.exec("ROLLBACK");
|
|
throw e;
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
const omp = {
|
|
label: "Oh My Pi",
|
|
read() {
|
|
let out;
|
|
try {
|
|
out = JSON.parse(ompRun(["auth", "export"]));
|
|
} catch (e) {
|
|
if (e.code === "ENOTINSTALLED") throw e;
|
|
throw err("EOMP", `omp auth export failed: ${(e.stderr || e.message || "").trim()}`);
|
|
}
|
|
return out && Array.isArray(out.credentials) && out.credentials.length > 0 ? out : null;
|
|
},
|
|
write(secret, outgoing) {
|
|
ompClear();
|
|
try {
|
|
ompRun(["auth", "import", "-"], JSON.stringify(secret));
|
|
} catch (e) {
|
|
if (outgoing) {
|
|
try {
|
|
ompRun(["auth", "import", "-"], JSON.stringify(outgoing));
|
|
} catch {
|
|
/* the outgoing set is still in its profile file */
|
|
}
|
|
}
|
|
throw err("EOMP", `omp auth import failed: ${(e.stderr || e.message || "").trim()}`);
|
|
}
|
|
return [path.join(ompDir(), "agent", "agent.db")];
|
|
},
|
|
identify(exp) {
|
|
const ids = [...new Set(exp.credentials.map(ompCredentialId))].sort();
|
|
const email = exp.credentials.map((c) => c.credential && c.credential.email).find(Boolean);
|
|
return {
|
|
key: ids.join("|"),
|
|
kind: "credentials",
|
|
email: email || null,
|
|
detail: [...new Set(exp.credentials.map((c) => c.provider))].join(", ") || null,
|
|
};
|
|
},
|
|
};
|
|
|
|
let ADAPTERS = { codex, omp };
|
|
|
|
/** Tools listed in the Profiles panel that cannot be switched, and why. */
|
|
const UNSUPPORTED = {
|
|
antigravity:
|
|
"Antigravity keeps its login in the system keyring (Secret Service), which AgentHub does not read or write. Sign in or out inside agy itself.",
|
|
};
|
|
|
|
// ── The store ──────────────────────────────────────────────────────────
|
|
|
|
function adapter(tool) {
|
|
const a = ADAPTERS[tool];
|
|
if (!a) {
|
|
throw err(
|
|
UNSUPPORTED[tool] ? "EUNSUPPORTED" : "EBADTOOL",
|
|
UNSUPPORTED[tool] || `no profiles for tool "${tool}"`
|
|
);
|
|
}
|
|
return a;
|
|
}
|
|
|
|
const profilesDir = (tool) => path.join(getClaudeHome(), "agenthub-profiles", tool);
|
|
|
|
function profileFile(tool, name) {
|
|
adapter(tool);
|
|
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(tool), `${name}.json`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function slugify(text) {
|
|
const base = String(text || "")
|
|
.normalize("NFD")
|
|
.replace(/[̀-ͯ]/g, "")
|
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 32);
|
|
return NAME_RE.test(base) ? base : "profile";
|
|
}
|
|
|
|
function freeName(tool, base) {
|
|
let name = base;
|
|
for (let n = 2; fs.existsSync(path.join(profilesDir(tool), `${name}.json`)); n += 1) {
|
|
name = `${base}-${n}`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/** One stored profile WITH its secret, or null. Never for a route. */
|
|
function readProfile(tool, name) {
|
|
const stored = readJson(profileFile(tool, name));
|
|
return stored && typeof stored === "object" && stored.secret ? stored : null;
|
|
}
|
|
|
|
/** Token-free view of the live login, or null when the tool holds none. */
|
|
function liveIdentity(tool) {
|
|
const a = adapter(tool);
|
|
const live = a.read();
|
|
return live ? { live, identity: a.identify(live) } : { live: null, identity: null };
|
|
}
|
|
|
|
const publicIdentity = (id) => (id ? { kind: id.kind, email: id.email, detail: id.detail } : null);
|
|
|
|
/**
|
|
* Every stored profile of `tool` plus the login that is live right now.
|
|
* @returns `{ tool, items, active }` — `items[].active` marks the profile of
|
|
* the live login. Secrets and identity keys are never included.
|
|
*/
|
|
function listProfiles(tool) {
|
|
const { identity: live } = liveIdentity(tool);
|
|
let names = [];
|
|
try {
|
|
names = fs
|
|
.readdirSync(profilesDir(tool))
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.slice(0, -5));
|
|
} catch {
|
|
/* none saved yet */
|
|
}
|
|
const items = [];
|
|
for (const name of names) {
|
|
if (!NAME_RE.test(name)) continue;
|
|
let stored;
|
|
try {
|
|
stored = readProfile(tool, name);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!stored) continue;
|
|
const id = ADAPTERS[tool].identify(stored.secret);
|
|
items.push({
|
|
name,
|
|
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
|
|
savedAt: stored.savedAt || null,
|
|
active: Boolean(live) && id.key === live.key,
|
|
...publicIdentity(id),
|
|
});
|
|
}
|
|
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
|
return { tool, items, active: publicIdentity(live) };
|
|
}
|
|
|
|
/** The saved profile holding the same login as `identity`, other than `except`. */
|
|
function holderOf(tool, key, except = null) {
|
|
for (const p of listProfiles(tool).items) {
|
|
if (p.name === except) continue;
|
|
const stored = readProfile(tool, p.name);
|
|
if (stored && ADAPTERS[tool].identify(stored.secret).key === key) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Snapshot the login `tool` holds right now. Re-saving into the login's own
|
|
* profile refreshes it; a second copy under another name is `EDUPLICATE`.
|
|
*/
|
|
function saveProfile(tool, name) {
|
|
const { live, identity } = liveIdentity(tool);
|
|
if (!live) {
|
|
throw err("ENOAUTH", `${ADAPTERS[tool].label} holds no login on this machine - sign in first`);
|
|
}
|
|
const explicit = name !== undefined && name !== null;
|
|
const readable =
|
|
(identity.email || "").split("@")[0] ||
|
|
(identity.detail ? `${identity.kind}-${identity.detail}` : identity.kind);
|
|
let target;
|
|
if (explicit) {
|
|
profileFile(tool, name);
|
|
target = name;
|
|
} else {
|
|
const own = holderOf(tool, identity.key);
|
|
target = own ? own.name : freeName(tool, slugify(readable));
|
|
}
|
|
const clash = holderOf(tool, identity.key, target);
|
|
if (clash) {
|
|
throw err(
|
|
"EDUPLICATE",
|
|
`this login is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
|
|
);
|
|
}
|
|
const existing = readProfile(tool, target);
|
|
const label = (existing && existing.label) || (explicit ? target : readable);
|
|
writeJson(profileFile(tool, target), {
|
|
name: target,
|
|
label,
|
|
savedAt: Date.now(),
|
|
secret: live,
|
|
});
|
|
return { ok: true, name: target, label, ...publicIdentity(identity) };
|
|
}
|
|
|
|
function renameProfile(tool, name, label) {
|
|
const stored = readProfile(tool, name);
|
|
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const clean = normalizeLabel(label);
|
|
const taken = listProfiles(tool).items.find(
|
|
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
|
|
);
|
|
if (taken) throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
|
|
writeJson(profileFile(tool, name), { ...stored, label: clean });
|
|
return { ok: true, name, label: clean };
|
|
}
|
|
|
|
/**
|
|
* Make a stored profile the tool's live login. The outgoing login is saved back
|
|
* into its own profile first (its tokens may have rotated since), and a switch
|
|
* away from a login that was never saved is refused.
|
|
*/
|
|
function activateProfile(tool, name) {
|
|
const a = adapter(tool);
|
|
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const { live, identity } = liveIdentity(tool);
|
|
let savedOutgoing = null;
|
|
if (live) {
|
|
const mine = holderOf(tool, identity.key);
|
|
if (!mine) {
|
|
throw err(
|
|
"EUNSAVED",
|
|
"the login in use right now is not saved as a profile - save it first, or it is lost on switch"
|
|
);
|
|
}
|
|
savedOutgoing = saveProfile(tool, mine.name).name;
|
|
}
|
|
const target = readProfile(tool, name);
|
|
const written = a.write(target.secret, live);
|
|
return { ok: true, name, savedOutgoing, written };
|
|
}
|
|
|
|
function deleteProfile(tool, name) {
|
|
const file = profileFile(tool, name);
|
|
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
|
const entry = listProfiles(tool).items.find((p) => p.name === name);
|
|
if (entry && entry.active) {
|
|
throw err(
|
|
"EACTIVE",
|
|
"cannot delete the saved copy of the login in use right now - switch away first"
|
|
);
|
|
}
|
|
fs.rmSync(file, { force: true });
|
|
return { ok: true, name };
|
|
}
|
|
|
|
/** Remove a profile whatever it holds: the pool took its login (routes/accounts.js). */
|
|
function dropProfile(tool, name) {
|
|
fs.rmSync(profileFile(tool, name), { force: true });
|
|
}
|
|
|
|
/** The tools this module covers, switchable or not, for the Profiles panel. */
|
|
function tools() {
|
|
return [
|
|
...Object.keys(ADAPTERS).map((id) => ({ tool: id, supported: true })),
|
|
...Object.entries(UNSUPPORTED).map(([id, reason]) => ({ tool: id, supported: false, reason })),
|
|
];
|
|
}
|
|
|
|
module.exports = {
|
|
tools,
|
|
listProfiles,
|
|
saveProfile,
|
|
renameProfile,
|
|
activateProfile,
|
|
deleteProfile,
|
|
// Token-bearing: for routes/accounts.js's move into the pool, never a response.
|
|
readProfile,
|
|
dropProfile,
|
|
// exported for tests
|
|
profilesDir,
|
|
ompCredentialId,
|
|
__setAdapter: (tool, a) => {
|
|
ADAPTERS = { ...ADAPTERS, [tool]: a };
|
|
},
|
|
};
|