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
+54 -29
View File
@@ -13,7 +13,6 @@
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const {
bucketKey,
emptyBucket,
@@ -93,24 +92,51 @@ function snapshotTranscript(sourceJsonlPath, sessionId) {
/**
* Copy `src` to `dest` only when `dest` is missing or smaller than `src`
* (i.e. the source grew). Creates parent dirs as needed.
* (i.e. the source grew). Creates parent dirs as needed. The source is OPENED
* through run-as (S3): a file in a member's home is copied only when the open
* file is a regular file that member owns — `statSync`/`copyFileSync` would
* follow a link the member planted to another account's transcript, with the
* dashboard's CAP_DAC_READ_SEARCH, into a snapshot the member may then read.
*/
const SNAPSHOT_MEMBER_MAX = 256 * 1024 * 1024;
const ZERO_MB = Buffer.alloc(1 << 20);
function copyIfNewer(src, dest) {
let srcSize;
let fd;
try {
srcSize = fs.statSync(src).size;
fd = require("../server/lib/run-as").openSafe(src);
} catch {
return; // source vanished mid-import — nothing to copy
return; // source vanished mid-import, or is not its member's own file
}
let destSize = -1;
try {
destSize = fs.statSync(dest).size;
} catch {
/* dest missing */
const srcSize = fs.fstatSync(fd).size;
let destSize = -1;
try {
destSize = fs.statSync(dest).size;
} catch {
/* dest missing */
}
if (destSize >= srcSize) return; // snapshot already at least as complete
// S3: a member's file is copied into the HOST's disk, so only up to a cap,
// and a run of zeros stays a hole — a sparse file costs its member nothing.
// ponytail: sync copy of ≤ 256 MiB; go async if member transcripts get that big.
if (srcSize > SNAPSHOT_MEMBER_MAX && require("../server/lib/run-as").isMemberFile(src)) return;
fs.mkdirSync(path.dirname(dest), { recursive: true });
const out = fs.openSync(dest, "w", 0o600);
try {
const buf = Buffer.allocUnsafe(ZERO_MB.length);
let pos = 0;
for (let n; pos < srcSize; pos += n) {
n = fs.readSync(fd, buf, 0, Math.min(buf.length, srcSize - pos), pos);
if (n <= 0) break;
if (!buf.subarray(0, n).equals(ZERO_MB.subarray(0, n))) fs.writeSync(out, buf, 0, n, pos);
}
fs.ftruncateSync(out, pos);
} finally {
fs.closeSync(out);
}
} finally {
fs.closeSync(fd);
}
if (destSize >= srcSize) return; // snapshot already at least as complete
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/**
@@ -130,10 +156,8 @@ function firstUserLabel(text) {
async function parseSessionFile(filePath) {
const sessionId = path.basename(filePath, ".jsonl");
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
// readLines, never readline: a member's one-line 512 MB file crashes readline.
const rl = require("../server/lib/run-as").readLines(filePath);
let cwd = null;
let model = null;
@@ -386,10 +410,8 @@ async function parseSessionFile(filePath) {
async function parseSubagentFile(filePath) {
const agentId = path.basename(filePath, ".jsonl").replace(/^agent-/, "");
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
// readLines, never readline: a member's one-line 512 MB file crashes readline.
const rl = require("../server/lib/run-as").readLines(filePath);
let task = null;
let model = null;
@@ -531,7 +553,7 @@ async function parseSubagentFile(filePath) {
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
try {
if (fs.existsSync(metaPath)) {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
const meta = JSON.parse(require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
if (meta.agentType) agentType = meta.agentType;
}
} catch {
@@ -1746,10 +1768,7 @@ async function backfillCompactions(dbModule) {
if (!session) continue;
const filePath = path.join(projPath, file);
const rl = readline.createInterface({
input: fs.createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
const rl = require("../server/lib/run-as").readLines(filePath);
const compactions = [];
for await (const line of rl) {
@@ -1910,13 +1929,15 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
*/
async function syncDefaultProjects(dbModule, options = {}) {
const mtimeCache = options.mtimeCache instanceof Map ? options.mtimeCache : new Map();
// One projects dir per call: this account's, or (S3) a member's own.
const root = options.projectsDir || getProjectsDir();
const changed = [];
if (!fs.existsSync(getProjectsDir())) return { changed };
if (!fs.existsSync(root)) return { changed };
let projectDirs;
try {
projectDirs = fs
.readdirSync(getProjectsDir(), { withFileTypes: true })
.readdirSync(root, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch {
@@ -1928,7 +1949,7 @@ async function syncDefaultProjects(dbModule, options = {}) {
let scanned = 0;
for (const projDir of projectDirs) {
const projPath = path.join(getProjectsDir(), projDir);
const projPath = path.join(root, projDir);
let files;
try {
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
@@ -1983,6 +2004,10 @@ async function syncDefaultProjects(dbModule, options = {}) {
// re-parse an unusable file every tick.
mtimeCache.set(sourcePath, mtime);
if (!session) continue;
// S3: a transcript in a member's home names only their own folder and
// session — never another account's (a crafted file's sessionId/cwd).
if (!require("../server/lib/run-as").sameOwner(sourcePath, session.cwd, existingRow?.cwd))
continue;
const result = importSession(dbModule, session);
snapshotTranscript(session._sourceJsonlPath, session.sessionId);
@@ -2249,7 +2274,7 @@ if (require.main === module) {
function findCompactionsInFile(filePath) {
if (!fs.existsSync(filePath)) return [];
const compactions = [];
const content = fs.readFileSync(filePath, "utf8");
const content = require("../server/lib/run-as").readFileSafe(filePath, "utf8");
for (const line of content.split("\n")) {
if (!line) continue;
try {