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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user