feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+58
-14
File diff suppressed because one or more lines are too long
+4
-2
@@ -105,9 +105,11 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `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 install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `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 |
|
||||
| `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. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). 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>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `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
|
||||
|
||||
|
||||
+602
-29
@@ -15,12 +15,11 @@ Local mode does not change at all. The login code stays dormant until
|
||||
> **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.
|
||||
> Until [one Linux user per account](#one-linux-user-per-account) is installed
|
||||
> (`agenthub server install-system`), every terminal pane runs as the one Linux
|
||||
> account that runs the dashboard. An agent in a pane can then read every
|
||||
> user's folders, transcripts and the database straight from disk. Without it,
|
||||
> use server mode only with people you trust.
|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -93,22 +92,25 @@ The login cookie gets `Secure` when the request came over HTTPS
|
||||
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
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). 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).
|
||||
rows), and under S3 an analyst's workspace runs as their own Linux account
|
||||
exactly like a developer's.
|
||||
|
||||
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
|
||||
no Data, Pricing 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; the AI proxy page shows them only their personal profiles' Edit. 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
|
||||
@@ -140,9 +142,38 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
|
||||
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.
|
||||
prompt box uses), the AI tools' config files (`/api/ai-tools/<id>/config…`),
|
||||
git credentials, remote sources, webhooks, reports, metrics, imports, data
|
||||
export, alert rules and `/api/users`. Those return `403 EADMIN`, and Settings
|
||||
hides them for developers. The command and skill lists show a developer only their
|
||||
own workspaces' project items: `?cwd=` outside one of them answers
|
||||
`403 EFOLDER`, and with no `cwd` they list no project items at all.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
|
||||
|
||||
- A developer's or analyst's new profile is always personal. An admin's is shared unless
|
||||
they choose **Only me (personal)** in the form (`scope: "personal"`).
|
||||
- A personal profile is named `u<user id>_<slug>`. Only its owner lists,
|
||||
edits, deletes or tests it, and only the owner's workspaces can start a
|
||||
session on it. Anyone else, an admin included, gets `404`, the same answer
|
||||
as a profile that does not exist. A resume of a session that ran on someone
|
||||
else's personal profile goes back to native.
|
||||
- Only an admin writes a shared profile. Everyone can choose one.
|
||||
- **No pane is given a proxy's key, and no route returns one.**
|
||||
- A session's CLI talks to AgentHub's own loopback gateway,
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>/v1/…`. Its key there is
|
||||
its pane's token (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY`
|
||||
for Codex).
|
||||
- AgentHub swaps in the real key and streams the request to the profile's
|
||||
endpoint.
|
||||
- The gateway answers only on this machine, only while the pane lives, and
|
||||
only for the profile the pane was started on.
|
||||
- An agent can still spend through the profile while its pane runs, but it
|
||||
cannot carry the key away.
|
||||
- Claude account-pool tokens are not covered: they still reach the pane (see
|
||||
[Claude account pool](#claude-account-pool)).
|
||||
|
||||
### Keep developers' folders apart
|
||||
|
||||
@@ -152,10 +183,16 @@ 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
|
||||
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. The
|
||||
same root limits a developer's folder browser (`GET /api/lanes/browse`, which
|
||||
opens on their own folder) and `GET /api/lanes/branches`, both judged by real
|
||||
path, so a link cannot point them elsewhere. Under S3 the home itself is no
|
||||
workspace (its dot folders hold the member's logins); use a folder in it. 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.
|
||||
not limited. Without [one Linux user per account](#one-linux-user-per-account)
|
||||
you create each user's folder yourself, and this keeps workspaces apart, not
|
||||
agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a
|
||||
user makes the folder (it is the Linux account's home).
|
||||
|
||||
## What the gate does
|
||||
|
||||
@@ -187,9 +224,17 @@ apart, not agents.
|
||||
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`.
|
||||
machine and only while that pane is alive, as header `x-agenthub-pane-token`
|
||||
(with one Linux user per account on: only from the Linux account that pane
|
||||
runs as, read from the kernel like a hook's sender).
|
||||
A workspace with no owner has no such user. Give old workspaces an owner with
|
||||
`agenthub users assign <workspace id> <user>`.
|
||||
`agenthub users assign <workspace id> <user>`. With one Linux user per
|
||||
account on, a workspace inside a member's home can be owned by that member
|
||||
only, and one outside the users root (its panes run as the host account) by
|
||||
an admin only: creating, assigning or starting one against that answers
|
||||
`409 EOWNERFOLDER` — by the user's role at that moment, and demoting or
|
||||
disabling a user ends their panes that run as the host. A pane's folder must
|
||||
be its workspace's account's too (`409 ECWDACCOUNT`).
|
||||
- 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
|
||||
@@ -262,9 +307,537 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
- `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.
|
||||
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`). Without one
|
||||
> Linux user per account they can also read every file of the pool. With it,
|
||||
> they still see their own pane's token (a member's pane gets it through its
|
||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||
> The AI Proxy gateway does not cover pool tokens.
|
||||
|
||||
## Codex account pool
|
||||
|
||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||
|
||||
```bash
|
||||
# An OpenAI API key (typed hidden, or piped on stdin)
|
||||
agenthub accounts add team-key --tool codex --api-key
|
||||
# A ChatGPT account: sign in in a scratch home, then hand that home over
|
||||
CODEX_HOME=$(mktemp -d) codex login
|
||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||
agenthub accounts list --tool codex
|
||||
agenthub accounts remove team-key --tool codex
|
||||
```
|
||||
|
||||
- **API key**: any number of panes at once. The pane gets the key through
|
||||
tmux `-e AGENTHUB_POOL_KEY=…`, and Codex is pointed at it with
|
||||
`-c model_providers.agenthub_pool={…env_key="AGENTHUB_POOL_KEY"…}` — the
|
||||
argv names the variable, never the key.
|
||||
- **ChatGPT**: Codex reads this login only from `$CODEX_HOME/auth.json`, so it
|
||||
is used only for a pane that runs as its developer (one Linux user per
|
||||
account): AgentHub writes the account's `auth.json` into that developer's
|
||||
`~/.codex` as them (0600) just before the pane starts.
|
||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||
- A developer who signed Codex in themselves keeps their own login.
|
||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||
same account (one `auth.json` per home).
|
||||
- Codex rotates refresh tokens. Every 10 s AgentHub copies a developer's
|
||||
`auth.json` back into the pool when its `last_refresh` is newer, so a new
|
||||
pane starts from the newest login — only from a home AgentHub placed that
|
||||
account in, never with a `last_refresh` in the future, and only when its
|
||||
`id_token` names the same account and email. The `id_token` signature is
|
||||
not checked: a developer the account was placed with can still spoil it
|
||||
for others until a real refresh from another home wins it back. Several panes may share one account
|
||||
(checked on codex 0.161: an older refresh token still refreshes).
|
||||
- Ranking is the Claude pool's: quota ≥ 90% last, then fewest live Codex
|
||||
panes, then $ in 5 h / 7 d. The quota is the newest `rate_limits` in the
|
||||
rollouts of the sessions that account's runs started (no network), each
|
||||
read only from the `CODEX_HOME` of the account its workspace runs as, and
|
||||
never one dated in the future.
|
||||
- A session on an AI Proxy keeps the proxy and does not use the pool.
|
||||
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
account (`ah-<username>`). An agent in Alice's terminal then cannot read Bob's
|
||||
folders, transcripts or the dashboard's database: the OS stops it, not just the
|
||||
UI. This is phase S3 of `docs/plans/server-mode/S3-PLAN.md`. It is ON when
|
||||
server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
(`server/lib/linux-accounts.js`, `s3Enabled`); without that file everything
|
||||
below is a no-op and server mode behaves as described above. To install it,
|
||||
follow the [operator runbook](#operator-runbook).
|
||||
|
||||
> **Status (2026-10-09):** built and checked on a real machine: the host
|
||||
> setup, the accounts, panes, git and file saves as the member, hooks checked
|
||||
> by account, reads across homes, shared tools in `/opt`, and the fixes from
|
||||
> the 2026-10-08 security review (P8 F1–F11). Built and tested with fake
|
||||
> accounts only: per-member quota (P4), the opt-in plugin baseline (P6) and
|
||||
> the Codex account pool (P7). The second review (2026-10-09, P2–P7) is fixed
|
||||
> (see [What the second review closed](#what-the-second-review-closed)).
|
||||
>
|
||||
> Not done yet (`docs/plans/server-mode/S3-PLAN.md`):
|
||||
> - the admin's plugin baseline through managed settings (P6 spike, not run):
|
||||
> the opt-in fallback ([Plugin baseline](#plugin-baseline-opt-in)) is
|
||||
> built; otherwise a member installs plugins in their own terminal
|
||||
> (`/plugin`);
|
||||
> - Codex and Antigravity hooks for members: provisioning installs none, so a
|
||||
> member's Codex and Antigravity sessions are tracked from their journals
|
||||
> (the pollers read every member's home), not from hooks;
|
||||
> - per-member quota, the plugin baseline and the Codex account pool (P4, P6,
|
||||
> P7) are not yet tried with real member accounts or a real Codex login.
|
||||
|
||||
### The model
|
||||
|
||||
- **Identity.** AgentHub user `alice` is Linux user `ah-alice`, home
|
||||
`/srv/agenthub/users/alice` (`0750`, owner `ah-alice`, group `agenthub`).
|
||||
The name must be lower case: a letter, then up to 28 letters, digits, `_` or
|
||||
`-` (`LINUX_NAME_RE`). While S3 is on, a user with any other name cannot be
|
||||
created (`400 EBADUSER`). Admins and analysts get a Linux account too. A
|
||||
workspace's folder decides who runs it (`server/lib/run-as.js`,
|
||||
`accountForPath`): a folder under `/srv/agenthub/users/<name>/` runs as
|
||||
`ah-<name>`; any other folder runs as the host account, as before (a
|
||||
workspace made before S3 outside the users root, for example). A folder under the users root whose
|
||||
user is unknown, disabled or not provisioned is an error (`409
|
||||
EMEMBERGONE`), never "run it as the host".
|
||||
- **The dashboard.** A SYSTEM unit, `agenthub.service`, runs it as the host
|
||||
account with `CAP_DAC_READ_SEARCH` (it reads every member's tool files —
|
||||
Claude writes transcripts `0600` — but writes none of them). Every program
|
||||
it starts goes through `setpriv --inh-caps=-all --ambient-caps=-all` first
|
||||
(`server/lib/drop-caps.js`), so host panes, git hooks and npm do not inherit
|
||||
the capability.
|
||||
- **Run-as through sudo.** Everything done AS a member is
|
||||
`sudo -n -u ah-<name> <command>`, and the sudoers file allows exactly
|
||||
`tmux`, `git`, `gh` (by the absolute paths in `server.conf`),
|
||||
`/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the
|
||||
plugin baseline) that way — never root. As root, the host may
|
||||
run only `/opt/agenthub/sbin/agenthub-provision` (create, disable or enable
|
||||
a member) and `systemctl start|stop|restart|enable|disable
|
||||
agenthub.service`. Members are never in sudoers.
|
||||
- **sudo logs every command line**, so nothing secret or private crosses it:
|
||||
a pane's environment (its pane token, a pool token) and its command (the
|
||||
prompt) go into a launch file that `write-as` writes as the member (`0600`,
|
||||
from stdin) and `/opt/agenthub/libexec/pane-launch` reads, deletes and
|
||||
execs. File contents go to `write-as` on stdin.
|
||||
- **hidepid is required.** `/proc` is mounted `hidepid=invisible`, with group
|
||||
`agenthub-proc` (the host account, `polkitd`) exempt. Without it any account
|
||||
could read another's command lines. `install-system` stops if the remount
|
||||
fails.
|
||||
- **Hooks are authenticated by the sender's uid**, read from the kernel's
|
||||
table of loopback connections (`/proc/net/tcp*`, `server/lib/hook-auth.js`),
|
||||
not from anything the hook says.
|
||||
- **The AI Proxy gateway.** A pane on an AI Proxy profile gets
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>` and its own pane token as
|
||||
the key; AgentHub swaps in the real key upstream
|
||||
(`server/lib/proxy-gateway.js`, see [AI Proxy profiles](#ai-proxy-profiles)).
|
||||
A Claude pane gets the token in a per-run `--settings` file, so a user or
|
||||
project `env.ANTHROPIC_*` cannot override it.
|
||||
- **Shared tools.** Members run every AI tool, node and AgentHub's own scripts
|
||||
from `/opt/agenthub` (`server/lib/shared-tools.js`), first on their pane's
|
||||
`PATH`, with self-update off (`DISABLE_AUTOUPDATER=1`).
|
||||
|
||||
### What install-system sets up
|
||||
|
||||
The pieces, and what each is for (the install steps are in the
|
||||
[runbook](#install)):
|
||||
|
||||
| Piece | What it is |
|
||||
| --- | --- |
|
||||
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
|
||||
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
|
||||
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
||||
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
||||
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
||||
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
||||
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
||||
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
||||
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
||||
| `/opt/agenthub/{tools,app}` | Owned by the host account: the dashboard updates them, every account runs them. `tools/bin` holds node and every installed AI tool; this install's hook handler, CLI, MCP server and statusline go in `app`, with the `agenthub` launcher in `tools/bin`. The AI tools are published there after each install or update from the AI Tools page, at server start, and by `agenthub server tools`. A publish copies a native binary, an npm package's folder, or a script's own version folder. A tool that is not published yet answers a member's start with `409 ENOTSHARED` |
|
||||
|
||||
What runs as the member, and how:
|
||||
|
||||
- **Panes.**
|
||||
- Each member has their own tmux server (`~/.agenthub/tmux/sock`). Every tmux command on it, attaching a browser terminal included, runs as `sudo -u ah-<name> tmux …`. The dashboard never connects to it as itself: the member controls that socket.
|
||||
- sudo logs every command line it runs, so a pane's environment (its tokens) and its command (the prompt) are not on it. `write-as` writes them to a launch file (`0600`, from stdin), and `/opt/agenthub/libexec/pane-launch` reads it, deletes it and starts the command. A prompt pasted later goes in on stdin.
|
||||
- A pane gets the member's `HOME`, `CODEX_HOME` and `PATH` (`/opt/agenthub/tools/bin` first), with `DISABLE_AUTOUPDATER=1` and none of the host's desktop variables.
|
||||
- **Git, `gh` and file saves.**
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the dashboard is refused (`400 EMEMBERCRED`): the member signs in to their git host from their own terminal.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
- files dropped into the prompt box;
|
||||
- a pane's launch file.
|
||||
|
||||
Codex gets its proxy as `-c` overrides, never a file in the member's `CODEX_HOME`.
|
||||
- **Reading a member's files.** A transcript, a Codex, Antigravity or Cursor journal, or a file in the workspace is read only when the opened file is a regular file the member owns. A link to another account's file is refused, and so is a FIFO. A journal speaks only for its own account: its session's folder must be inside that home. A member's Antigravity conversation (SQLite, which opens its `-journal`/`-wal` by name) is read from a private copy; the walk that turns a Cursor project slug back into a folder follows no link and gives up after 500 folders.
|
||||
- **Hooks.** A hook is matched to its sender's Linux account, read from the kernel's table of loopback connections.
|
||||
- A member's Claude Code reports through `/etc/claude-code/managed-settings.json` → `/opt/agenthub/libexec/member-hook` → `/opt/agenthub/app/scripts/hook-handler.js`.
|
||||
- The host's hooks are accepted as before.
|
||||
- A member's hook may report only work inside their own home: another account's folder, session or pane is `403 EFOREIGN`, and a transcript path outside the home is dropped.
|
||||
- Any other account is `403 EPEER`.
|
||||
- **Liveness.** A member's session counts as alive while the pane holding it lives, because the dashboard cannot read another account's process directory. A session the member started in their own shell ends once it has been idle for the idle timeout.
|
||||
|
||||
Not offered for a workspace in a member's folder (`403 EMEMBERLANE`): the
|
||||
Add-workspace **Worktree** mode and the runtime layer (`lanes up/down/hook`,
|
||||
profiles, proof, lane MCP). Both run as the dashboard's own account. Use
|
||||
**Repo** mode and the prompt box's **New worktree** instead.
|
||||
|
||||
**First sign-in.** Every account gets AgentHub's own setup once, in the
|
||||
browser: the default agent and permission mode, the look (System, Dark or
|
||||
Light, a theme for each, language, terminal font), the pet and notifications,
|
||||
then a guided tour to the first workspace and session. The choices are stored
|
||||
per account (`/api/me/prefs`), so they follow the user to any browser; an
|
||||
account that already has a workspace skips it, and Settings → Appearance →
|
||||
**Run setup again** brings it back.
|
||||
|
||||
A member's own Claude Code skips its first-run questions: `agenthub-provision`
|
||||
marks its onboarding done and trusts the member's home (which covers every
|
||||
folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||
in their terminal.
|
||||
|
||||
### Plugin baseline (opt-in)
|
||||
|
||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||
root) to give every NEW member your plugins:
|
||||
|
||||
```json
|
||||
{ "marketplaces": ["owner/repo"], "plugins": ["name@marketplace"] }
|
||||
```
|
||||
|
||||
After a user is created, AgentHub runs, as that member and in the
|
||||
background, `claude plugin marketplace add <source>` for each marketplace and
|
||||
then `claude plugin install --scope user <plugin>` for each plugin; each
|
||||
result is logged as `[plugin-baseline]`. `agenthub users provision <name>`
|
||||
(or `--all`) runs it again in the foreground. No file, nothing runs. `-y` is
|
||||
never passed, so a plugin that installs by running a marketplace-declared
|
||||
command fails here; the member installs it from their own terminal. Members
|
||||
add their own plugins with `/plugin` as usual.
|
||||
|
||||
### What the second review closed
|
||||
|
||||
The 2026-10-09 review looked for ways a member can point the dashboard's own
|
||||
rights (`CAP_DAC_READ_SEARCH`, the host account, sudo as any member) at files
|
||||
that are not theirs. Each case below is refused, with a test in
|
||||
`server/__tests__/server-s3-review2.test.js`:
|
||||
|
||||
- **A workspace folder swapped for a link.** The file view, image view, blame
|
||||
and the file editor resolve the workspace's real path and refuse it
|
||||
(`403 EFOREIGN`) when it lands in another account's files — another
|
||||
member's folder, the host's home, `/etc`. The account a read or save runs as
|
||||
is never taken from where a link points.
|
||||
- **A member's git dir naming a path outside their home.** The `info/exclude`
|
||||
line AgentHub adds for a new worktree or `.mcp.json` is written only when the
|
||||
repo's real git dir is the same account's; a FIFO there cannot hang the
|
||||
dashboard.
|
||||
- **Browsing and branch lists.** A developer's `GET /api/lanes/browse` and
|
||||
`GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and
|
||||
so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`,
|
||||
refused before the path is looked at, so the answer says nothing about
|
||||
another member's files).
|
||||
- **The top of a member's home.** Its dot folders (`.claude`, `.codex`,
|
||||
`.config`, `.ssh`, `.agenthub`…) hold logins and pane tokens: no workspace
|
||||
file view serves them to anyone (`403 ESECRET`), and the home itself is no
|
||||
workspace.
|
||||
- **Transcripts by session id.** A scan by id looks only in the session
|
||||
owner's home, so a file another member plants under that id is never shown.
|
||||
- **Codex journals.** A rollout journal sets the status only of a lane in its
|
||||
own account's home, and the newest-40 window is per home.
|
||||
- **A disabled member's sessions.** Opening one reads from their home instead
|
||||
of crashing the server, and the liveness reaper skips them instead of
|
||||
stopping.
|
||||
- **Host terminals.** A browser terminal's `tmux attach` (node-pty, which
|
||||
forks outside `child_process`) is started through `setpriv` too, so it does
|
||||
not keep `CAP_DAC_READ_SEARCH`.
|
||||
- **AI Proxy edits.** Editing a profile rewrites a running gateway pane's own
|
||||
settings file (with its own token), and deletes the file of a pane that has
|
||||
ended.
|
||||
|
||||
### What review round 5 closed
|
||||
|
||||
The fifth review (2026-10-09) looked at the dashboard's ONE thread and at who
|
||||
a pane really is. Each case below is refused or bounded, with a test in
|
||||
`server/__tests__/server-s3-review5.test.js`:
|
||||
|
||||
- **AgentHub's data dir is never a config file.** `GET /api/cc-config/file`
|
||||
refuses anything in it — the database, the push keys, and each gateway
|
||||
pane's settings file, which holds that pane's live token.
|
||||
- **A member's stopped or flooding tmux server.** Every call on it has a 3 s
|
||||
limit and a 1 MiB answer cap; one that hit either is not asked again for a
|
||||
minute. Meanwhile only that member's panes count as unknown (kept, not
|
||||
ended); everyone else's are still checked. Every other synchronous call
|
||||
that runs as a member (git in their folder, `write-as`) stops after 5 s.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
||||
a whole member file is read only up to a cap (1 MiB for a login or a
|
||||
settings file, 16 MiB for an index or a workflow journal, 256 MiB for a
|
||||
SQLite copy, 64 MiB otherwise); a member transcript over 256 MiB is neither
|
||||
parsed nor snapshotted, and the snapshot keeps a sparse file's holes.
|
||||
- **A request that makes a route throw** answers 500 instead of stopping the
|
||||
server (a member folder of a gone user crashed `POST /api/lanes/worktree`).
|
||||
- **Who a pane runs as.** A non-admin owns no workspace outside their home
|
||||
(above), a pane's folder is its workspace's account's, and a pane token
|
||||
works only from that account's uid.
|
||||
- **A path an analyst names.** Browsing, branch lists and the `@` file search
|
||||
stay in their own folder or workspaces, like a developer's.
|
||||
- **Drops** are saved where the pane that reads them can read them: as the
|
||||
workspace's account (the client names the workspace), not the dropper's.
|
||||
- **The Codex pool and ChatGPT Teams.** A login is the pool's only when
|
||||
AgentHub placed it in that home AND it is the same person (email): every
|
||||
seat of a Team shares one `account_id`, so a member's own seat is theirs.
|
||||
|
||||
### Accepted ceilings
|
||||
|
||||
- The dashboard process can read any file, `/etc/shadow` included. `sudo`
|
||||
needs that file, so the unit cannot hide it (`InaccessiblePaths` hides only
|
||||
`/root`). Treat the host account as privileged, and keep it out of the
|
||||
`sudo` group on a real server: there the sudoers file above is all it may
|
||||
do.
|
||||
- Anything that runs as the host account can run anything as any member: the
|
||||
sudoers file lets it start `tmux` as them. A host compromise is every
|
||||
member's compromise.
|
||||
- `install-system` installs and runs code from the host account's install, as
|
||||
root. Re-run it only from a tree you trust.
|
||||
- sudo's log (readable by root and `adm`) shows members' commit messages,
|
||||
branch names, pull-request titles and file paths, never a token or a prompt.
|
||||
- A member may point a personal AI Proxy at any URL, so the dashboard will
|
||||
POST to it. They have a shell on the machine anyway.
|
||||
- The gateway forwards any path under `/v1/` to the profile's upstream, and an
|
||||
agent can spend through a profile while its pane lives.
|
||||
- A Claude pool token is in its pane's environment: the member of that pane
|
||||
can read it (see [Claude account pool](#claude-account-pool)). The same goes
|
||||
for a Codex pool API key, and for a Codex ChatGPT login written into the
|
||||
member's `~/.codex/auth.json` ([Codex account pool](#codex-account-pool)).
|
||||
- The Codex pool's quota reading comes from rollout journals, which their
|
||||
member writes: a member can forge the reading of their OWN session (say a
|
||||
pool account is full, or empty) and so steer the balancer for everyone
|
||||
until a newer reading wins. They cannot speak for another account's run:
|
||||
a run's rollout is read only from its own account's `CODEX_HOME`, and never
|
||||
one dated in the future.
|
||||
- A member session started in the member's own shell, outside a dashboard
|
||||
pane, counts as alive only until its idle timeout: the dashboard cannot read
|
||||
another account's `/proc/<pid>`.
|
||||
|
||||
## Operator runbook
|
||||
|
||||
Every command below runs as the **host account** (the one that runs the
|
||||
dashboard) unless it says `sudo`. `agenthub server install-system` calls sudo
|
||||
itself, once.
|
||||
|
||||
### What a new machine needs before `install-system`
|
||||
|
||||
`install-system` does not do any of this:
|
||||
|
||||
1. A Linux machine with systemd, kernel ≥ 5.8 (for `hidepid=invisible`),
|
||||
`sudo` with `visudo`, the shadow tools (`useradd`, `usermod`, `groupadd`),
|
||||
util-linux (`runuser`, `setpriv` at `/usr/bin/setpriv`, `mount`), `pkill`,
|
||||
`sha256sum`.
|
||||
2. A host account that is not root, with sudo rights for this one run (take
|
||||
them away again afterwards, see step 9 below).
|
||||
3. Node (the version AgentHub needs), `tmux`, `git` and, if you want it, `gh`
|
||||
on that account's `PATH`. Their absolute paths are recorded once, in
|
||||
`/etc/agenthub/server.conf` and the sudoers file.
|
||||
4. AgentHub installed for the host account, as the plugin or a checkout
|
||||
(`npm run setup && npm run build`), and running once
|
||||
(`agenthub start`).
|
||||
5. Server mode on, with the first admin:
|
||||
`agenthub server setup --hostname agenthub.example.com --admin boss`
|
||||
(see [Set it up](#set-it-up)).
|
||||
6. The AI tools members will use, installed for the host account (the AI
|
||||
Tools page or by hand).
|
||||
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
agenthub server install-system --dry-run # print the one sudo command it will run
|
||||
agenthub server install-system # install, then switch to the system unit
|
||||
agenthub users provision --all # Linux accounts for users made earlier (the admin from setup)
|
||||
agenthub server tools # publish the AI tools to /opt now (the server also does it at start)
|
||||
```
|
||||
|
||||
What `agenthub server install-system` does, in order (`installSystem` in
|
||||
`bin/agenthub.js`, root half `scripts/server/install-system.sh`; running it
|
||||
again changes nothing that is already in place):
|
||||
|
||||
1. Refuses to run as root. Resolves, as the host account: its name, node, this
|
||||
install's root, the data dir, the port (`DASHBOARD_PORT`, else 4820) and
|
||||
the paths of `tmux`, `git` and `gh`.
|
||||
2. As root (`sudo bash install-system.sh …`), after checking each value:
|
||||
1. creates groups `agenthub`, `agenthub-members`, `agenthub-proc`; adds the
|
||||
host account to `agenthub` and `agenthub-proc`, and `polkitd` (if it
|
||||
exists) to `agenthub-proc`;
|
||||
2. creates `/srv/agenthub/users` (`0751` root), `/opt/agenthub/{sbin,libexec}`
|
||||
and `/etc/agenthub` (root), `/opt/agenthub/{tools,tools/bin,app}` (the
|
||||
host account's);
|
||||
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
||||
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
|
||||
4. installs `agenthub-provision`, `write-as` and `pane-launch` from
|
||||
`scripts/server/`;
|
||||
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
||||
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
||||
`/opt/agenthub/app`;
|
||||
6. writes `/opt/agenthub/libexec/member-hook` (the port is baked in);
|
||||
7. writes `/etc/sudoers.d/agenthub`, only after `visudo -c` accepts it;
|
||||
8. writes `/etc/claude-code/managed-settings.json` (the member hooks) unless
|
||||
a file that is not AgentHub's is already there;
|
||||
9. `chmod o-rwx` the host's `~/.claude`, and warns when the host's home is
|
||||
open to other accounts;
|
||||
10. adds the `hidepid=invisible,gid=agenthub-proc` line to `/etc/fstab`
|
||||
(backup: `/etc/fstab.agenthub-bak`) and remounts `/proc` with it;
|
||||
11. writes `/etc/systemd/system/agenthub.service` and runs
|
||||
`systemctl daemon-reload`. It does not start it.
|
||||
3. As the host account again: writes `<data dir>/runtime/agenthub.system.env`
|
||||
(node, plugin root, port, client dist), sets `AGENTHUB_USERS_ROOT` in
|
||||
AgentHub's settings file, and disables the `systemd --user` unit (its tmux
|
||||
panes stay).
|
||||
4. Stops a dashboard still running without the system unit, then
|
||||
`sudo -n systemctl enable` and `start --no-block agenthub.service`, and
|
||||
fails unless the unit is active 3 seconds later.
|
||||
|
||||
What a new machine still needs by hand after it:
|
||||
|
||||
1. `agenthub users provision --all`: users created before S3 was on (the
|
||||
admin from `server setup` is one) have no Linux account yet.
|
||||
2. Log out and back in once, so your own shell joins `agenthub` and
|
||||
`agenthub-proc` (the unit gets them on its own).
|
||||
3. `chmod o-rwx ~` for the host account if the installer warned: the database
|
||||
lives under it.
|
||||
4. If the installer left a foreign `/etc/claude-code/managed-settings.json`
|
||||
alone, add the member hooks to it yourself: for each of `PreToolUse`,
|
||||
`PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`,
|
||||
`Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a
|
||||
`command` hook `/opt/agenthub/libexec/member-hook <Event>` with
|
||||
`"timeout": 5`.
|
||||
5. Any other daemon that must see every process (monitoring agents, for
|
||||
example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`.
|
||||
6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a
|
||||
shared folder) must be installed under `/opt/agenthub/tools` by hand.
|
||||
7. Remove the host account from the `sudo` group (`sudo deluser <host> sudo`
|
||||
on Debian/Ubuntu). Re-running `install-system` later needs it back for
|
||||
that run.
|
||||
8. Pool accounts, if you use a pool: `agenthub accounts add <name>` (Claude)
|
||||
or `agenthub accounts add <name> --tool codex --api-key|--from <dir>`
|
||||
([Codex account pool](#codex-account-pool)).
|
||||
9. Optional: `/etc/agenthub/plugin-baseline.json` for new members' plugins
|
||||
([Plugin baseline](#plugin-baseline-opt-in)).
|
||||
|
||||
### Add, disable and enable a user
|
||||
|
||||
```bash
|
||||
agenthub users add alice [--role admin|analyst|developer] # or the Users page
|
||||
agenthub users disable alice
|
||||
agenthub users enable alice
|
||||
agenthub users provision alice # the Linux account only (a user made before S3, or after a failed create)
|
||||
```
|
||||
|
||||
- **Add** creates the Linux account FIRST (`agenthub-provision create`):
|
||||
`useradd ah-alice` with home `/srv/agenthub/users/alice`, group
|
||||
`agenthub-members`; the home is set to `ah-alice:agenthub 0750`; as the
|
||||
member it makes `~/.agenthub/{tmux,handoff}` (`0700`) and marks Claude
|
||||
Code's onboarding done and the home trusted in `~/.claude.json`. If any of
|
||||
that fails, no AgentHub user is made (`502 EPROVISION`, with the helper's own
|
||||
words). An account `ah-alice` that already exists outside
|
||||
`agenthub-members`, or an existing folder `/srv/agenthub/users/alice` with no
|
||||
account, is refused. With a [plugin baseline](#plugin-baseline-opt-in)
|
||||
file in place, its plugins are then installed as the member in the
|
||||
background.
|
||||
- **Disable** disables the AgentHub user and ends their logins FIRST, then
|
||||
locks the Linux account (`usermod --lock --expiredate 1`) and kills every
|
||||
process of `ah-alice` (`pkill -KILL -u`: their tmux server and every pane).
|
||||
A failed lock still answers `502 EPROVISION`, but the logins are already
|
||||
gone. A user with no Linux account (made before S3, or a name that does not
|
||||
fit) is disabled without any sudo. Their files stay.
|
||||
- **Enable** clears the expiry date (when there is a Linux account) and
|
||||
re-enables the AgentHub user.
|
||||
- **Delete**: AgentHub never deletes a user, an account or a home. To remove
|
||||
one for good, disable it, then by hand: `sudo userdel ah-alice`, and move or
|
||||
delete `/srv/agenthub/users/alice` yourself. The AgentHub user row stays.
|
||||
- **A name that does not fit** (upper case, a dot): `provision` refuses it
|
||||
(`provision --all` reports it and goes on with the next user, exit code 1).
|
||||
Create a new user with a fitting name, move the old workspaces' folders into
|
||||
their home (`/srv/agenthub/users/<name>/…`, owned by `ah-<name>`) and add
|
||||
them there again. `agenthub users assign <workspace id> <name>` gives a
|
||||
workspace only to the member whose home holds it, or — for a folder outside
|
||||
the users root, whose panes run as the host — to an admin; anything else
|
||||
answers `EOWNERFOLDER`.
|
||||
|
||||
### Upgrade
|
||||
|
||||
- Update AgentHub as usual (Settings → Updates, `/agenthub-update`). The
|
||||
bootstrap repoints the unit by rewriting
|
||||
`<data dir>/runtime/agenthub.system.env` (a host-owned file, no root) and
|
||||
restarts it with `sudo -n systemctl restart --no-block agenthub.service`.
|
||||
Panes survive (`KillMode=process`).
|
||||
- At start, the server republishes its own scripts to `/opt/agenthub/app`
|
||||
and every installed AI tool to `/opt/agenthub/tools`, in the background. An
|
||||
install or update from the AI Tools page publishes that tool right after.
|
||||
- An update never touches a root-owned file. **Re-run `agenthub server
|
||||
install-system`** when a release changes anything under `scripts/server/`
|
||||
(provision helper, `write-as`, `pane-launch`, the member hook, sudoers, the
|
||||
unit; an install from before the plugin baseline needs it once for the
|
||||
`claude` sudoers entry), when the port changes (the member hook has it baked in), and when
|
||||
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
||||
recorded ones).
|
||||
- Keep every other AgentHub server off the same database (a dev server on
|
||||
another port): an older build cannot see members' tmux servers and marks
|
||||
their runs abandoned.
|
||||
|
||||
### Undo and uninstall
|
||||
|
||||
```bash
|
||||
agenthub server install-system --uninstall # back to the systemd --user unit
|
||||
```
|
||||
|
||||
As root it stops, disables and deletes the system unit (only if it is
|
||||
AgentHub's), deletes the sudoers file, the managed settings (only if
|
||||
unchanged since it wrote them), the `/etc/fstab` line (backup:
|
||||
`/etc/fstab.agenthub-bak`; `/proc` is remounted `hidepid=off`),
|
||||
`/opt/agenthub/{sbin,libexec}` and `server.conf`. Then, as the host account,
|
||||
it repoints the `systemd --user` unit at this install and runs `systemctl
|
||||
--user enable --now agenthub`.
|
||||
|
||||
It keeps `/srv/agenthub/users` and every home, the `ah-*` accounts, the groups
|
||||
and `/opt/agenthub/{tools,app}`. It does not stop members' running tmux
|
||||
servers: disable those users first if they should end. With `server.conf`
|
||||
gone, S3 is off and server mode works as without it.
|
||||
|
||||
To remove everything:
|
||||
|
||||
1. `agenthub server install-system --uninstall`
|
||||
2. `agenthub server off`, then `agenthub uninstall --yes` (the Uninstall
|
||||
button refuses in server mode, `ESERVERMODE`).
|
||||
3. By hand, as root, only if you want them gone: `userdel` each `ah-*`
|
||||
account, `groupdel agenthub agenthub-members agenthub-proc`, and delete
|
||||
`/srv/agenthub`, `/opt/agenthub` and `/etc/agenthub`. These hold members'
|
||||
work: AgentHub never deletes them.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause, and what to do |
|
||||
| --- | --- |
|
||||
| `install-system: generated sudoers did not validate` | Nothing was installed past that step. Check the tool paths it printed (`--dry-run`). |
|
||||
| `install-system: …/managed-settings.json exists and is not ours` | Members' Claude hooks are not installed; add them by hand (above). |
|
||||
| `install-system` stops at the `/proc` remount | The kernel does not support `hidepid=invisible` (< 5.8). The `/etc/fstab` line is already added: remove it or restore `/etc/fstab.agenthub-bak` before rebooting. |
|
||||
| `a dashboard is still running — stop it first` | A dashboard outside both units holds the port: `agenthub stop`, then run it again. |
|
||||
| `agenthub.service did not stay up` | `sudo journalctl -u agenthub -n 50`. The unit reads `<data dir>/runtime/agenthub.system.env`. |
|
||||
| `502 EPROVISION` when adding a user | The helper's own message says why (name, existing account or folder). No AgentHub user was made. |
|
||||
| `400 EBADUSER` | The username does not fit the Linux rule (above). |
|
||||
| `409 EMEMBERGONE` on a start or a git action | The workspace's folder is under the users root but its user is disabled, unknown or not provisioned: `agenthub users enable <name>` or `agenthub users provision <name>`. |
|
||||
| `409 ENOTSHARED` on a member's start | That tool is not published to `/opt/agenthub/tools` yet: `agenthub server tools`. |
|
||||
| `403 EMEMBERLANE` | Worktree mode and the runtime layer are not offered in a member's folder; use Repo mode and the prompt box's New worktree. |
|
||||
| `400 EMEMBERCRED` | A member signs in to their git host from their own terminal; AgentHub's stored git logins are not used for them. |
|
||||
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
|
||||
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
|
||||
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
|
||||
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
|
||||
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
|
||||
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
|
||||
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
|
||||
| `[plugin-baseline] <name>: … failed` with `sudo: a password is required` | The sudoers file predates the baseline: re-run `install-system`. |
|
||||
| A member's first Claude run asks about Bypass Permissions | Expected: that consent stays the member's own. |
|
||||
|
||||
## Turn it off
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# First-run setup and tour (per account)
|
||||
|
||||
Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
|
||||
|
||||
- **Bypass is never remembered** (CLAUDE.md rule). The wizard remembers a default *permission mode* (Auto / Accept edits / Ask / Plan); bypass stays the red per-session switch.
|
||||
- **Per account, on the server.** The choices follow the account to any browser. Local mode is one account, `local`.
|
||||
- **The tutorial is a step-by-step spotlight tour.**
|
||||
|
||||
## Server
|
||||
|
||||
- [x] The `user_prefs` table: `user_key` (`u<id>`, or `local`), `prefs` (JSON), `onboarded_at`, `updated_at`. It is migration-safe (`CREATE IF NOT EXISTS`).
|
||||
- [x] `server/routes/me.js`, mounted at `/api/me`:
|
||||
- `GET /prefs` returns `{prefs, onboardedAt}`;
|
||||
- `PUT /prefs` merges `{prefs}`, takes only keys in `PREF_KEYS`, string values of at most 8 KB each, and `null` deletes a key;
|
||||
- `POST /onboarded` marks the account as onboarded.
|
||||
- Writes are same-origin. A member may write their own prefs, and nobody else's.
|
||||
- [x] S3: when a member saves a mode, the theme in their Claude Code `~/.claude/settings.json` follows it (dark or light), written through `write-as`.
|
||||
- [x] P6: `agenthub-provision create` seeds the member's `~/.claude.json` with `hasCompletedOnboarding` and trust for the home folder. It runs as the member (`runuser` + the shared node). Verified: trusting the home also covers folders under it.
|
||||
|
||||
## Client
|
||||
|
||||
- [x] `lib/userPrefs.ts`:
|
||||
- **On login:** hydrate localStorage from the server, then re-apply the theme, language and pet.
|
||||
- **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced).
|
||||
- [x] New preference keys:
|
||||
- `agenthub.defaultTool`, read by AddLaneModal before the install default;
|
||||
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is never accepted here.
|
||||
- [x] `components/onboarding/OnboardingWizard.tsx`, mounted in Layout. It shows when the account is not onboarded AND has no workspace (an account that already has one is marked onboarded silently). Steps:
|
||||
1. Default agent (detected tools as a grid, plus "show more") and the default permission mode.
|
||||
2. Appearance: System, Dark and Light as preview cards, a theme grid for dark and for light below them, then language and terminal font.
|
||||
3. Pet: none or an installed pet, and its size.
|
||||
4. Notifications: enable, sound and preview, send a test.
|
||||
|
||||
The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour.
|
||||
- [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps:
|
||||
- Add workspace
|
||||
- the folder field
|
||||
- Create
|
||||
- the prompt box
|
||||
- sending the prompt
|
||||
- the session pane
|
||||
- done
|
||||
- [x] "Run setup again" lives in Settings → Appearance (every account can open it; System is not a member's page).
|
||||
- [x] i18n in English and Vietnamese. Tests: the server route; the client wizard, tour and prefs; snapshots.
|
||||
- [x] Docs: `docs/API.md`, `docs/SERVER-MODE.md` and the CLAUDE.md marker.
|
||||
@@ -18,12 +18,12 @@ Goal (2026-10-07): finish the server part — S2 ownership as proposed, review i
|
||||
Decided: own Linux user per account; skills/plugins = admin baseline + user's own; AI Proxy = user's own + admin's shared (no key shown); tool binaries shared under `/opt/agenthub/tools`; Claude pool shared (exists); NEW Codex multi-account pool. Spike allowed on this machine with sudo (undo log: `/tmp/agh-spike/UNDO.sh`).
|
||||
|
||||
- [x] S0 spike (2026-10-08, this machine; test users agh-test1/2 kept for S3 dev, undo `/tmp/agh-spike/UNDO.sh`) → results below
|
||||
- [ ] Per-user AI Proxy store (+ admin shared)
|
||||
- [ ] S3 provisioning + spawn/attach as the account
|
||||
- [ ] S4 shared tools in /opt
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline)
|
||||
- [ ] Codex account pool
|
||||
- [ ] Security review, docs, suites
|
||||
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
|
||||
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
|
||||
- [x] S4 shared tools in /opt — S3-PLAN P5
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built
|
||||
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
|
||||
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
|
||||
|
||||
### S0 results (claude 2.1.293, codex 0.155.1, tmux 3.7c, node 26)
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# S3 plan: one Linux user per account (2026-10-08, after S0 and a 4-way code review)
|
||||
|
||||
Scope (user, 2026-10-08):
|
||||
- own workspaces, sessions and stats;
|
||||
- one Linux user per account;
|
||||
- own skills and plugins: an admin baseline, plus the member's own;
|
||||
- own AI Proxy, with the admin's shared ones usable by everyone;
|
||||
- one shared set of tool binaries;
|
||||
- the shared Claude pool;
|
||||
- a new Codex pool.
|
||||
|
||||
The S0 results and test users are recorded in `CHECKLIST.md`.
|
||||
|
||||
## Decisions 2026-10-08 (after the review)
|
||||
|
||||
- `hidepid=invisible` is required: `install-system` refuses without it.
|
||||
- **Hide shared proxy keys** (new phase P1b): AgentHub becomes a loopback endpoint for AI Proxy profiles.
|
||||
- The pane gets `ANTHROPIC_BASE_URL`/`base_url` = `http://127.0.0.1:<port>/api/proxy-gw/<profile>` and a per-pane token as its key.
|
||||
- AgentHub checks the token (pane alive, the lane owner may use the profile), swaps in the real key, and streams the request to the profile's `baseUrl`.
|
||||
- Pool OAuth tokens are NOT covered: that ceiling stays documented.
|
||||
- **Codex ChatGPT accounts may be used concurrently** (user's choice, against the recommendation). Refresh tokens rotate, so P7 starts with a spike on a real spare account. The design to verify:
|
||||
- AgentHub owns the refresh: one canonical `auth.json` per account in the pool;
|
||||
- it refreshes before expiry under a lock and rewrites every live copy;
|
||||
- it checks whether codex re-reads `auth.json` and what happens when two copies refresh.
|
||||
- If codex refreshes on its own and logs the others out, report back to the user before building.
|
||||
- Members do NOT get (a) the Add-workspace **Worktree** mode (managed worktrees) or (b) the runtime layer. They use **Repo** mode plus the prompt box's **New worktree** (session worktrees). Both refusals are `EMEMBERLANE`, and admins keep both on ownerless workspaces.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Identity**
|
||||
- AgentHub user `alice` maps to Linux user `ah-alice`. The prefix cannot clash with a system account or the host account.
|
||||
- Her home is `$AGENTHUB_USERS_ROOT/alice`, which is today's `folderAllowed` rule; in server mode it becomes mandatory.
|
||||
- Admins get a Linux user too.
|
||||
- Ownerless (pre-S3) workspaces stay host-run and staff-only.
|
||||
|
||||
**Dashboard process.** In server mode only, it runs as a system unit:
|
||||
- `User=<host>`
|
||||
- `SupplementaryGroups=agenthub`
|
||||
- `AmbientCapabilities=CAP_DAC_READ_SEARCH`
|
||||
- `InaccessiblePaths=-/etc/shadow -/etc/gshadow -/etc/sudoers -/etc/sudoers.d -/root`
|
||||
- `Restart=always`
|
||||
- `KillMode=process`
|
||||
|
||||
It must **not** set `NoNewPrivileges` or `CapabilityBoundingSet`, because both block `sudo`. `/home` stays visible, because the host's DB lives there.
|
||||
|
||||
**Three privileges, each narrow:**
|
||||
1. **Read anything.** `CAP_DAC_READ_SEARCH` covers every tool and every file mode, with no per-read cost and no per-tool code. It does NOT cover `/proc/<pid>/cwd` of another uid; that needs CAP_SYS_PTRACE, which is root-equivalent, so liveness changes instead (see P3).
|
||||
2. **Act as a member.** sudoers: `<host> ALL=(%agenthub-members) NOPASSWD: <tmux>, <git>, <gh>, /opt/agenthub/libexec/write-as`. It never allows root.
|
||||
3. **Provision.** sudoers: `<host> ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision`, a fixed script with a strict name check.
|
||||
|
||||
**Panes**
|
||||
- Each account has its own tmux server, with its socket at `<home>/.agenthub/tmux/sock`.
|
||||
- The socket's dir is setgid `agenthub`, and the socket is 0770 with `server-access -a <host>`.
|
||||
- S0 showed that tmux keeps group rw across attach, and that another member is refused.
|
||||
- The server is started once via `sudo -u ah-x tmux -S … new-session`. After that every command is a plain host-side `tmux -S` client (no sudo): send-keys, respawn, paste, kill, attach.
|
||||
- Pane env:
|
||||
- `HOME`
|
||||
- `PATH=/opt/agenthub/tools/bin:…`
|
||||
- `CODEX_HOME=<home>/.codex`
|
||||
- `CLAUDE_DASHBOARD_PORT`
|
||||
- `DISABLE_AUTOUPDATER=1`
|
||||
- none of the host's `XDG_RUNTIME_DIR`/`DISPLAY`.
|
||||
|
||||
**Hooks**
|
||||
- A hook is authenticated by the peer uid, read from `/proc/net/tcp`. S0 confirmed this: a connection from `agh-test1` mapped to uid 1002.
|
||||
- That uid's account may only touch its own sessions, runs and lanes, and `transcript_path` must lie under that account's home. Today any local process can forge any hook.
|
||||
- Claude hooks are installed via `/etc/claude-code/managed-settings.json` and call `node /opt/agenthub/app/scripts/hook-handler.js`. The Codex and agy forwarders are written into each member's home at provisioning.
|
||||
|
||||
**Secrets in argv**
|
||||
- `/proc` on this host is not `hidepid`. Every member's `ps` therefore sees what the dashboard passes as `tmux -e TOKEN=…` and `sudo … env K=V`.
|
||||
- Mount `/proc` with `hidepid=invisible`, plus a `gid=` exemption for the host. `install-system` refuses to continue without it.
|
||||
|
||||
## Not offered for member-owned workspaces in S3 v1
|
||||
|
||||
These answer 403 `EMEMBERLANE` in server mode:
|
||||
- managed worktrees (`POST /api/lanes/worktree`). Members use session worktrees instead (`claude --worktree`, made by Claude as the member).
|
||||
- the runtime layer: lanes up, down, hook, profile, slots, proof, lane-mcp, env seed, merge driver and gc. These spawn as the host, write into `LANES_ROOT` under the host's home, and kill and probe pids.
|
||||
|
||||
Boot-time cleanups (`clearLaneProxies`, `removeStageDoc`) skip member lanes.
|
||||
|
||||
## Phases (order: P0 → P1 → P1b → P2 → P3 → P4 → P5 → P8 review → P6 → P7)
|
||||
|
||||
Branch: `server-s3`. Commits happen only when the user asks.
|
||||
|
||||
### P0. Leaks that exist today (no sudo; can ship at any time)
|
||||
- [x] Make `GET /api/ai-tools/:id/config/:index` staff-only. Today a member can read the host's catalog config files.
|
||||
- [x] Members' `/cc-config/commands|skills`: `?cwd=` must lie inside one of their own workspaces. Today they can list another user's skills.
|
||||
- [x] (moved to P3) `cc-reload`: before S3 every pane shares the host's `~/.claude`, so reloading member panes after a host plugin change is CORRECT today. Restrict it to panes on the changed home only once panes have their own (P3).
|
||||
- Verify: one server-mode test per leak.
|
||||
|
||||
### P1. Per-user AI Proxy (no sudo; also useful without S3)
|
||||
- [x] Store layout:
|
||||
- shared = today's `agenthub-proxies/`, writable by admins only;
|
||||
- personal = `agenthub-proxies/users/<userId>/`, writable by its owner;
|
||||
- a user's names are unique across both (`freeName` checks the shared ones).
|
||||
- [x] API: the list stays an array and each item gets `scope: "shared"|"personal"`. `/ai-proxies` leaves `ADMIN_WRITE_PREFIXES`, and route checks take its place. A member may test only their own profiles; the stored-key fallback still requires the saved `baseUrl`.
|
||||
- [x] `resolveProxy(tool, name, ownerId)` looks in personal, then shared. `dashboard_runs.proxy` keeps the name, and a resume resolves with the lane owner's id.
|
||||
- [x] Client: Settings shows two groups, and the picker lists both.
|
||||
- [x] Docs: a shared key is hidden from the browser only; a pane can read it (the same ceiling as the pool).
|
||||
- Done 2026-10-08: server-mode-proxies.test.js (11), AiProxyConfig tests (+4); full suites server 2657/2660 (3 = lane-detect, port 3000 held by a foreign next-server), client 1135/1135.
|
||||
- Verify: server tests (A cannot see, use or test B's; a member cannot write shared; an admin can), client tests, a snapshot diff review.
|
||||
|
||||
### P1b. Hide shared proxy keys (no sudo)
|
||||
- [x] `/api/proxy-gw/:profile/*`:
|
||||
- loopback only;
|
||||
- a per-pane token (hash in `dashboard_runs`, alive pane only), and the run's lane owner may use the profile;
|
||||
- streams body and SSE both ways and swaps `x-api-key`/`Authorization`;
|
||||
- never logs a key;
|
||||
- has a timeout.
|
||||
- [x] `proxyForSpawn` points Claude/Codex at the gateway, never at the real key, for shared profiles (and for personal ones too: one code path).
|
||||
- Done 2026-10-08: server-mode-proxy-gateway.test.js (11); verified the real CLIs' requests: claude 2.1.293 → `<base>/v1/messages?beta=true` with `Authorization: Bearer <ANTHROPIC_AUTH_TOKEN>`, codex 0.155.1 → `<base_url>/responses` with `Bearer <env_key>`. Server mode only; local mode unchanged.
|
||||
- Verify: an e2e against a fake upstream (header swapped, streaming kept, a dead pane's token refused, another member's profile refused).
|
||||
|
||||
### P2. Host setup (sudo; on this machine with the agh-test users)
|
||||
- [x] `sudo agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- creates groups `agenthub` and `agenthub-members`;
|
||||
- creates `/srv/agenthub/users` (0751) and `/opt/agenthub/{tools,app,libexec,sbin}`;
|
||||
- writes the sudoers file and checks it with `visudo -c`;
|
||||
- writes the system unit and the managed-settings hooks;
|
||||
- checks `hidepid`.
|
||||
- [x] `service-manager` gets a system variant used when server mode is on and the system unit exists; stop and restart go through sudoers `systemctl … agenthub`.
|
||||
- [x] `plugin-bootstrap`: in server mode it never starts, stops or repoints the server (deps only). Under any uid other than the host it does nothing (otherwise a member's SessionStart would spawn a second dashboard).
|
||||
- [x] Provision helper (root, bash):
|
||||
- checks the name against `^[a-z][a-z0-9_-]{0,28}$`, makes the Linux name `ah-<name>`, and refuses an existing account;
|
||||
- create: `useradd`, the home, `.agenthub/{tmux,handoff}`, the Codex/agy hook forwarders, the baseline settings;
|
||||
- disable: `usermod -L`, then end that account's tmux server;
|
||||
- never deletes.
|
||||
- [x] `agenthub users add` and the Users page call it; `users.linux_user` is set.
|
||||
- Done 2026-10-08 and INSTALLED on this machine (system unit runs the server-s3 worktree on :4820; ah-admin, ah-vinnt provisioned; undo: /tmp/agh-spike/UNDO.sh step 0). Findings: `InaccessiblePaths=/etc/shadow` breaks sudo (PAM) — only /root is hidden; a host with its own `(ALL) NOPASSWD: ALL` makes the sudoers allowlist moot (keep the host out of `sudo` on a real server); `usermod --unlock` refuses a passwordless account (enable = clear the expiry); the test runner must not see the host's install (`AGENTHUB_SYSTEM_UNIT_DIR`, `AGENTHUB_SERVER_CONF` in scripts/test-server.js). Tests: server-s3-system.test.js (14); full suite 2679/2682 (3 = port 3000).
|
||||
- Verify: on this machine, `agenthub users add alice` creates `ah-alice`; running it again changes nothing.
|
||||
|
||||
### P3. Spawning and acting as the member
|
||||
- [x] `server/lib/run-as.js`:
|
||||
- `accountForLane` / `accountForCwd` return `{linuxUser, home, uid}`, or null (null = the host, which is always the case in local mode);
|
||||
- `sudoArgv(acct, cmd, args, env)`.
|
||||
- [x] `tmux.js`:
|
||||
- one socket per account, chosen from session name → lane → owner;
|
||||
- start via sudo and add `server-access`;
|
||||
- `listSessions`, `liveSessions` and `serverPid` loop over every socket, with per-socket caches.
|
||||
- Also: `pty-attach` gets `-S`; same change in `cc-update-all-helper` and `lanes shell`.
|
||||
- [x] `worktree.js` `git()`: member lanes go through `sudo -u`. The same for `git-exclude` and `gh`. In `git-ops`, the patch goes via stdin and the temp index under the member's tmp.
|
||||
- [x] fs writes in member lanes go through `/opt/agenthub/libexec/write-as`: `lane-file-write`, the `.gitignore` append. Callers keep their existing guards.
|
||||
- [x] Pane handoff files go to `<home>/.agenthub/handoff/` (member-owned, setgid `agenthub` 2770; the host writes 0644). These are:
|
||||
- proxy settings;
|
||||
- the Codex proxy (`-c` flags, never a file in the member's `CODEX_HOME`);
|
||||
- workspace context and prompt files;
|
||||
- drop uploads.
|
||||
- [x] `cc-reload`: only panes whose Claude home is the one that changed (host-run panes for a host plugin update).
|
||||
- [x] Liveness: a member session is alive iff its pane's tmux session is live. No `/proc/<pid>/cwd` read; without this, live member sessions get reaped.
|
||||
- [x] Hooks: peer-uid auth, `transcript_path` under the account's home, and `threadOf` and the agy check accept member homes.
|
||||
- [x] Member-lane refusals (`EMEMBERLANE`).
|
||||
- Done 2026-10-08, verified LIVE on this machine as member `s3probe` (ah-s3probe):
|
||||
- a shell pane and a Claude pane run as the member; attach over /ws-pty works;
|
||||
- stage, commit and file save through the API run as the member, and the file stays the member's;
|
||||
- Claude → `/api/proxy-gw` → a fake upstream received the REAL key, and the pane showed "pong";
|
||||
- member-hook hooks were accepted by peer uid and bound to the workspace;
|
||||
- the 0600 transcript was served.
|
||||
Found on the way:
|
||||
- sudo LOGS its argv to the journal, so no secret may cross sudo: the member's tmux server starts with a secret-free `agenthub-boot` session, and the real one is created as a client;
|
||||
- tmux gives a pane created by an unattached client THAT CLIENT's PATH, so member-socket calls carry MEMBER_PATH;
|
||||
- a member's first Claude run asks for theme, trust and bypass consent → P6 seeds onboarding + trust of the home (bypass consent stays the member's);
|
||||
- git commit messages appear in the sudo journal (not secret; privacy note for P8);
|
||||
- an OLD dashboard on the same DB (the 4830 dev server) abandons member runs, because it cannot see their tmux.
|
||||
- Verify:
|
||||
- unit tests with fakes: argv builders, socket choice, the peer-uid parser on fixture text;
|
||||
- an opt-in integration script (`AGENTHUB_S3_IT=1`, real test users): start a pane, commit, save a file, ingest a hook;
|
||||
- a browser e2e as two members.
|
||||
|
||||
### P4. Reads across homes
|
||||
- [x] `homes()` = the host plus each enabled user's home (`run-as.memberHomes`). Done per home: Claude transcript resolvers (`claude-home.projectsDirFor`/`allProjectsDirs`), session sync, codex-usage, codex-ingest, codex threadOf, agy hook path. Moved to P5 (no member can run agy/Cursor before /opt): antigravity and cursor pollers. Per-member quota readers: P5. Legacy-import marker: not needed (the 30 s sync sweeps member dirs). Originally per home:
|
||||
- Claude session sync (watchers plus the 30 s poll);
|
||||
- the codex-usage, codex-ingest, antigravity and cursor pollers (`running` keyed by home);
|
||||
- the legacy-import marker.
|
||||
- [x] Transcript routes: fixed at the resolvers instead (cwd → the member's projects dir), which covers every caller. Verified live: the member's 0600 transcript was served.
|
||||
- [x] Quota readers per member home: Claude, Codex, Cursor, Grok, Kimi, OpenCode. agy's live `/usage` stays host-only, because it needs the agy binary, which updates itself.
|
||||
Done 2026-10-09: `quotas({home})` (`server/lib/quota.js`) reads each tool at its default path under the member's home through `run-as`'s owner-checked reads (`opencode.db` + WAL from a private copy); `GET /api/pricing/quota` gives a developer/analyst their own (`accountForUser`), nothing when unprovisioned, the host's to an admin. No member Claude profiles (P6). Test: `server-s3-quota.test.js`. Not yet checked live on the test users.
|
||||
- Verify: tests with two homes in a tmp dir, then a real check on the test users.
|
||||
|
||||
### P5. Shared tools in /opt (S4)
|
||||
- [x] `/opt/agenthub/tools` is owned by the host: the dashboard updates it, members get r-x.
|
||||
- [x] Publish step after an install or update (and once at `install-system`):
|
||||
- copy the versioned binary to `tools/<id>/<version>` and flip `bin/<id>`;
|
||||
- npm tools use `--prefix /opt/agenthub/tools/npm`.
|
||||
- [x] For members, publish the running build (scripts, server, bin, mcp, statusline, production deps; no client) to `/opt/agenthub/app/<version>` at server start in server mode. Members need it for hooks, the CLI, MCP and the statusline.
|
||||
- [x] The pane `PATH` puts `/opt/agenthub/tools/bin` first, and the catalog detects tools there.
|
||||
- Done 2026-10-08 (`server/lib/shared-tools.js`, `agenthub server tools`):
|
||||
- a publish copies an ELF file, an npm package folder, or a script's own version folder; a shared `bin` folder is refused;
|
||||
- publishing runs after each install/update job, in the background at server start, and on the CLI command;
|
||||
- a member's catalog-tool pane runs `<root>/bin/<command>`, or gets 409 ENOTSHARED;
|
||||
- the app scripts are published with the `agenthub` launcher;
|
||||
- the agy and cursor pollers also read member homes.
|
||||
Live: claude, codex, agy and cursor-agent published (1.6 GB); claude, codex, cursor-agent and agenthub run as `ah-s3probe` (agy was not run: it self-updates).
|
||||
Follow-up: per-member quota readers. A member's Claude runs on the shared pool, and the quota route already masks accounts for members.
|
||||
- Verify: after an update from the UI, a member pane sees the new version.
|
||||
|
||||
### P6. Skills and plugins
|
||||
- [x] Seed a new member's `~/.claude.json`: `hasCompletedOnboarding`, theme, and trust for their home (live finding 2026-10-08). The Bypass Permissions consent stays the member's own.
|
||||
- Done: `agenthub-provision create` seeds onboarding + trust of the home (merged; a non-JSON file is left alone); the theme follows AgentHub's dark/light mode (`member-claude.setTheme`). Test: `server-s3-provision.test.js` runs the real script with stub `id`/`useradd`/`chown`/`runuser` and its fixed paths pointed at a temp dir.
|
||||
- [ ] Spike: does `/etc/claude-code/managed-settings.json` (`enabledPlugins` plus marketplaces) give a new Linux user the admin's baseline plugins? (Script ready, NOT run — see "P6 spike: how to run it" below.)
|
||||
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
|
||||
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
|
||||
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`.
|
||||
|
||||
#### P6 spike: how to run it (a human, on the S3 box)
|
||||
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
|
||||
1. Write the fragment to test, e.g. `/tmp/spike-fragment.json`:
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"agenthub": { "source": { "source": "github", "repo": "<owner>/agenthub" } }
|
||||
},
|
||||
"enabledPlugins": { "agenthub@agenthub": true }
|
||||
}
|
||||
```
|
||||
2. Note the current file: `sudo sha256sum /etc/claude-code/managed-settings.json`.
|
||||
3. Run: `sudo bash scripts/server/spike-managed-plugins.sh run /tmp/spike-fragment.json 60`. It backs the file up once (`managed-settings.json.agenthub-spike-bak`, or a `.agenthub-spike-none` marker when there was none), merges only those two keys, creates the throwaway user `agh-spike` (not an AgentHub member), lists its marketplaces/plugins, opens one interactive `claude` for 60 s in a private tmux socket, lists again, and prints a verdict per plugin. Report: `/var/tmp/agenthub-spike-managed/` (`verdict.txt`, `session-screen.txt`, the before/after JSON).
|
||||
- If the screen shows a login prompt, the result before login may not be final: attach with `sudo runuser -u agh-spike -- tmux -L agh-spike attach` during the wait, or rerun after `undo` with a longer wait.
|
||||
4. Undo (always): `sudo bash scripts/server/spike-managed-plugins.sh undo` — restores the file byte for byte (or removes it), ends `agh-spike`'s processes and deletes the user and its home, then checks install-system's mark (`/etc/agenthub/managed-settings.sha256`) matches again. Check step 2's hash matches.
|
||||
5. Record here: Claude Code version, `verdict.txt`, and whether plugins appeared before a session, only after one, or never. "Listed after" ⇒ managed settings are enough, and install-system can write the baseline there (the fallback stays for plugins managed settings cannot express). "Absent" ⇒ keep the fallback.
|
||||
|
||||
### P7. Codex pool
|
||||
- [x] `codex-pool.js`, modelled on `claude-pool.js`, with two kinds of account:
|
||||
- `apikey`: any concurrency, the key in env;
|
||||
- `chatgpt`: `auth.json`, and ONE live pane per account, because refresh tokens rotate. It is copied into the member's `CODEX_HOME` at spawn and copied back under a lock when the pane ends; the newest `last_refresh` wins.
|
||||
- [x] `pickAccount` for Codex, `dashboard_runs.account`, `agenthub accounts add --tool codex`, and quota from that account's rollouts. Built 2026-10-09: an API key goes `-e AGENTHUB_POOL_KEY` + a `-c` provider (Codex's TUI ignores `CODEX_API_KEY`/`OPENAI_API_KEY`); a ChatGPT `auth.json` is written only into a member's CODEX_HOME (never the host's login, never over the member's own, sticky while a pane of theirs runs on one); copy-back runs on the run-heal beat (newest `last_refresh`). Not yet run against a real account.
|
||||
- **Spike done 2026-10-08** (codex 0.161.0, a free ChatGPT account): two `CODEX_HOME` copies of one `auth.json` ran at the same time; a refresh in one copy (forced by an expired `access_token` — 0.161 ignores `last_refresh`) rotated its refresh token, and the OLD refresh token still refreshed in a second and a third copy minutes later; every copy kept working and nothing was logged out. So a ChatGPT account may run in several panes at once: copy `auth.json` per pane, copy the newest back when a pane ends. Unverified: whether reuse of an old refresh token is still accepted days later (keep the copy-back so a new pane starts from the newest).
|
||||
|
||||
### P8. Security review and docs
|
||||
Review ran 2026-10-08 (four lenses: root/sudo, cross-member, secrets, local-mode). Root cause of every serious finding: the dashboard (host uid, `CAP_DAC_READ_SEARCH`) connects to, writes into and reads from paths inside a member's home, which the member can swap. Fixes:
|
||||
- [x] F1 tmux: every call on a member's socket runs AS the member (`sudo -u`), attach included; a session's env + argv go in a launch file written by `write-as` (stdin) and run by `/opt/agenthub/libexec/pane-launch` — nothing secret or private crosses sudo; no boot session, no `server-access`, no socket chmod (drop `chmod` from sudoers).
|
||||
- [x] F2 the host never writes, chmods or deletes inside a member's home: handoff files, drops, context copies and git scratch all go through `write-as` (new modes: `unlink`, `mkdir`).
|
||||
- [x] F3 a member's file is read only after `open` + `fstat` says it is a regular file owned by that member (O_NONBLOCK: a FIFO cannot hang the event loop) — transcripts, subagents, Codex/agy/Cursor journals, the lane file reads.
|
||||
- [x] F4 provision: root never touches a path inside an existing home; `.agenthub` is made by the member (`runuser`), 0700.
|
||||
- [x] F5 the ambient capability is dropped for every child the dashboard spawns (`setpriv`), so host panes, git hooks and npm do not inherit read-everything.
|
||||
- [x] F6 `accountForPath` fails closed for a path under the users root with no usable account (`EMEMBERGONE`), never "run it as the host".
|
||||
- [x] F7 small: `buildArgv` gets `laneId` (ENOTSHARED never fired); peerUid compares loopback addresses; `testProxy` `redirect: "manual"`; `sudoArgv` key check; `startSessionSync` catches `allProjectsDirs`; `tmux.__reset` resets `memberImpl`; `accountForLane` checks `s3Enabled` first; run.js maps ENOTSHARED/EMEMBERGONE 409; absolute `git`/`tmux`/`gh` paths from server.conf in sudo argv. NOT done: commit message via `-F -` — the sudo log is root/adm-readable and root reads the repo anyway (documented ceiling).
|
||||
- [x] F8 member hooks: the transcript path is checked before the Codex mapping reads it; a member hook touches only a lane inside its own home (status + detection); Claude/Codex/agy/Cursor journals speak only for their own account (`sameOwner`: cwd + existing row; a Codex thread id never takes over another account's row). `livenessReap`: kept — a member session no pane holds already falls through to the idle gate (documented).
|
||||
- [x] F9 install-system: stops a detached dashboard before the unit starts; keeps env-file keys the bootstrap did not pass (`DASHBOARD_PORT`); `--uninstall` rewrites the user unit before enabling it; `chown -R -h` / `cp --no-dereference`; `chmod o-rwx` the host's `~/.claude`; UI text for `systemd-system`.
|
||||
- [x] F10 shared tools: never copy a tool folder that is a direct child of `$HOME`, and skip files matching `CONFIG_SECRET_RE`.
|
||||
- [x] F11 Claude via the gateway: the pane token goes in a per-run `--settings` file, so a user/project `env.ANTHROPIC_*` cannot override it.
|
||||
- Accepted ceilings (document): a host-account compromise is every member's (it may sudo as any member); install-system trusts the host's tree; members may make the host POST to any URL through a personal proxy (they have a shell on the box anyway); the gateway forwards any `/v1/` path; sudo logs branch names, PR titles and paths.
|
||||
- [x] A multi-lens adversarial review of P2–P7 before merging (2026-10-09, lenses root/sudo, cross-member, secrets, local-mode; 12 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review2.test.js`, `drop-caps.test.js`, `server-mode-proxies.test.js`):
|
||||
- F12 lane file read/save: the workspace's real path must stay the same account's (`git-diff.js` `laneRoot`, `EFOREIGN`), so a folder swapped for a link never picks the account a read or `write-as` runs as;
|
||||
- F13 `excludeFromGit`/`excludeFromRepoRoot`: the git dir the member's git prints must realpath into the same account's files; read with `readFileSafe` (no FIFO hang), never a host write for a member;
|
||||
- F14 node-pty `tmux attach` goes through `setpriv` (`drop-caps.ptyArgv`);
|
||||
- F15 `GET /lanes/browse` and `GET /lanes/branches`: a developer stays in their own folder (real path), `EFOLDER`;
|
||||
- F16 a by-id transcript/subagent scan looks only in the session owner's projects dir (`claude-home.sessionProjectsDirs`);
|
||||
- F17 Codex status mirror: a journal flips only its own account's lanes (`sameOwner`), `MAX_FILES` per home;
|
||||
- F18 a member's home is no workspace under S3 (`folderAllowed`), and the dot folders at the top of a member's home are `ESECRET` for every workspace file view (`inMemberDotDir`);
|
||||
- F19 reads use `ownerForRead`, never the throwing `accountForPath`: `projectsDirFor`, the liveness reaper, `cc-reload`, the Codex pool's live check;
|
||||
- F20 an AI Proxy edit rewrites a live gateway pane's own `--settings` file (its own token) and deletes a dead pane's.
|
||||
- Not done (optional in the finding): deleting a run's handoff `proxy-*.json` when the run ends — its token dies with the run.
|
||||
- [x] A third review (2026-10-09; 10 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review3.test.js`, `codex-pool.test.js`, `server-s3-system.test.js`):
|
||||
- F21 a workspace inside a member's home is owned by that member only (`run-as.assertOwnerFits`: `createLane`, `assignWorkspace`, `spawnRun`, `spawnShell` — `EOWNERFOLDER`), so no pane carries another owner's token;
|
||||
- F22 the transcript snapshot copies through `openSafe` (a member's planted link is never copied into the host's data dir);
|
||||
- F23 a member's long first prompt stays in its launch file (no `run-prompts` file the member cannot read);
|
||||
- F24 disable ends the logins first and locks only a Linux account that exists; F25 `provision --all` goes on past a failing user;
|
||||
- F26 the Codex settle pass names a session from its own home's index;
|
||||
- F27 the workspace memory view reads through `openSafe`;
|
||||
- F28 a non-admin's `?cwd=` on `/cc-config` and `/codex-config` names only their own workspaces (`scope.ownCwdQuery`), and the config `file` reads never serve a credential file (`isSecretConfigPath`);
|
||||
- F29 the Codex pool copies a refresh back only from a home it placed the account in, never dated in the future, with the same `id_token` identity (signature unchecked: documented ceiling);
|
||||
- F30 the startup AGENTS.md cleanup skips members' folders.
|
||||
- [x] Review round 5 (2026-10-09; 5 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review5.test.js`, `cursor-usage.test.js`, `run.test.js`, `codex-pool.test.js`):
|
||||
- F31 the Cursor slug walk (`cwdFromSlug`) follows no symlink and gives up after 500 directories, once per transcript — a member's crafted tree or link loop no longer freezes the dashboard;
|
||||
- F32 a member's Antigravity conversation is read from a private copy (`readFileSafe` of the `.db` and its `-wal`), so a FIFO `-journal`/`-wal` beside it, which SQLite opens by name, blocks nothing;
|
||||
- F33 a non-member's drops go to `<data dir>/drops/` (0700), never a predictable `/tmp/agenthub-drops-<uid>` another account could create first;
|
||||
- F34 a developer's extra workspace folder is checked against their own folder (`scope.folderAllowed`, real path) before anything probes it, and again on the path it records (`403 EFOLDER`);
|
||||
- F35 the Codex pool's quota reads a run's rollout only from the `CODEX_HOME` of the account its workspace runs as, never one dated in the future. Accepted ceiling (documented): a member can still forge their own session's reading.
|
||||
- F36 (critical) `GET /api/cc-config/file` never serves the data dir (`isSecretConfigPath`): an analyst read an admin gateway pane's live pane token from `proxy-settings/`, plus `vapid-keys.json` and the database;
|
||||
- F37 every tmux call on a member's socket is bounded (`memberCall`: 3 s, SIGKILL, 1 MiB) with a 60 s back-off — a SIGSTOPped member server froze the event loop for good;
|
||||
- F38 a failing member server makes only that member's panes unknown (`liveSessions().has` true for them), never null for everyone; member list errors are cached;
|
||||
- F39 `execIn` and `write-as` (every sync sudo into a member) stop after 5 s (`MEMBER_EXEC`) — a FIFO `include.path` or a FIFO write target hung git and write-as;
|
||||
- F40 transcripts and journals are read with `readLines` (chunked, a line over 64 MiB is skipped, never built) — readline crashed the server on a one-line 512 MB sparse file (import-history, sessions, codex-usage);
|
||||
- F41 `readFileSafe*` read a member's file only up to a cap (`max`; 1 MiB logins/settings, 16 MiB indexes/journals, 256 MiB SQLite copies, 64 MiB default), and transcript-cache skips a member transcript over 256 MiB;
|
||||
- F42 the transcript snapshot skips a member file over 256 MiB and writes zero runs as holes — a sparse file was copied out as real blocks into the host's disk;
|
||||
- F43 an async route's rejection goes to `next(err)` (Express `Layer.handle_request`), and `refuseS3Folder` uses `ownerForRead` — `POST /api/lanes/worktree` with a gone member's folder crashed the server;
|
||||
- F44 a workspace outside the users root may be owned only by an admin (live role: `assertOwnerFits` at create, assign and every spawn), and demoting/disabling a user ends their host panes — a developer-owned host folder started host-account panes;
|
||||
- F45 a pane's cwd must be its workspace's account's (`ECWDACCOUNT`, checked before anything is written for the pane);
|
||||
- F46 a pane token (dashboard and AI Proxy gateway) is accepted only from the uid its pane runs as (`peerIsPaneAccount`);
|
||||
- F47 the Codex pool counts a home's login as placed only when AgentHub placed it there AND the id_token's email/account match — a ChatGPT Team's seats share `account_id`; the live-pane stickiness follows the live run;
|
||||
- F48 a path an analyst names in a GET (`browse`, `branches`, `/run/files`) is scoped like a developer's (`ownFolder`, `insideOwnWorkspace`);
|
||||
- F49 a drop is saved as the account of the workspace the client names (`?laneId=`, or the pane's run id), not the requester's.
|
||||
- [x] Update (2026-10-09, branch `s3-docs`):
|
||||
- `docs/SERVER-MODE.md`: an S3 section and a runbook;
|
||||
- CLAUDE.md rules: a pane runs as its owner; the dashboard never uses sudo to root except the provision helper; hooks are authenticated by peer uid; `hidepid` is required;
|
||||
- the AgentHub-Server operator docs.
|
||||
|
||||
## Known risks
|
||||
- `sudo` costs about 10 ms per git call. That is fine: an open Changes panel makes 5 calls every 30 s.
|
||||
- The host's tmux client and the member's server must be the same binary, so the path is pinned.
|
||||
- A member can revoke `server-access` or chmod their socket. Only their own panes go dark, and the UI says so.
|
||||
- Per-member tmux servers live in the system unit's cgroup. `KillMode=process` keeps them across restarts (the rule stays), and Quit kills them explicitly.
|
||||
- Existing usernames that do not fit the Linux rule (uppercase, dots) are refused with a clear message, and the admin renames them.
|
||||
Reference in New Issue
Block a user