feat(pool): pin a user to one Claude/Codex pool account

An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
This commit is contained in:
2026-10-19 10:05:19 +07:00
parent aa8ac6cad9
commit 9c98638ec0
20 changed files with 776 additions and 39 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* @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`). 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
* pool still bounds: an account a pane may not use (expired, or a login the
* pane's home cannot take) falls back to the balancer, and a login never
* replaces another one a live pane in the same home runs on.
*
* Server mode only: in local mode there are no users and nothing is pinned.
* Removing the account drops its pins (`dropAccount`); deleting the user drops
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const TOOLS = ["claude", "codex"];
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");
function validTool(tool) {
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
return tool;
}
/** The account name `ownerId`'s new `tool` sessions are pinned to, or null. */
function pinFor(ownerId, tool) {
if (!require("./auth").isServerMode() || !Number.isInteger(ownerId)) return null;
try {
return (
db()
.prepare("SELECT account FROM account_pins WHERE user_id = ? AND tool = ?")
.get(ownerId, tool)?.account ?? null
);
} catch {
return null; // a spawn never fails over a pin
}
}
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
function listPins(tool) {
return db()
.prepare(
`SELECT p.user_id AS userId, u.username, p.account, p.pinned_at AS pinnedAt
FROM account_pins p JOIN users u ON u.id = p.user_id
WHERE p.tool = ? ORDER BY u.username`
)
.all(validTool(tool));
}
/**
* Pin `userId`'s new `tool` sessions to `account`, or unpin them (`account`
* null). Returns `{userId, username, account}`.
*/
function setPin(userId, tool, account) {
validTool(tool);
const user = Number.isInteger(userId)
? db().prepare("SELECT id, username FROM users WHERE id = ?").get(userId)
: null;
if (!user) throw err("ENOUSER", `no user ${userId}`);
if (account == null || account === "") {
db().prepare("DELETE FROM account_pins WHERE user_id = ? AND tool = ?").run(user.id, tool);
return { userId: user.id, username: user.username, account: null };
}
const name = String(account);
if (!poolOf(tool).hasAccount(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
}
db()
.prepare(
`INSERT INTO account_pins (user_id, tool, account) VALUES (?, ?, ?)
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
pinned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
)
.run(user.id, tool, name);
return { userId: user.id, username: user.username, account: name };
}
/** A removed account takes its pins 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 */
}
}
module.exports = { TOOLS, pinFor, listPins, setPin, dropAccount };
+1
View File
@@ -367,6 +367,7 @@ function deleteUser(username) {
} catch {
/* no personal profiles */
}
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length };
}
+25 -8
View File
@@ -18,6 +18,8 @@
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* An admin may pin a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
@@ -193,6 +195,7 @@ function removeAccount(name) {
}
}
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("claude", n);
quotaCache.delete(n);
}
@@ -434,10 +437,15 @@ function liveInHome(name, member) {
});
}
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
function pick(member) {
/**
* The full record a new Claude pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
let live = null;
if (!member) {
// A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk:
@@ -453,10 +461,15 @@ function pick(member) {
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
if (live) return live;
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
}
}
// 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");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live;
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
@@ -489,8 +502,8 @@ function pick(member) {
* background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is.
*/
function pickAccount(member = null) {
const a = pick(member);
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
return a.kind === "login"
? { name: a.name, kind: "login" }
@@ -502,8 +515,8 @@ function pickAccount(member = null) {
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
* before the pane starts and writes a login into the member's home.
*/
function forSpawn(member = null) {
const a = pick(member);
function forSpawn(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
@@ -524,8 +537,12 @@ function forSpawn(member = null) {
};
}
/** Is `name` an account in the pool (any state)? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
BLOCK_PERCENT,
hasAccount,
REFRESH_AHEAD_MS,
addAccount,
addLoginAccount,
+24 -8
View File
@@ -29,6 +29,7 @@
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
* rollouts of the sessions that account's runs started (no network), each
* from the CODEX_HOME of the account its workspace runs as.
* A user an admin pinned to one account (account-pins.js) skips the ranking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -139,6 +140,7 @@ 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("codex", n);
quotaCache = null;
}
@@ -332,10 +334,15 @@ function syncBack() {
// ── Picking ────────────────────────────────────────────────────────────────
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
function pick(member) {
/**
* The full record a new Codex pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts();
if (usable.length === 0) return null;
let live = null;
if (!member) {
// A host pane's CODEX_HOME holds the host's own login.
usable = usable.filter((a) => a.kind === "apikey");
@@ -348,10 +355,15 @@ function pick(member) {
} else {
// One auth.json per home: while a pane of theirs runs on a pool
// login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
if (live) return live;
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
}
}
// 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");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
if (live) return live;
if (usable.length === 0) return null;
const ranked = usable
.map((a) => {
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
}
/** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null) {
const a = pick(member);
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
return a ? { name: a.name, kind: a.kind } : null;
}
@@ -405,8 +417,8 @@ function pickAccount(member = null) {
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
* (a member's launch file); `place()` runs just before the pane starts.
*/
function forSpawn(member = null) {
const a = pick(member);
function forSpawn(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
if (a.kind === "apikey") {
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
@@ -439,8 +451,12 @@ function forSpawn(member = null) {
};
}
/** Is `name` an account in the pool? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
BLOCK_PERCENT,
hasAccount,
addAccount,
removeAccount,
poolStatus,
+4 -3
View File
@@ -461,19 +461,20 @@ function spawnRun(args) {
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// account, if the admin added any (claude-pool.js) — or the one the
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
// A real login (kind `login`) is written into a MEMBER's home by place();
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
const poolAccount =
entry.launcher === "claude" && !proxyProfile
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
// Codex likewise, from its own pool (codex-pool.js): an API key through
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
const codexPool =
entry.launcher === "codex" && !proxyProfile
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
const paneCred = require("./auth").newPaneToken();
if (forkSession === true) {