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:
+73
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user