- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
13 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 developer 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 # 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 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 |
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, 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 — 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
# 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/<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 anyone but an admin and the owner of the pane's workspace (an analyst included). Open sockets are re-checked:/wsevery 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=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 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: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
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:
agenthub users list
agenthub users add <name> [--role admin|analyst|developer]
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 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.
# 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.