From e6ce8009b1f8b991d6854d1653e47280ff116456 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 3 Nov 2026 10:50:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(server):=20S3=20box=20is=20for=20coding=20?= =?UTF-8?q?=E2=80=94=20OOM=20kills=20the=20command=20not=20the=20session,?= =?UTF-8?q?=20no=20tests=20from=20Claude,=204=20AI=20sessions=20per=20memb?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tool-run (CLAUDE_CODE_SHELL_PREFIX in member panes) runs each command Claude runs at oom_score_adj 1000 (session 300, Terminal tab 800, dashboard/tmux -100), so an OOM inside the capped unit kills the test run and tells Claude why, instead of killing the conversation. - member-guard.js, a synchronous managed hook for ah-* accounts: denies test runners and container commands from Claude's Bash, and refuses the prompts of a session started by hand past MAX_AI_SESSIONS. - ai-procs.js counts a member's top-level AI processes from /proc; spawnRun refuses a new pane past the limit (429 ESESSIONLIMIT). Needs `agenthub server install-system` once, then a restart. --- CLAUDE.md | 3 +- docs/API.md | 2 +- docs/SERVER-MODE.md | 55 +++++--- scripts/member-guard.js | 188 ++++++++++++++++++++++++++ scripts/server/install-system.sh | 11 +- scripts/server/pane-launch.sh | 27 ++-- scripts/server/tool-run.sh | 23 ++++ server/__tests__/member-guard.test.js | 126 +++++++++++++++++ server/lib/ai-procs.js | 116 ++++++++++++++++ server/lib/pty-run.js | 25 ++++ server/routes/lanes.js | 3 +- server/routes/run.js | 2 +- 12 files changed, 549 insertions(+), 32 deletions(-) create mode 100644 scripts/member-guard.js create mode 100755 scripts/server/tool-run.sh create mode 100644 server/__tests__/member-guard.test.js create mode 100644 server/lib/ai-procs.js diff --git a/CLAUDE.md b/CLAUDE.md index 60d02919..737a7fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.67 (2026-11-03) — user chat removed; its tables and uploads are dropped at start._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.68 (2026-11-03) — S3: members' panes capped, Claude's commands die first on OOM, no tests from Claude, 4 AI sessions per member._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -84,6 +84,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`, `clone-runner.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`. `/cc-config` is in neither: its router gates itself (`server/lib/cc-target.js` — a non-admin reaches only an ALLOWLIST of reads and own-config writes; `/profiles*`, `/plugins/update-all` and every unlisted endpoint stay an admin's) and every handler acts on ONE target from `resolveCcTarget` — the host's home for an admin/local mode, a member's own `~/.claude` under S3 (writes through `write-as`, `claude plugin|mcp` as their sudo argv: user scope, no `-y`, no env/headers), `403 ENOACCOUNT` otherwise, never the host's; a new endpoint there joins `MEMBER_ROUTES` only if it acts on the target. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. +- **Under S3 the box is for writing code, and a member's runaway job dies alone.** The unit caps its cgroup (`MemoryMax=85%`, `MemorySwapMax=1G`, `OOMPolicy=continue`); inside it `oom_score_adj` orders the kill — Claude's commands 1000 through `CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`, a Terminal tab 800, an AI session 300, dashboard/tmux -100 — so a test run dies, never the conversation. `scripts/member-guard.js` (synchronous managed hook, `ah-*` only) denies test runs and containers from Claude's Bash and refuses the prompts of a session past `MAX_AI_SESSIONS` (default 4, counted from /proc by `server/lib/ai-procs.js`, which `spawnRun` also checks: `429 ESESSIONLIMIT`). It decides locally and fails open like every hook. See `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|cursor]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it; a Cursor API key (`server/lib/cursor-pool.js`) is `-e CURSOR_API_KEY` and nothing else. **Pool groups** (`server/lib/pool-groups.js`) narrow every balancer through `allowed(ownerId, tool)`: a group's member takes only its accounts, a user in no group only the accounts no group lists, a pin wins, then the user's OWN choice (`account_choices`, `/api/me/pool-accounts` — only an account `allowed` names, re-checked at every spawn, refused while pinned); a login already live in a home is never swapped for a group change. The tool list is `account-pins.js` `TOOLS` (no DB CHECK). - **A shared Oh My Pi login is reached only through omp's gateway, by a per-pane key, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_OMP_KEY`; that key is one already QUEUED in the clients file (the gateway re-reads it only every 10 s, and an omp started on a refused key hangs for good), and the clients file holds only SHA-256s — of the queue and of LIVE omp panes' keys. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. - Preserve existing behavior unless explicitly asked to change it. diff --git a/docs/API.md b/docs/API.md index ccb4b0fb..1286d99f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,7 +89,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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. 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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `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 and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `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; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `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). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). 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, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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 41825a1f..6e02f7d5 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -1156,26 +1156,47 @@ The same re-run installs `agenthub-provision delete`, behind the Users page's **Delete user**: until then a delete answers `502 EPROVISION` ("unknown action: delete") and changes nothing. -The same re-run (v2.8.68) keeps a member's test run from freezing the box — -on 11-02 some 36 open sessions plus test runs filled RAM and the 4 GB swap on -a slow disk, and only a hard reset brought it back: +The same re-run (v2.8.68) keeps members from freezing the box — on 11-02 +some 36 open sessions plus test runs filled RAM and the 4 GB swap on a slow +disk, and only a hard reset brought it back. The box is for writing code; +tests run on each member's own machine: -- `pane-launch` starts every member pane at `nice 10`, best-effort IO - priority 7 and `oom_score_adj 500`, and defaults `VITEST_MAX_THREADS` / - `VITEST_MAX_FORKS` to 2 (Vitest ≤ 3; a member may set their own). Only - panes started after the re-run get these; the nice cannot be taken back - without root. -- `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`, +- **Memory.** `agenthub.service` gets `MemoryMax=85%`, `MemorySwapMax=1G`, `OOMPolicy=continue` and `OOMScoreAdjust=-100`. The cgroup holds the server - AND every member pane, so memory runs out inside the unit first, and the - kernel kills the biggest pane process there — not the dashboard, tmux, or - the rest of the machine. `daemon-reload` (which the re-run does) applies - the memory caps to the running unit; `OOMScoreAdjust` takes effect at the - next restart (`sudo -n /usr/bin/systemctl restart --no-block - agenthub.service` — panes survive, `KillMode=process`). + AND every member pane, so memory runs out inside the unit first and the + kernel kills one process there by `oom_score_adj`: every command a member's + Claude runs is 1000 (`CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`, + which also tells Claude why its command died), a Terminal tab 800, an AI + session 300, the dashboard and tmux -100. A test run dies; the session + holding the conversation does not. Codex runs its commands without that + wrapper, so its commands sit at 300 beside it. +- **CPU and disk.** `pane-launch` starts every member pane at `nice 10` and + best-effort IO priority 7 (the nice cannot be taken back without root), and + defaults `VITEST_MAX_THREADS` / `VITEST_MAX_FORKS` to 2. +- **No tests or containers from Claude.** A synchronous `PreToolUse` hook + (`/opt/agenthub/app/scripts/member-guard.js`, Bash only, `ah-*` accounts + only) denies `vitest`, `jest`, `phpunit`, `pest`, `pytest`, `npm test`, + `npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`, + `podman`, … and tells Claude to leave testing to the member's machine. + A person typing a command in a Terminal tab, and Codex, are not covered. +- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=` in + `/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's + running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`, + `cursor-agent`, `omp`, a nested one counted once), so a `claude` typed into + a Terminal tab counts and the tab itself does not. A new pane past the + limit answers `429 ESESSIONLIMIT`; a session started by hand past it has + every prompt refused by the guard's `UserPromptSubmit` until another one + ends. The host account (admins) is not limited. -Check with `systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy` -and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj`. +The memory caps apply to the running unit at once (the re-run does +`daemon-reload`); `OOMScoreAdjust`, the session limit on new panes and the +guard script in `/opt/agenthub/app` arrive with the next restart +(`sudo -n /usr/bin/systemctl restart --no-block agenthub.service` — panes +survive, `KillMode=process`). Panes started before the re-run keep their old +priority. Check with +`systemctl show agenthub.service -p MemoryMax,MemorySwapMax,OOMPolicy` +and, in a new member pane, `ps -o ni= -p $$; cat /proc/self/oom_score_adj; +echo $CLAUDE_CODE_SHELL_PREFIX`. ### Deleting a user diff --git a/scripts/member-guard.js b/scripts/member-guard.js new file mode 100644 index 00000000..796ecb9d --- /dev/null +++ b/scripts/member-guard.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * @file member-guard.js + * @description Server mode, one Linux user per account (S3): the members' + * SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json + * (install-system.sh), run AS the member from /opt/agenthub/app. The box is + * shared and small, so it is for writing code only: + * + * - `PreToolUse` (matcher Bash): denies a test run or a container command + * (`isHeavy`) — the member commits, pulls the branch on their own machine + * and runs it there. Claude gets the reason and stops trying. + * - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions + * at once (server/lib/ai-procs.js). The dashboard refuses a pane past the + * limit itself (ESESSIONLIMIT); this catches a `claude` typed into a + * Terminal tab, by refusing every prompt of a session that is not among + * the account's oldest `limit` ones. + * + * Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like + * every AgentHub hook: any error lets the call through. A person's own + * terminal commands and Codex (whose hooks are opt-in) are not covered. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); + +const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js")); + +/** Test runners and container tools, by the program a command segment runs. */ +const HEAVY_BINS = new Set([ + "vitest", + "jest", + "mocha", + "ava", + "karma", + "playwright", + "cypress", + "phpunit", + "pest", + "paratest", + "pytest", + "py.test", + "tox", + "nox", + "rspec", + "ctest", + "nextest", + "docker", + "docker-compose", + "podman", + "podman-compose", + "nerdctl", + "buildah", + "kind", + "minikube", + "k3d", +]); +/** Wrappers that run the command after them. */ +const PASS_THROUGH = new Set([ + "sudo", + "env", + "time", + "nice", + "ionice", + "timeout", + "npx", + "bunx", + "pnpx", + "exec", + "command", + "xvfb-run", + "stdbuf", +]); +const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/; + +/** The words of one command segment, wrappers and env assignments dropped. */ +function words(segment) { + const w = segment + .trim() + .split(/\s+/) + .map((x) => x.replace(/^['"]|['"]$/g, "")) + .filter(Boolean); + for (;;) { + while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift(); + if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break; + w.shift(); + // a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`) + while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift(); + } + return w; +} + +/** Does this one segment run tests or containers? */ +function segmentIsHeavy(segment) { + const w = words(segment); + if (!w.length) return false; + const bin = path.basename(w[0]); + const [a, b] = [w[1] || "", w[2] || ""]; + if (HEAVY_BINS.has(bin)) return true; + if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) { + if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit + if (["run", "run-script", "exec", "x", "dlx"].includes(a)) { + return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b)); + } + return false; + } + if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) { + return ["test", "verify", "integrationTest"].includes(a); + } + if (bin === "cargo") return a === "test" || a === "nextest"; + if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check"); + if (bin === "php" || bin === "artisan") { + const rest = bin === "php" ? w.slice(1) : w; + const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x)); + if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true; + return rest.some((x) => HEAVY_BINS.has(path.basename(x))); + } + if (/^python[0-9.]*$/.test(bin)) { + const i = w.indexOf("-m"); + return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]); + } + return false; +} + +/** Does a Bash command run tests or containers anywhere in it? */ +function isHeavy(command) { + if (typeof command !== "string") return false; + // Inner `bash -c "..."` / `sh -c '...'` bodies are checked too. + const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map( + (m) => m[2] + ); + return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy)); +} + +const DENY_REASON = + "AgentHub: this shared server is for writing code only — tests and containers " + + "(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " + + "a few of them at once freeze it for the whole team. Do not try another way to " + + "run them. Finish the change, tell the user which tests to run, and let them " + + "commit, pull the branch on their own machine and test there."; + +function guardTool(input) { + if (input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return; + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: DENY_REASON, + }, + }) + "\n" + ); +} + +function guardPrompt() { + const limit = procs.sessionLimit(); + if (!limit) return; + const mine = procs.sessionOf(process.pid); + if (mine == null) return; + const all = procs.sessionsOf(process.getuid()); + const at = all.findIndex((s) => s.pid === mine); + if (at < limit) return; // among the oldest `limit` (or not found: let it be) + process.stderr.write( + `AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` + + "server, and this one started past the limit. Close another session (or this one) first.\n" + ); + process.exit(2); +} + +function main() { + const event = process.argv[2]; + let raw = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (raw += c)); + process.stdin.on("end", () => { + try { + if (!os.userInfo().username.startsWith("ah-")) return; + if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}")); + else if (event === "UserPromptSubmit") guardPrompt(); + } catch { + /* fail-open */ + } + }); +} + +if (require.main === module) main(); + +module.exports = { isHeavy, segmentIsHeavy, words, guardTool }; diff --git a/scripts/server/install-system.sh b/scripts/server/install-system.sh index 2c7e01c0..89c9dbd0 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/tool-run wraps each command a member's Claude runs (OOM: it dies first) # /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) @@ -107,6 +108,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/tool-run.sh" /opt/agenthub/libexec/tool-run 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) ── @@ -164,7 +166,14 @@ else const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}), hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5, ...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }]; - process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n"); + const hooks = Object.fromEntries(ev.map((e) => [e, hook(e)])); + // The guard is synchronous (it decides): no test runs or containers from + // Claude, and at most MAX_AI_SESSIONS AI sessions per member (member-guard.js). + const guard = (e) => ({ type: "command", timeout: 5, + command: `/opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/member-guard.js ${e}` }); + hooks.PreToolUse.push({ matcher: "Bash", hooks: [guard("PreToolUse")] }); + hooks.UserPromptSubmit.push({ hooks: [guard("UserPromptSubmit")] }); + process.stdout.write(JSON.stringify({ hooks }, null, 2) + "\n"); ' >"$MANAGED" chmod 0644 "$MANAGED" sha256sum "$MANAGED" >"$MANAGED_MARK" diff --git a/scripts/server/pane-launch.sh b/scripts/server/pane-launch.sh index f2608f2e..b34edea2 100644 --- a/scripts/server/pane-launch.sh +++ b/scripts/server/pane-launch.sh @@ -11,7 +11,7 @@ # # The file is NUL-separated: the number of environment entries, the # NAME=value entries, then the argv (none = a login shell). Every pane starts -# at a lower CPU/IO priority and as the kernel's first OOM pick (see below). +# at a lower CPU/IO priority, Claude's commands as the kernel's first OOM pick. # @author Nguyễn Ngọc Trí Vĩ set -eu f=${1:?usage: pane-launch } @@ -24,19 +24,26 @@ for kv in "${parts[@]:1:n}"; do export "$kv" done # A member's pane shares the 4-core box with the dashboard and every other -# member: its test run, build or dev server must slow down before anything -# else does, and die first if memory runs out (11-02: 36 sessions plus test -# runs filled RAM and swap, and the box froze until a hard reset). A member -# cannot take the nice back (that needs root); oom_score_adj they can lower -# only to the unit's own -100, and even then the biggest process dies. The unit -# caps the whole cgroup below RAM (install-system.sh), so the kernel's pick -# inside it is the biggest pane process, never the dashboard or tmux. +# member, so it runs at a lower CPU/IO priority (a member cannot take the +# nice back: that needs root). Memory (11-02: 36 sessions plus test runs +# filled RAM and swap, and the box froze until a hard reset): the unit caps +# its cgroup below RAM (install-system.sh), and inside it the kernel kills +# by oom_score_adj — the dashboard and tmux at the unit's -100, an AI session +# at 300, a Terminal tab at 800, and every command Claude runs at 1000 +# through CLAUDE_CODE_SHELL_PREFIX=tool-run. So a runaway command dies and +# the session that holds the conversation is told why; a member who lowers +# their own value can go no lower than -100. renice -n 10 -p $$ >/dev/null 2>&1 || true ionice -c 2 -n 7 -p $$ >/dev/null 2>&1 || true -echo 500 >/proc/self/oom_score_adj 2>/dev/null || true +TOOL_RUN=/opt/agenthub/libexec/tool-run +[ -x "$TOOL_RUN" ] && export CLAUDE_CODE_SHELL_PREFIX="${CLAUDE_CODE_SHELL_PREFIX:-$TOOL_RUN}" # Vitest ≤ 3 starts one worker per core by default and these override the # config; a member may set their own in the pane. export VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-2}" VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-2}" argv=("${parts[@]:n+1}") -if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi +if [ ${#argv[@]} -eq 0 ]; then + echo 800 >/proc/self/oom_score_adj 2>/dev/null || true + exec -l "${SHELL:-/bin/bash}" +fi +echo 300 >/proc/self/oom_score_adj 2>/dev/null || true exec "${argv[@]}" diff --git a/scripts/server/tool-run.sh b/scripts/server/tool-run.sh new file mode 100755 index 00000000..10e93440 --- /dev/null +++ b/scripts/server/tool-run.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# @file tool-run.sh +# @description Server mode, one Linux user per account (S3): installed as +# /opt/agenthub/libexec/tool-run and named by CLAUDE_CODE_SHELL_PREFIX in +# every member pane (pane-launch). Claude Code runs each Bash command (and +# hook) as `tool-run ''`. The command runs at the kernel's +# highest OOM preference, so when memory runs out it is the command — a test +# run, a build — that is killed, never the Claude session above it that holds +# the conversation (pane-launch puts that one at 300). When the cgroup's OOM +# count moved while the command ran, Claude is told why it died. +# @author Nguyễn Ngọc Trí Vĩ +[ $# -gt 0 ] || exec /bin/bash +echo 1000 >/proc/self/oom_score_adj 2>/dev/null +cg=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null) +events=/sys/fs/cgroup${cg}/memory.events +kills() { sed -n 's/^oom_kill //p' "$events" 2>/dev/null; } +before=$(kills) +/bin/bash -c "$1" +rc=$? +if [ "$rc" -ne 0 ] && [ -n "$before" ] && [ "$(kills)" != "$before" ]; then + printf '\n%s\n' "AgentHub: the server ran out of memory, so a process of this command was stopped (exit $rc). This shared server is for writing code only: do not run tests, containers or other heavy jobs here — tell the user to run them on their own machine." >&2 +fi +exit "$rc" diff --git a/server/__tests__/member-guard.test.js b/server/__tests__/member-guard.test.js new file mode 100644 index 00000000..05b945d7 --- /dev/null +++ b/server/__tests__/member-guard.test.js @@ -0,0 +1,126 @@ +/** + * @file Tests for the S3 members' synchronous hook (scripts/member-guard.js) + * and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js): + * which Bash commands count as a test run or a container command, and which + * processes count as one top-level AI session, read from a fake /proc. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { isHeavy, guardTool } = require("../../scripts/member-guard.js"); +const procs = require("../lib/ai-procs.js"); + +describe("member-guard: a test run or a container command", () => { + const heavy = [ + "npx vitest run", + "vitest", + "npm test", + "npm t", + "npm run test:unit", + "pnpm test", + "yarn test --watch=false", + "bun test", + "cd client && NODE_ENV=test npx vitest related src/a.ts --run", + "timeout 300 npx jest", + "./vendor/bin/phpunit --filter Foo", + "php artisan test", + "vendor/bin/pest", + "python -m pytest -q", + "pytest tests/", + "go test ./...", + "cargo test", + "docker compose up -d", + "docker-compose up", + "podman run alpine", + "node_modules/.bin/playwright test", + 'bash -c "npm run e2e"', + "make test", + "npm run build && npm test 2>&1 | tail -20", + ]; + const fine = [ + "npm run build", + "npm install -D vitest", + "git commit -m 'add vitest tests'", + "rg -n docker src/", + "cat tests/a.test.js", + "ls node_modules/.bin/vitest", + "echo run npm test later", + "npx tsc -b", + "npm run lint", + "php artisan migrate:status", + "go build ./...", + "git log --grep test", + "composer install", + ]; + for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true)); + for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false)); + + it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => { + const out = []; + const write = process.stdout.write; + process.stdout.write = (s) => out.push(String(s)); + try { + guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } }); + guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } }); + guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } }); + } finally { + process.stdout.write = write; + } + assert.equal(out.length, 1); + const o = JSON.parse(out[0]).hookSpecificOutput; + assert.equal(o.hookEventName, "PreToolUse"); + assert.equal(o.permissionDecision, "deny"); + assert.match(o.permissionDecisionReason, /own machine/); + }); +}); + +/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */ +function fakeProc(list) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-")); + for (const p of list) { + const d = path.join(root, String(p.pid)); + fs.mkdirSync(d); + // pid (comm) state ppid ... field 22 = starttime (index 19 after the ')') + const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)]; + fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`); + fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0"); + } + return root; +} + +describe("ai-procs: a member's top-level AI sessions", () => { + const uid = process.getuid(); + const root = fakeProc([ + { pid: 10, ppid: 1, start: 1, argv: ["tmux"] }, + { pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] }, + { pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] }, + { pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted + { pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab + { pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] }, + { pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child + { pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server + { pid: 31, ppid: 10, start: 8, argv: ["vitest"] }, + ]); + + it("counts each session once, oldest first, and no tool server", () => { + assert.deepEqual( + procs.sessionsOf(uid, root).map((s) => s.pid), + [21, 11] + ); + }); + it("finds the session a hook process runs under", () => { + assert.equal(procs.sessionOf(13, root), 11); + assert.equal(procs.sessionOf(31, root), null); + }); + it("reads the limit from server.conf, 4 without it, 0 = off", () => { + const conf = path.join(root, "server.conf"); + assert.equal(procs.sessionLimit(conf), 4); + fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n"); + assert.equal(procs.sessionLimit(conf), 0); + }); +}); diff --git a/server/lib/ai-procs.js b/server/lib/ai-procs.js new file mode 100644 index 00000000..0145056d --- /dev/null +++ b/server/lib/ai-procs.js @@ -0,0 +1,116 @@ +/** + * @file ai-procs.js + * @description Server mode, one Linux user per account (S3): counts the AI + * Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever + * started them (a dashboard pane, or `claude` typed into a terminal tab). + * A session is a process whose program is one of the catalog tools' binaries + * (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process + * among its ancestors, so a nested `claude -p`, Codex's native child behind + * its npm launcher, or a subagent never counts twice. Used by `spawnRun` + * (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js` + * (refuses the prompt of a session over the limit started by hand), which + * runs AS the member and sees only that member's processes (hidepid) — that + * is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +/** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */ +const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]); +/** Interpreters whose first argument names the tool (an npm launcher). */ +const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]); +/** A tool's server, not a session (the host's omp share). */ +const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]); + +const DEFAULT_LIMIT = 4; +const CONF = "/etc/agenthub/server.conf"; + +const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, ""); + +/** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */ +function readProc(pid, procRoot = "/proc") { + try { + const dir = path.join(procRoot, String(pid)); + const stat = fs.readFileSync(path.join(dir, "stat"), "utf8"); + // comm may hold spaces and parentheses: the fields start after the LAST ')'. + const f = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean); + const uid = fs.statSync(dir).uid; + let name = base(argv[0]); + let rest = argv.slice(1); + if (INTERPRETERS.has(name)) { + const i = rest.findIndex((a) => !a.startsWith("-")); + name = i === -1 ? "" : base(rest[i]); + rest = i === -1 ? [] : rest.slice(i + 1); + } + const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a)); + return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai }; + } catch { + return null; // exited while we looked, or not ours to read + } +} + +/** + * The account's top-level AI sessions, oldest first. + * @param {number} uid + * @returns {{pid:number, start:number}[]} + */ +function sessionsOf(uid, procRoot = "/proc") { + const procs = new Map(); + let names = []; + try { + names = fs.readdirSync(procRoot); + } catch { + return []; + } + for (const n of names) { + if (!/^\d+$/.test(n)) continue; + const p = readProc(n, procRoot); + if (p) procs.set(p.pid, p); + } + const out = []; + for (const p of procs.values()) { + if (!p.ai || p.uid !== uid) continue; + let nested = false; + const seen = new Set(); + for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) { + seen.add(a.pid); + if (a.ai) { + nested = true; + break; + } + } + if (!nested) out.push({ pid: p.pid, start: p.start }); + } + return out.sort((a, b) => a.start - b.start || a.pid - b.pid); +} + +/** The pid of the top-level AI session `pid` runs under (itself included), or null. */ +function sessionOf(pid, procRoot = "/proc") { + let top = null; + const seen = new Set(); + for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) { + seen.add(p.pid); + if (p.ai) top = p.pid; + if (p.ppid <= 1) break; + } + return top; +} + +/** + * The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in + * server.conf (0 = no limit), else 4. + */ +function sessionLimit(confFile = CONF) { + try { + const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m); + if (m) return Number(m[1]); + } catch { + /* no server.conf: the default */ + } + return DEFAULT_LIMIT; +} + +module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit }; diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 502c0f1d..7a593b16 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -126,6 +126,30 @@ function laneIdFromRunId(id) { return m ? Number(m[1]) : null; } +/** + * S3: a member runs at most `MAX_AI_SESSIONS` AI sessions at once (default 4, + * server.conf), counted from the processes their account runs right now — + * a `claude` typed into a Terminal tab counts, the tab itself does not + * (ai-procs.js; scripts/member-guard.js refuses the prompts of one started + * past the limit by hand). The host account is an admin's and not limited. + * @throws ESESSIONLIMIT + */ +function assertSessionRoom(laneId) { + const account = require("./run-as").accountForLane(laneId); + if (!account) return; + const procs = require("./ai-procs"); + const limit = procs.sessionLimit(); + if (!limit) return; + const running = procs.sessionsOf(account.uid).length; + if (running >= limit) { + throw makeErr( + "ESESSIONLIMIT", + `AgentHub: you are running ${running} AI sessions — at most ${limit} at once on this server. ` + + "Close one (end its pane) and start again." + ); + } +} + function makeErr(code, message) { const err = new Error(message); err.code = code; @@ -475,6 +499,7 @@ function spawnRun(args) { assertCwdAccount(laneId, cwd); // ECWDACCOUNT // Server mode: an owner over their spend quota starts nothing new (user-quota.js). require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA + assertSessionRoom(laneId); // ESESSIONLIMIT const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED // A Claude session on the vendor's own API runs on the least-loaded pool // account, if the admin added any (claude-pool.js) — or the one the diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 55c76a69..4bcd3040 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -3533,11 +3533,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { // cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault. // ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state. // EQUOTA (server mode): the workspace owner is over their spend quota. + // ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed. const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) ? 409 - : err.code === "EQUOTA" + : err.code === "EQUOTA" || err.code === "ESESSIONLIMIT" ? 429 : 500; return res.status(status).json({ error: { code: err.code, message: err.message } }); diff --git a/server/routes/run.js b/server/routes/run.js index aa772dc8..fd5c533d 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -673,7 +673,7 @@ router.post("/", (req, res) => { return res.status(409).json({ error: { code: err.code, message: err.message } }); } // Server mode: the workspace owner is over their spend quota (user-quota.js). - if (err.code === "EQUOTA") { + if (err.code === "EQUOTA" || err.code === "ESESSIONLIMIT") { return res.status(429).json({ error: { code: err.code, message: err.message } }); } // Nor is a host missing tmux: the request was fine, the dependency is not