685eb748e8
- The update dialog can be closed while an update applies: it keeps running, the page still reloads itself when the new server is up, and a failure re-opens the dialog with its error. - Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI, Oh My Pi, and every other installed tool, which says it has no pool), accounts as cards with 5-hour and weekly quota shown as what is LEFT plus their reset times, and the saved profiles of Settings → AI tools → Profiles as cards with "Share to pool". - Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to a cursor-agent pane only through tmux -e CURSOR_API_KEY; `agenthub accounts add <name> --tool cursor`. - Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's members run only on its accounts of each tool, a user in no group on the accounts no group lists, a pin still wins. account_pins loses its tool CHECK (rebuilt, pins kept); three new tables. - Oh My Pi login sharing can be switched from its pool tab too.
394 lines
14 KiB
JavaScript
394 lines
14 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), and
|
|
* `cursor` for the Cursor pool (server/lib/cursor-pool.js: API keys only, no
|
|
* quota). 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`); a Cursor
|
|
* one is a Cursor API key (`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`. Pool GROUPS (pool-groups.js) split the pool by
|
|
* team: `POST /groups` (`{name}`), `PATCH /groups/:id` (`{name?, userIds?,
|
|
* accounts?: {tool: [names]}}`), `DELETE /groups/:id`; `GET /` lists them
|
|
* under `groups`.
|
|
* 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 cursorPool = require("../lib/cursor-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 groups = require("../lib/pool-groups");
|
|
|
|
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(),
|
|
cursor: cursorPool.poolStatus(),
|
|
pins: auth.isServerMode()
|
|
? Object.fromEntries(pins.TOOLS.map((t) => [t, pins.listPins(t)]))
|
|
: Object.fromEntries(pins.TOOLS.map((t) => [t, []])),
|
|
// Server mode's pool groups (pool-groups.js); none in local mode.
|
|
groups: auth.isServerMode() ? groups.listGroups() : [],
|
|
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,
|
|
ENOGROUP: 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, { anyTool = false } = {}) {
|
|
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 (!anyTool && !pins.TOOLS.includes(body.tool)) {
|
|
throw Object.assign(new Error(`tool: ${pins.TOOLS.join(", ")}`), { 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 === "cursor"
|
|
? cursorPool.addAccount(body.name, { apiKey: body.token })
|
|
: 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}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
// ── Groups: each team's members run on the team's own accounts ─────────────
|
|
|
|
router.post(
|
|
"/groups",
|
|
poolChange(
|
|
async (_req, body) => {
|
|
const g = groups.createGroup(body.name);
|
|
return { status: 201, json: { group: g }, action: `pool: create group ${g.name}` };
|
|
},
|
|
{ anyTool: true }
|
|
)
|
|
);
|
|
|
|
/**
|
|
* One group's edit, each part optional: `name`, `userIds` (exactly its members
|
|
* afterwards — one group per user, so a user in another group moves here),
|
|
* `accounts` (`{tool: [names]}`, exactly its accounts of each tool named).
|
|
*/
|
|
router.patch(
|
|
"/groups/:id",
|
|
poolChange(
|
|
async (req, body) => {
|
|
const id = Number(req.params.id);
|
|
let g = groups.listGroups().find((x) => x.id === id);
|
|
if (!g) throw Object.assign(new Error(`no group ${req.params.id}`), { code: "ENOGROUP" });
|
|
const audits = [];
|
|
if (body.name !== undefined && body.name !== g.name) {
|
|
groups.renameGroup(id, body.name);
|
|
audits.push({ action: `pool: rename group ${g.name} to ${String(body.name).trim()}` });
|
|
}
|
|
const name = body.name !== undefined ? String(body.name).trim() : g.name;
|
|
if (body.userIds !== undefined) {
|
|
groups.setGroupUsers(id, body.userIds);
|
|
audits.push({ action: `pool: group ${name} members ${JSON.stringify(body.userIds)}` });
|
|
}
|
|
if (body.accounts !== undefined) {
|
|
if (!body.accounts || typeof body.accounts !== "object" || Array.isArray(body.accounts)) {
|
|
throw Object.assign(new Error("accounts: {tool: [names]}"), { code: "EBADNAME" });
|
|
}
|
|
for (const [tool, names] of Object.entries(body.accounts)) {
|
|
groups.setGroupAccounts(id, tool, names);
|
|
audits.push({ action: `pool: group ${name} ${tool} accounts ${JSON.stringify(names)}` });
|
|
}
|
|
}
|
|
g = groups.listGroups().find((x) => x.id === id);
|
|
return {
|
|
json: { group: g },
|
|
audits: audits.length ? audits : [{ action: `pool: group ${name}` }],
|
|
};
|
|
},
|
|
{ anyTool: true }
|
|
)
|
|
);
|
|
|
|
router.delete(
|
|
"/groups/:id",
|
|
poolChange(
|
|
async (req) => {
|
|
const g = groups.deleteGroup(Number(req.params.id));
|
|
return { json: { ok: true }, action: `pool: delete group ${g.name}` };
|
|
},
|
|
{ anyTool: true }
|
|
)
|
|
);
|
|
|
|
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) => {
|
|
({ claude: pool, codex: codexPool, cursor: cursorPool })[body.tool].removeAccount(
|
|
req.params.name
|
|
);
|
|
return {
|
|
json: { ok: true },
|
|
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
module.exports = router;
|