Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
12 KiB
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 member sees only their own workspaces and the sessions, prompts, events, tokens and cost under them (see 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
sudoon 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:
# 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 # member
agenthub users add carol --role analyst # read-only
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 againstDASHBOARD_ALLOWED_HOSTSand compares it with the browser'sOrigin); - forward WebSocket upgrades (
/wsfor live updates,/ws-pty/*for terminals); - set
X-Forwarded-ForandX-Forwarded-Proto.
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 |
member |
Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
analyst |
Reads everything (usage, prompts, every user's sessions); no terminal, no write |
"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 and /reports
(ADMIN_WRITE_PREFIXES in server/lib/auth.js). A member who tries one gets
403 EADMIN. An analyst who tries any write gets 403 EREADONLY.
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 member 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 members. - An admin or analyst sees everything. Each time one of them opens a
session a member owns (its page, transcript, events or cost), the read is
written to the audit log:
agenthub users audit, orGET /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.
A member 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 for members.
Keep members' folders apart
# in ~/.config/agenthub/settings.env
AGENTHUB_USERS_ROOT=/srv/agenthub/users
With this set, a member can create a workspace only inside
/srv/agenthub/users/<username>; 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/healthand/api/auth/*answer without a login. Everything else under/apianswers401 EAUTHuntil 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 gets403 ELOCALONLY.POST /api/hooks/ingest-batchkeeps its own bearer token. - Every write (any method but GET/HEAD) that carries an
OriginorReferermust name the dashboard's own allowed host, or loopback. Anything else gets403 EBADORIGIN. This is on top of theSameSite=Strictcookie, which a sibling subdomain would still satisfy. - WebSockets need a login, and an
Originthat is the request's own allowed Host. A terminal (/ws-pty/*) is refused to an analyst and to anyone but the owner of the pane's workspace. Open sockets are re-checked:/wsevery 30 s, terminals every 10 s (AGENTHUB_PTY_RECHECK_MS). A logout, a disable, a password change, or a demotion to analyst closes them. - A login lasts 30 days in the httpOnly,
SameSite=Strictcookieagenthub_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 ETOOMANYuntil the window passes. The counters are kept in memory, so a restart resets them. - Inside a pane, the
agenthubCLI, the MCP server and the statusline act as the owner of the pane's workspace. Each pane the dashboard starts gets its ownAGENTHUB_PANE_TOKENthrough 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 headerx-agenthub-pane-token. A workspace with no owner has no such user. Give old workspaces an owner withagenthub users assign <workspace id> <user>. - Named locks (
agenthub lock …) are per user: a member'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 member cannot create a session by hand (
POST /api/sessions→403), and cannot change a workspace's pane or session columns:PATCHaccepts onlytitle,pipeline,notes,linksandbranch(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
Accounts are managed from the host's shell, never from a web route:
agenthub users list
agenthub users add <name> [--role admin|analyst|member]
agenthub users passwd <name> # ends that user's logins
agenthub users role <name> <role>
agenthub users disable <name> # ends that user's logins; users are never deleted
agenthub users enable <name>
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
agenthub users audit [--limit N] # who read which member'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.
# 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):
- 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.
- Next, the account with the fewest live panes, so concurrent sessions spread out.
- 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/loginfor 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/accountsreturns 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
agenthub server off
systemctl --user restart agenthub
Accounts are kept, so agenthub server setup turns server mode back on with
the same users.