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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+27 -16
View File
@@ -555,6 +555,8 @@ export type AuditEntry = {
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
@@ -675,13 +677,15 @@ export const api = {
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
me: {
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
prefs: () => request<MePrefs>("/me/prefs"),
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
/** GET /api/me/prefs — this account's preferences and first-run flag. */
prefs: () => request<UserPrefs>("/me/prefs"),
/** PUT /api/me/prefs — merge; `null` deletes a key. */
savePrefs: (prefs: Record<string, string | null>) =>
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
@@ -2448,13 +2452,19 @@ export const api = {
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
* run id) saves it where THAT workspace's panes can read it. */
dropFile: (file: File, lane?: number | string | null) => {
const laneId =
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
const qs = new URLSearchParams({ name: file.name });
if (laneId != null) qs.set("laneId", String(laneId));
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
});
},
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -3600,6 +3610,10 @@ export interface AiProxy {
label: string;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** "shared": the host's (an admin's, in server mode), usable by everyone;
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
* only, and nobody else lists it. */
scope?: "shared" | "personal";
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
baseUrl: string;
/** Whether a key is stored. The key itself never leaves the server. */
@@ -3651,6 +3665,9 @@ export interface AiProxyInput {
models?: Partial<AiProxyModels>;
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
behavesAs?: string;
/** Server mode, on create: an admin's profile is shared unless "personal";
* a member's is always personal. Ignored in local mode. */
scope?: "shared" | "personal";
}
/** One markdown-based config artifact (skill/agent/command/output-style),
@@ -4312,12 +4329,6 @@ export interface RunStartArgs {
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
* name (`agenthub-lane-<laneId>`). */
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
export interface MePrefs {
prefs: Record<string, string>;
onboardedAt: string | null;
}
export interface RunHandle {
id: string;
laneId: number | null;
@@ -4528,7 +4539,7 @@ export interface RemoteSourceInput {
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "launchd";
kind: "systemd" | "systemd-system" | "launchd";
enabled: boolean;
foreign: boolean;
}