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
+33 -3
View File
@@ -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