Files
AgentHub/server/lib/cursor-pool.js
T
nntrivi2001 0f17efe3b4 feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
2026-10-28 10:41:53 +07:00

157 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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").preferredFor(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,
};