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
+34 -1
View File
@@ -9,7 +9,8 @@
* same account; removing the account takes it out of those homes; and the
* Users-page route moves a saved switch-user profile in — the one this
* machine's own Claude Code is logged into with its LIVE login, the host's
* home then being one more placement the server refreshes.
* home then being one more placement the server refreshes. A pin of the
* owner to another login waits while a live pane holds the first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -188,6 +189,38 @@ describe("a pool login", () => {
assert.equal(pool.__read("team"), null);
});
it("follows the owner's pin, but never over another login a live pane holds", () => {
const { db } = require("../db");
const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pool.addLoginAccount("alpha", { oauth: oauth("a"), account: ACCOUNT });
pool.addLoginAccount("beta", {
oauth: oauth("b"),
account: { accountUuid: "uuid-beta", emailAddress: "beta@example.com" },
});
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "unpinned: the balancer's pick");
pins.setPin(miaId, "claude", "beta");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(pool.forSpawn(acct, null).name, "alpha", "another owner is not pinned");
// A pane of mia's runs on alpha: beta waits until it ends.
pool.forSpawn(acct).place();
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
const lane = db
.prepare("INSERT INTO lanes (cwd, owner_id) VALUES (?, ?)")
.run(path.join(HOME, "proj"), miaId).lastInsertRowid;
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
assert.equal(pool.forSpawn(acct, miaId).name, "alpha");
db.prepare("UPDATE dashboard_runs SET status = 'completed' WHERE id = 'pin-run'").run();
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
pool.removeAccount("beta");
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins");
db.prepare("DELETE FROM dashboard_runs WHERE id = 'pin-run'").run();
db.prepare("DELETE FROM lanes WHERE id = ?").run(lane);
});
it("refuses a second copy of one Claude account", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
@@ -4,7 +4,8 @@
* 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
* in local mode the routes refuse.
* in local mode the routes refuse. An admin pins a user to one account
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -144,3 +145,75 @@ describe("the account pool from the Users page", () => {
assert.ok(claudePool.pickAccount(), "team-1 is still there");
});
});
describe("pinning a user to one account", () => {
const pins = require("../lib/account-pins");
const pin = (cookie, body) =>
call("PUT", "/api/accounts/pins", {
cookie,
body: { tool: "claude", currentPassword: BOSS_PASS, ...body },
});
it("lets an admin pin and unpin, audited, and new panes follow the pin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
claudePool.addAccount("team-z", `sk-ant-oat01-${"z".repeat(40)}`);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1", "the balancer's pick");
const r = await pin(boss, { userId: miaId, account: "team-z" });
assert.equal(r.status, 200);
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" });
assert.equal(claudePool.pickAccount(null, miaId).name, "team-z");
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves");
const audit = db.prepare("SELECT owner_id, path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.equal(audit.owner_id, miaId);
assert.match(audit.path, /pin mia to claude account team-z/);
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.pins.claude.map((p) => [p.username, p.account]),
[["mia", "team-z"]]
);
assert.deepEqual(list.body.pins.codex, []);
const off = await pin(boss, { userId: miaId, account: null });
assert.equal(off.body.pin.account, null);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1");
claudePool.removeAccount("team-z");
});
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
assert.equal(
(await pin(boss, { userId: miaId, account: "nope" })).body.error.code,
"ENOACCOUNT"
);
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
assert.equal(
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
"EREAUTH"
);
const ana = await login("ana", "analyst-pass-1");
const w = await call("PUT", "/api/accounts/pins", {
cookie: ana,
body: { tool: "claude", userId: miaId, account: "team-1", currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
assert.equal(pins.pinFor(miaId, "claude"), null);
});
it("is ignored in local mode and dropped with its user", async () => {
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pins.setPin(miaId, "claude", "team-1");
delete process.env.AGENTHUB_MODE;
try {
assert.equal(pins.pinFor(miaId, "claude"), null);
} finally {
process.env.AGENTHUB_MODE = "server";
}
assert.equal(pins.pinFor(miaId, "claude"), "team-1");
db.prepare("DELETE FROM users WHERE id = ?").run(miaId);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM account_pins WHERE user_id = ?").get(miaId).n,
0
);
});
});