diff --git a/CLAUDE.md b/CLAUDE.md index 737a7fc2..90a661e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +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. +- **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 (unless an admin's Settings switch — `member-policy.json` in the app root, `/api/ai-tools/member-policy` — allows them) 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/client/src/components/settings/MemberPolicySettings.tsx b/client/src/components/settings/MemberPolicySettings.tsx new file mode 100644 index 00000000..90d7c0f3 --- /dev/null +++ b/client/src/components/settings/MemberPolicySettings.tsx @@ -0,0 +1,53 @@ +/** + * @file MemberPolicySettings — Settings → AI tools → Tests on this server: + * the admin's server-wide switch that allows or blocks members' Claude from + * running test runners and container commands on this box + * (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`, + * enforced by `scripts/member-guard.js`). Renders nothing outside server mode + * with per-user accounts (S3), or for anyone but an admin. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api, type MemberPolicyState } from "../../lib/api"; +import { toast } from "../../lib/toast"; +import { usePermissions } from "../AuthGate"; +import { SettingsSection, SettingsSwitchRow } from "./SettingsRow"; + +export function MemberPolicySettings() { + const { t } = useTranslation("settings"); + const { isAdmin, local } = usePermissions(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (local || !isAdmin) return; + api.aiTools + .memberPolicy() + .then(setState) + .catch(() => setState(null)); + }, [local, isAdmin]); + + if (local || !isAdmin || !state?.available) return null; + return ( + + { + setBusy(true); + try { + setState(await api.aiTools.setMemberPolicy(on)); + } catch (err) { + toast.error(err); + } finally { + setBusy(false); + } + }} + /> + + ); +} diff --git a/client/src/components/settings/__tests__/MemberPolicySettings.test.tsx b/client/src/components/settings/__tests__/MemberPolicySettings.test.tsx new file mode 100644 index 00000000..7aea0eab --- /dev/null +++ b/client/src/components/settings/__tests__/MemberPolicySettings.test.tsx @@ -0,0 +1,57 @@ +/** + * @file Tests for MemberPolicySettings — the admin's "allow members to run + * tests" switch in Settings → AI tools: hidden in local mode, for a non-admin + * and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "../../../i18n"; +import { MemberPolicySettings } from "../MemberPolicySettings"; +import { api } from "../../../lib/api"; + +const perms = vi.hoisted(() => ({ isAdmin: true, local: false })); +vi.mock("../../AuthGate", () => ({ usePermissions: () => perms })); +vi.mock("../../../lib/api", () => ({ + api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } }, +})); + +const get = vi.mocked(api.aiTools.memberPolicy); +const set = vi.mocked(api.aiTools.setMemberPolicy); + +describe("MemberPolicySettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + perms.isAdmin = true; + perms.local = false; + }); + + it("lets an admin allow tests, off by default", async () => { + get.mockResolvedValue({ available: true, allowTests: false }); + set.mockResolvedValue({ available: true, allowTests: true }); + render(); + const sw = await screen.findByRole("switch", { name: "Allow members to run tests" }); + expect(sw).toHaveAttribute("aria-checked", "false"); + fireEvent.click(sw); + await waitFor(() => expect(set).toHaveBeenCalledWith(true)); + await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true")); + }); + + it("hides outside S3", async () => { + get.mockResolvedValue({ available: false, allowTests: false }); + const { container } = render(); + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it("asks nothing for a non-admin or in local mode", () => { + perms.isAdmin = false; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + perms.isAdmin = true; + perms.local = true; + render(); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index e3ef1521..df8b69d9 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -1169,6 +1169,11 @@ "vi": "Tiếng Việt" } }, + "memberPolicy": { + "title": "Tests on this server", + "switch": "Allow members to run tests", + "hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way." + }, "ompSharing": { "title": "Oh My Pi login sharing", "switch": "Share this server's Oh My Pi login", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 0854a9e2..9f9d0a82 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -1157,6 +1157,11 @@ "vi": "Tiếng Việt" } }, + "memberPolicy": { + "title": "Chạy test trên server này", + "switch": "Cho phép thành viên chạy test", + "hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp." + }, "ompSharing": { "title": "Dùng chung đăng nhập Oh My Pi", "switch": "Dùng chung đăng nhập Oh My Pi của máy chủ", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index a02d74ff..67971860 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3782,6 +3782,14 @@ export const api = { method: "PUT", body: JSON.stringify({ enabled }), }), + /** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */ + memberPolicy: () => request("/ai-tools/member-policy"), + /** PUT /api/ai-tools/member-policy - an admin's switch. */ + setMemberPolicy: (allowTests: boolean) => + request("/ai-tools/member-policy", { + method: "PUT", + body: JSON.stringify({ allowTests }), + }), /** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */ refresh: () => request("/ai-tools/refresh", { method: "POST" }), /** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */ @@ -5274,6 +5282,13 @@ export interface ArtifactCandidate { } /** `GET /api/ai-tools/omp-sharing` — never a token. */ +export interface MemberPolicyState { + /** Server mode with per-user Linux accounts (S3): the only place it applies. */ + available: boolean; + /** May members' Claude run test runners and container commands? Off by default. */ + allowTests: boolean; +} + export interface OmpSharingState { /** Server mode with per-user Linux accounts (S3): the only place it applies. */ available: boolean; diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index c7134082..a80ed792 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings"; import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings"; import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings"; +import { MemberPolicySettings } from "../components/settings/MemberPolicySettings"; import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings"; import { PoolAccountSettings } from "../components/settings/PoolAccountSettings"; import { OmpSharingSettings } from "../components/settings/OmpSharingSettings"; @@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) { {/* The host's omp login for members: server mode + S3, an admin's. */} + {/* Members' test runs on this box: server mode + S3, an admin's. */} + ), }, diff --git a/docs/API.md b/docs/API.md index 1286d99f..2af794ca 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`. 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). +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. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). 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 6e02f7d5..db3b3523 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -1179,6 +1179,10 @@ tests run on each member's own machine: `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. + An admin can allow them again with **Settings → AI tools → Tests on this + server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in + `/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect + at members' next Bash call, no restart. - **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`, diff --git a/scripts/member-guard.js b/scripts/member-guard.js index 796ecb9d..d2cd53a9 100644 --- a/scripts/member-guard.js +++ b/scripts/member-guard.js @@ -8,7 +8,8 @@ * * - `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. + * and runs it there. Claude gets the reason and stops trying. An admin + * can allow them again (Settings → AI tools, server/lib/member-policy.js). * - `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 @@ -25,6 +26,7 @@ const os = require("node:os"); const path = require("node:path"); const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js")); +const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js")); /** Test runners and container tools, by the program a command segment runs. */ const HEAVY_BINS = new Set([ @@ -139,8 +141,12 @@ const DENY_REASON = "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; +/** The admin's switch (Settings → AI tools), beside this script in the app root. */ +const testsAllowed = () => + policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true; + +function guardTool(input, allowed = testsAllowed()) { + if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return; process.stdout.write( JSON.stringify({ hookSpecificOutput: { diff --git a/server/__tests__/member-guard.test.js b/server/__tests__/member-guard.test.js index 05b945d7..58e4187a 100644 --- a/server/__tests__/member-guard.test.js +++ b/server/__tests__/member-guard.test.js @@ -77,6 +77,18 @@ describe("member-guard: a test run or a container command", () => { assert.equal(o.permissionDecision, "deny"); assert.match(o.permissionDecisionReason, /own machine/); }); + + it("lets everything through once an admin allows tests", () => { + const out = []; + const write = process.stdout.write; + process.stdout.write = (s) => out.push(String(s)); + try { + guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true); + } finally { + process.stdout.write = write; + } + assert.equal(out.length, 0); + }); }); /** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */ @@ -93,6 +105,28 @@ function fakeProc(list) { return root; } +describe("member-policy: the admin's switch, read by the guard", () => { + it("blocks tests without a file, and writes a world-readable one", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-")); + const prev = process.env.AGENTHUB_APP_ROOT; + process.env.AGENTHUB_APP_ROOT = dir; + try { + const policy = require("../lib/member-policy.js"); + assert.equal(policy.read().allowTests, false); + fs.writeFileSync(path.join(dir, policy.FILE), "not json"); + assert.equal(policy.read().allowTests, false); + assert.equal(policy.setAllowTests(true).allowTests, true); + const f = path.join(dir, policy.FILE); + assert.equal(fs.statSync(f).mode & 0o777, 0o644); + assert.equal(policy.read(f).allowTests, true); + assert.equal(policy.setAllowTests(false).allowTests, false); + } finally { + if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT; + else process.env.AGENTHUB_APP_ROOT = prev; + } + }); +}); + describe("ai-procs: a member's top-level AI sessions", () => { const uid = process.getuid(); const root = fakeProc([ diff --git a/server/lib/member-policy.js b/server/lib/member-policy.js new file mode 100644 index 00000000..c8e456f2 --- /dev/null +++ b/server/lib/member-policy.js @@ -0,0 +1,56 @@ +/** + * @file member-policy.js + * @description Server mode, one Linux user per account (S3): the admin's + * switch for what members' Claude may run on this box — today whether test + * runners and container commands are allowed (`allowTests`, off by default). + * Kept in `/member-policy.json` (/opt/agenthub/app, host-owned, + * world-readable), because `scripts/member-guard.js` runs AS the member and + * reads it from there; a missing or unreadable file means the default, so + * tests stay blocked unless an admin turned them on. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const FILE = "member-policy.json"; +const DEFAULTS = Object.freeze({ allowTests: false }); + +const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app"; +const file = () => path.join(appRoot(), FILE); + +/** The policy in force (defaults for anything missing or unreadable). */ +function read(p = file()) { + try { + const j = JSON.parse(fs.readFileSync(p, "utf8")); + return { allowTests: j?.allowTests === true }; + } catch { + return { ...DEFAULTS }; + } +} + +/** Does it apply here? Only S3 runs members' panes as their own accounts. */ +function available() { + try { + return require("./linux-accounts").s3Enabled(); + } catch { + return false; + } +} + +function state() { + return { available: available(), ...read() }; +} + +/** Writes the switch (atomically, 0644: members' hooks read it). */ +function setAllowTests(on) { + const next = { ...read(), allowTests: on === true }; + fs.mkdirSync(appRoot(), { recursive: true }); + const tmp = `${file()}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o644 }); + fs.chmodSync(tmp, 0o644); + fs.renameSync(tmp, file()); + return state(); +} + +module.exports = { FILE, DEFAULTS, read, available, state, setAllowTests }; diff --git a/server/openapi-extra/ai-tools.js b/server/openapi-extra/ai-tools.js index c009518a..66934301 100644 --- a/server/openapi-extra/ai-tools.js +++ b/server/openapi-extra/ai-tools.js @@ -173,6 +173,43 @@ const paths = { }, }, }, + "/api/ai-tools/member-policy": { + get: { + tags: ["AI Tools"], + summary: "What members' Claude may run on this server", + description: + "Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).", + operationId: "aiToolsMemberPolicy", + responses: { + 200: { description: "`{available, allowTests}`." }, + }, + }, + put: { + tags: ["AI Tools"], + summary: "Allow or block members' test runs", + description: + "An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.", + operationId: "aiToolsSetMemberPolicy", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["allowTests"], + properties: { allowTests: { type: "boolean" } }, + }, + }, + }, + }, + responses: { + 200: { description: "The new state, as GET." }, + 400: { description: "EBADFIELD" }, + 403: { description: "Not an admin" }, + 409: { description: "ENOS3 — not server mode with per-user accounts" }, + }, + }, + }, "/api/ai-tools/{id}": { put: { tags: ["AI Tools"], diff --git a/server/routes/ai-tools.js b/server/routes/ai-tools.js index 3579e037..2a93a785 100644 --- a/server/routes/ai-tools.js +++ b/server/routes/ai-tools.js @@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => { res.json(state); }); +// What members' Claude may run on this box (server/lib/member-policy.js, +// read by scripts/member-guard.js): S3 only, an admin's switch. +router.get("/member-policy", (_req, res) => { + res.json(require("../lib/member-policy").state()); +}); + +router.put("/member-policy", sameOriginGuard, (req, res) => { + const on = req.body?.allowTests; + if (typeof on !== "boolean") { + return res.status(400).json({ error: { code: "EBADFIELD", message: "allowTests: boolean" } }); + } + const policy = require("../lib/member-policy"); + if (!policy.available()) { + return res.status(409).json({ + error: { code: "ENOS3", message: "this needs server mode with per-user accounts" }, + }); + } + const state = policy.setAllowTests(on); + if (req.user) { + require("../lib/audit").recordAccountChange( + req.user.id, + null, + "PUT", + `members' test runs ${on ? "allowed" : "blocked"}` + ); + } + res.json(state); +}); + router.put("/:id", sameOriginGuard, (req, res) => { const { enabled, default: makeDefault, defaultModel } = req.body || {}; // Every field checked before anything is written: a bad one saves nothing.