# Server mode AgentHub has one codebase and two ways to run it: | | Local mode (default) | Server mode | | --- | --- | --- | | Who uses it | You, on your own machine | Several people over the web | | Login | None (the loopback bind is the trust boundary) | Every page, API call and WebSocket needs one | | Turn on | Nothing to do: every install starts here | `agenthub server setup` | | Extra code loaded | None: `/api/auth/me` answers `{mode: "local"}` and nothing else changes | `server/lib/auth.js`, the login page | Local mode does not change at all. The login code stays dormant until `AGENTHUB_MODE=server` is set. > **What server mode separates, and what it does not.** In the dashboard and > its API, each developer sees only their own workspaces and the sessions, > prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)). > Every terminal pane, though, still runs as the one Linux account that runs > the dashboard. An agent in a pane can therefore read every user's folders, > transcripts and the database straight from disk. Use server mode only with > people you trust until phase S3 (one Linux user per account) lands. S3 and S4 > (shared tools) need `sudo` on the host and are planned in the AgentHub-Server > repo. ## Set it up On the machine that will host the dashboard, logged in as the account that runs it: ```bash # 1. Turn server mode on, allow the public name, and create the first admin agenthub server setup --hostname agenthub.example.com --admin boss # (asks for the admin's password twice; from a script: pipe it on stdin) # 2. Restart the dashboard so it picks the mode up systemctl --user restart agenthub # or: agenthub start # 3. Add the other people agenthub users add alice # developer agenthub users add carol --role analyst # reads everyone, works in their own ``` `setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into AgentHub's settings file (`~/.config/agenthub/settings.env`). The real environment and a checkout's `.env` still take priority over that file. Run setup again whenever you need to: it creates an admin only when none exists. ## Put HTTPS in front of it Keep the dashboard on its default loopback bind (do **not** set `DASHBOARD_HOST`) and serve it through a reverse proxy on the same machine. The proxy has to: - pass the original `Host` (the dashboard checks it against `DASHBOARD_ALLOWED_HOSTS` and compares it with the browser's `Origin`); - forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals); - set `X-Forwarded-For` and `X-Forwarded-Proto`. nginx: ```nginx server { listen 443 ssl; server_name agenthub.example.com; ssl_certificate /etc/ssl/agenthub.crt; ssl_certificate_key /etc/ssl/agenthub.key; location / { proxy_pass http://127.0.0.1:4820; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 1d; } } ``` The login cookie gets `Secure` when the request came over HTTPS (`X-Forwarded-Proto: https`). ## Roles | Role | Can | | --- | --- | | `admin` | Everything, including the host's own configuration and every user's data | | `analyst` | Reads everything (usage, prompts, every user's sessions), and works in their OWN workspaces like a developer. Never changes another user's rows or the host's configuration | | `developer` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration | `developer` was called `member` before; a database with members is migrated on start (`server/db.js`), and `member` is no longer accepted. "The host's configuration" means a write (anything but GET/HEAD) under `/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`, `/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`, `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and `/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only an admin can do this". An analyst's other writes are scoped like a developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in `server/lib/scope.js` limits every non-GET request of an analyst to their own rows). The UI hides what a role cannot use (`usePermissions` in `client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows no Data, Pricing, AI proxy or per-tool pages, no scheduled reports or git credentials, no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels, no install/update/enable on AI tools, no pet upload or delete, and no update check. An analyst's Workspace page and sidebar list only their own workspaces (`useWorkLanes`); other users' work is read on Sessions, Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are hidden for a session that is not theirs. An analyst's alert feed (`GET /api/alerts`) carries `can_ack` on each alert — true only for their own sessions' — and the feed offers Acknowledge only there; Ack all acknowledges only theirs. ## What each user sees Every workspace has an owner: the user who created it (`lanes.owner_id`). Workspaces made before server mode, or by the host account, have none. - A session belongs to the workspace that owns it: `sessions.lane_id`, or the workspace whose pane holds it. Its agents, events, tokens, cost, alerts, snapshots and workflow runs belong to that session. - A **developer** sees only rows of their own workspaces, everywhere: lists, counts, charts, cost, the Sessions/Events/Agents tables, exports, live WebSocket updates and terminals. A request naming someone else's row (in the path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row that does not exist (`scopeGuard`, `server/lib/scope.js`). Sessions with no workspace, such as ones started in a terminal outside AgentHub, are not shown to developers. - An **admin** or **analyst** sees everything. Each time one of them opens a session a developer owns (its page, transcript, events or cost), the read is written to the audit log: `agenthub users audit`, or `GET /api/users/audit`. - `GET /api/users` (admin/analyst) lists every account with its workspaces, sessions, and the tokens and $ it used in the last 7 and 30 days — the Users page in the sidebar shows it, with the audit log under it. A developer also cannot open the host's own configuration, or any view across all users: Claude Code / Codex config (except the command and skill lists the prompt box uses), git credentials, remote sources, webhooks, reports, metrics, imports, data export, alert rules and `/api/users`. Those return `403 EADMIN`, and Settings hides them. ### Keep developers' folders apart ```bash # in ~/.config/agenthub/settings.env AGENTHUB_USERS_ROOT=/srv/agenthub/users ``` With this set, a developer or analyst can create a workspace only inside `/srv/agenthub/users/`; any other folder gets `403 EFOLDER`. A folder that already belongs to another user's workspace gets `409 EOWNED`. Admins are not limited. Create each user's folder yourself. Until S3 this keeps workspaces apart, not agents. ## What the gate does - `/api/health` and `/api/auth/*` answer without a login. Everything else under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the login page. Paths are checked the way Express routes them: decoded, with repeated slashes collapsed, and case-insensitive. - Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket with no forwarding header. A request through the proxy gets `403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token. - Every write (any method but GET/HEAD) that carries an `Origin` or `Referer` must name the dashboard's own allowed host, or loopback. Anything else gets `403 EBADORIGIN`. This is on top of the `SameSite=Strict` cookie, which a sibling subdomain would still satisfy. - WebSockets need a login, and an `Origin` that is the request's own allowed Host. A terminal (`/ws-pty/*`) is refused to anyone but an admin and the owner of the pane's workspace (an analyst included). Open sockets are re-checked: `/ws` every 30 s, terminals every 10 s (`AGENTHUB_PTY_RECHECK_MS`). A logout, a disable, a password change, or losing the pane's workspace closes them. - A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie `agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password change and disabling the user each end it. - Passwords are stored as scrypt hashes and must be at least 8 characters. Every login attempt counts before its password is checked, so attempts sent in parallel count too. After 10 failed logins from one address, or 30 for one username, in 15 minutes, further logins answer `429 ETOOMANY` until the window passes. The counters are kept in memory, so a restart resets them. - **Inside a pane**, the `agenthub` CLI, the MCP server and the statusline act as the owner of the pane's workspace. Each pane the dashboard starts gets its own `AGENTHUB_PANE_TOKEN` through tmux `-e`, and the database stores only its hash (`dashboard_runs.pane_token_hash`). The token is accepted only from this machine and only while that pane is alive, as header `x-agenthub-pane-token`. A workspace with no owner has no such user. Give old workspaces an owner with `agenthub users assign `. - Named locks (`agenthub lock …`) are per user: a developer's lock names live in their own namespace, so one user's agents never see or take another's. - Browser push subscriptions belong to the user who made them. A notification goes only to that user's own browsers, never to the host's desktop. - A developer or analyst cannot create a session by hand (`POST /api/sessions` → `403`), and cannot change a workspace's pane or session columns: `PATCH` accepts only `title`, `pipeline`, `notes`, `links` and `branch` (`403 EFIELD`). Pet uploads are admin-only. - The native file picker stays local-only (`EREMOTE`) and every page falls back to its folder browser. ## Accounts An admin manages accounts on the **Users** page (sidebar; admins and analysts see it, an analyst only reads): add a user, change a role, disable or enable, reset a password, give a workspace. Every change asks for the admin's OWN password again — the server refuses without it (`EREAUTH`, and `ETOOMANY` after 10 wrong tries in 15 minutes) — an admin can never demote or disable themselves (`ESELF`), and each change is written to the audit log. In local mode these routes refuse (`ELOCALMODE`): there is no login to check. The same from the host's shell: ```bash agenthub users list agenthub users add [--role admin|analyst|developer] agenthub users passwd # ends that user's logins agenthub users role agenthub users disable # ends that user's logins; users are never deleted agenthub users enable agenthub users assign # give a pre-server-mode workspace an owner agenthub users audit [--limit N] # who read which developer's session agenthub server status ``` ## Claude account pool An admin can sign every Claude account the company has into AgentHub. Each new Claude session then runs on the account with the most room left, and nobody has to log in or switch accounts by hand. The pool works in local mode too, and it stays empty (changing nothing) until you add an account. ```bash # Once per account: sign in as that account and make a one-year token claude setup-token # Add it to the pool (the token is typed hidden, or piped on stdin) agenthub accounts add team-1 agenthub accounts list # live panes, $ in the last 5 h / 7 d, quota, state agenthub accounts remove team-1 ``` How a new session picks its account (`server/lib/claude-pool.js`): 1. An account whose last quota reading is at or above 90% in the 5-hour or weekly window goes last. It is still used if every account is that full. 2. Next, the account with the fewest live panes, so concurrent sessions spread out. 3. Then the account with the least $ spent in the last 5 hours, then the last 7 days. AgentHub sums this from the usage of the sessions it started on each account (`dashboard_runs.account`). - The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That token outranks the machine's own `/login` for that process only. - A session on an AI Proxy keeps the proxy's key and does not use the pool. - A token more than a year old is skipped. - A resumed session picks an account again, like a new one. - Quota is read with each account's own token, in the background, at most every 5 minutes. If the usage endpoint refuses a setup-token, the state column shows `no quota (…)` and ranking uses spend alone. - `GET /api/accounts` returns the same table, without tokens. > **Ceiling:** anyone with a terminal in AgentHub can read the token of the > account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`), and, as one > Linux user, every file of the pool. Hiding tokens from panes needs one Linux > user per account (phase S3), or AgentHub acting as the pane's API endpoint. ## Turn it off ```bash agenthub server off systemctl --user restart agenthub ``` Accounts are kept, so `agenthub server setup` turns server mode back on with the same users.