feat(settings): every AI tool's login profiles in one Settings section, linked to the account pool
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.
This commit is contained in:
@@ -338,6 +338,42 @@ describe("moving a saved profile in from the Users page", () => {
|
||||
pool.removeAccount("work");
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and lists Codex profiles without a token", async () => {
|
||||
const dir = path.join(TMP, ".claude", "agenthub-profiles", "codex");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const idToken = `x.${Buffer.from(JSON.stringify({ email: "cx@example.com" })).toString("base64url")}.s`;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "cx.json"),
|
||||
JSON.stringify({
|
||||
name: "cx",
|
||||
label: "Codex work",
|
||||
secret: {
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: "at-cx",
|
||||
refresh_token: "rt-cx",
|
||||
account_id: "a-cx",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const list = await call("GET", "/api/accounts/profiles?tool=codex");
|
||||
assert.deepEqual(
|
||||
list.body.profiles.map((p) => [p.name, p.label, p.email]),
|
||||
[["cx", "Codex work", "cx@example.com"]]
|
||||
);
|
||||
assert.ok(!JSON.stringify(list.body).includes("rt-cx"));
|
||||
const moved = await call("POST", "/api/accounts", {
|
||||
tool: "codex",
|
||||
profile: "cx",
|
||||
currentPassword: "admin-pass-1",
|
||||
});
|
||||
assert.equal(moved.status, 201);
|
||||
assert.equal(moved.body.account.kind, "chatgpt");
|
||||
assert.equal(fs.existsSync(path.join(dir, "cx.json")), false, "the pool is its one holder");
|
||||
require("../lib/codex-pool").removeAccount("cx");
|
||||
});
|
||||
|
||||
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
|
||||
const pasted = oauth("p");
|
||||
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for the non-Claude login profiles (tool-profiles.js): saving the
|
||||
* live Codex login, switching between logins, the guards that keep a switch from
|
||||
* losing the only copy of one, and Oh My Pi's credential identity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tool-profiles-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, ".codex");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
|
||||
const AUTH = path.join(process.env.CODEX_HOME, "auth.json");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const jwt = (claims) => `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
||||
|
||||
/** Leave a ChatGPT login on disk the way `codex login` would. */
|
||||
function login(account, email, refresh = "rt-1") {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
AUTH,
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email,
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: "at",
|
||||
refresh_token: refresh,
|
||||
account_id: account,
|
||||
},
|
||||
last_refresh: "2026-10-01T00:00:00Z",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("tool-profiles: codex", () => {
|
||||
it("reports the live login and saves it under a name derived from it", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
assert.deepEqual(profiles.listProfiles("codex").active, {
|
||||
kind: "chatgpt",
|
||||
email: "alice@example.com",
|
||||
detail: "plus",
|
||||
});
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.name, "alice");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].secret, undefined, "never a token in a listing");
|
||||
const mode = fs.statSync(path.join(profiles.profilesDir("codex"), "alice.json")).mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
});
|
||||
|
||||
it("re-saving refreshes the login's own profile; a second name is EDUPLICATE", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-a", "alice@example.com", "rt-2");
|
||||
assert.equal(profiles.saveProfile("codex").name, "alice");
|
||||
assert.equal(profiles.readProfile("codex", "alice").secret.tokens.refresh_token, "rt-2");
|
||||
assert.throws(() => profiles.saveProfile("codex", "other"), { code: "EDUPLICATE" });
|
||||
});
|
||||
|
||||
it("switches, saving the outgoing login back first", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
// Alice's tokens rotated while she was live? Bob is live now; switch to Alice.
|
||||
const r = profiles.activateProfile("codex", "alice");
|
||||
assert.equal(r.savedOutgoing, "bob");
|
||||
const live = JSON.parse(fs.readFileSync(AUTH, "utf8"));
|
||||
assert.equal(live.tokens.account_id, "acct-a");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active]),
|
||||
[
|
||||
["alice", true],
|
||||
["bob", false],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-c", "carol@example.com");
|
||||
assert.throws(() => profiles.activateProfile("codex", "alice"), { code: "EUNSAVED" });
|
||||
});
|
||||
|
||||
it("will not delete the live login's profile, deletes another", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.throws(() => profiles.deleteProfile("codex", "bob"), { code: "EACTIVE" });
|
||||
profiles.deleteProfile("codex", "alice");
|
||||
assert.deepEqual(
|
||||
profiles.listProfiles("codex").items.map((p) => p.name),
|
||||
["bob"]
|
||||
);
|
||||
});
|
||||
|
||||
it("renames the label only, and refuses a taken one", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
profiles.renameProfile("codex", "alice", "Công việc");
|
||||
assert.equal(
|
||||
profiles.listProfiles("codex").items.find((p) => p.name === "alice").label,
|
||||
"Công việc"
|
||||
);
|
||||
assert.throws(() => profiles.renameProfile("codex", "bob", "công việc"), {
|
||||
code: "ELABELTAKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("an API-key login is identified by its key, shown by its last four", () => {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-test-abcdefghij1234" }));
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.kind, "apikey");
|
||||
assert.equal(r.detail, "…1234");
|
||||
});
|
||||
|
||||
it("saving with no login is ENOAUTH", () => {
|
||||
assert.throws(() => profiles.saveProfile("codex"), { code: "ENOAUTH" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: tools", () => {
|
||||
it("lists antigravity as unsupported and refuses it", () => {
|
||||
const agy = profiles.tools().find((t) => t.tool === "antigravity");
|
||||
assert.equal(agy.supported, false);
|
||||
assert.throws(() => profiles.listProfiles("antigravity"), { code: "EUNSUPPORTED" });
|
||||
assert.throws(() => profiles.listProfiles("nope"), { code: "EBADTOOL" });
|
||||
});
|
||||
|
||||
it("an omp credential is identified by who it is, never by its rotating token", () => {
|
||||
const a = { provider: "anthropic", credential: { type: "oauth", access: "1", email: "a@x" } };
|
||||
const b = { provider: "anthropic", credential: { type: "oauth", access: "2", email: "a@x" } };
|
||||
assert.equal(profiles.ompCredentialId(a), profiles.ompCredentialId(b));
|
||||
assert.equal(profiles.ompCredentialId(a), "anthropic:a@x");
|
||||
});
|
||||
|
||||
it("a switch hands the adapter the outgoing login so it can restore it", () => {
|
||||
let disk = { who: "a" };
|
||||
const writes = [];
|
||||
profiles.__setAdapter("fake", {
|
||||
label: "Fake",
|
||||
read: () => disk,
|
||||
write: (secret, outgoing) => {
|
||||
writes.push([secret.who, outgoing && outgoing.who]);
|
||||
disk = secret;
|
||||
return [];
|
||||
},
|
||||
identify: (s) => ({ key: s.who, kind: "x", email: null, detail: null }),
|
||||
});
|
||||
profiles.saveProfile("fake", "a");
|
||||
disk = { who: "b" };
|
||||
profiles.saveProfile("fake", "b");
|
||||
profiles.activateProfile("fake", "a");
|
||||
assert.deepEqual(writes, [["a", "b"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: omp", () => {
|
||||
it("switches through a cleared store + `omp auth import -`, never by argv", () => {
|
||||
const omp = path.join(ROOT, ".omp");
|
||||
const db = path.join(omp, "agent", "agent.db");
|
||||
const state = path.join(ROOT, "omp-state.json");
|
||||
const argv = path.join(ROOT, "omp-argv.log");
|
||||
fs.mkdirSync(path.dirname(db), { recursive: true });
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const d = new DatabaseSync(db);
|
||||
d.exec(`CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, data TEXT);
|
||||
CREATE TABLE auth_credential_blocks (credential_id INTEGER);
|
||||
CREATE TABLE auth_credential_refresh_leases (credential_id INTEGER);
|
||||
INSERT INTO auth_credentials (provider, data) VALUES ('anthropic', '{}');`);
|
||||
d.close();
|
||||
// A fake omp: export prints the state file, import REPLACES it from stdin
|
||||
// only when the store was cleared first (as the real one merges).
|
||||
const bin = path.join(ROOT, "omp");
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
fs.appendFileSync(${JSON.stringify(argv)}, process.argv.slice(2).join(" ") + "\\n");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(${JSON.stringify(db)});
|
||||
const rows = db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
|
||||
if (process.argv[3] === "export") {
|
||||
process.stdout.write(fs.readFileSync(${JSON.stringify(state)}, "utf8"));
|
||||
} else {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
if (rows) { process.stderr.write("merged into a non-empty store"); process.exit(1); }
|
||||
fs.writeFileSync(${JSON.stringify(state)}, JSON.stringify(input));
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const set = (email, token) =>
|
||||
fs.writeFileSync(
|
||||
state,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
credentials: [
|
||||
{ provider: "anthropic", credential: { type: "oauth", access: token, email } },
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.AGENTHUB_OMP_DIR = omp;
|
||||
process.env.AGENTHUB_OMP_BIN = bin;
|
||||
try {
|
||||
set("a@x", "secret-a");
|
||||
assert.equal(profiles.saveProfile("omp").name, "a");
|
||||
set("b@x", "secret-b");
|
||||
profiles.saveProfile("omp");
|
||||
const r = profiles.activateProfile("omp", "a");
|
||||
assert.equal(r.savedOutgoing, "b");
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(state, "utf8")).credentials[0].credential.email,
|
||||
"a@x"
|
||||
);
|
||||
assert.ok(!fs.readFileSync(argv, "utf8").includes("secret"), "secrets only on stdin");
|
||||
const { items, active } = profiles.listProfiles("omp");
|
||||
assert.equal(active.email, "a@x");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active, p.detail]),
|
||||
[
|
||||
["a", true, "anthropic"],
|
||||
["b", false, "anthropic"],
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_OMP_DIR;
|
||||
delete process.env.AGENTHUB_OMP_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,7 @@ const snapshotsRouter = require("./routes/snapshots");
|
||||
const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const toolProfilesRouter = require("./routes/tool-profiles");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
@@ -209,6 +210,7 @@ function createApp() {
|
||||
app.use("/api/import", importRouter);
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/tool-profiles", toolProfilesRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
|
||||
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
|
||||
|
||||
@@ -63,6 +63,8 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
// The AI tools' login profiles: switching one changes the host's own login.
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
@@ -88,6 +90,7 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
const STAFF_ONLY_PREFIXES = [
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/remote-sources",
|
||||
"/webhooks",
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @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 };
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,9 @@
|
||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
||||
* can be moved in (identity only). Like every Users-page change
|
||||
* they need the admin's own password again (`currentPassword`), are audited
|
||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
|
||||
@@ -35,6 +37,7 @@ const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const toolProfiles = require("../lib/tool-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
const router = Router();
|
||||
@@ -57,8 +60,19 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
|
||||
/** Saved switch-user profiles an admin may move into the pool — never a token. */
|
||||
router.get("/profiles", (_req, res) => {
|
||||
router.get("/profiles", (req, res) => {
|
||||
try {
|
||||
if (req.query.tool === "codex") {
|
||||
return res.json({
|
||||
profiles: toolProfiles.listProfiles("codex").items.map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
email: p.email,
|
||||
subscriptionType: p.detail,
|
||||
active: p.active,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
profiles: profiles.listProfiles().items.map((p) => ({
|
||||
name: p.name,
|
||||
@@ -185,6 +199,20 @@ function fromProfile(name, poolName) {
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a saved Codex profile (Settings → AI tools → Profiles) into the pool.
|
||||
* The profile file goes, as for Claude: the pool is now the login's holder.
|
||||
* The host's own CODEX_HOME is left as it is — Codex's old refresh token still
|
||||
* refreshes (codex-pool.js), so the host keeps working on it.
|
||||
*/
|
||||
function fromCodexProfile(name, poolName) {
|
||||
const stored = toolProfiles.readProfile("codex", name);
|
||||
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
|
||||
const account = codexPool.addAccount(poolName || name, { auth: stored.secret });
|
||||
toolProfiles.dropProfile("codex", name);
|
||||
return account;
|
||||
}
|
||||
|
||||
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
|
||||
function codexSecret(token) {
|
||||
const text = String(token || "").trim();
|
||||
@@ -203,7 +231,9 @@ router.post(
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file tool-profiles.js
|
||||
* @description `/api/tool-profiles` — the login profiles of the AI tools other
|
||||
* than Claude Code (server/lib/tool-profiles.js; Claude's stay under
|
||||
* `/api/cc-config/profiles`, and Settings → AI tools → Profiles shows both).
|
||||
* `GET /` lists every covered tool (`supported: false` + `reason` for one that
|
||||
* cannot be switched); `POST /:tool` saves the live login, `PATCH /:tool`
|
||||
* renames, `POST /:tool/activate` switches, `DELETE /:tool` deletes — the
|
||||
* profile name always in the body. Identity metadata only, never a token. The
|
||||
* writes change the HOST's own login, so `/tool-profiles` is in
|
||||
* ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES like `/cc-config`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
// Every write swaps a live login: never from a cross-site form.
|
||||
router.use((req, res, next) => (req.method === "GET" ? next() : sameOriginGuard(req, res, next)));
|
||||
|
||||
const STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADTOOL: 404,
|
||||
ENOTFOUND: 404,
|
||||
EUNSUPPORTED: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
EBADJSON: 409,
|
||||
EACTIVE: 409,
|
||||
EUNSAVED: 409,
|
||||
ENOAUTH: 409,
|
||||
EDUPLICATE: 409,
|
||||
ELABELTAKEN: 409,
|
||||
EOMP: 502,
|
||||
};
|
||||
|
||||
const handle = (fn) => async (req, res) => {
|
||||
try {
|
||||
res.json(await fn(req));
|
||||
} catch (e) {
|
||||
res
|
||||
.status(STATUS[e.code] || 500)
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
};
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
handle(() => ({
|
||||
tools: profiles.tools().map((t) => {
|
||||
if (!t.supported) return { ...t, items: [], active: null };
|
||||
try {
|
||||
return { ...t, ...profiles.listProfiles(t.tool), error: null };
|
||||
} catch (e) {
|
||||
// One tool's failure (omp not installed) never hides the others.
|
||||
return { ...t, items: [], active: null, error: { code: e.code, message: e.message } };
|
||||
}
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
const name = (req) => (req.body || {}).name;
|
||||
|
||||
router.post(
|
||||
"/:tool",
|
||||
handle((req) => profiles.saveProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.patch(
|
||||
"/:tool",
|
||||
handle((req) => profiles.renameProfile(req.params.tool, name(req), (req.body || {}).label))
|
||||
);
|
||||
router.post(
|
||||
"/:tool/activate",
|
||||
handle((req) => profiles.activateProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.delete(
|
||||
"/:tool",
|
||||
handle((req) => profiles.deleteProfile(req.params.tool, name(req)))
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user