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
+35 -27
View File
@@ -22,7 +22,6 @@
const express = require("express");
const { Router } = express;
const fs = require("node:fs");
const os = require("node:os");
const crypto = require("node:crypto");
const path = require("node:path");
const runs = require("../lib/pty-run");
@@ -66,22 +65,7 @@ function sanitiseCwd(input) {
return resolved;
}
/**
* Server mode: whether a member may work in `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. Always true for everyone else.
*/
function insideOwnWorkspace(req, dir) {
const visible = scope.visibleLaneIds(req);
if (visible === null) return true;
const real = lanesLib.canonicalCwd(dir);
return visible.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
const insideOwnWorkspace = scope.insideOwnWorkspace;
const refuseFolder = (res) =>
res.status(403).json({
@@ -128,26 +112,47 @@ const DROP_TYPES = {
};
const DROP_MAX_BYTES = 20 * 1024 * 1024;
const DROP_TTL_MS = 24 * 60 * 60_000;
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
* workspace `?laneId=` names (scopeGuard already refused one this user may
* not see), else the requester's own — written AS that member under S3, so
* their pane can read it; else this account's data dir, never a predictable
* name in the shared tmp, which anyone on the machine could create first and
* then swap the dropped files in. */
function dropDir(req) {
const runAs = require("../lib/run-as");
const laneId = Number(req.query?.laneId);
const member =
Number.isInteger(laneId) && laneId > 0 && lanesLib.getLane(laneId)
? runAs.accountForLane(laneId)
: runAs.accountForUser(req?.user?.username);
return member
? { dir: path.join(require("../lib/run-as").handoffDir(member), "drops"), member }
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
}
// Saves one dropped body under the drop dir and answers its path. Pruned on
// write: files older than a day go.
function saveDrop(res, name, body) {
function saveDrop(req, res, name, body) {
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const { dir, member } = dropDir(req);
const runAs = require("../lib/run-as");
if (member) runAs.mkdirAs(member, dir);
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
if (member) runAs.removeAs(member, f);
else fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, name);
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -167,7 +172,7 @@ router.post(
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
}
);
@@ -184,7 +189,7 @@ router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "?name= and a non-empty file body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
});
router.get("/history", (req, res) => {
@@ -420,7 +425,10 @@ router.post("/", (req, res) => {
proxy:
(body.proxy ?? body.router) !== undefined
? (body.proxy ?? body.router)
: resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null),
: resumeProxy(
typeof body.resumeSessionId === "string" ? body.resumeSessionId : null,
lane?.owner_id ?? null
),
model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
@@ -443,7 +451,7 @@ router.post("/", (req, res) => {
return res.status(201).json(handle);
} catch (err) {
// A busy pane is a conflict with live state, not a malformed request.
if (err.code === "ERUNLIVE") {
if (["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)) {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not