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