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