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();