Files
AgentHub/server/routes/accounts.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

314 lines
11 KiB
JavaScript

/**
* @file accounts.js
* @description `GET /api/accounts` — the Claude account pool as the balancer
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
* key or a token. `?force=1` re-reads every quota first.
*
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
* `DELETE /:name` (`{tool}`). A Claude account is one of: `profile` — a login
* saved by the switch-user profiles (cc-profiles.js), MOVED into the pool (its
* single-use refresh token may have only one holder, so the profile is
* deleted; the account the host's own CLI is logged into moves in with its
* live login and the host's home becomes one more placement the server
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
* one is an OpenAI API key or the text of an `auth.json` (`token`).
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
* login goes into their home at once, so running panes move without a
* 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`.
* 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).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const pool = require("../lib/claude-pool");
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();
router.get("/", async (req, res) => {
try {
await pool.refreshQuotas({ force: req.query.force === "1" });
if (req.query.force === "1") codexPool.resetQuota();
res.json({
accounts: pool.poolStatus(),
codex: codexPool.poolStatus(),
pins: auth.isServerMode()
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
: { claude: [], codex: [] },
blockPercent: pool.BLOCK_PERCENT,
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
});
/** Saved switch-user profiles an admin may move into the pool — never a token. */
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,
label: p.label,
email: p.email,
subscriptionType: p.subscriptionType,
active: p.active,
})),
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPROFILES", message: e.message } });
}
});
const STATUS = {
ENOTFOUND: 404,
EACTIVE: 409,
ELOCALMODE: 403,
EREAUTH: 403,
EBADTOOL: 400,
EBADUSER: 400,
EBADNAME: 400,
EBADTOKEN: 400,
ENOACCOUNT: 404,
ENOUSER: 404,
EEXISTS: 409,
ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent
EUPSTREAM: 502,
};
/** One pool change: server mode, the admin's password confirmed, audited. */
function poolChange(handler) {
return async (req, res) => {
try {
if (!auth.isServerMode() || !req.user) {
throw Object.assign(new Error("the pool is changed from the host's shell in local mode"), {
code: "ELOCALMODE",
});
}
const body = req.body && typeof req.body === "object" ? req.body : {};
if (body.tool !== "claude" && body.tool !== "codex") {
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
}
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
for (const a of out.audits || [{ target: out.target, action: out.action }]) {
recordAccountChange(req.user.id, a.target ?? null, req.method, a.action);
}
res.status(out.status || 200).json(out.json);
} catch (e) {
const status = STATUS[e.code];
if (!status) return res.status(500).json({ error: { message: e.message } });
res.status(status).json({ error: { code: e.code, message: e.message } });
}
};
}
/**
* A pasted `.credentials.json` (or Keychain item) → `{oauth, account}`. The
* file names no account, so Anthropic is asked whose token it is (refreshing
* an expired one first — what the exchange returns is what gets stored):
* the pool copies a member's refresh back only when it is that same account.
*/
async function pastedLogin(text) {
let parsed;
try {
parsed = JSON.parse(String(text || ""));
} catch {
throw Object.assign(new Error("not valid JSON — paste the whole .credentials.json"), {
code: "EBADTOKEN",
});
}
let oauth = parsed?.claudeAiOauth || parsed;
if (!oauth?.accessToken || !oauth?.refreshToken) return { oauth }; // addLoginAccount says why
const usage = require("../lib/cc-usage");
let owner = await usage.fetchOwner(oauth.accessToken);
if (!owner) {
oauth = await usage.exchangeRefresh(oauth);
owner = await usage.fetchOwner(oauth.accessToken);
}
if (!owner) {
throw Object.assign(new Error("Anthropic did not accept this login"), { code: "EBADTOKEN" });
}
return { oauth, account: { accountUuid: owner.uuid, emailAddress: owner.email } };
}
/**
* Move a saved profile into the pool: the pool becomes its one holder. The
* account this machine's own Claude Code is logged into moves in with the
* LIVE login (the saved copy's refresh token is spent once the CLI refreshed)
* and keeps running here: the host's home becomes one more placement the
* server refreshes and pushes into. A macOS Keychain login cannot be written
* that way (EACTIVE).
*/
function fromProfile(name, poolName) {
const stored = profiles.readProfile(name);
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
const active = profiles.listProfiles().items.find((p) => p.name === name)?.active;
let oauth = stored.credentials;
if (active) {
const live = profiles.readActiveAuth();
if (live.keychain || !live.oauth) {
throw Object.assign(
new Error(
"this machine's Claude Code keeps that login in the macOS Keychain — switch it to another profile first"
),
{ code: "EACTIVE" }
);
}
oauth = live.oauth;
}
const account = pool.addLoginAccount(poolName || name, {
oauth,
account: stored.account || null,
userID: stored.userID || null,
hostHolds: Boolean(active),
});
// The only saved copy of the live account is not deletable (EACTIVE) — and
// need not be: removing the file is enough, the pool holds the login now.
require("node:fs").rmSync(require("node:path").join(profiles.profilesDir(), `${name}.json`), {
force: true,
});
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();
if (!text.startsWith("{")) return { apiKey: text };
try {
return { auth: JSON.parse(text) };
} catch {
throw Object.assign(new Error("not valid JSON — paste the whole auth.json"), {
code: "EBADTOKEN",
});
}
}
router.post(
"/",
poolChange(async (_req, body) => {
const account =
body.tool === "codex"
? 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
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
: pool.addAccount(body.name, body.token);
return {
status: 201,
json: { account },
action: `pool: add ${body.tool} account ${account.name}`,
};
})
);
router.put(
"/pins",
poolChange(async (_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
const switched = pin.account
? await applyNow(pin.userId, pin.username, body.tool, pin.account)
: null;
return {
json: { pin, switched },
target: pin.userId,
action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
: `pool: unpin ${pin.username} from ${body.tool}`,
};
})
);
/** Switch an added user now; a failed switch still leaves the pin for the next spawn. */
async function applyNow(userId, username, tool, account) {
try {
return await pins.applyPin(userId, tool);
} catch (e) {
console.warn(`[accounts] switch ${username} → ${account}: ${e.message}`);
return "next-session";
}
}
router.put(
"/:name/users",
poolChange(async (req, body) => {
const account = req.params.name;
const { added, removed } = pins.setAccountUsers(body.tool, account, body.userIds);
const switched = [];
for (const u of added) {
switched.push({ ...u, switched: await applyNow(u.userId, u.username, body.tool, account) });
}
return {
json: { added: switched, removed },
audits: [
...added.map((u) => ({
target: u.userId,
action: `pool: pin ${u.username} to ${body.tool} account ${account}`,
})),
...removed.map((u) => ({
target: u.userId,
action: `pool: unpin ${u.username} from ${body.tool}`,
})),
],
};
})
);
router.delete(
"/:name",
poolChange((req, body) => {
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
return {
json: { ok: true },
action: `pool: remove ${body.tool} account ${req.params.name}`,
};
})
);
module.exports = router;