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
+2 -2
View File
File diff suppressed because one or more lines are too long
+18 -7
View File
@@ -4321,7 +4321,7 @@ async function cmdCodexAccounts(sub, name, flags) {
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] — * `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js). * 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 pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional; const [first, second] = positional;
const username = sub === "pin" ? second : first; const username = sub === "pin" ? second : first;
@@ -4333,17 +4333,28 @@ function cmdAccountPin(sub, positional, tool) {
.get(username); .get(username);
if (!user) throw new Error(`no user ${username}`); if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null); 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()) { 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(" 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`. */ /** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) { function pinnedUsers(tool, account) {
try { try {
+15 -11
View File
@@ -8,8 +8,9 @@
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI * `.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 * 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 * 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 * never sends it back. An admin can also switch a user to one account
* (`PUT /api/accounts/pins`): that user's new sessions skip the balancer. * (`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ĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -291,11 +292,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
pool={pool} pool={pool}
initial={pinning} initial={pinning}
onClose={() => setPinning(null)} onClose={() => setPinning(null)}
onDone={(user, account) => { onDone={(user, account, switched) => {
toast.success( const key = !account
account ? "unpinned"
? t("pool.pinDone.pinned", { user, account }) : switched === "switched" || switched === "own-login" || switched === "expired"
: t("pool.pinDone.unpinned", { user }) ? switched
: "pinned";
(key === "own-login" || key === "expired" ? toast.info : toast.success)(
t(`pool.pinDone.${key}`, { user, account })
); );
setPinning(null); setPinning(null);
void load(); 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 * Switch a user to one account of a tool, or back to automatic. Opened on an
* existing pin it starts on that user with "None", so one click unpins. * existing pin it starts on that user with "Automatic", so one click unpins.
*/ */
function PinUserDialog({ function PinUserDialog({
pool, pool,
@@ -319,7 +323,7 @@ function PinUserDialog({
pool: AccountPool; pool: AccountPool;
initial: { tool: PoolTool; userId?: number }; initial: { tool: PoolTool; userId?: number };
onClose: () => void; onClose: () => void;
onDone: (user: string, account: string | null) => void; onDone: (user: string, account: string | null, switched: string | null) => void;
}) { }) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const [tool, setTool] = useState<PoolTool>(initial.tool); const [tool, setTool] = useState<PoolTool>(initial.tool);
@@ -348,7 +352,7 @@ function PinUserDialog({
setError(null); setError(null);
try { try {
const r = await api.accountPool.pin(tool, userId, account || null, current); 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) { } catch (e) {
setError(errorText(t, e)); setError(errorText(t, e));
} finally { } finally {
@@ -5,8 +5,8 @@
* admin adds an account only with their own password typed (the token goes * 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 * 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 * 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 * sees no add or remove control. An admin switches a user to one account and
* unpins them, each with their own password; the pins show on the accounts. * back to automatic, each with their own password; it shows on the accounts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -173,17 +173,20 @@ describe("AccountPoolCard", () => {
...POOL, ...POOL,
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, 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(); const user = userEvent.setup();
render(<AccountPoolCard isAdmin />); render(<AccountPoolCard isAdmin />);
expect(await screen.findByText("Pinned: leo")).toBeInTheDocument(); expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /pin a user/i })); await user.click(screen.getByRole("button", { name: /switch a user's account/i }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument());
await user.selectOptions(screen.getByLabelText("User"), "7"); await user.selectOptions(screen.getByLabelText("User"), "7");
await user.selectOptions(screen.getByLabelText("Account"), "team-1"); await user.selectOptions(screen.getByLabelText("Account"), "team-1");
const confirm = Array.from(dialog.querySelectorAll("button")).find( const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Save pin" (b) => b.textContent === "Switch"
)!; )!;
expect(confirm).toBeDisabled(); expect(confirm).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass"); await user.type(screen.getByLabelText("Your password"), "boss-pass");
@@ -198,14 +201,17 @@ describe("AccountPoolCard", () => {
...POOL, ...POOL,
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] }, 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(); const user = userEvent.setup();
render(<AccountPoolCard isAdmin />); render(<AccountPoolCard isAdmin />);
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"); const dialog = await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Your password"), "boss-pass"); await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click( 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(() => await waitFor(() =>
expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass") expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass")
+12 -9
View File
@@ -198,21 +198,24 @@
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account." "codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
}, },
"refreshFailed": "refresh failed", "refreshFailed": "refresh failed",
"pinned": "Pinned: {{users}}", "pinned": "Used by: {{users}}",
"pinUser": "Pin a user", "pinUser": "Switch a user's account",
"pinTitle": "Pin a user to an account", "pinTitle": "Switch a user's 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.", "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": "Save pin", "pinButton": "Switch",
"user": "User", "user": "User",
"chooseUser": "Choose a user…", "chooseUser": "Choose a user…",
"loadingUsers": "Loading…", "loadingUsers": "Loading…",
"pinAccount": "Account", "pinAccount": "Account",
"noPin": "None — the least-used account", "noPin": "Automatic — the least-used account",
"pinsTitle": "Pinned users", "pinsTitle": "Switched users",
"unpin": "Unpin {{name}}", "unpin": "Back to automatic for {{name}}",
"pinDone": { "pinDone": {
"pinned": "{{user}} now runs on {{account}}", "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": { "toolName": {
"claude": "Claude", "claude": "Claude",
+12 -9
View File
@@ -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." "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", "refreshFailed": "làm mới lỗi",
"pinned": "Đã ghim: {{users}}", "pinned": "Đang dùng: {{users}}",
"pinUser": "Ghim user", "pinUser": "Đổi tài khoản cho user",
"pinTitle": "Ghim user vào một tài khoản", "pinTitle": "Đổi tài khoản cho user",
"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.", "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": "Lưu ghim", "pinButton": "Đổi",
"user": "User", "user": "User",
"chooseUser": "Chọn user…", "chooseUser": "Chọn user…",
"loadingUsers": "Đang tải…", "loadingUsers": "Đang tải…",
"pinAccount": "Tài khoản", "pinAccount": "Tài khoản",
"noPin": "Không ghim — tài khoản ít tải nhất", "noPin": "Tự động — tài khoản ít tải nhất",
"pinsTitle": "User đã ghim", "pinsTitle": "User đã đổi tài khoản",
"unpin": "Bỏ ghim {{name}}", "unpin": "Trả {{name}} về tự động",
"pinDone": { "pinDone": {
"pinned": "{{user}} giờ chạy trên {{account}}", "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": { "toolName": {
"claude": "Claude", "claude": "Claude",
+9 -8
View File
@@ -1061,15 +1061,16 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ ...body, currentPassword }), 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) => pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) =>
request<{ pin: { userId: number; username: string; account: string | null } }>( request<{
"/accounts/pins", pin: { userId: number; username: string; account: string | null };
{ /** What happened now: a Claude login goes into the user's home at once. */
method: "PUT", switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null;
body: JSON.stringify({ tool, userId, account, currentPassword }), }>("/accounts/pins", {
} method: "PUT",
), body: JSON.stringify({ tool, userId, account, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */ /** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) => remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, { request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
+1 -1
View File
@@ -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). `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`): **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`):
+1 -1
View File
@@ -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 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/<command>`), 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 tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), 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 server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | 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 <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (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 <name>\|remove <name>` | 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 <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (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 <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; 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 | | `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; 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 ### Interactive REPL
+21 -14
View File
@@ -355,7 +355,7 @@ claude setup-token
agenthub accounts add team-1 agenthub accounts add team-1
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1 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 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`. pane still needs that member's own `/login`.
- A token more than a year old is skipped. - A token more than a year old is skipped.
- A resumed session picks an account again, like a new one. - 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 - **Switching a user to one account.** An admin can take one user off the
user: **Pin a user** in the Account pool card (or ranking: **Switch a user's account** in the Account pool card (or
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>` to `agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>` for
undo; their own password again, audited). Every new session in a workspace automatic again; their own password again, audited). Every session in a
that user OWNS then runs on that account, even when it is full — other users workspace that user OWNS then runs on that account, even when it is full;
are not affected, and the account is not reserved for them. The pin is a other users are not affected, and the account is not reserved for them.
preference the pool still bounds: an expired account, or a login a host pane - **A Claude login switches at once, without a restart.** The server first
may not take, falls back to the ranking; and since a home holds one copies every home's newer pair back into the pool (so the login leaving
`.credentials.json`, a pinned login waits until that user's panes on another the home keeps its only live refresh token), then writes the new login
pool login have ended (a setup-token or API key pin goes in by env and into the user's `~/.claude/.credentials.json` as them. New sessions start
applies at once). Running sessions keep their account. Removing the account on it; sessions already running keep their process and move to it the
drops its pins; deleting the user drops theirs. Pins apply in server mode only next time Claude Code re-reads its credentials (it does so before it
(`server/lib/account-pins.js`). 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 - 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 5 minutes. If the usage endpoint refuses a setup-token, the state column shows
`no quota (…)` and ranking uses spend alone. `no quota (…)` and ranking uses spend alone.
+18 -4
View File
@@ -10,7 +10,8 @@
* Users-page route moves a saved switch-user profile in — the one this * 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 * 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 * 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> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -189,7 +190,7 @@ describe("a pool login", () => {
assert.equal(pool.__read("team"), null); 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 { db } = require("../db");
const pins = require("../lib/account-pins"); const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id; 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) `INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')` VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
).run(path.join(HOME, "proj"), new Date().toISOString(), lane); ).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
assert.equal(pool.forSpawn(acct, miaId).name, "alpha"); assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "a bare pin waits for the pane");
db.prepare("UPDATE dashboard_runs SET status = 'completed' WHERE id = 'pin-run'").run(); // 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(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"); pool.removeAccount("beta");
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins"); 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 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" }); const r = await pin(boss, { userId: miaId, account: "team-z" });
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" }); 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, miaId).name, "team-z");
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves"); 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(); 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 * @file account-pins.js
* @description Account pins: an admin's choice that one server-mode user's * @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 * 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 * balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
* user and tool (`account_pins`); the user is the workspace's OWNER * 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 * (`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 * 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 * pane's home cannot take) falls back to the balancer. A Claude login is
* replaces another one a live pane in the same home runs on. * 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. * 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 * 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 }; 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. */ /** A removed account takes its pins with it. */
function dropAccount(tool, account) { function dropAccount(tool, account) {
try { 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 * 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 * sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js). * held them (pool-usage.js).
* An admin may pin a user to one account (account-pins.js): that user's * An admin may switch a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it. * 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 * 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 * every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend. * 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 ────────────────────────────────────────── // ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */ /** name → { at, fiveHour?, sevenDay?, error? } */
@@ -461,7 +497,10 @@ function pick(member, ownerId = null) {
} else { } else {
// One .credentials.json per home: while a pane of theirs runs on a // One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one. // 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 // A pinned token goes in by env and wins outright; a pinned login waits
@@ -550,6 +589,7 @@ module.exports = {
poolStatus, poolStatus,
pickAccount, pickAccount,
forSpawn, forSpawn,
switchHome,
refreshQuotas, refreshQuotas,
refreshLogins, refreshLogins,
__read: readAccount, __read: readAccount,
+5 -2
View File
@@ -731,7 +731,7 @@ const paths = {
tags: ["Auth"], tags: ["Auth"],
operationId: "pinPoolAccount", operationId: "pinPoolAccount",
summary: 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: { requestBody: {
required: true, required: true,
content: { content: {
@@ -750,7 +750,10 @@ const paths = {
}, },
}, },
responses: { 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"), 400: err("EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"), 403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"), 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 * 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`). * one is an OpenAI API key or the text of an `auth.json` (`token`).
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one * `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 /` * user's sessions of a tool to one pool account (account-pins.js) — a Claude
* lists the pins under `pins`. * 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 * `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 * 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: * (the account NAME only), and refuse in local mode. The secret only goes IN:
@@ -213,10 +214,19 @@ router.post(
router.put( router.put(
"/pins", "/pins",
poolChange((_req, body) => { poolChange(async (_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null); 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 { return {
json: { pin }, json: { pin, switched },
target: pin.userId, target: pin.userId,
action: pin.account action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}` ? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`