/** * @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`). * `GET /profiles` 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ĩ */ 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 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(), 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 { 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, EBADNAME: 400, EBADTOKEN: 400, ENOACCOUNT: 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); recordAccountChange(req.user.id, null, req.method, out.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; } /** 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" ? 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.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;