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
+73 -2
View File
@@ -6,6 +6,9 @@
// First, before any module destructures child_process: no console window per
// git/npm call on Windows (server/lib/windows-hide.js).
require("./lib/windows-hide").install();
// S3 system unit: children never inherit its ambient CAP_DAC_READ_SEARCH
// (server/lib/drop-caps.js) — same "before child_process is read" rule.
require("./lib/drop-caps").install();
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// A server started from inside a Claude Code session (the bootstrap fallback,
@@ -50,6 +53,25 @@ if (require.main === module) {
if (require.main === module) require("./lib/claude-home").migrateLegacyDataDir();
const express = require("express");
// Express 4 ignores the promise an async handler returns: a rejection (a
// 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.
{
const Layer = require("express/lib/router/layer");
Layer.prototype.handle_request = function handle(req, res, next) {
const fn = this.handle;
if (fn.length > 3) return next(); // an error handler: not for a request
try {
const r = fn(req, res, next);
if (r && typeof r.then === "function") r.then(undefined, next);
} catch (err) {
next(err);
}
};
}
const cors = require("cors");
const fs = require("fs");
const path = require("path");
@@ -124,6 +146,10 @@ function createApp() {
// allowlist (anti DNS-rebinding), and an optional bearer-token gate on /api/*.
app.use(cors(corsOptions()));
app.use(hostGuard);
// Server mode's AI Proxy key gateway (server/lib/proxy-gateway.js): before
// the body parser (it streams the raw body) and the token/login gates (a
// pane authenticates with its own pane token, loopback only).
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
app.use(express.json({ limit: "1mb" }));
app.use("/api", tokenGuard);
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
@@ -296,6 +322,25 @@ function startServer(app, port) {
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
writeServerInfo(port);
// The AI Proxy gateway URL a server-mode pane is given names this port.
require("./lib/proxy-gateway").setListenPort(server.address().port);
// S3: what members' panes run — this install's scripts and every
// installed AI tool, copied under /opt/agenthub (idle, in the background).
if (require("./lib/linux-accounts").s3Enabled()) {
const shared = require("./lib/shared-tools");
const t = setTimeout(() => {
shared
.publishApp()
.then(() => shared.publishAll())
.then((r) => {
const bad = r.filter((x) => x.error);
if (bad.length)
console.warn("[shared-tools]", bad.map((x) => `${x.id}: ${x.error}`).join("; "));
})
.catch((e) => console.warn("[shared-tools]", e.message));
}, 5000);
if (t.unref) t.unref();
}
// A server that starts ends a Quit: the bootstrap may start it again.
require("./lib/quit").clearQuit();
const sharedDbPeers = peersSharingDataDir();
@@ -705,6 +750,13 @@ function startBackgroundServices() {
OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any`
);
const healTimer = setInterval(() => {
try {
// A Codex pool login a member's pane refreshed goes back to the pool
// (also after its pane ended), so the next pane starts from it.
require("./lib/codex-pool").syncBack();
} catch (err) {
console.warn("codex pool sync failed:", err.message);
}
try {
if (!anyRun.get().any) return; // nothing to heal: no tmux spawn at all
const live = liveSessions();
@@ -909,7 +961,7 @@ function startWorkflowPoll(broadcast) {
function startSessionSync(broadcast) {
const fs = require("fs");
const dbModule = require("./db");
const { getProjectsDir } = require("./lib/claude-home");
const { getProjectsDir, allProjectsDirs } = require("./lib/claude-home");
const { syncDefaultProjects } = require("../scripts/import-history");
const projectsDir = getProjectsDir();
@@ -923,7 +975,26 @@ function startSessionSync(broadcast) {
return;
}
running = true;
syncDefaultProjects(dbModule, { mtimeCache })
// This account's projects dir, then every member's (S3: their sessions
// write under their own home) — one after another, same mtime cache.
// In the chain: a throw here (S3's member lookup) must not leave
// `running` set and stop the sync for good.
Promise.resolve()
.then(allProjectsDirs)
.then((dirs) =>
dirs.reduce(
(prev, dir) =>
prev.then(async (acc) => {
const { changed } = await syncDefaultProjects(dbModule, {
mtimeCache,
projectsDir: dir,
});
return acc.concat(changed);
}),
Promise.resolve([])
)
)
.then((changed) => ({ changed }))
.then(({ changed }) => {
for (const { sessionId, isNew } of changed) {
let row;