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