/** * @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 * `/agenthub-profiles//` (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ĩ */ 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 }; }, };