feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
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.
This commit is contained in:
+17
@@ -63,6 +63,8 @@ The server is **local-first** and is hardened to keep the dashboard off the netw
|
||||
|
||||
For deliberate LAN exposure, set `DASHBOARD_HOST` to a non-loopback address and list the names clients use in `DASHBOARD_ALLOWED_HOSTS`.
|
||||
|
||||
The routes that act on the machine (starting a session, creating a workspace, git writes, workspace sources, locks…) check the browser's `Origin` (`sameOriginGuard`). They accept a loopback origin, or an origin that **is** the request's own Host when that Host is one of `DASHBOARD_ALLOWED_HOSTS`. Before server mode they accepted loopback only, so a dashboard opened at a LAN name answered those writes with `403 EBADORIGIN`. Since then, the writes work from that LAN name too. Set `DASHBOARD_TOKEN` if the LAN is not trusted.
|
||||
|
||||
### Optional token (`DASHBOARD_TOKEN`)
|
||||
|
||||
Authentication is **off by default** (the loopback bind is the trust boundary). When `DASHBOARD_TOKEN` is set, every `/api/*` request **and** the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
|
||||
@@ -73,6 +75,20 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
|
||||
|
||||
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
|
||||
|
||||
### Server mode login (`AGENTHUB_MODE=server`)
|
||||
|
||||
A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](SERVER-MODE.md)). Every `/api/*` call and WebSocket then needs the login cookie `agenthub_sid` (httpOnly, `SameSite=Strict`), except `/api/health` and `/api/auth/*`. A call without one answers `401` with code `EAUTH`. Hooks are accepted only from the host itself (`403 ELOCALONLY` otherwise). An `analyst` may only read (`403 EREADONLY`), and only an `admin` may write the host's configuration (`403 EADMIN`).
|
||||
|
||||
| Route | Answer |
|
||||
| --- | --- |
|
||||
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` |
|
||||
| `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 **member** 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 with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
|
||||
|
||||
```mermaid
|
||||
@@ -2429,6 +2445,7 @@ GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
POST /api/run/drop-file?name= Any raw file body (≤20 MB) → {path}: same dir, 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -101,6 +101,10 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
|
||||
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `kill <pid>` stop command. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once) |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
|
||||
@@ -609,6 +609,23 @@ CREATE TABLE report_runs (
|
||||
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
|
||||
```
|
||||
|
||||
### users / auth_sessions
|
||||
|
||||
Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`). They are created on every install and stay empty in local mode.
|
||||
|
||||
| Table | Columns |
|
||||
| --- | --- |
|
||||
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
|
||||
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
|
||||
|
||||
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
||||
|
||||
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
|
||||
|
||||
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
|
||||
|
||||
Users are never deleted, only disabled. Disabling a user or changing their password deletes their `auth_sessions` rows.
|
||||
|
||||
### lane_sources (and `lanes.instructions`)
|
||||
|
||||
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
|
||||
|
||||
+1
-1
@@ -407,7 +407,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
|
||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
|
||||
|
||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
@@ -45,6 +45,7 @@ graph TB
|
||||
| [client/README.md](../client/README.md) | React frontend architecture, components, state management | Frontend developers |
|
||||
| [server/README.md](../server/README.md) | Express backend, database, WebSocket, API | Backend developers |
|
||||
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
|
||||
| [SERVER-MODE.md](./SERVER-MODE.md) | Server mode: logins and roles for a dashboard shared over the web (`agenthub server setup`), reverse proxy setup | Operators |
|
||||
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
|
||||
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
|
||||
| [MCP.md](./MCP.md) | The read-only MCP server (`agenthub mcp`): setup and tool reference | MCP integrators |
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# 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](#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 # 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 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 |
|
||||
| `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`, 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.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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/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 an analyst and to anyone but the
|
||||
owner of the pane's workspace. 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 a demotion to analyst 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 <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: `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
|
||||
|
||||
Accounts are managed from the host's shell, never from a web route:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Server mode — working checklist
|
||||
|
||||
Goal (2026-10-07): finish the server part — S2 ownership as proposed, review it.
|
||||
|
||||
- [x] S1 login, roles, `agenthub server|users` (uncommitted)
|
||||
- [x] Claude account pool + balancer (uncommitted)
|
||||
- [x] S2 core: `lanes.owner_id`, `server/lib/scope.js` (predicates, `scopeGuard`, broadcast audience, folder root), STAFF_ONLY_PREFIXES, pty attach check, heartbeat re-check
|
||||
- [x] S2 routes: aggregates, rows, workflows (+ read worker), workspaces/runs — 4-agent workflow, each with its own test file
|
||||
- [x] Bypass fix: `apiPath()` canonical path + case-insensitive rules (found by the workspaces agent)
|
||||
- [x] S6 basics: `GET /api/users`, audit log (`audit_log`, `agenthub users audit`), Settings → Users, members' Settings hides staff pages
|
||||
- [x] Adversarial review (5 lenses, 21 findings, each verified) and fixes: lane id coercion (1e1), PATCH run_id, array ids, POST agents/sessions, transcript path traversal (all modes), push per user, pool/quota leaks, pty re-check, CSRF origin on writes, parallel login throttle, overlapping folders, pane credential for CLI/MCP/statusline, lock namespace + lock-name path traversal (all modes), AuthGate retry/cache
|
||||
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
|
||||
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
|
||||
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
|
||||
Reference in New Issue
Block a user