From 776ad6466f68a37a54ca77a5fcda0fa58fe39747 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 19 Oct 2026 10:23:44 +0700 Subject: [PATCH] feat(pool): an admin's switch moves a user's Claude login at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 4 +- bin/agenthub.js | 25 +++++++--- .../src/components/users/AccountPoolCard.tsx | 26 ++++++----- .../users/__tests__/AccountPoolCard.test.tsx | 24 ++++++---- client/src/i18n/locales/en/users.json | 21 +++++---- client/src/i18n/locales/vi/users.json | 21 +++++---- client/src/lib/api.ts | 17 +++---- docs/API.md | 2 +- docs/CLI.md | 2 +- docs/SERVER-MODE.md | 35 ++++++++------ server/__tests__/claude-pool-login.test.js | 22 +++++++-- .../server-mode-account-pool.test.js | 1 + server/lib/account-pins.js | 26 +++++++++-- server/lib/claude-pool.js | 46 +++++++++++++++++-- server/openapi-extra/auth.js | 7 ++- server/routes/accounts.js | 18 ++++++-- 16 files changed, 208 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 41831797..e8cba2a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.9 (2026-10-16) — workspace instructions reach Claude as a CLAUDE.md in an --add-dir folder (not --append-system-prompt-file), so /tui works._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.10 (2026-10-19) — an admin can switch a user to one pool account (account_pins); a Claude login goes into their home at once._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -81,7 +81,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. -- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. +- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. diff --git a/bin/agenthub.js b/bin/agenthub.js index e7e1d06b..442cf83b 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -4321,7 +4321,7 @@ async function cmdCodexAccounts(sub, name, flags) { * `agenthub accounts pin ` / `unpin ` [--tool codex] — * that user's new sessions run on that account (server/lib/account-pins.js). */ -function cmdAccountPin(sub, positional, tool) { +async function cmdAccountPin(sub, positional, tool) { const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js")); const [first, second] = positional; const username = sub === "pin" ? second : first; @@ -4333,17 +4333,28 @@ function cmdAccountPin(sub, positional, tool) { .get(username); if (!user) throw new Error(`no user ${username}`); const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null); - console.log( - pin.account - ? `${c.green("✔")} ${pin.username}'s new ${tool} sessions run on ${pin.account}` - : `${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer` - ); if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) { console.log(c.dim(" Pins apply in server mode only.")); } - console.log(c.dim(" Running sessions keep their account until they end.")); + if (!pin.account) { + console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`); + return; + } + const switched = await pins.applyPin(user.id, tool); + console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`); + console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`)); } +/** What `applyPin` did, for the CLI. */ +const SWITCH_NOTE = { + switched: "Switched now: running sessions take it at their next login check, new ones at once.", + kept: "Their home already holds it.", + "own-login": + "They signed in with their own login, which is left alone: no pool login is written over it.", + expired: "That login has expired: new sessions fall back to the balancer until it is re-added.", + "next-session": "New sessions use it; running ones keep their account until they end.", +}; + /** The `pinned` column: who an admin pinned to `account`. */ function pinnedUsers(tool, account) { try { diff --git a/client/src/components/users/AccountPoolCard.tsx b/client/src/components/users/AccountPoolCard.tsx index 81954067..6a23307b 100644 --- a/client/src/components/users/AccountPoolCard.tsx +++ b/client/src/components/users/AccountPoolCard.tsx @@ -8,8 +8,9 @@ * `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI * API key or a pasted `auth.json` — or removes one, each time with their own * password again; an analyst only reads. The secret only goes IN: the server - * never sends it back. An admin can also pin a user to one account - * (`PUT /api/accounts/pins`): that user's new sessions skip the balancer. + * never sends it back. An admin can also switch a user to one account + * (`PUT /api/accounts/pins`): that user's sessions skip the balancer, and a + * Claude login goes into their home at once — no restart. * @author Nguyễn Ngọc Trí Vĩ */ @@ -291,11 +292,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { pool={pool} initial={pinning} onClose={() => setPinning(null)} - onDone={(user, account) => { - toast.success( - account - ? t("pool.pinDone.pinned", { user, account }) - : t("pool.pinDone.unpinned", { user }) + onDone={(user, account, switched) => { + const key = !account + ? "unpinned" + : switched === "switched" || switched === "own-login" || switched === "expired" + ? switched + : "pinned"; + (key === "own-login" || key === "expired" ? toast.info : toast.success)( + t(`pool.pinDone.${key}`, { user, account }) ); setPinning(null); void load(); @@ -307,8 +311,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) { } /** - * Pin a user to one account of a tool, or unpin them ("None"). Opened on an - * existing pin it starts on that user with "None", so one click unpins. + * Switch a user to one account of a tool, or back to automatic. Opened on an + * existing pin it starts on that user with "Automatic", so one click unpins. */ function PinUserDialog({ pool, @@ -319,7 +323,7 @@ function PinUserDialog({ pool: AccountPool; initial: { tool: PoolTool; userId?: number }; onClose: () => void; - onDone: (user: string, account: string | null) => void; + onDone: (user: string, account: string | null, switched: string | null) => void; }) { const { t } = useTranslation("users"); const [tool, setTool] = useState(initial.tool); @@ -348,7 +352,7 @@ function PinUserDialog({ setError(null); try { const r = await api.accountPool.pin(tool, userId, account || null, current); - onDone(r.pin.username, r.pin.account); + onDone(r.pin.username, r.pin.account, r.switched ?? null); } catch (e) { setError(errorText(t, e)); } finally { diff --git a/client/src/components/users/__tests__/AccountPoolCard.test.tsx b/client/src/components/users/__tests__/AccountPoolCard.test.tsx index 83251f29..f8b6e8df 100644 --- a/client/src/components/users/__tests__/AccountPoolCard.test.tsx +++ b/client/src/components/users/__tests__/AccountPoolCard.test.tsx @@ -5,8 +5,8 @@ * admin adds an account only with their own password typed (the token goes * in once and is not shown back) — a saved login moved in (the server's own * included), or a setup-token — and removes one the same way, and an analyst - * sees no add or remove control. An admin pins a user to one account and - * unpins them, each with their own password; the pins show on the accounts. + * sees no add or remove control. An admin switches a user to one account and + * back to automatic, each with their own password; it shows on the accounts. * @author Nguyễn Ngọc Trí Vĩ */ @@ -173,17 +173,20 @@ describe("AccountPoolCard", () => { ...POOL, pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, }); - accountPool.pin.mockResolvedValue({ pin: { userId: 7, username: "mia", account: "team-1" } }); + accountPool.pin.mockResolvedValue({ + pin: { userId: 7, username: "mia", account: "team-1" }, + switched: "switched", + }); const user = userEvent.setup(); render(); - expect(await screen.findByText("Pinned: leo")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /pin a user/i })); + expect(await screen.findByText("Used by: leo")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /switch a user's account/i })); const dialog = await screen.findByRole("dialog"); await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument()); await user.selectOptions(screen.getByLabelText("User"), "7"); await user.selectOptions(screen.getByLabelText("Account"), "team-1"); const confirm = Array.from(dialog.querySelectorAll("button")).find( - (b) => b.textContent === "Save pin" + (b) => b.textContent === "Switch" )!; expect(confirm).toBeDisabled(); await user.type(screen.getByLabelText("Your password"), "boss-pass"); @@ -198,14 +201,17 @@ describe("AccountPoolCard", () => { ...POOL, pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, }); - accountPool.pin.mockResolvedValue({ pin: { userId: 8, username: "leo", account: null } }); + accountPool.pin.mockResolvedValue({ + pin: { userId: 8, username: "leo", account: null }, + switched: null, + }); const user = userEvent.setup(); render(); - await user.click(await screen.findByRole("button", { name: "Unpin leo" })); + await user.click(await screen.findByRole("button", { name: "Back to automatic for leo" })); const dialog = await screen.findByRole("dialog"); await user.type(screen.getByLabelText("Your password"), "boss-pass"); await user.click( - Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Save pin")! + Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Switch")! ); await waitFor(() => expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass") diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 9f5f2216..34dec552 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -198,21 +198,24 @@ "codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account." }, "refreshFailed": "refresh failed", - "pinned": "Pinned: {{users}}", - "pinUser": "Pin a user", - "pinTitle": "Pin a user to an account", - "pinHint": "That user's new sessions always run on the chosen account instead of the least-used one — even when it is full. Sessions already running keep their account; a login waits until the user's sessions on another login have ended.", - "pinButton": "Save pin", + "pinned": "Used by: {{users}}", + "pinUser": "Switch a user's account", + "pinTitle": "Switch a user's account", + "pinHint": "That user's sessions run on the chosen account instead of the least-used one — even when it is full. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions.", + "pinButton": "Switch", "user": "User", "chooseUser": "Choose a user…", "loadingUsers": "Loading…", "pinAccount": "Account", - "noPin": "None — the least-used account", - "pinsTitle": "Pinned users", - "unpin": "Unpin {{name}}", + "noPin": "Automatic — the least-used account", + "pinsTitle": "Switched users", + "unpin": "Back to automatic for {{name}}", "pinDone": { "pinned": "{{user}} now runs on {{account}}", - "unpinned": "{{user}} is back on the least-used account" + "unpinned": "{{user}} is back on the least-used account", + "switched": "{{user}} switched to {{account}} — running sessions follow at their next login check", + "own-login": "{{user}} signed in with their own login, which was left alone", + "expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account" }, "toolName": { "claude": "Claude", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 211329b1..d6c55031 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -198,21 +198,24 @@ "codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT." }, "refreshFailed": "làm mới lỗi", - "pinned": "Đã ghim: {{users}}", - "pinUser": "Ghim user", - "pinTitle": "Ghim user vào một tài khoản", - "pinHint": "Phiên mới của user này luôn chạy trên tài khoản đã chọn thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. Phiên đang chạy giữ nguyên tài khoản; một login sẽ chờ tới khi các phiên của user trên login khác kết thúc.", - "pinButton": "Lưu ghim", + "pinned": "Đang dùng: {{users}}", + "pinUser": "Đổi tài khoản cho user", + "pinTitle": "Đổi tài khoản cho user", + "pinHint": "Phiên của user này chạy trên tài khoản đã chọn thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới.", + "pinButton": "Đổi", "user": "User", "chooseUser": "Chọn user…", "loadingUsers": "Đang tải…", "pinAccount": "Tài khoản", - "noPin": "Không ghim — tài khoản ít tải nhất", - "pinsTitle": "User đã ghim", - "unpin": "Bỏ ghim {{name}}", + "noPin": "Tự động — tài khoản ít tải nhất", + "pinsTitle": "User đã đổi tài khoản", + "unpin": "Trả {{name}} về tự động", "pinDone": { "pinned": "{{user}} giờ chạy trên {{account}}", - "unpinned": "{{user}} quay lại dùng tài khoản ít tải nhất" + "unpinned": "{{user}} quay lại dùng tài khoản ít tải nhất", + "switched": "Đã đổi {{user}} sang {{account}} — phiên đang chạy sẽ theo ở lần kiểm tra đăng nhập kế tiếp", + "own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè", + "expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất" }, "toolName": { "claude": "Claude", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b1fce90b..411d3e78 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1061,15 +1061,16 @@ export const api = { method: "POST", body: JSON.stringify({ ...body, currentPassword }), }), - /** PUT /api/accounts/pins — admin: pin a user to one account (`null` unpins), password again. */ + /** PUT /api/accounts/pins — admin: switch a user to one account (`null` = automatic), password again. */ pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) => - request<{ pin: { userId: number; username: string; account: string | null } }>( - "/accounts/pins", - { - method: "PUT", - body: JSON.stringify({ tool, userId, account, currentPassword }), - } - ), + request<{ + pin: { userId: number; username: string; account: string | null }; + /** What happened now: a Claude login goes into the user's home at once. */ + switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null; + }>("/accounts/pins", { + method: "PUT", + body: JSON.stringify({ tool, userId, account, currentPassword }), + }), /** DELETE /api/accounts/:name — admin, with their OWN password again. */ remove: (tool: PoolTool, name: string, currentPassword: string) => request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, { diff --git a/docs/API.md b/docs/API.md index fb25d1dd..4edf4a1b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -91,7 +91,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}` `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). -An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` pins that user's new sessions of `tool` to one pool account (`account: null` unpins; `404 ENOUSER`/`ENOACCOUNT`), with the same password step-up and audit (the user as `owner_id`). Removing an account drops its pins; deleting a user drops theirs. +An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). Removing an account drops its pins; deleting a user drops theirs. **The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`): diff --git a/docs/CLI.md b/docs/CLI.md index f55e495f..64d60be5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it | `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) | | `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared | | `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count | -| `agenthub accounts list\|add \|remove ` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add --tool codex --api-key` (key typed hidden or on stdin) or `--from ` (a `codex login` home or its `auth.json`). `pin ` / `unpin ` (add `--tool codex` for Codex) makes one user's new sessions always run on that account (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) | +| `agenthub accounts list\|add \|remove ` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add --tool codex --api-key` (key typed hidden or on stdin) or `--from ` (a `codex login` home or its `auth.json`). `pin ` / `unpin ` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) | | `agenthub users list\|add [--role admin\|analyst\|member]\|passwd \|role \|disable \|enable \|provision \|--all\|assign \|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin | ### Interactive REPL diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 9cab9a59..121a5ce6 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -355,7 +355,7 @@ claude setup-token agenthub accounts add team-1 agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state agenthub accounts remove team-1 -agenthub accounts pin team-1 mia # mia's new sessions always run on team-1 (unpin mia to undo) +agenthub accounts pin team-1 mia # switch mia to team-1 (unpin mia = automatic again) ``` In server mode an admin can do the same on the **Users** page, in the @@ -424,19 +424,26 @@ How a new session picks its account (`server/lib/claude-pool.js`): pane still needs that member's own `/login`. - A token more than a year old is skipped. - A resumed session picks an account again, like a new one. -- **Pinning a user to one account.** An admin can skip the ranking for one - user: **Pin a user** in the Account pool card (or - `agenthub accounts pin [--tool codex]`, `unpin ` to - undo; their own password again, audited). Every new session in a workspace - that user OWNS then runs on that account, even when it is full — other users - are not affected, and the account is not reserved for them. The pin is a - preference the pool still bounds: an expired account, or a login a host pane - may not take, falls back to the ranking; and since a home holds one - `.credentials.json`, a pinned login waits until that user's panes on another - pool login have ended (a setup-token or API key pin goes in by env and - applies at once). Running sessions keep their account. Removing the account - drops its pins; deleting the user drops theirs. Pins apply in server mode only - (`server/lib/account-pins.js`). +- **Switching a user to one account.** An admin can take one user off the + ranking: **Switch a user's account** in the Account pool card (or + `agenthub accounts pin [--tool codex]`, `unpin ` for + automatic again; their own password again, audited). Every session in a + workspace that user OWNS then runs on that account, even when it is full; + other users are not affected, and the account is not reserved for them. + - **A Claude login switches at once, without a restart.** The server first + copies every home's newer pair back into the pool (so the login leaving + the home keeps its only live refresh token), then writes the new login + into the user's `~/.claude/.credentials.json` as them. New sessions start + on it; sessions already running keep their process and move to it the + next time Claude Code re-reads its credentials (it does so before it + refreshes its access token, so at the latest when that token expires). + A login the user made themselves (`/login`) is never written over — the + card says so. Their sessions are booked to the account they started on. + - A setup-token, a Codex account, or a host pane gets the account at its + next spawn; running ones keep theirs. + - An expired account falls back to the ranking. Removing the account drops + the switch; deleting the user drops theirs. Server mode only + (`server/lib/account-pins.js`). - Quota is read with each account's own token, in the background, at most every 5 minutes. If the usage endpoint refuses a setup-token, the state column shows `no quota (…)` and ranking uses spend alone. diff --git a/server/__tests__/claude-pool-login.test.js b/server/__tests__/claude-pool-login.test.js index 801883fc..1f8f21bd 100644 --- a/server/__tests__/claude-pool-login.test.js +++ b/server/__tests__/claude-pool-login.test.js @@ -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ĩ */ @@ -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(); diff --git a/server/__tests__/server-mode-account-pool.test.js b/server/__tests__/server-mode-account-pool.test.js index 30978f42..13a8ecd8 100644 --- a/server/__tests__/server-mode-account-pool.test.js +++ b/server/__tests__/server-mode-account-pool.test.js @@ -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(); diff --git a/server/lib/account-pins.js b/server/lib/account-pins.js index 57b0aaa7..08451e7b 100644 --- a/server/lib/account-pins.js +++ b/server/lib/account-pins.js @@ -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 }; diff --git a/server/lib/claude-pool.js b/server/lib/claude-pool.js index 2d859d02..c4da7784 100644 --- a/server/lib/claude-pool.js +++ b/server/lib/claude-pool.js @@ -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, diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js index 512b59d2..f13ade48 100644 --- a/server/openapi-extra/auth.js +++ b/server/openapi-extra/auth.js @@ -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"), diff --git a/server/routes/accounts.js b/server/routes/accounts.js index 0e5de62d..01fca68f 100644 --- a/server/routes/accounts.js +++ b/server/routes/accounts.js @@ -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}`