/** * @file server-s3-review2.test.js * @description S3 plan, second review: a member cannot point the dashboard's * own rights at another account's files. A workspace folder swapped for a * link is refused for file reads and saves (EFOREIGN); a member's git dir * that names a path outside their home is never read or written by the host, * and a FIFO there cannot hang it; a developer browses and lists branches * only in their own folder; a transcript scan by id looks only in the * session owner's home; a Codex journal flips only its own account's lanes, * capped per home; nobody reads the dot folders at the top of a member's home * (logins, pane tokens), and the home itself is no workspace; a disabled * member's session neither throws in a read nor stops the liveness reaper. * A fake passwd (every member = this test's uid) and server.conf make the * members provisioned; fake sudo runs their commands as this account. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const { execFileSync } = require("node:child_process"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review2-"))); const USERS = path.join(TMP, "users"); const MIA = path.join(USERS, "mia"); const BOB = path.join(USERS, "bob"); const DAN = path.join(USERS, "dan"); const HOSTDIR = path.join(TMP, "hostdir"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.LANES_ROOT = path.join(TMP, "lanes"); process.env.CLAUDE_HOME = path.join(TMP, "host-claude"); process.env.CODEX_HOME = path.join(TMP, "host-codex"); process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf"); process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd"); const { createApp, startServer } = require("../index"); const { db } = require("../db"); const auth = require("../lib/auth"); const linux = require("../lib/linux-accounts"); const runAs = require("../lib/run-as"); const tmux = require("../lib/tmux"); const claudeHome = require("../lib/claude-home"); const { fakeSudo } = require("./fixtures/fake-sudo"); let server; let BASE; const cookies = {}; const lanes = {}; const call = (who, method, p, body) => fetch(BASE + p, { method, headers: { "Content-Type": "application/json", cookie: cookies[who] }, body: body ? JSON.stringify(body) : undefined, }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); const gitInit = (dir) => { fs.mkdirSync(dir, { recursive: true }); execFileSync("git", ["init", "-q", dir]); execFileSync("git", [ "-C", dir, "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "x", ]); }; const addLane = (cwd, owner, tool = "claude") => Number( db .prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')") .run(cwd, tool, db.prepare("SELECT id FROM users WHERE username = ?").get(owner).id) .lastInsertRowid ); before(async () => { gitInit(path.join(MIA, "proj")); gitInit(path.join(BOB, "proj")); fs.writeFileSync(path.join(BOB, "proj", "bob.txt"), "bob's file\n"); fs.mkdirSync(HOSTDIR, { recursive: true }); fs.writeFileSync(path.join(HOSTDIR, "host.txt"), "host's file\n"); // mia swaps folders in her own home for links: to bob's, and to the host's. fs.symlinkSync(path.join(BOB, "proj"), path.join(MIA, "tobob")); fs.symlinkSync(HOSTDIR, path.join(MIA, "tohost")); // bob's logins and a pane's handoff file, at the top of his home. fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true }); fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"secret"}\n'); fs.mkdirSync(path.join(BOB, ".agenthub", "handoff"), { recursive: true }); fs.writeFileSync(path.join(BOB, ".agenthub", "handoff", "proxy-x--agenthub-lane-1.json"), "{}"); fs.writeFileSync(path.join(BOB, "notes.txt"), "plain\n"); fs.mkdirSync(path.join(DAN, "proj"), { recursive: true }); const uid = process.getuid(); fs.writeFileSync( process.env.AGENTHUB_PASSWD, ["mia", "bob", "ana", "dan"].map((n) => `ah-${n}:x:${uid}:${uid}::/:/bin/sh`).join("\n") + "\n" ); fs.writeFileSync( process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n` ); linux.__resetConfCache(); runAs.__resetCache(); linux.__setRunImpl(() => "ok"); runAs.__setExecImpl(fakeSudo().exec); tmux.__setExecImpl(() => ""); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; process.env.AGENTHUB_MODE = "server"; process.env.AGENTHUB_USERS_ROOT = USERS; for (const [n, role] of [ ["boss", "admin"], ["mia", "developer"], ["bob", "developer"], ["ana", "analyst"], ["dan", "developer"], ]) { await auth.createUser(n, `${n}-pass-123`, role); const login = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: n, password: `${n}-pass-123` }), }); cookies[n] = login.headers.get("set-cookie").split(";")[0]; } lanes.tobob = addLane(path.join(MIA, "tobob"), "mia"); lanes.tohost = addLane(path.join(MIA, "tohost"), "mia"); lanes.bobHome = addLane(BOB, "bob"); }); after(() => { tmux.__reset(); delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_USERS_ROOT; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("a workspace folder swapped for a link", () => { it("is refused for a file read, into another member's folder or the host's", async () => { const a = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/file?path=bob.txt`); assert.equal(a.status, 403, JSON.stringify(a.body)); assert.equal(a.body.error.code, "EFOREIGN"); const b = await call("mia", "GET", `/api/lanes/${lanes.tohost}/git/file?path=host.txt`); assert.equal(b.status, 403, JSON.stringify(b.body)); }); it("is refused for a save", async () => { const { writeLaneFile } = require("../lib/lane-file-write"); const sha = require("../lib/git-diff").contentSha( fs.readFileSync(path.join(HOSTDIR, "host.txt")) ); await assert.rejects(writeLaneFile(path.join(MIA, "tohost"), "host.txt", "pwned\n", sha), { code: "EFOREIGN", }); assert.equal(fs.readFileSync(path.join(HOSTDIR, "host.txt"), "utf8"), "host's file\n"); }); }); describe("a member's git dir that names a path outside their home", () => { it("is neither read nor written by the host", () => { const { excludeFromGit } = require("../lib/git-exclude"); // mia's repo: `.git` is a file pointing at a git dir she made in /tmp, // whose info/exclude is a link to a host-owned file. const evil = path.join(TMP, "evil"); gitInit(evil); const target = path.join(HOSTDIR, "settings.json"); fs.writeFileSync(target, '{"a":1}\n'); fs.rmSync(path.join(evil, ".git", "info", "exclude"), { force: true }); fs.symlinkSync(target, path.join(evil, ".git", "info", "exclude")); const repo = path.join(MIA, "redirect"); fs.mkdirSync(repo, { recursive: true }); fs.writeFileSync(path.join(repo, ".git"), `gitdir: ${path.join(evil, ".git")}\n`); excludeFromGit(repo, [".claude/worktrees/"]); assert.equal(fs.readFileSync(target, "utf8"), '{"a":1}\n'); }); it("cannot hang it on a FIFO", () => { const { excludeFromGit } = require("../lib/git-exclude"); const repo = path.join(MIA, "fifo"); gitInit(repo); const ex = path.join(repo, ".git", "info", "exclude"); fs.rmSync(ex, { force: true }); execFileSync("mkfifo", [ex]); const t0 = Date.now(); excludeFromGit(repo, [".mcp.json"]); assert.ok(Date.now() - t0 < 5000); }); it("still gets its exclude line in its own repo", () => { const { excludeFromGit } = require("../lib/git-exclude"); const repo = path.join(MIA, "proj"); excludeFromGit(repo, [".mcp.json"]); const text = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8"); assert.match(text, /^\/\.mcp\.json$/m); }); }); describe("a developer browses and lists branches only in their own folder", () => { it("browse", async () => { const other = await call("mia", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`); assert.equal(other.status, 403); assert.equal(other.body.error.code, "EFOLDER"); const viaLink = await call( "mia", "GET", `/api/lanes/browse?path=${encodeURIComponent(path.join(MIA, "tohost"))}` ); assert.equal(viaLink.status, 403); const own = await call("mia", "GET", "/api/lanes/browse"); assert.equal(own.status, 200, JSON.stringify(own.body)); assert.equal(own.body.path, MIA); assert.equal(own.body.parent, null); assert.ok(own.body.entries.some((e) => e.name === "proj")); const admin = await call("boss", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`); assert.equal(admin.status, 200); }); it("an admin starts in their own home, and opens the host's only with host=1", async () => { // No Linux account: the host's home, and no Server folder tick. const plain = await call("boss", "GET", "/api/lanes/browse"); assert.equal(plain.status, 200); assert.equal(plain.body.path, os.homedir()); assert.equal(plain.body.hostHome, undefined); // With one (ah-boss, home /boss): their own home first. const BOSS = path.join(USERS, "boss"); fs.mkdirSync(path.join(BOSS, "work"), { recursive: true }); const passwd = fs.readFileSync(process.env.AGENTHUB_PASSWD, "utf8"); const uid = process.getuid(); fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd + `ah-boss:x:${uid}:${uid}::/:/bin/sh\n`); runAs.__resetCache(); try { const own = await call("boss", "GET", "/api/lanes/browse"); assert.equal(own.status, 200, JSON.stringify(own.body)); assert.equal(own.body.path, BOSS); assert.equal(own.body.home, BOSS); assert.equal(own.body.hostHome, os.homedir()); assert.ok(own.body.parent, "an admin is not held to their home"); const host = await call("boss", "GET", "/api/lanes/browse?host=1"); assert.equal(host.body.path, os.homedir()); assert.equal(host.body.home, os.homedir()); // A developer's host=1 changes nothing: still their own folder. const mia = await call("mia", "GET", "/api/lanes/browse?host=1"); assert.equal(mia.body.path, MIA); assert.equal(mia.body.hostHome, undefined); } finally { fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd); runAs.__resetCache(); } }); it("hidden=1 shows dot entries, never the logins at the top of their home", async () => { fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true }); fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true }); const top = await call("mia", "GET", "/api/lanes/browse?hidden=1"); assert.equal(top.status, 200, JSON.stringify(top.body)); assert.equal(top.body.home, MIA); assert.ok(!top.body.entries.some((e) => e.name.startsWith("."))); const inner = await call( "mia", "GET", `/api/lanes/browse?hidden=1&path=${encodeURIComponent(path.join(MIA, "proj"))}` ); assert.ok(inner.body.entries.some((e) => e.name === ".cfg")); }); it("mkdir: only in their own folder, made as them", async () => { const mine = await call("mia", "POST", "/api/lanes/browse/mkdir", { parent: MIA, name: "fresh", }); assert.equal(mine.status, 201, JSON.stringify(mine.body)); assert.ok(fs.statSync(path.join(MIA, "fresh")).isDirectory()); for (const parent of [BOB, path.join(MIA, "tohost")]) { const r = await call("mia", "POST", "/api/lanes/browse/mkdir", { parent, name: "x" }); assert.equal(r.status, 403, `${parent}: ${JSON.stringify(r.body)}`); assert.equal(r.body.error.code, "EFOLDER"); } assert.ok(!fs.existsSync(path.join(BOB, "x"))); assert.ok(!fs.existsSync(path.join(HOSTDIR, "x"))); }); it("branches", async () => { const other = await call( "mia", "GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(BOB, "proj"))}` ); assert.equal(other.status, 403); const own = await call( "mia", "GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(MIA, "proj"))}` ); // Not refused (the real sudo it would run is not this test's). assert.notEqual(own.status, 403, JSON.stringify(own.body)); }); }); describe("a transcript scan by id looks only in the session owner's home", () => { it("never serves a file another member planted under that session id", () => { const sid = "aaaaaaaa-1111-4222-8333-444444444444"; const cwd = path.join(MIA, "proj"); const planted = path.join(BOB, ".claude", "projects", "x"); fs.mkdirSync(path.join(planted, sid, "subagents"), { recursive: true }); fs.writeFileSync(path.join(planted, `${sid}.jsonl`), "{}\n"); fs.writeFileSync(path.join(planted, sid, "subagents", "agent-1.jsonl"), "{}\n"); assert.equal(claudeHome.getTranscriptPath(sid, cwd), null); assert.equal(claudeHome.findTranscriptPath(sid, cwd), null); assert.equal(claudeHome.findTranscriptPath(sid, null), null, "no folder: the host's dir only"); assert.equal(claudeHome.getSubagentTranscriptPath(sid, cwd, "1"), null); assert.deepEqual(claudeHome.sessionProjectsDirs(cwd), [path.join(MIA, ".claude", "projects")]); }); }); describe("a Codex journal speaks only for its own account", () => { const journal = (home, name, cwd, mtime) => { const day = path.join(home, ".codex", "sessions", "2026", "10", "09"); fs.mkdirSync(day, { recursive: true }); const f = path.join(day, `rollout-2026-10-09T00-00-00-${name}.jsonl`); fs.writeFileSync(f, JSON.stringify({ type: "session_meta", payload: { cwd } }) + "\n"); fs.utimesSync(f, mtime / 1000, mtime / 1000); return f; }; it("never flips another member's lane, and one home cannot crowd the others out", () => { const ingest = require("../lib/codex-ingest"); const bobLane = addLane(path.join(BOB, "proj"), "bob", "codex"); const now = Date.now(); // mia writes bob's folder into her own fresh journal… journal(MIA, "forged", path.join(BOB, "proj"), now); ingest.__resetCache(); ingest.ingestCodexOnce(); assert.equal(db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status, "idle"); // …and keeps 40 fresher journals of her own; bob's real one must still count. for (let i = 0; i < 41; i++) journal(MIA, `own${i}`, path.join(MIA, "proj"), now); journal(BOB, "real", path.join(BOB, "proj"), now - 5000); ingest.__resetCache(); ingest.ingestCodexOnce(); assert.equal( db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status, "running" ); }); }); describe("the top of a member's home", () => { it("is not readable through a workspace rooted at it, by anyone", async () => { for (const p of [ ".claude/.credentials.json", ".agenthub/handoff/proxy-x--agenthub-lane-1.json", ]) { const r = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=${p}`); assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`); assert.equal(r.body.error.code, "ESECRET"); } const ok = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=notes.txt`); assert.equal(ok.status, 200, JSON.stringify(ok.body)); }); it("is no workspace (S3)", async () => { const r = await call("mia", "POST", "/api/lanes", { cwd: MIA }); assert.equal(r.status, 403, JSON.stringify(r.body)); assert.equal(r.body.error.code, "EFOLDER"); }); }); describe("a disabled member's session", () => { it("is still read from their home, and does not stop the liveness reaper", () => { db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run(); const danCwd = path.join(DAN, "proj"); assert.equal(claudeHome.projectsDirFor(danCwd), path.join(DAN, ".claude", "projects")); assert.doesNotThrow(() => claudeHome.getTranscriptPath("dan-sess", danCwd)); const ins = db.prepare( "INSERT INTO sessions (id, name, status, cwd, tool, updated_at) VALUES (?, ?, 'active', ?, 'claude', '2020-01-01T00:00:00.000Z')" ); ins.run("dan-sess", "dan", danCwd); ins.run("host-sess", "host", HOSTDIR); const { livenessReap } = require("../routes/hooks"); assert.doesNotThrow(() => livenessReap({ ignoreIdleGate: true, probe: { available: true, cwds: new Set() } }) ); const st = db.prepare("SELECT status FROM sessions WHERE id = 'host-sess'").get().status; assert.notEqual(st, "active"); }); });