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
+23 -1
View File
@@ -16,6 +16,9 @@
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
* one is an OpenAI API key or the text of an `auth.json` (`token`).
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
* user's new sessions of a tool to one pool account (account-pins.js); `GET /`
* lists the pins under `pins`.
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
* they need the admin's own password again (`currentPassword`), are audited
* (the account NAME only), and refuse in local mode. The secret only goes IN:
@@ -29,6 +32,7 @@ const codexPool = require("../lib/codex-pool");
const auth = require("../lib/auth");
const { recordAccountChange } = require("../lib/audit");
const profiles = require("../lib/cc-profiles");
const pins = require("../lib/account-pins");
const router = Router();
@@ -39,6 +43,9 @@ router.get("/", async (req, res) => {
res.json({
accounts: pool.poolStatus(),
codex: codexPool.poolStatus(),
pins: auth.isServerMode()
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
: { claude: [], codex: [] },
blockPercent: pool.BLOCK_PERCENT,
});
} catch (e) {
@@ -72,6 +79,7 @@ const STATUS = {
EBADNAME: 400,
EBADTOKEN: 400,
ENOACCOUNT: 404,
ENOUSER: 404,
EEXISTS: 409,
ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent
@@ -93,7 +101,7 @@ function poolChange(handler) {
}
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
recordAccountChange(req.user.id, null, req.method, out.action);
recordAccountChange(req.user.id, out.target ?? null, req.method, out.action);
res.status(out.status || 200).json(out.json);
} catch (e) {
const status = STATUS[e.code];
@@ -203,6 +211,20 @@ router.post(
})
);
router.put(
"/pins",
poolChange((_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
return {
json: { pin },
target: pin.userId,
action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
: `pool: unpin ${pin.username} from ${body.tool}`,
};
})
);
router.delete(
"/:name",
poolChange((req, body) => {