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
+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,