feat: account pool per tool with groups and a Cursor pool; update runs in the background

- 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.
This commit is contained in:
2026-10-22 17:02:08 +07:00
parent 44426faf16
commit 685eb748e8
30 changed files with 2891 additions and 299 deletions
+138
View File
@@ -0,0 +1,138 @@
/**
* @file cursor-pool.test.js
* @description The Cursor account pool (server/lib/cursor-pool.js), fake keys
* only: key validation and file modes, the balancer (fewest live Cursor panes,
* a pin wins), the key reaching a `cursor-agent` pane only through tmux `-e
* CURSOR_API_KEY` and never its argv, the account recorded on the run, pins
* for `cursor` and the account_pins rebuild that drops the old tool CHECK, and
* an empty pool leaving every spawn as it was.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cursor-pool-test-")));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const pool = require("../lib/cursor-pool");
const pins = require("../lib/account-pins");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const KEY = (c) => `key_${c.repeat(40)}`;
let tmuxArgv;
const envOf = (argv, name) =>
argv
.slice(0, argv.indexOf("--"))
.find((x) => String(x).startsWith(`${name}=`))
?.slice(name.length + 1);
function run(id, account, status = "running") {
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, tool)
VALUES (?, 'interactive', '/tmp', ?, ?, ?, 'cursor')`
).run(id, status, new Date().toISOString(), account);
}
before(() => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") tmuxArgv = args;
return "";
});
});
after(() => {
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
tmuxArgv = null;
});
describe("cursor pool: accounts", () => {
it("stores a key 0600 in a 0700 dir and never lists it", () => {
const added = pool.addAccount("team", { apiKey: KEY("a") });
assert.deepEqual(Object.keys(added).sort(), ["addedAt", "kind", "name"]);
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
assert.equal(fs.statSync(path.join(pool.poolDir(), "team.json")).mode & 0o777, 0o600);
const listed = pool.poolStatus();
assert.equal(listed.length, 1);
assert.ok(!JSON.stringify(listed).includes(KEY("a")));
assert.equal(listed[0].quotaPercent, null);
});
it("refuses a bad name, a bad key and a taken name", () => {
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
assert.throws(() => pool.addAccount("x", { apiKey: "short" }), { code: "EBADTOKEN" });
assert.throws(() => pool.addAccount("x", { apiKey: `${KEY("a")}\nX=1` }), {
code: "EBADTOKEN",
});
pool.addAccount("x", { apiKey: KEY("a") });
assert.throws(() => pool.addAccount("x", { apiKey: KEY("b") }), { code: "EEXISTS" });
pool.removeAccount("x");
assert.throws(() => pool.removeAccount("x"), { code: "ENOACCOUNT" });
});
it("picks the account with the fewest live panes", () => {
pool.addAccount("a", { apiKey: KEY("a") });
pool.addAccount("b", { apiKey: KEY("b") });
run("r1", "a");
run("r2", "b", "completed");
assert.equal(pool.pickAccount().name, "b");
});
});
describe("cursor pool: spawn", () => {
it("an empty pool leaves a Cursor spawn exactly as before", () => {
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor" });
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), undefined);
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, null);
});
it("the key reaches the pane through tmux -e only, and the run names its account", () => {
pool.addAccount("a", { apiKey: KEY("a") });
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor", initialPrompt: "hi" });
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), KEY("a"));
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, "a");
// Other tools never get it.
tmuxArgv = null;
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
assert.ok(!tmuxArgv.some((x) => String(x).includes("CURSOR_API_KEY")));
});
});
describe("cursor pool: pins", () => {
it("account_pins takes a cursor pin, and removing the account drops it", () => {
const sql = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
.get().sql;
assert.ok(!sql.includes("CHECK(tool IN"), "no tool CHECK left");
assert.ok(pins.TOOLS.includes("cursor"));
const uid = db
.prepare("INSERT INTO users (username, password_hash) VALUES ('pinned', 'x')")
.run().lastInsertRowid;
pool.addAccount("a", { apiKey: KEY("a") });
db.prepare("INSERT INTO account_pins (user_id, tool, account) VALUES (?, 'cursor', 'a')").run(
uid
);
assert.equal(pins.listPins("cursor").length, 1);
pool.removeAccount("a");
assert.equal(pins.listPins("cursor").length, 0);
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* @file pool-groups.test.js
* @description Account pool groups (server/lib/pool-groups.js and the
* `/api/accounts/groups` routes), fake accounts only: with no group every
* account is shared as before; a group's member gets only the group's
* accounts of each tool (none → no pool account), a user in no group only
* the accounts no group lists, a pin wins over the group; one group per user;
* a removed account or deleted group leaves no rows behind; and the routes
* need an admin's own password again, are audited and refuse in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-groups-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const groups = require("../lib/pool-groups");
const cursorPool = require("../lib/cursor-pool");
const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
let server;
let BASE;
let MIA;
let LEO;
let ZOE;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", BOSS_PASS, "admin");
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
ZOE = (await auth.createUser("zoe", "member-pass-3", "developer")).id;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
db.prepare("DELETE FROM pool_groups").run();
db.prepare("DELETE FROM account_pins").run();
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
fs.rmSync(dir, { recursive: true, force: true });
}
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
});
describe("pool groups: who may take which account", () => {
it("shares every account while no group exists", () => {
assert.equal(cursorPool.pickAccount(MIA).name, "a");
assert.ok(["a", "b", "c"].every(groups.allowed(MIA, "cursor")));
});
it("gives a member only the group's accounts, the rest only the shared ones", () => {
const g = groups.createGroup("Team A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b"]);
assert.equal(cursorPool.pickAccount(MIA).name, "b");
assert.equal(cursorPool.pickAccount(LEO).name, "a", "b is no longer shared");
// An account may serve several groups; a group with no account of a tool
// leaves its members on their own sign-in.
const h = groups.createGroup("Team B");
groups.setGroupUsers(h.id, [ZOE]);
assert.equal(cursorPool.pickAccount(ZOE), null);
groups.setGroupAccounts(h.id, "cursor", ["b", "c"]);
assert.ok(["b", "c"].includes(cursorPool.pickAccount(ZOE).name));
assert.equal(cursorPool.pickAccount(LEO).name, "a");
// Claude follows the same groups.
claudePool.addAccount("shared", TOKEN("s"));
claudePool.addAccount("team", TOKEN("t"));
groups.setGroupAccounts(g.id, "claude", ["team"]);
assert.equal(claudePool.pickAccount(null, MIA).name, "team");
assert.equal(claudePool.pickAccount(null, LEO).name, "shared");
});
it("puts a user in one group only, and honours a pin outside it", () => {
const g = groups.createGroup("A");
const h = groups.createGroup("B");
groups.setGroupUsers(g.id, [MIA, LEO]);
groups.setGroupUsers(h.id, [MIA]);
const list = groups.listGroups();
assert.deepEqual(
list.find((x) => x.name === "A").users.map((u) => u.username),
["leo"]
);
assert.deepEqual(
list.find((x) => x.name === "B").users.map((u) => u.username),
["mia"]
);
groups.setGroupAccounts(h.id, "cursor", ["b"]);
pins.setPin(MIA, "cursor", "c");
assert.equal(cursorPool.pickAccount(MIA).name, "c");
});
it("drops a removed account from its groups, and a group's rows with it", () => {
const g = groups.createGroup("A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b"]);
cursorPool.removeAccount("b");
assert.deepEqual(groups.listGroups()[0].accounts.cursor, []);
groups.deleteGroup(g.id);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM pool_group_users").get().n, 0);
assert.throws(() => groups.setGroupAccounts(g.id, "cursor", ["a"]), { code: "ENOGROUP" });
assert.throws(() => groups.createGroup(""), { code: "EBADNAME" });
});
});
describe("pool groups: routes", () => {
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
const boss = await login("boss", BOSS_PASS);
const bad = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "Team A", currentPassword: "nope" },
});
assert.equal(bad.body.error.code, "EREAUTH");
const made = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "Team A", currentPassword: BOSS_PASS },
});
assert.equal(made.status, 201);
const id = made.body.group.id;
const dup = await call("POST", "/api/accounts/groups", {
cookie: boss,
body: { name: "team a", currentPassword: BOSS_PASS },
});
assert.equal(dup.body.error.code, "EEXISTS");
const edit = await call("PATCH", `/api/accounts/groups/${id}`, {
cookie: boss,
body: {
name: "Team Alpha",
userIds: [MIA],
accounts: { cursor: ["a"] },
currentPassword: BOSS_PASS,
},
});
assert.equal(edit.status, 200);
assert.equal(edit.body.group.name, "Team Alpha");
assert.deepEqual(edit.body.group.accounts.cursor, ["a"]);
const missing = await call("PATCH", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { accounts: { cursor: ["nope"] }, currentPassword: BOSS_PASS },
});
assert.equal(missing.body.error.code, "ENOACCOUNT");
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.equal(list.body.groups[0].users[0].username, "mia");
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.match(audit.path, /group Team Alpha cursor accounts/);
const dev = await login("leo", "member-pass-2");
const refused = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: dev,
body: { currentPassword: "member-pass-2" },
});
assert.equal(refused.status, 403);
const del = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(del.status, 200);
const gone = await call("DELETE", `/api/accounts/groups/${id}`, {
cookie: boss,
body: { currentPassword: BOSS_PASS },
});
assert.equal(gone.status, 404);
});
});
@@ -1,6 +1,6 @@
/**
* @file server-mode-account-pool.test.js
* @description The Claude/Codex account pool from the Users page: only an
* @description The Claude/Codex/Cursor account pool from the Users page: only an
* admin may add or remove an account, each change needs the admin's OWN
* password again (EREAUTH), the token only goes in — no answer (the add, the
* list) ever carries it — the audit log names the account, not its secret, and
@@ -25,6 +25,7 @@ const { db } = require("../db");
const auth = require("../lib/auth");
const claudePool = require("../lib/claude-pool");
const codexPool = require("../lib/codex-pool");
const cursorPool = require("../lib/cursor-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
@@ -113,6 +114,30 @@ describe("the account pool from the Users page", () => {
assert.equal(gone.status, 404);
});
it("adds a Cursor API key, lists it under `cursor` and removes it", async () => {
const boss = await login("boss", BOSS_PASS);
const CKEY = `key_${"c".repeat(40)}`;
const add = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "cursor", name: "cu-1", token: CKEY, currentPassword: BOSS_PASS },
});
assert.equal(add.status, 201);
assert.equal(cursorPool.__read("cu-1").key, CKEY);
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.cursor.map((a) => a.name),
["cu-1"]
);
assert.deepEqual(list.body.pins.cursor, []);
assert.ok(!JSON.stringify(list.body).includes(CKEY));
const del = await call("DELETE", "/api/accounts/cu-1", {
cookie: boss,
body: { tool: "cursor", currentPassword: BOSS_PASS },
});
assert.equal(del.status, 200);
assert.equal(cursorPool.__read("cu-1"), null);
});
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
const boss = await login("boss", BOSS_PASS);
const add = (body) =>
@@ -123,7 +148,7 @@ describe("the account pool from the Users page", () => {
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
assert.equal((await add({ tool: "antigravity" })).body.error.code, "EBADTOOL");
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
});
+49 -2
View File
@@ -1958,16 +1958,63 @@ for (const [col, type] of [
}
// An admin's pin of one server-mode user to one pool account per tool
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
// The account is named, not referenced — the pool lives in files.
// The account is named, not referenced — the pool lives in files. The tool
// list is account-pins.js's `TOOLS`, not a CHECK (a new pool needs no rebuild).
db.exec(`
CREATE TABLE IF NOT EXISTS account_pins (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
tool TEXT NOT NULL,
account TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, tool)
);
`);
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
// team's own pool accounts. One group per user; an account, named like a pin,
// may serve several groups. Accounts no group lists are the shared pool.
db.exec(`
CREATE TABLE IF NOT EXISTS pool_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS pool_group_users (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS pool_group_accounts (
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE,
tool TEXT NOT NULL,
account TEXT NOT NULL,
PRIMARY KEY (group_id, tool, account)
);
`);
// A database from before the Cursor pool: its `tool` CHECK allowed only
// claude/codex. Rebuilt with every pin kept.
{
const meta = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
.get();
if (meta?.sql?.includes("CHECK(tool IN")) {
db.exec(`
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE account_pins_new (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL,
account TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, tool)
);
INSERT INTO account_pins_new (user_id, tool, account, pinned_at)
SELECT user_id, tool, account, pinned_at FROM account_pins;
DROP TABLE account_pins;
ALTER TABLE account_pins_new RENAME TO account_pins;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
// An existing database made before auto_vacuum: the setting is stored only by
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
+17 -6
View File
@@ -2,7 +2,7 @@
* @file account-pins.js
* @description Account pins: an admin's choice that one server-mode user's
* new sessions of a tool run on one named pool account instead of the one the
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
* balancer would pick (claude-pool.js / codex-pool.js / cursor-pool.js `pick`) — the Users
* page's "switch a user's account". One pin per user and tool
* (`account_pins`); the user is the workspace's OWNER
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
@@ -19,14 +19,15 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const TOOLS = ["claude", "codex"];
const TOOLS = ["claude", "codex", "cursor"];
const POOLS = { claude: "./claude-pool", codex: "./codex-pool", cursor: "./cursor-pool" };
const err = (code, message) => Object.assign(new Error(message), { code });
const db = () => require("../db").db;
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
const poolOf = (tool) => require(POOLS[tool]);
function validTool(tool) {
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
if (!TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${TOOLS.join(", ")}`);
return tool;
}
@@ -132,13 +133,23 @@ async function applyPin(userId, tool) {
return poolOf(tool).switchHome(member, pin);
}
/** A removed account takes its pins with it. */
/** A removed account takes its pins — and its place in every group — with it. */
function dropAccount(tool, account) {
try {
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
} catch {
/* no database (a bare CLI test): nothing pinned */
}
require("./pool-groups").dropAccount(tool, account);
}
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
module.exports = {
TOOLS,
poolOf,
pinFor,
listPins,
setPin,
setAccountUsers,
applyPin,
dropAccount,
};
+4
View File
@@ -503,6 +503,10 @@ function pick(member, ownerId = null) {
live = liveLogin ? placed || liveLogin : null;
}
}
// The owner's group's accounts only (pool-groups.js) — a login already live
// in their home stays: one .credentials.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "claude");
usable = usable.filter((a) => allowed(a.name));
// A pinned token goes in by env and wins outright; a pinned login waits
// while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "claude");
+4
View File
@@ -358,6 +358,10 @@ function pick(member, ownerId = null) {
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
}
}
// The owner's group's accounts only (pool-groups.js) — a login already live
// in their home stays: one auth.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "codex");
usable = usable.filter((a) => allowed(a.name));
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
// waits while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "codex");
+156
View File
@@ -0,0 +1,156 @@
/**
* @file cursor-pool.js
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
* cursor`, or the Users page), and the balancer that gives every new
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
* it from there and it outranks the account's own `cursor-agent login` for
* that process only; nothing is written into anyone's home. An empty pool
* changes nothing: panes run on their home's own login as before.
*
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
* 0700, file 0600); no route returns a key.
*
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
* there is nothing else to rank by. A user an admin pinned to one account
* (account-pins.js) skips the ranking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
// A Cursor API key is one opaque token; never whitespace, never a newline
// (it travels as an env value through tmux and a member's launch file).
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
const KEY_ENV = "CURSOR_API_KEY";
const err = (code, message) => Object.assign(new Error(message), { code });
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
function validName(name) {
const n = String(name || "").trim();
if (!NAME_RE.test(n))
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
return n;
}
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
if (a.kind === "apikey" && typeof a.key === "string") return a;
} catch {
/* absent or not ours */
}
return null;
}
function allAccounts() {
let names = [];
try {
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
return names
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.map(readAccount)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
}
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
function addAccount(name, { apiKey } = {}) {
const n = validName(name);
const key = String(apiKey || "").trim();
if (!KEY_RE.test(key)) {
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
return { name: n, kind: record.kind, addedAt: record.addedAt };
}
function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("cursor", n);
}
function livePanes(account) {
const { db } = require("../db");
return db
.prepare(
`SELECT COUNT(*) AS n FROM dashboard_runs
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
)
.get(account).n;
}
/** Every account with what the balancer sees — never a key. */
function poolStatus() {
return allAccounts().map((a) => ({
name: a.name,
kind: a.kind,
addedAt: a.addedAt,
livePanes: livePanes(a.name),
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
quota: null,
quotaPercent: null,
full: false,
}));
}
/** The record a new Cursor pane should run on, or null (empty pool). */
function pick(ownerId = null) {
// The owner's group's accounts only (pool-groups.js).
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
const usable = allAccounts().filter((a) => allowed(a.name));
if (usable.length === 0) return null;
const pin = require("./account-pins").pinFor(ownerId, "cursor");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned) return pinned;
return usable
.map((a) => ({ a, n: livePanes(a.name) }))
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
}
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
function pickAccount(ownerId = null) {
const a = pick(ownerId);
return a ? { name: a.name, kind: a.kind } : null;
}
/**
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
* goes through tmux `-e` (a member's launch file), never argv.
*/
function forSpawn(ownerId = null) {
const a = pick(ownerId);
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
}
/** Is `name` an account in the pool? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
hasAccount,
addAccount,
removeAccount,
poolStatus,
pickAccount,
forSpawn,
poolDir,
__read: readAccount,
};
+205
View File
@@ -0,0 +1,205 @@
/**
* @file pool-groups.js
* @description Account pool GROUPS (server mode): an admin splits the pool so
* each team's sessions run on that team's own accounts. A group is a name, its
* members (one group per user, `pool_group_users`) and the pool accounts it
* may use, of every tool (`pool_group_accounts`; one account may serve many
* groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask
* `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may
* take: a member of a group gets only that group's accounts of the tool —
* none means the user's own sign-in, as with an empty pool — and a user in no
* group gets the SHARED pool, the accounts no group lists. With no group at
* all every account is shared, exactly as before groups existed. A pin
* (account-pins.js) is an admin's explicit choice and is honoured whatever the
* group. Local mode has no users: everything is shared there.
*
* Accounts are named, not referenced — the pool lives in files — so removing
* an account drops its group rows (`dropAccount`); deleting a user or a group
* cascades.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const err = (code, message) => Object.assign(new Error(message), { code });
const db = () => require("../db").db;
const pins = () => require("./account-pins");
const NAME_MAX = 64;
function validName(name) {
const n = String(name ?? "").trim();
if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) {
throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`);
}
return n;
}
function groupRow(id) {
const g = Number.isInteger(id)
? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id)
: null;
if (!g) throw err("ENOGROUP", `no group ${id}`);
return g;
}
/** Every group with its members and accounts per tool. */
function listGroups() {
const groups = db()
.prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name")
.all();
const users = db()
.prepare(
`SELECT g.group_id AS groupId, u.id AS userId, u.username
FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username`
)
.all();
const accounts = db()
.prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account")
.all();
return groups.map((g) => ({
...g,
users: users
.filter((u) => u.groupId === g.id)
.map(({ userId, username }) => ({ userId, username })),
accounts: Object.fromEntries(
pins().TOOLS.map((t) => [
t,
accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account),
])
),
}));
}
function createGroup(name) {
const n = validName(name);
try {
const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid;
return { id: Number(id), name: n };
} catch (e) {
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
throw e;
}
}
function renameGroup(id, name) {
const g = groupRow(id);
const n = validName(name);
try {
db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id);
} catch (e) {
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
throw e;
}
return { id: g.id, name: n };
}
/** Its members go back to the shared pool; its accounts become shared if no other group lists them. */
function deleteGroup(id) {
const g = groupRow(id);
db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id);
return g;
}
/**
* Exactly `userIds` are the group's members afterwards — a user in another
* group moves here (one group per user). One transaction.
*/
function setGroupUsers(id, userIds) {
const g = groupRow(id);
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
throw err("EBADUSER", "userIds: an array of user ids");
}
const want = [...new Set(userIds)];
for (const uid of want) {
if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid))
throw err("ENOUSER", `no user ${uid}`);
}
db().transaction(() => {
db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id);
const put = db().prepare(
`INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id`
);
for (const uid of want) put.run(uid, g.id);
})();
return g;
}
/** Exactly `names` are the group's accounts of `tool` afterwards. */
function setGroupAccounts(id, tool, names) {
const g = groupRow(id);
if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`);
if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) {
throw err("EBADNAME", "accounts: an array of account names");
}
const want = [...new Set(names)];
const pool = pins().poolOf(tool);
for (const n of want) {
if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`);
}
db().transaction(() => {
db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool);
const put = db().prepare(
"INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)"
);
for (const n of want) put.run(g.id, tool, n);
})();
return g;
}
/**
* Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a
* predicate on the account name: their group's, else the shared ones; their
* pin always. Everything when groups do not apply (local mode, no group yet,
* or no database to ask).
*/
function allowed(ownerId, tool) {
if (!require("./auth").isServerMode()) return () => true;
try {
if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true;
const pin = pins().pinFor(ownerId, tool);
const group = Number.isInteger(ownerId)
? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId)
: null;
const names = new Set(
(group
? db()
.prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?")
.all(group.group_id, tool)
: []
).map((r) => r.account)
);
const grouped = group
? null
: new Set(
db()
.prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?")
.all(tool)
.map((r) => r.account)
);
return (name) => name === pin || (group ? names.has(name) : !grouped.has(name));
} catch {
return () => true; // a spawn never fails over a group lookup
}
}
/** A removed account leaves every group. */
function dropAccount(tool, account) {
try {
db()
.prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?")
.run(tool, account);
} catch {
/* no database (a bare CLI test): nothing grouped */
}
}
module.exports = {
listGroups,
createGroup,
renameGroup,
deleteGroup,
setGroupUsers,
setGroupAccounts,
allowed,
dropAccount,
};
+4 -1
View File
@@ -488,6 +488,8 @@ function spawnRun(args) {
entry.launcher === "codex" && !proxyProfile
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
// Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`.
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null;
const paneCred = require("./auth").newPaneToken();
// A member's omp on the host's shared omp login (omp-share.js): its
// models.yml points at the loopback gateway, with a key already listed there.
@@ -611,7 +613,7 @@ function spawnRun(args) {
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
proxy: proxyProfile ? proxyProfile.name : null,
account: (poolAccount || codexPool)?.name ?? null,
account: (poolAccount || codexPool || cursorPool)?.name ?? null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: initialPrompt || "",
status: "running",
@@ -668,6 +670,7 @@ function spawnRun(args) {
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? poolAccount.env : {}),
...(codexPool ? codexPool.env : {}),
...(cursorPool ? cursorPool.env : {}),
...(ompShare ? ompShare.env : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
+153 -8
View File
@@ -3,10 +3,13 @@
* - GET /api/auth/me (Auth)
* - POST /api/auth/login (Auth)
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
* - GET /api/accounts (Auth) — the Claude, Codex and Cursor account pools and
* the pool groups, no keys or tokens
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
* - POST /api/accounts/groups, PATCH/DELETE /api/accounts/groups/{id} (Auth) —
* pool groups: a team's members run only on the group's accounts
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
@@ -43,6 +46,27 @@ const schemas = {
pinnedAt: { type: "string" },
},
},
PoolGroup: {
type: "object",
required: ["id", "name", "users", "accounts"],
properties: {
id: { type: "integer" },
name: { type: "string" },
createdAt: { type: "string" },
users: {
type: "array",
items: {
type: "object",
properties: { userId: { type: "integer" }, username: { type: "string" } },
},
},
accounts: {
type: "object",
description: "Per tool (claude, codex, cursor), the pool account names it may use",
additionalProperties: { type: "array", items: { type: "string" } },
},
},
},
UserPrefs: {
type: "object",
required: ["prefs", "onboardedAt"],
@@ -636,7 +660,7 @@ const paths = {
tags: ["Auth"],
operationId: "listPoolAccounts",
summary:
"The Claude and Codex account pools as the balancer sees them (never a key or token)",
"The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)",
parameters: [
{
name: "force",
@@ -681,6 +705,27 @@ const paths = {
properties: {
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
cursor: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
},
},
groups: {
type: "array",
description:
"Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group's accounts, a user in no group on the accounts no group lists",
items: { $ref: "#/components/schemas/PoolGroup" },
},
cursor: {
type: "array",
description:
"The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend",
items: {
type: "object",
properties: {
name: { type: "string" },
kind: { type: "string", enum: ["apikey"] },
addedAt: { type: "string" },
livePanes: { type: "integer" },
},
},
},
codex: {
@@ -715,7 +760,7 @@ const paths = {
tags: ["Auth"],
operationId: "addPoolAccount",
summary:
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back",
requestBody: {
required: true,
content: {
@@ -724,7 +769,7 @@ const paths = {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
name: { type: "string", description: "Defaults to the profile's name" },
profile: {
type: "string",
@@ -736,7 +781,8 @@ const paths = {
},
token: {
type: "string",
description: "Claude: a setup-token; Codex: an API key or auth.json text",
description:
"Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key",
},
currentPassword: { type: "string" },
},
@@ -770,7 +816,7 @@ const paths = {
type: "object",
required: ["tool", "userId", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
userId: { type: "integer" },
account: { type: "string", nullable: true },
currentPassword: { type: "string" },
@@ -816,7 +862,7 @@ const paths = {
type: "object",
required: ["tool", "userIds", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
userIds: { type: "array", items: { type: "integer" } },
currentPassword: { type: "string" },
},
@@ -836,6 +882,105 @@ const paths = {
},
},
},
"/api/accounts/groups": {
post: {
tags: ["Auth"],
operationId: "createPoolGroup",
summary: "Admin: a new, empty pool group, with their own password again",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "currentPassword"],
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
},
},
},
},
responses: {
201: { description: "`{group: {id, name}}`" },
400: err("EBADNAME"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
409: err("EEXISTS — a group has that name"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/groups/{id}": {
patch: {
tags: ["Auth"],
operationId: "updatePoolGroup",
summary:
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: {
name: { type: "string" },
userIds: { type: "array", items: { type: "integer" } },
accounts: {
type: "object",
description: "`{tool: [account names]}` for claude, codex, cursor",
additionalProperties: { type: "array", items: { type: "string" } },
},
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description: "`{group}`",
content: {
"application/json": {
schema: {
type: "object",
properties: { group: { $ref: "#/components/schemas/PoolGroup" } },
},
},
},
},
400: err("EBADNAME, EBADUSER, EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
409: err("EEXISTS — a group has that name"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
delete: {
tags: ["Auth"],
operationId: "deletePoolGroup",
summary:
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: { description: "`{ok: true}`" },
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOGROUP"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/{name}": {
delete: {
tags: ["Auth"],
@@ -850,7 +995,7 @@ const paths = {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
currentPassword: { type: "string" },
},
},
+99 -19
View File
@@ -3,8 +3,9 @@
* @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.
* `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
@@ -15,13 +16,17 @@
* 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`).
* 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`.
* 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
@@ -34,11 +39,13 @@
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();
@@ -49,9 +56,12 @@ router.get("/", async (req, res) => {
res.json({
accounts: pool.poolStatus(),
codex: codexPool.poolStatus(),
cursor: cursorPool.poolStatus(),
pins: auth.isServerMode()
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
: { claude: [], codex: [] },
? 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) {
@@ -98,6 +108,7 @@ const STATUS = {
EBADTOKEN: 400,
ENOACCOUNT: 404,
ENOUSER: 404,
ENOGROUP: 404,
EEXISTS: 409,
ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent
@@ -105,7 +116,7 @@ const STATUS = {
};
/** One pool change: server mode, the admin's password confirmed, audited. */
function poolChange(handler) {
function poolChange(handler, { anyTool = false } = {}) {
return async (req, res) => {
try {
if (!auth.isServerMode() || !req.user) {
@@ -114,8 +125,8 @@ function poolChange(handler) {
});
}
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" });
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);
@@ -230,15 +241,17 @@ 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);
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 },
@@ -247,6 +260,71 @@ router.post(
})
);
// ── 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) => {
@@ -302,7 +380,9 @@ router.put(
router.delete(
"/:name",
poolChange((req, body) => {
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
({ 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}`,