feat(pool): an admin's switch moves a user's Claude login at once

PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
This commit is contained in:
2026-10-19 10:23:44 +07:00
parent 9c98638ec0
commit 776ad6466f
16 changed files with 208 additions and 89 deletions
+18 -4
View File
@@ -10,7 +10,8 @@
* 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. A pin of the
* owner to another login waits while a live pane holds the first.
* owner to another login waits while a live pane holds the first, until the
* admin's switch writes it into the home (never over the member's own login).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -189,7 +190,7 @@ 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", () => {
it("follows the owner's pin, and an admin's switch moves the home's login now", async () => {
const { db } = require("../db");
const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
@@ -212,9 +213,22 @@ describe("a pool login", () => {
`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, "alpha", "a bare pin waits for the pane");
// The admin's switch writes beta into the home now; the pane is not touched.
assert.equal(await pins.applyPin(miaId, "claude"), "switched");
assert.equal(diskCreds().refreshToken, oauth("b").refreshToken);
assert.equal(pool.__read("alpha").placed.mia, undefined, "alpha no longer holds mia's home");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(await pins.applyPin(miaId, "claude"), "kept");
// Unpinned with the pane still live: the home's login stays, no flip back.
pins.setPin(miaId, "claude", null);
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
// A login the member made themselves is never written over.
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("own") }));
pins.setPin(miaId, "claude", "alpha");
assert.equal(await pins.applyPin(miaId, "claude"), "own-login");
assert.equal(diskCreds().refreshToken, oauth("own").refreshToken);
pins.setPin(miaId, "claude", "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();
@@ -162,6 +162,7 @@ describe("pinning a user to one account", () => {
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(r.body.switched, "next-session", "a setup-token goes in at the next spawn");
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();
+21 -5
View File
@@ -2,12 +2,14 @@
* @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
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
* page's "switch a user's account". 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.
* pane's home cannot take) falls back to the balancer. A Claude login is
* written into the user's home right away (`applyPin`), over the pool login
* their running panes use, so those move to it without a restart.
*
* 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
@@ -79,6 +81,20 @@ function setPin(userId, tool, account) {
return { userId: user.id, username: user.username, account: name };
}
/**
* Make a pin take effect now where it can: a Claude login is written into
* the user's home at once (claude-pool `switchHome`), so their running panes
* move to it without a restart. Anything else goes in at the next spawn.
* Answers what happened (see `switchHome`).
*/
async function applyPin(userId, tool) {
const pin = pinFor(userId, tool);
if (!pin || tool !== "claude") return "next-session";
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
const member = user ? require("./run-as").accountForUser(user.username) : null;
return poolOf(tool).switchHome(member, pin);
}
/** A removed account takes its pins with it. */
function dropAccount(tool, account) {
try {
@@ -88,4 +104,4 @@ function dropAccount(tool, account) {
}
}
module.exports = { TOOLS, pinFor, listPins, setPin, dropAccount };
module.exports = { TOOLS, pinFor, listPins, setPin, applyPin, dropAccount };
+43 -3
View File
@@ -18,8 +18,10 @@
* 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.
* An admin may switch a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it, and a login is written
* into their home at once (`switchHome`) — running panes take it at their
* next credentials read, without a restart.
* 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.
@@ -335,6 +337,40 @@ async function refreshLogins() {
}
}
/**
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
* home NOW, over the pool login it holds — never over a login the member made
* themselves. Every home's newer pair is copied back first (refreshLogins),
* so the login leaving the home keeps its only live refresh token. Running
* panes are not restarted: Claude Code takes the new pair when it next
* re-reads its credentials (before a refresh), new panes at once. Answers
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
* `next-session` (a setup-token, or no member home: it goes in at spawn).
*/
async function switchHome(member, name) {
const first = readAccount(validName(name));
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
if (first.kind !== "login" || !member || member.host) return "next-session";
await refreshLogins();
const a = readAccount(first.name);
if (!a || expired(a)) return "expired";
const disk = homeCreds(member);
if (disk && ownsHome(a, member.name, disk)) return "kept";
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
if (disk && !holder) return "own-login";
if (holder) {
const placed = { ...holder.placed };
delete placed[member.name];
save({ ...holder, placed });
}
save(writeLogin(a, member));
runAs().markClaudeOnboarded(member, {
...(a.account ? { oauthAccount: a.account } : {}),
...(a.userID ? { userID: a.userID } : {}),
});
return "switched";
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
@@ -461,7 +497,10 @@ function pick(member, ownerId = null) {
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
// The login in the home is the one they run on — after an admin's
// switch (switchHome) it is no longer the one those panes started on.
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
live = liveLogin ? placed || liveLogin : null;
}
}
// A pinned token goes in by env and wins outright; a pinned login waits
@@ -550,6 +589,7 @@ module.exports = {
poolStatus,
pickAccount,
forSpawn,
switchHome,
refreshQuotas,
refreshLogins,
__read: readAccount,
+5 -2
View File
@@ -731,7 +731,7 @@ const paths = {
tags: ["Auth"],
operationId: "pinPoolAccount",
summary:
"Admin: pin one user's new sessions of a tool to one pool account (`account: null` unpins), with their own password again",
"Admin: switch one user's sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart",
requestBody: {
required: true,
content: {
@@ -750,7 +750,10 @@ const paths = {
},
},
responses: {
200: { description: "`{pin: {userId, username, account}}` — `account` null when unpinned" },
200: {
description:
"`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null",
},
400: err("EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"),
+14 -4
View File
@@ -17,8 +17,9 @@
* 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`.
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
* login goes into their home at once, so running panes move without a
* restart (`switched`); `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:
@@ -213,10 +214,19 @@ router.post(
router.put(
"/pins",
poolChange((_req, body) => {
poolChange(async (_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
let switched = null;
if (pin.account) {
try {
switched = await pins.applyPin(pin.userId, body.tool);
} catch (e) {
console.warn(`[accounts] switch ${pin.username} → ${pin.account}: ${e.message}`);
switched = "next-session";
}
}
return {
json: { pin },
json: { pin, switched },
target: pin.userId,
action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`