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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user