docs(server-mode): plan moved in from the deleted AgentHub-Server repo; P6 spike result; Express patch only on 4.x

- docs/plans/server-mode/README.md: the original multi-user design, now kept
  here (AgentHub is the one source); desktop-parity and S3-PLAN link to it.
- S3-PLAN P6: managed-settings spike run on agh-test1 — the managed
  marketplace/plugin arrive at the first interactive session (onboarding
  seeded), never via the CLI alone, and no user-scope record is written.
- server/index.js: the Layer.handle_request patch applies only on Express 4
  (Express 5 handles async rejections itself and has no lib/router/layer).
This commit is contained in:
2026-10-09 15:18:57 +07:00
parent 84e2a9f0ac
commit 118eaee2ec
4 changed files with 111 additions and 6 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
Comes BEFORE the multi-user server plan ([`docs/plans/server-mode/README.md`](../server-mode/README.md))
— that plan's owner column hangs
off the session ownership built in phase 1 here._
## Where we stand
+98
View File
@@ -0,0 +1,98 @@
# Multi-user server — AgentHub as a shared AI-tools host
_Status: the original design, written 2026-10-02. It lived for a while in a
separate AgentHub-Server repo, which has since been deleted; since 2026-10-09
it is kept here again, and AgentHub is the only source. Kept as the design
record. For what was built and what is still open, see
[`S3-PLAN.md`](S3-PLAN.md) and [`CHECKLIST.md`](CHECKLIST.md). The operator
runbook is in [`docs/SERVER-MODE.md`](../../SERVER-MODE.md)._
> Two things changed after this design was written:
> - The `member` role is now `developer`.
> - An `analyst` is no longer read-only. An analyst reads every user's rows and
> writes only their own.
## Where the code lives (decided 2026-10-02)
- **No fork while the local AgentHub is still moving.** A fork would need every
local fix carried over by hand, and S1–S3 touch nearly every route, the DB and
`pty-run.js` — two lines editing those in parallel conflict on every sync.
- **Server features go into the AgentHub codebase as a server MODE**, off by
default: login, roles and per-user scoping are additive; with the mode off the
local app behaves as one implicit admin and nothing changes for it.
- **What only a server deployment needs ships in this repo too**:
- provisioning, the sudoers rule and the pane helper are in
`scripts/server/`, installed by `agenthub server install-system`;
- the shared tools layout is `/opt/agenthub/tools`;
- the operator docs are the runbook in `docs/SERVER-MODE.md`.
(This first went to a separate AgentHub-Server repo. That repo is gone.)
- If a separate codebase ever becomes necessary, fork from a tagged, stable
AgentHub release, never from work in progress.
## Goal
Several people (A, B, C) use one AgentHub over the web. One Linux account (the
**host account**) has installed every AI tool and holds the shared logins:
AI Router profiles, several Claude native accounts, several Codex accounts.
- Each person logs in to AgentHub, works only in their own folder, and can
switch Claude/Codex account, pick any AI Router and use any installed tool —
without installing a tool or logging in to one themselves.
- Nobody sees anyone else's workspaces, sessions, prompts, tokens or cost.
- An **admin** sees and manages everything; an **analyst** reads everything
(usage, prompts) but changes nothing.
## Why one Linux user per account
Isolation inside AgentHub alone covers only the web UI. An agent runs a shell
(and can be put in bypass mode) as whatever Linux user started it; if A's agent
runs as the same user as B's, "read /srv/agenthub/users/B" just works. Only the
OS can stop an agent, so each AgentHub account maps to its own Linux user, and
the folders are `0750` with the AgentHub service in the group.
The cheaper variant — one Linux user, isolation in the UI only — is phase S1-S2
on their own. It is acceptable only where everyone is trusted and "cannot see"
means "is not shown".
## What is shared and what is not
| Thing | Shared? | Where |
| --- | --- | --- |
| Tool binaries | **Yes**, installed once | `/opt/agenthub/tools/` (root or `agenthub` owned, world `r-x`), on every pane's `PATH`. Self-updaters off (`DISABLE_AUTOUPDATER=1` for Claude; per tool, verified in S0); AgentHub's **Update** runs as the owner of `/opt/agenthub/tools`. |
| Today's install | — | `~/.local/bin/claude`, `~/.codex/packages/…/codex`, `~/.local/bin/agy` inside a `0750` home: other users cannot reach them, and Codex's binary lives in its credentials dir. Hence the move. |
| Tool config / login / transcripts | **No**, per account | `/srv/agenthub/users/<acct>/.claude`, `.codex`, `.gemini`, `.cursor`… (`HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME` point here). Sharing this dir would share every transcript. |
| Account logins (Claude, Codex, …) | **Yes**, as a vault | Host account, `agenthub`-owned, `0700`. Injected into a pane at spawn (S5). |
| AI Router profiles | **Yes** | Already per workspace (`router-apply.js`); the store moves to the vault dir. |
| Hooks | **Yes**, one copy | Claude: `/etc/claude-code/managed-settings.json` (applies to every user). Codex: written into each account's `CODEX_HOME` at provisioning. |
| Database | One SQLite, rows owned | `owner` on every user-data table (S2). |
## Phases
| # | Scope |
| --- | --- |
| S0 | **Spike** (needs `sudo`): two test Linux users; tools under `/opt/agenthub/tools`; each tool started with a foreign `HOME`; verify per tool how a login can be handed in (below) and how its auto-update is turned off; measure what concurrent use of one OAuth login does. Output: a table in this file, go/no-go per tool. |
| S1 | **Accounts and login**, still one Linux user. `users` table (id, name, role `admin|analyst|member`, password hash via `crypto.scrypt`, disabled), `auth_sessions` (httpOnly SameSite=Strict cookie), login page, every `/api` and `/ws` behind it except `/api/hooks` (local only, see S3). First run: create the admin from the CLI (`agenthub users add --admin`). |
| S2 | **Ownership.** `owner` (user id) on `lanes`, `sessions`, `token_usage`, `events`, `dashboard_runs`, `lane_sources`, alerts, ai-tools prefs. A session's owner = its workspace's owner (phase-1 `lane_id`), else derived from its cwd under `/srv/agenthub/users/<acct>/`. Every query of a member is scoped (one helper, not per route); WebSocket broadcasts filtered per connection. Workspace creation refuses a folder outside the account root. Admin/analyst bypass the scope; analyst is read-only. |
| S3 | **Per-account Linux user.** Provisioning (`agenthub users add`): `useradd`, `/srv/agenthub/users/<acct>` `<acct>:agenthub 0750`, `HOME` there, per-tool dirs, Codex hooks. Spawning as the account: a small privileged helper (or a `sudoers` rule limited to `tmux` under group `agenthub-users`) — per-account tmux server; `pty-run.js`/`tmux.js`/`pty-attach.js` gain a target user. Hooks keep posting to localhost; the server trusts the owner it derives from the cwd/run, never one the hook names. Transcripts read through group `agenthub`. **Security review before merge.** |
| S4 | **Shared tools.** Catalog detection also looks in `/opt/agenthub/tools/bin`; AI Tools install/update write there; per-tool auto-update off in each pane's env. |
| S5 | **Login vault and switching.** A member picks, per run or as a default, a Claude profile / Codex profile / AI Router. At spawn the pane gets it: Claude a long-lived token (`claude setup-token` → `CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e`), Cursor `CURSOR_API_KEY`, Codex `auth.json` copied into the account's `CODEX_HOME` (or an API key), others per S0. Today's `cc-profiles.js` switch rewrites ONE live credentials file — that becomes "copy into this account", never global. Codex profiles are new. Refresh-token rotation (two people on one OAuth login) is the open risk S0 measures. |
| S6 | **Admin and analyst views.** User management; every page gets a user filter for admin/analyst; prompt analytics (UserPromptSubmit events, transcripts) and token/cost per user; admin may attach read-only to any pane. Every admin read of another user's content is audit-logged. |
| S7 | **Hardening.** TLS (reverse proxy), bind address, login rate limit, cookie flags, `sameOriginGuard` kept, `/api/hooks` accepted from loopback only, backup of the vault. |
## Rules this plan will change in `CLAUDE.md`
- Settings/`CLAUDE_HOME` become per account; the host account's `~/.claude` is no
longer "the" Claude home.
- `cc-profiles.js` "a switch changes the live account" → per account.
- Every new route is owner-scoped; "a route never takes X from the request"
rules gain "…nor an owner".
- The bootstrap/systemd rules stay for the host account; members never run the
bootstrap.
## Open questions for the user (before S3)
1. With Claude Enterprise each person has a seat — should members log in their
own seat (cleaner audit, no rotation risk) and the vault serve only Codex,
AI Routers and spares?
2. Does an admin's live view of a pane need to be announced to the member?
3. Retention of prompts for analytics — how long, and who may export them?
+8 -2
View File
@@ -196,7 +196,13 @@ Branch: `server-s3`. Commits happen only when the user asks.
### 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] Spike: does `/etc/claude-code/managed-settings.json` (`enabledPlugins` plus marketplaces) give a new Linux user the admin's baseline plugins? **Yes, from the first interactive session on.** Run 2026-10-09 on `agh-test1` (not a new user), Claude Code 2.1.293, with `sg-all@sg` (no hooks, public git marketplace) in a ~80 s window. The file was then restored byte for byte, and `agh-test1`'s `~/.claude` was restored from a tarball and verified.
- Before any session: `claude plugin marketplace list` and `plugin list` were both `[]`. The CLI alone syncs nothing.
- First session, onboarding NOT seeded: it stopped at the theme picker and synced nothing. The provision seed (P6, above) is a precondition.
- Onboarding seeded and NOT logged in: within seconds the session cloned the `sg` marketplace, fetched `sg-all` 0.2.0 into `~/.claude/plugins/cache/` and marked it `.in_use`.
- `installed_plugins.json` stayed empty, so `claude plugin list` shows `[]` even then. A managed-enabled plugin is loaded from the cache with no user-scope record. Check a member's plugins by the cache, not by `plugin list`.
- So: install-system can put the baseline in managed settings for anything a marketplace can express. The `plugin-baseline.json` fallback stays for the rest, and it applies to every Claude Code on the machine, the host's included (enabling a plugin the host already has changes nothing for it).
- Not checked: skills actually listed inside a logged-in session (no login on the test user).
- [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`.
@@ -283,7 +289,7 @@ Review ran 2026-10-08 (four lenses: root/sudo, cross-member, secrets, local-mode
- [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.
- the design plan moved back from the deleted AgentHub-Server repo into [`README.md`](README.md); the operator docs are the runbook in `docs/SERVER-MODE.md`.
## Known risks
- `sudo` costs about 10 ms per git call. That is fine: an open Changes panel makes 5 calls every 30 s.
+3 -2
View File
@@ -58,8 +58,9 @@ const express = require("express");
// throw after the first await) became an unhandled rejection, and Node exits
// on one — any request that made a handler throw took the whole server down.
// Every handler's rejection goes to `next(err)` (a 500) instead, the way a
// synchronous throw already did. Express 5 does this itself.
{
// synchronous throw already did. Express 5 does this itself, and has no
// `lib/router/layer` to patch — so only a 4.x gets it.
if (require("express/package.json").version.split(".")[0] === "4") {
const Layer = require("express/lib/router/layer");
Layer.prototype.handle_request = function handle(req, res, next) {
const fn = this.handle;