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:
+54
-29
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user