Files
AgentHub/server/__tests__/tool-profiles.test.js
T
nntrivi2001 3113479cd2 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.
2026-10-22 11:11:38 +07:00

258 lines
9.6 KiB
JavaScript

/**
* @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;
}
});
});