7798530528
Codex >= 0.164 sends every request through a shared app-server daemon that outlives its TUIs and keeps the login it started with, so writing a new auth.json changed nothing: open sessions and even new ones stayed on the old (out-of-quota) account. After a switch, restart that CODEX_HOME's daemon when one runs; open TUIs reconnect to it on their own (verified on 0.164.0 with two keys). The response says so (following), and the toast tells the user running sessions follow.
488 lines
17 KiB
JavaScript
488 lines
17 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. A
|
|
* switch then restarts Codex's shared app-server daemon when one runs,
|
|
* so running and new sessions follow the new login (`follow`).
|
|
* - `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()];
|
|
},
|
|
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
|
// outlives every TUI and holds the login it started with: a new auth.json
|
|
// reached neither a running session nor a NEW one, which kept the old
|
|
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
|
// login, and every open TUI reconnects to it on its own (verified on
|
|
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
|
follow() {
|
|
return restartCodexDaemon();
|
|
},
|
|
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,
|
|
};
|
|
},
|
|
};
|
|
|
|
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
|
const codexDaemonSocket = () =>
|
|
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
|
|
|
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
|
function restartCodexDaemon() {
|
|
if (!fs.existsSync(codexDaemonSocket())) return false;
|
|
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
|
if (!bin) return false;
|
|
cp.execFile(
|
|
bin,
|
|
["app-server", "daemon", "restart"],
|
|
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
|
(e) => {
|
|
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
|
}
|
|
);
|
|
return true;
|
|
}
|
|
|
|
// ── 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);
|
|
// `following`: running sessions take the new login without a restart of their own.
|
|
const following = typeof a.follow === "function" ? a.follow() : false;
|
|
return { ok: true, name, savedOutgoing, written, following };
|
|
}
|
|
|
|
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 };
|
|
},
|
|
};
|