From c83ddf62a0ef455cbe91c5891d29c27b7744ed05 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 15 Oct 2026 14:44:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(server-s3):=20a=20member's=20tmux=20follow?= =?UTF-8?q?s=20tmux=20upgrades=20=E2=80=94=20tmux-member=20+=20Users=20?= =?UTF-8?q?=E2=86=92=20Members'=20tmux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member's tmux server keeps the version it started with until their last pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a 3.8 client on a 3.6 server spun it at 100% CPU (three members needed a manual kill on 2026-10-15). - /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a member's tmux call with the binary of THEIR running server (/proc//exe, found from the socket in /proc/net/unix), else TMUX_BIN. It runs as the member and execs only a process they own. sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN until then. - Users → Members' tmux (GET /api/users/tmux): each member server's version beside the installed one, sessions, agents working. An admin restarts one (POST /api/users/:id/tmux-restart) or every idle outdated one (POST /api/users/tmux-restart-idle), with their password again, audited per member; a server that will not stop answers 409 ETMUXSTUCK naming its pid for root. - server-s3-system.test.js: `delete` is a provision action since Delete user, so the "unknown action" case now uses `purge`. Operators: run `sudo agenthub server install-system` once. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../src/components/users/MemberTmuxCard.tsx | 267 ++++++++++++++++++ .../users/__tests__/MemberTmuxCard.test.tsx | 100 +++++++ client/src/i18n/locales/en/users.json | 30 ++ client/src/i18n/locales/vi/users.json | 30 ++ client/src/lib/api.ts | 43 +++ client/src/pages/Users.tsx | 6 +- client/src/pages/__tests__/Users.test.tsx | 2 + docs/API.md | 2 +- docs/SERVER-MODE.md | 30 +- scripts/server/install-system.sh | 4 +- scripts/server/tmux-member.sh | 34 +++ server/__tests__/member-tmux.test.js | 186 ++++++++++++ server/__tests__/server-s3-panes.test.js | 13 +- server/__tests__/server-s3-system.test.js | 4 +- server/lib/linux-accounts.js | 22 +- server/lib/member-tmux.js | 103 +++++++ server/lib/tmux.js | 49 ++++ server/openapi-extra/auth.js | 73 +++++ server/routes/users.js | 81 ++++++ 20 files changed, 1066 insertions(+), 15 deletions(-) create mode 100644 client/src/components/users/MemberTmuxCard.tsx create mode 100644 client/src/components/users/__tests__/MemberTmuxCard.test.tsx create mode 100755 scripts/server/tmux-member.sh create mode 100644 server/__tests__/member-tmux.test.js create mode 100644 server/lib/member-tmux.js diff --git a/CLAUDE.md b/CLAUDE.md index 713ec103..41299169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **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`); 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. +- **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. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/client/src/components/users/MemberTmuxCard.tsx b/client/src/components/users/MemberTmuxCard.tsx new file mode 100644 index 00000000..ffc7482e --- /dev/null +++ b/client/src/components/users/MemberTmuxCard.tsx @@ -0,0 +1,267 @@ +/** + * @file MemberTmuxCard.tsx + * @description The Users page's tmux card (one Linux user per account only, + * `GET /api/users/tmux`): each member's own tmux server — the version it runs + * beside the installed one, its sessions, whether an agent of theirs is + * working — so an admin sees who still runs an old tmux after an upgrade. + * An admin restarts one member's server, or every idle outdated one at once + * (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each + * time with their own password again; a restart ends that member's panes, + * and the next start runs the installed tmux. An analyst only reads. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react"; +import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api"; +import { formatDateTime } from "../../lib/format"; +import { toast } from "../../lib/toast"; +import { isSubmitEnter } from "../../lib/keys"; +import { useSpin } from "../../hooks/useSpin"; +import { ConfirmModal } from "../ConfirmModal"; + +function errorText(t: TFunction<"users">, e: unknown): string { + const err = e as Error & { code?: string }; + return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" }) + ? t(`tmux.errors.${err.code}`) + : err.message; +} + +const BADGE = "badge ml-1.5 py-0 text-[10px]"; +const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`; +const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`; + +export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) { + const { t } = useTranslation("users"); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + // A member row = restart that one; "idle" = every idle outdated member. + const [restarting, setRestarting] = useState(null); + + const load = useCallback( + () => + api.users + .tmux() + .then((r) => { + setData(r); + setError(null); + }) + .catch((e: Error) => setError(e.message)), + [] + ); + const [spinning, refresh] = useSpin(load); + useEffect(() => { + void load(); + }, [load]); + + if (!data?.available) return null; + const rows = data.users.filter((u) => u.running); + const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0); + const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; + + return ( +
+
+ +
+

{t("tmux.title")}

+

+ {t("tmux.description", { version: data.installed ?? "?" })} +

+
+ + {isAdmin && idleOutdated.length > 0 && ( + + )} +
+ {!data.matched && ( +

+ {t("tmux.notMatched")} +

+ )} + {error && ( +

+ {error} +

+ )} + {rows.length === 0 ? ( +

{t("tmux.empty")}

+ ) : ( +
+ + + + + + + + {isAdmin && } + + + + {rows.map((u) => ( + + + + + + {isAdmin && ( + + )} + + ))} + +
{t("tmux.user")}{t("tmux.version")}{t("tmux.sessions")}{t("tmux.agents")}{t("actions")}
{u.username} + {u.stuck ? "?" : (u.version ?? "?")} + {u.stuck && {t("tmux.stuck")}} + {u.outdated && {t("tmux.outdated")}} + {u.startedAt && ( +
+ {t("tmux.since", { + at: formatDateTime(new Date(u.startedAt).toISOString()), + })} +
+ )} +
+ {u.sessions ?? "—"} + + {u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")} + + +
+
+ )} + {restarting && ( + setRestarting(null)} + onDone={(msg) => { + toast.success(msg); + setRestarting(null); + void load(); + }} + /> + )} +
+ ); +} + +function RestartDialog({ + target, + idle, + onClose, + onDone, +}: { + target: MemberTmuxRow | "idle"; + idle: MemberTmuxRow[]; + onClose: () => void; + onDone: (message: string) => void; +}) { + const { t } = useTranslation("users"); + const [current, setCurrent] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const one = target === "idle" ? null : target; + + const submit = async () => { + if (!current || busy) return; + setBusy(true); + setError(null); + try { + if (one) { + await api.users.tmuxRestart(one.id, current); + onDone(t("tmux.done", { name: one.username })); + } else { + const r = await api.users.tmuxRestartIdle(current); + // A server that would not stop needs root: say which, the rest went. + if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n")); + onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" })); + } + } catch (e) { + setError(errorText(t, e)); + } finally { + setBusy(false); + } + }; + + return ( + 0 ? "tmux.restartHintBusy" : "tmux.restartHint", { + name: one.username, + count: one.sessions ?? 0, + }) + : t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") }) + } + confirmLabel={t("tmux.restartButton")} + cancelLabel={t("common:cancel")} + busy={busy} + disabled={!current} + onConfirm={() => void submit()} + onCancel={onClose} + > +
{ + if (isSubmitEnter(e)) { + e.preventDefault(); + void submit(); + } + }} + > + + setCurrent(e.target.value)} + autoComplete="current-password" + aria-label={t("yourPassword")} + /> + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( + + ); +} diff --git a/client/src/components/users/__tests__/MemberTmuxCard.test.tsx b/client/src/components/users/__tests__/MemberTmuxCard.test.tsx new file mode 100644 index 00000000..766d1946 --- /dev/null +++ b/client/src/components/users/__tests__/MemberTmuxCard.test.tsx @@ -0,0 +1,100 @@ +/** + * @file MemberTmuxCard.test.tsx + * @description The Users page's members' tmux card: hidden without one Linux + * user per account, lists each running member server with its version and a + * badge when it is older than the installed tmux, warns until tmux-member is + * installed, and an admin restarts one member — or every idle outdated one, + * the working member left out — only with their own password typed; an + * analyst sees no restart control. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { MemberTmux } from "../../../lib/api"; + +const users = vi.hoisted(() => ({ + tmux: vi.fn(), + tmuxRestart: vi.fn(), + tmuxRestartIdle: vi.fn(), +})); +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, api: { users } }; +}); +vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { MemberTmuxCard } from "../MemberTmuxCard"; + +const DATA: MemberTmux = { + available: true, + installed: "3.8", + matched: true, + users: [ + { id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true }, + { id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true }, + { + id: 4, + username: "ann", + running: true, + version: "3.8", + sessions: 3, + busy: 0, + outdated: false, + }, + { id: 5, username: "bob", running: false, busy: 0, outdated: false }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + users.tmux.mockResolvedValue(DATA); +}); + +describe("MemberTmuxCard", () => { + it("renders nothing without one Linux user per account", async () => { + users.tmux.mockResolvedValue({ available: false, users: [] }); + const { container } = render(); + await waitFor(() => expect(users.tmux).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists running servers and marks the ones older than the installed tmux", async () => { + render(); + expect(await screen.findByText("mia")).toBeInTheDocument(); + expect(screen.queryByText("bob")).toBeNull(); + expect(screen.getAllByText("older than installed")).toHaveLength(2); + expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument(); + // An analyst reads only. + expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull(); + }); + + it("warns until tmux-member is installed", async () => { + users.tmux.mockResolvedValue({ ...DATA, matched: false }); + render(); + expect(await screen.findByText(/install-system/)).toBeInTheDocument(); + }); + + it("restarts one member only with the admin's password", async () => { + users.tmuxRestart.mockResolvedValue({}); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" })); + expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument(); + const go = screen.getByRole("button", { name: "Restart" }); + expect(go).toBeDisabled(); + await userEvent.type(screen.getByLabelText("Your password"), "pw"); + await userEvent.click(go); + await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw")); + }); + + it("restarts every idle outdated member at once, the working one left out", async () => { + users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" })); + expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText("Your password"), "pw"); + await userEvent.click(screen.getByRole("button", { name: "Restart" })); + await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw")); + }); +}); diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 33ed32c4..c0868aa4 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -204,5 +204,35 @@ "body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.", "confirm": "Delete for good", "needsPassword": "Enter your own password below first." + }, + "tmux": { + "title": "Members' tmux", + "description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.", + "refresh": "Refresh", + "notMatched": "Run `sudo agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.", + "empty": "No member has a tmux server running.", + "user": "User", + "version": "tmux", + "sessions": "Sessions", + "agents": "Agents", + "stuck": "not answering", + "outdated": "older than installed", + "since": "since {{at}}", + "working_one": "{{count}} working", + "working_other": "{{count}} working", + "idle": "idle", + "restart": "Restart {{name}}'s tmux", + "restartIdle_one": "Restart {{count}} idle member", + "restartIdle_other": "Restart {{count}} idle members", + "restartIdleTitle": "Restart idle members' tmux", + "restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.", + "restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.", + "restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.", + "restartButton": "Restart", + "done": "{{name}}'s tmux restarted", + "doneIdle": "Restarted: {{names}}", + "errors": { + "ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server." + } } } diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 774d7893..69034fd0 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -204,5 +204,35 @@ "body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.", "confirm": "Xoá vĩnh viễn", "needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước." + }, + "tmux": { + "title": "Tmux của member", + "description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.", + "refresh": "Làm mới", + "notMatched": "Chạy `sudo agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.", + "empty": "Không member nào đang chạy tmux server.", + "user": "Người dùng", + "version": "tmux", + "sessions": "Session", + "agents": "Agent", + "stuck": "không trả lời", + "outdated": "cũ hơn bản cài", + "since": "từ {{at}}", + "working_one": "{{count}} đang chạy", + "working_other": "{{count}} đang chạy", + "idle": "rảnh", + "restart": "Khởi động lại tmux của {{name}}", + "restartIdle_one": "Khởi động lại {{count}} member đang rảnh", + "restartIdle_other": "Khởi động lại {{count}} member đang rảnh", + "restartIdleTitle": "Khởi động lại tmux của member đang rảnh", + "restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.", + "restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.", + "restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.", + "restartButton": "Khởi động lại", + "done": "Đã khởi động lại tmux của {{name}}", + "doneIdle": "Đã khởi động lại: {{names}}", + "errors": { + "ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server." + } } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index d8b335c5..c4aef177 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -544,6 +544,31 @@ export type ServerUserUsage = AuthUser & { logins?: number; lastLoginAt?: string | null; }; +/** One member's own tmux server (`GET /api/users/tmux`). */ +export interface MemberTmuxRow { + id: number; + username: string; + running: boolean; + /** Did not answer (hung, or backed off). */ + stuck?: boolean; + version?: string | null; + pid?: number | null; + startedAt?: number | null; + sessions?: number | null; + /** Workspaces of theirs with an agent working now. */ + busy: number; + /** Runs an older tmux than the installed one. */ + outdated: boolean; +} +/** `GET /api/users/tmux`: `available` only with one Linux user per account. */ +export interface MemberTmux { + available: boolean; + installed?: string | null; + /** Calls on an older server already use its own binary (tmux-member installed). */ + matched?: boolean; + users: MemberTmuxRow[]; +} + /** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */ export type UserStorage = { bytes: number; @@ -915,6 +940,24 @@ export const api = { method: "POST", body: JSON.stringify({ ...body, currentPassword }), }), + /** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */ + tmux: () => request("/users/tmux"), + /** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */ + tmuxRestart: (id: number, currentPassword: string) => + request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, { + method: "POST", + body: JSON.stringify({ currentPassword }), + }), + /** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */ + tmuxRestartIdle: (currentPassword: string) => + request<{ + restarted: string[]; + skipped: string[]; + failed: { username: string; message: string }[]; + }>("/users/tmux-restart-idle", { + method: "POST", + body: JSON.stringify({ currentPassword }), + }), /** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */ get: (id: number) => request(`/users/${id}`), /** PATCH /api/users/:id — profile, role, state, password, workspaces and diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index f8ee70dc..d8300271 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -9,7 +9,9 @@ * an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile, * role, active/disabled, password, owned workspaces, sign out everywhere, sent * as one change), and the Claude/Codex account pool every user's sessions run - * on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the + * on (`AccountPoolCard`), and (one Linux user per account) each member's own + * tmux server beside the installed version, restartable by an admin + * (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the * server refuses without it); an analyst only reads. A row shows the display * name and email under the username when set. * Laid out like the other list pages: icon header, a row of totals @@ -48,6 +50,7 @@ import { ConfirmModal } from "../components/ConfirmModal"; import { StatCard } from "../components/StatCard"; import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog"; import { AccountPoolCard } from "../components/users/AccountPoolCard"; +import { MemberTmuxCard } from "../components/users/MemberTmuxCard"; import { TableRowSkeleton } from "../components/Skeleton"; type Role = AuthUser["role"]; @@ -296,6 +299,7 @@ export function Users() { +
diff --git a/client/src/pages/__tests__/Users.test.tsx b/client/src/pages/__tests__/Users.test.tsx index 993ec504..02a1b331 100644 --- a/client/src/pages/__tests__/Users.test.tsx +++ b/client/src/pages/__tests__/Users.test.tsx @@ -40,6 +40,8 @@ const users = vi.hoisted(() => ({ setPassword: vi.fn(), assignWorkspace: vi.fn(), get: vi.fn(), + // No S3 here: the members' tmux card stays hidden. + tmux: vi.fn(async () => ({ available: false, users: [] })), })); vi.mock("../../lib/api", async (importOriginal) => { diff --git a/docs/API.md b/docs/API.md index 258858c7..c529b621 100644 --- a/docs/API.md +++ b/docs/API.md @@ -85,7 +85,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared | -In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). +In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 284a6174..9456dac0 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -544,9 +544,9 @@ The pieces, and what each is for (the install steps are in the | `ah-` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) | | groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) | | `agenthub.service` | A SYSTEM unit, `User=`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root | -| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers | +| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers | | `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on | -| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch ` starts a member's pane from its launch file | +| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch ` starts a member's pane from its launch file | | `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-`. It refuses an account that exists outside `agenthub-members` and never deletes anything | | `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone | | `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one | @@ -682,9 +682,22 @@ a pane really is. Each case below is refused or bounded, with a test in running host server uses that server's own binary (`/proc//exe`, found from its socket in `/proc`), so installing a newer tmux under a running server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at - 100% CPU. A member's server is still asked with `TMUX_BIN` (the one path - sudoers allows), so after a tmux upgrade restart each member's tmux server - once it is idle (`sudo kill ` of `tmux -S /srv/agenthub/users//.agenthub/tmux/sock`). + 100% CPU. +- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes + through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs + the call with the binary of the member's running server (`/proc//exe`, + found from their socket), else with `TMUX_BIN`, so a newer tmux never asks + an older server. Until `sudo agenthub server install-system` has installed + it, calls use `TMUX_BIN` directly, as before. A member's server keeps its + version until their last pane closes; the next start runs `TMUX_BIN`. +- **Users → Members' tmux** (admin/analyst read, admin acts): each member's + running server with its version beside the installed one (an *older than + installed* badge), its sessions and whether an agent of theirs is working. + **Restart** ends one member's server — every pane of theirs closes, their + Claude sessions resume from the workspace — and **Restart N idle members** + does it for every outdated member with no agent working; both take the + admin's password again and are audited per member. A server that will not + stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it. - **A member's huge or sparse files.** Transcripts and journals are read line by line with a 64 MiB cap per line (a 512 MB line used to crash the server); a whole member file is read only up to a cap (1 MiB for a login or a @@ -746,10 +759,11 @@ itself, once. ### After an update that changes the member helpers -`write-as`, `pane-launch` and the sudoers file are copied to +`write-as`, `pane-launch`, `tmux-member` and the sudoers file are copied to `/opt/agenthub/libexec` by `install-system` only — an update of AgentHub does not touch root-owned files. When a release changes them (v2.7.7: `write-as` -learned `move`/`copy` for the File Explorer), re-run it once: +learned `move`/`copy` for the File Explorer; v2.8.4 added `tmux-member`), +re-run it once: ```bash sudo agenthub server install-system @@ -821,7 +835,7 @@ again changes nothing that is already in place): host account's); 3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`, `USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`); - 4. installs `agenthub-provision`, `write-as` and `pane-launch` from + 4. installs `agenthub-provision`, `write-as`, `pane-launch` and `tmux-member` from `scripts/server/`; 5. copies node to `/opt/agenthub/tools/node/` (linked from `tools/bin/node`) and this install's `scripts/` and `server/lib/` to diff --git a/scripts/server/install-system.sh b/scripts/server/install-system.sh index 3c2a5495..bf84ee1f 100644 --- a/scripts/server/install-system.sh +++ b/scripts/server/install-system.sh @@ -16,6 +16,7 @@ # /opt/agenthub/libexec/member-hook Claude hook forwarder for members # /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u) # /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file +# /opt/agenthub/libexec/tmux-member a member's tmux, with their running server's own binary # /etc/sudoers.d/agenthub checked with visudo before it is installed # /etc/claude-code/managed-settings.json the member hooks (never over a foreign file) # /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab) @@ -105,6 +106,7 @@ chmod 0644 /etc/agenthub/server.conf install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch +install -m 0755 -o root -g root "$ROOT/scripts/server/tmux-member.sh" /opt/agenthub/libexec/tmux-member # ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ── NODE_VER=$("$NODE" -p process.version) @@ -138,7 +140,7 @@ visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty" { echo "# AgentHub server mode (install-system). Members are never in sudoers." [ -z "$NOTTY" ] || echo "$NOTTY" - echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude" + echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, /opt/agenthub/libexec/tmux-member, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude" echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision" for a in start stop restart enable disable; do [ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra="" diff --git a/scripts/server/tmux-member.sh b/scripts/server/tmux-member.sh new file mode 100755 index 00000000..0885fb8b --- /dev/null +++ b/scripts/server/tmux-member.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# @file tmux-member.sh +# @description Server mode, one Linux user per account (S3): installed as +# /opt/agenthub/libexec/tmux-member, the one tmux the host may run AS a +# member (sudoers). Called as `tmux-member -S `, it runs +# the args with the binary of the tmux server already listening on that +# socket (`/proc//exe`, which still runs after an upgrade replaced the +# file), else with TMUX_BIN from /etc/agenthub/server.conf (no server yet: +# this call starts one, on the installed version). A client must match its +# server: a tmux 3.8 client asking a 3.6 server kept it at 100% CPU. +# +# It runs as the member and execs only a process THEY own (under hidepid they +# see no other), so it can run nothing they could not run themselves. +# @author Nguyễn Ngọc Trí Vĩ +set -u +bin=$(sed -n 's/^TMUX_BIN=//p' /etc/agenthub/server.conf 2>/dev/null | head -n 1) +[[ "$bin" == /* ]] || { echo "tmux-member: no TMUX_BIN in /etc/agenthub/server.conf" >&2; exit 2; } +if [ "${1:-}" = -S ] && [ -n "${2:-}" ]; then + # /proc/net/unix: Num RefCount Protocol Flags Type St Inode Path; 00010000 = listening. + inode=$(awk -v s="$2" '$4 == "00010000" && $8 == s { print $7; exit }' /proc/net/unix 2>/dev/null) + if [ -n "$inode" ]; then + for d in /proc/[0-9]*; do + [ -O "$d" ] || continue + read -r comm <"$d/comm" 2>/dev/null || continue + [[ "$comm" == tmux* ]] || continue + for fd in "$d"/fd/*; do + if [ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && [ -x "$d/exe" ]; then + exec "$d/exe" "$@" + fi + done + done + fi +fi +exec "$bin" "$@" diff --git a/server/__tests__/member-tmux.test.js b/server/__tests__/member-tmux.test.js new file mode 100644 index 00000000..c16d1286 --- /dev/null +++ b/server/__tests__/member-tmux.test.js @@ -0,0 +1,186 @@ +/** + * @file Tests for each member's own tmux server on the Users page (S3, + * server/lib/member-tmux.js + `/api/users/tmux*`): its version beside the + * installed one, an admin's restart of one member (password again, audited, + * ETMUXSTUCK when it will not stop) and of every idle outdated member — a + * member whose agent is working is skipped. Member tmux calls are faked. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "member-tmux-")); +const USERS = path.join(TMP, "users"); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); +process.env.DASHBOARD_LIVENESS_PROBE = "0"; + +const { createApp, startServer } = require("../index"); +const { db } = require("../db"); +const auth = require("../lib/auth"); +const linux = require("../lib/linux-accounts"); +const tmux = require("../lib/tmux"); + +let server; +let BASE; +const ids = {}; +const BOSS_PASS = "admin-pass-1"; + +// Fake member tmux servers, by Linux login. +const servers = {}; +function fakeMember(acct, args) { + const s = servers[acct.login]; + if (!s || !s.alive) throw Object.assign(new Error("no server running"), { status: 1 }); + if (args[0] === "display-message") return `${s.version}\t${s.pid}\t1700000000\t${s.sessions}\n`; + if (args[0] === "kill-server") { + if (s.stuck) throw Object.assign(new Error("server exited unexpectedly"), { status: 1 }); + s.alive = false; + return ""; + } + if (args[0] === "list-sessions") return ""; + return ""; +} + +async function call(method, p, { cookie, body } = {}) { + const res = await fetch(BASE + p, { + method, + headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +async function login(username, password) { + const res = await fetch(BASE + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null; +} + +before(async () => { + process.env.AGENTHUB_MODE = "server"; + process.env.AGENTHUB_USERS_ROOT = USERS; + fs.mkdirSync(USERS, { recursive: true }); + server = await startServer(createApp(), 0); + BASE = `http://127.0.0.1:${server.address().port}`; + ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id; + ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id; + ids.zed = (await auth.createUser("zed", "member-pass-1", "developer")).id; + ids.ann = (await auth.createUser("ann", "member-pass-1", "developer")).id; + ids.stu = (await auth.createUser("stu", "member-pass-1", "developer")).id; + // zed's agent is working right now. + db.prepare( + "INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'running')" + ).run(path.join(USERS, "zed", "p"), ids.zed); + // S3 on: a server.conf whose TMUX_BIN answers 3.8, and tmux-member installed. + const bin = path.join(TMP, "tmux"); + fs.writeFileSync(bin, "#!/bin/sh\necho 'tmux 3.8'\n", { mode: 0o755 }); + const wrapper = path.join(TMP, "tmux-member"); + fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 }); + process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf"); + fs.writeFileSync( + process.env.AGENTHUB_SERVER_CONF, + `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nTMUX_BIN=${bin}\nGIT_BIN=/usr/bin/git\n` + ); + linux.__setTmuxMember(wrapper); + Object.assign(servers, { + "ah-mia": { version: "3.6", pid: 11, sessions: 2, alive: true }, + "ah-zed": { version: "3.6", pid: 12, sessions: 1, alive: true }, + "ah-ann": { version: "3.8", pid: 13, sessions: 3, alive: true }, + "ah-stu": { version: "3.6", pid: 14, sessions: 1, alive: true, stuck: true }, + }); + tmux.__setAccountImpl(() => null, fakeMember); +}); + +after(() => { + delete process.env.AGENTHUB_MODE; + delete process.env.AGENTHUB_USERS_ROOT; + delete process.env.AGENTHUB_SERVER_CONF; + linux.__setTmuxMember("/opt/agenthub/libexec/tmux-member"); + linux.__resetConfCache(); + tmux.__reset(); + server?.close(); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe("member tmux servers on the Users page", () => { + it("lists each member's version beside the installed one, and who is working", async () => { + const boss = await login("boss", BOSS_PASS); + const r = await call("GET", "/api/users/tmux", { cookie: boss }); + assert.equal(r.status, 200); + assert.equal(r.body.available, true); + assert.equal(r.body.installed, "3.8"); + assert.equal(r.body.matched, true); + const by = Object.fromEntries(r.body.users.map((u) => [u.username, u])); + assert.equal(by.mia.version, "3.6"); + assert.equal(by.mia.outdated, true); + assert.equal(by.mia.sessions, 2); + assert.equal(by.zed.busy, 1); + assert.equal(by.ann.outdated, false); + // boss (no server of theirs) is listed as not running. + assert.equal(by.boss.running, false); + }); + + it("is staff-only to read and an admin's to restart", async () => { + const mia = await login("mia", "member-pass-1"); + assert.equal((await call("GET", "/api/users/tmux", { cookie: mia })).status, 403); + const r = await call("POST", `/api/users/${ids.ann}/tmux-restart`, { + cookie: mia, + body: { currentPassword: "member-pass-1" }, + }); + assert.equal(r.status, 403); + assert.equal(servers["ah-ann"].alive, true); + }); + + it("restarts one member's server with the admin's password, audited", async () => { + const boss = await login("boss", BOSS_PASS); + const wrong = await call("POST", `/api/users/${ids.ann}/tmux-restart`, { + cookie: boss, + body: { currentPassword: "nope" }, + }); + assert.equal(wrong.body.error.code, "EREAUTH"); + assert.equal(servers["ah-ann"].alive, true); + const ok = await call("POST", `/api/users/${ids.ann}/tmux-restart`, { + cookie: boss, + body: { currentPassword: BOSS_PASS }, + }); + assert.equal(ok.status, 200); + assert.equal(servers["ah-ann"].alive, false); + const audit = db + .prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1") + .get(ids.ann); + assert.match(audit.path, /ann: tmux restarted \(was 3\.8, 3 session/); + }); + + it("names the pid to kill as root when a server will not stop", async () => { + const boss = await login("boss", BOSS_PASS); + const r = await call("POST", `/api/users/${ids.stu}/tmux-restart`, { + cookie: boss, + body: { currentPassword: BOSS_PASS }, + }); + assert.equal(r.status, 409); + assert.equal(r.body.error.code, "ETMUXSTUCK"); + assert.match(r.body.error.message, /sudo kill -9 14/); + }); + + it("restarts every idle outdated member, skipping one whose agent works", async () => { + servers["ah-ann"] = { version: "3.8", pid: 13, sessions: 1, alive: true }; + const boss = await login("boss", BOSS_PASS); + const r = await call("POST", "/api/users/tmux-restart-idle", { + cookie: boss, + body: { currentPassword: BOSS_PASS }, + }); + assert.equal(r.status, 200); + assert.deepEqual(r.body.restarted, ["mia"]); + assert.deepEqual(r.body.skipped, ["zed"]); + assert.equal(r.body.failed[0].username, "stu"); + assert.equal(servers["ah-mia"].alive, false); + assert.equal(servers["ah-zed"].alive, true); + assert.equal(servers["ah-ann"].alive, true); // already on 3.8 + }); +}); diff --git a/server/__tests__/server-s3-panes.test.js b/server/__tests__/server-s3-panes.test.js index e2ad81c5..26f59492 100644 --- a/server/__tests__/server-s3-panes.test.js +++ b/server/__tests__/server-s3-panes.test.js @@ -240,9 +240,20 @@ describe("run-as: a path under the users root belongs to that user", () => { process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n` ); - linux.__resetConfCache(); + linux.__setTmuxMember(path.join(TMP, "no-tmux-member")); assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git"); assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux"); + // Once install-system put tmux-member there, a member's tmux goes through + // it (their running server's own binary); the installed one is still known. + const wrapper = path.join(TMP, "tmux-member"); + fs.writeFileSync(wrapper, "#!/bin/sh\n", { mode: 0o755 }); + linux.__setTmuxMember(wrapper); + try { + assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], wrapper); + assert.equal(linux.serverConf().tmuxInstalled, "/opt/x/tmux"); + } finally { + linux.__setTmuxMember(path.join(TMP, "no-tmux-member")); + } assert.equal( runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3], "/opt/agenthub/libexec/write-as" diff --git a/server/__tests__/server-s3-system.test.js b/server/__tests__/server-s3-system.test.js index 8f8e01a5..3068c68d 100644 --- a/server/__tests__/server-s3-system.test.js +++ b/server/__tests__/server-s3-system.test.js @@ -187,6 +187,7 @@ describe("Linux accounts", () => { hostUid: 4242, usersRoot: "/srv/agenthub/users", port: 4820, + tmuxInstalled: null, bins: {}, }); assert.deepEqual(linux.accountFor("mia"), { @@ -199,7 +200,8 @@ describe("Linux accounts", () => { for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) { assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad); } - assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/); + // `delete` is an action now (Delete user); anything else is still refused. + assert.throws(() => linux.provision("purge", "mia"), /unknown provision action/); assert.deepEqual(argvs, []); }); diff --git a/server/lib/linux-accounts.js b/server/lib/linux-accounts.js index a2f8ebb5..a7f9cf46 100644 --- a/server/lib/linux-accounts.js +++ b/server/lib/linux-accounts.js @@ -46,6 +46,13 @@ function serverConf() { return confCache.value; } +let TMUX_MEMBER = "/opt/agenthub/libexec/tmux-member"; +/** Test seam: where tmux-member is looked for. */ +function __setTmuxMember(p) { + TMUX_MEMBER = p; + confCache = { at: 0, path: null, value: null }; +} + function readConf(file) { let text; try { @@ -56,16 +63,28 @@ function readConf(file) { const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || ""; const usersRoot = get("USERS_ROOT"); if (!path.isAbsolute(usersRoot)) return null; + // A member's tmux runs through tmux-member once install-system put it there + // (and in sudoers): it picks their RUNNING server's own binary, so a tmux + // upgrade never mixes a new client with an old server. Before that, TMUX_BIN. + let tmuxBin = get("TMUX_BIN"); + try { + fs.accessSync(TMUX_MEMBER, fs.constants.X_OK); + if (path.isAbsolute(tmuxBin)) tmuxBin = TMUX_MEMBER; + } catch { + /* not installed yet */ + } return { hostUser: get("HOST_USER"), hostUid: Number(get("HOST_UID")), usersRoot, port: Number(get("PORT")) || null, + // The installed tmux (a new member server starts on it). + tmuxInstalled: path.isAbsolute(get("TMUX_BIN")) ? get("TMUX_BIN") : null, // The absolute paths sudoers lists (sudo would resolve a bare name on its // own secure_path, which may not be them). bins: Object.fromEntries( [ - ["tmux", get("TMUX_BIN")], + ["tmux", tmuxBin], ["git", get("GIT_BIN")], ["gh", get("GH_BIN")], ].filter(([, v]) => path.isAbsolute(v)) @@ -154,6 +173,7 @@ function __resetConfCache() { module.exports = { __resetConfCache, + __setTmuxMember, confPath, LINUX_NAME_RE, serverConf, diff --git a/server/lib/member-tmux.js b/server/lib/member-tmux.js new file mode 100644 index 00000000..9f708dd9 --- /dev/null +++ b/server/lib/member-tmux.js @@ -0,0 +1,103 @@ +/** + * @file member-tmux.js + * @description Server mode, one Linux user per account (S3): each member's + * own tmux server as the Users page shows it — the version it runs next to + * the installed one (`TMUX_BIN`), how many sessions it holds and whether an + * agent of theirs is working — and an admin's restart of it. A tmux server + * keeps the version it started with until its last session closes, so after + * a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps + * every call on it matched to its own binary, and this is how an admin moves + * a member onto the new version on purpose. A restart ends every pane of + * theirs (their Claude sessions resume from the workspace). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFileSync } = require("node:child_process"); + +const { db } = require("../db"); +const linux = require("./linux-accounts"); +const tmux = require("./tmux"); + +const err = (code, message) => Object.assign(new Error(message), { code }); + +// The installed tmux's version (`3.8`), re-read at most once a minute. +let installed = { at: 0, bin: null, version: null }; +function installedVersion() { + const bin = linux.serverConf()?.tmuxInstalled || null; + if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version; + let version = null; + if (bin) { + try { + version = + String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" })) + .trim() + .replace(/^tmux\s+/, "") || null; + } catch { + /* missing or broken: unknown */ + } + } + installed = { at: Date.now(), bin, version }; + return version; +} + +/** How many of `userId`'s workspaces have an agent working right now. */ +function busyCount(userId) { + return db + .prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'") + .get(userId).n; +} + +/** One user's row; null for an account with no Linux side. */ +function rowFor(user, installedV) { + const acct = linux.accountFor(user.username); + if (!acct) return null; + const info = tmux.memberServerInfo(acct); + return { + id: user.id, + username: user.username, + ...info, + busy: busyCount(user.id), + outdated: !!(info.running && info.version && installedV && info.version !== installedV), + }; +} + +/** `{available, installed, matched, users: [...]}` for the Users page. */ +function overview(users) { + if (!linux.s3Enabled()) return { available: false, users: [] }; + const conf = linux.serverConf(); + const v = installedVersion(); + return { + available: true, + installed: v, + // Whether calls on an older server already use its own binary. + matched: conf?.bins?.tmux !== conf?.tmuxInstalled, + users: users.map((u) => rowFor(u, v)).filter(Boolean), + }; +} + +/** + * Restart `user`'s tmux server. `{restarted: false}` when none ran; throws + * ETMUXSTUCK (with the pid) when it would not stop — root has to kill it. + */ +function restart(user) { + const acct = linux.accountFor(user.username); + if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)"); + const before = tmux.memberServerInfo(acct); + if (!before.running) return { restarted: false, before }; + try { + tmux.killMemberServer(acct); + } catch { + /* judged by what is left below */ + } + const after = tmux.memberServerInfo(acct); + if (after.running) { + const pid = before.pid || after.pid; + throw err( + "ETMUXSTUCK", + `${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || ""}\` on the server` + ); + } + return { restarted: true, before }; +} + +module.exports = { overview, restart, installedVersion }; diff --git a/server/lib/tmux.js b/server/lib/tmux.js index ba5f21a2..ce976636 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -720,6 +720,53 @@ function killSession(name) { noteSession(name, accountOfQuiet(name), false); } +/** + * A member's tmux server as the Users page shows it: `{running: false}`, or + * `{running: true, version, pid, startedAt, sessions}`, or `{stuck: true}` + * when it did not answer (hung, or backed off). Asked with `tmux-member`, i.e. + * with that server's own binary. + */ +function memberServerInfo(acct) { + try { + const out = String( + memberCall(acct, [ + "display-message", + "-p", + "#{version}\t#{pid}\t#{start_time}\t#{server_sessions}", + ]) + ).trim(); + const [version, pid, start, sessions] = out.split("\t"); + return { + running: true, + version: version || null, + pid: /^\d+$/.test(pid) ? Number(pid) : null, + startedAt: /^\d+$/.test(start) ? Number(start) * 1000 : null, + sessions: /^\d+$/.test(sessions) ? Number(sessions) : null, + }; + } catch (err) { + if (err.code === "ETMUXHUNG" || err.code === "ETIMEDOUT" || err.code === "ENOBUFS") { + return { running: true, stuck: true }; + } + // "no server running" / "error connecting": nothing to report. + return { running: false }; + } +} + +/** + * Stop a member's tmux server — every pane of theirs ends (an admin's Users + * page action, so the next start runs the installed tmux). Throws when the + * server did not go: a stuck one may need root (`sudo kill `). + */ +function killMemberServer(acct) { + unreachable.delete(acct.login); // asked deliberately, past any back-off + try { + memberCall(acct, ["kill-server"]); + } finally { + listGen++; + listed.delete(acct.login); + } +} + /** Session names starting with `prefix`. Empty array if tmux has no server running at all. */ function listSessions(prefix) { const names = []; @@ -867,6 +914,8 @@ module.exports = { upgradeAdvice, serverPid, memberAttach, + memberServerInfo, + killMemberServer, __setExecImpl, __setAccountImpl, __settle, diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js index 8b808825..6b244d5b 100644 --- a/server/openapi-extra/auth.js +++ b/server/openapi-extra/auth.js @@ -14,6 +14,8 @@ * - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture * - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form * - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst) + * - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle + * (Auth) — each member's own tmux server and an admin's restart of it (S3) * Exports `{ tags, schemas, paths }`, combined into the spec by * server/openapi-extra.js. See docs/SERVER-MODE.md. * @author Nguyễn Ngọc Trí Vĩ @@ -409,6 +411,77 @@ const paths = { }, }, }, + "/api/users/tmux": { + get: { + tags: ["Auth"], + operationId: "listMemberTmux", + summary: + "Each member's own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)", + responses: { + 200: { + description: + "{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3", + }, + 403: err("EADMIN — a developer"), + }, + }, + }, + "/api/users/{id}/tmux-restart": { + post: { + tags: ["Auth"], + operationId: "restartMemberTmux", + summary: + "End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)", + parameters: [idParam], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["currentPassword"], + properties: { currentPassword: { type: "string" } }, + }, + }, + }, + }, + responses: { + 200: { description: "{user}" }, + 403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"), + 404: err("ENOUSER"), + 409: err( + "ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3" + ), + }, + }, + }, + "/api/users/tmux-restart-idle": { + post: { + tags: ["Auth"], + operationId: "restartIdleMemberTmux", + summary: + "Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["currentPassword"], + properties: { currentPassword: { type: "string" } }, + }, + }, + }, + }, + responses: { + 200: { + description: + "{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}", + }, + 403: err("EREAUTH / EADMIN / ELOCALMODE"), + }, + }, + }, "/api/users/{id}/avatar": { get: { tags: ["Auth"], diff --git a/server/routes/users.js b/server/routes/users.js index 7ec14b39..84c81dc7 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -8,6 +8,12 @@ * (server/lib/audit.js); `GET /storage` how much disk each user's home takes * (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures). * + * `GET /tmux` (S3) is each member's own tmux server — its version beside the + * installed one, its sessions, whether an agent of theirs works — and an + * admin restarts one (`POST /:id/tmux-restart`) or every idle outdated one + * (`POST /tmux-restart-idle`), with their password again and audited + * (server/lib/member-tmux.js); a restart ends that member's panes. + * * `GET /:id` is one account with the workspaces it owns, for the Edit user form; * `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's * owner groups — staff only like the rest of `/users`. @@ -96,6 +102,11 @@ router.get("/audit", (req, res) => { res.json({ entries: listAudit(req.query.limit) }); }); +/** S3: each member's tmux server — its version beside the installed one (member-tmux.js). */ +router.get("/tmux", (_req, res) => { + res.json(require("../lib/member-tmux").overview(listUsers())); +}); + const STATUS = { EBADUSER: 400, EBADEMAIL: 400, @@ -114,6 +125,9 @@ const STATUS = { ETOOMANY: 429, // S3: the Linux account could not be created/locked (the helper said why). EPROVISION: 502, + ENOS3: 409, + // A member's tmux server that would not stop: root has to kill it. + ETMUXSTUCK: 409, }; /** @@ -290,6 +304,73 @@ router.delete( }) ); +/** + * Restart one member's tmux server (every pane of theirs ends, so the next + * start runs the installed tmux) — the admin's password again, audited. + */ +router.post( + "/:id/tmux-restart", + accountChange(async (req) => { + const u = target(req); + const r = require("../lib/member-tmux").restart(u); + return { + user: auth.publicUser(u), + action: r.restarted + ? `${u.username}: tmux restarted (was ${r.before.version || "?"}, ${r.before.sessions ?? "?"} session(s))` + : `${u.username}: tmux restart — no server was running`, + }; + }) +); + +/** + * Restart the tmux server of every member running an older tmux than the + * installed one while no agent of theirs works. One password, one audit + * entry per member restarted. + */ +router.post("/tmux-restart-idle", async (req, res) => { + try { + if (!auth.isServerMode() || !req.user) { + throw Object.assign(new Error("accounts are managed in server mode only"), { + code: "ELOCALMODE", + }); + } + const body = req.body && typeof req.body === "object" ? req.body : {}; + await auth.confirmPassword(req.user, body.currentPassword); + const mt = require("../lib/member-tmux"); + const all = db.prepare("SELECT * FROM users ORDER BY id").all(); + const rows = mt.overview(all.map(auth.publicUser)).users; + const restarted = []; + const skipped = []; + const failed = []; + for (const row of rows) { + if (!row.outdated) continue; + if (row.busy > 0) { + skipped.push(row.username); + continue; + } + const u = all.find((x) => x.id === row.id); + try { + const r = mt.restart(u); + if (!r.restarted) continue; + restarted.push(row.username); + recordAccountChange( + req.user.id, + u.id, + "POST", + `${u.username}: tmux restarted (idle, was ${row.version})` + ); + } catch (e) { + failed.push({ username: row.username, message: e.message }); + } + } + res.json({ restarted, skipped, failed }); + } catch (err) { + const status = STATUS[err.code]; + if (!status) return res.status(500).json({ error: { message: err.message } }); + res.status(status).json({ error: { code: err.code, message: err.message } }); + } +}); + router.post( "/:id/password", accountChange(async (req, body) => {