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:
2026-10-22 11:11:38 +07:00
parent 4eb7734ab9
commit 3113479cd2
26 changed files with 2154 additions and 747 deletions
@@ -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" };