/** * @file server-s3-review5.test.js * @description S3 plan, fifth review: a member's Antigravity conversation is * read from a private copy (a FIFO beside it blocks nothing); a developer's * extra workspace folder is refused outside their home before it is probed; * the config reader never serves the data dir (pane tokens, push keys, the * database); a stopped or flooding member tmux server and a hung sudo never * block the dashboard nor blind it to other members' panes; a one-line 512 MB * file, a huge member file and a sparse transcript neither crash the server * nor fill the host's disk, and the snapshot copy yields to the event loop * and runs once per file; an async route that throws answers 500; a * non-admin owns no host-folder workspace and a pane's cwd is its * workspace's account's; a pane token works only from its pane's uid; an * analyst's GET path stays in their own folder; a drop goes to the pane's * account; an image a session read is served only from a member session's * own home (a host session's from anywhere). A fake passwd (every member = this test's uid) and server.conf * make the members provisioned; fake sudo runs their commands as this * account — and a fake `sudo` on PATH that never returns stands in for a * stalled member, in a child process. * @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 { spawnSync } = require("node:child_process"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review5-"))); const USERS = path.join(TMP, "users"); const MIA = path.join(USERS, "mia"); const BOB = path.join(USERS, "bob"); // The real layout: the data dir (and its database) inside CLAUDE_HOME. const DATA = path.join(TMP, "host-claude", "agenthub"); process.env.DASHBOARD_DATA_DIR = DATA; process.env.DASHBOARD_DB_PATH = path.join(DATA, "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"); process.env.AGENTHUB_USERS_ROOT = USERS; 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 { fakeSudo } = require("./fixtures/fake-sudo"); let server; let BASE; const cookies = {}; 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 idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id; const addLane = (cwd, owner) => Number( db .prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')") .run(cwd, idOf(owner)).lastInsertRowid ); const UID = process.getuid(); let tmuxCalls = []; let liveRuns = new Set(); const fakeTmux = (args) => { tmuxCalls.push(args); if (args[0] === "-V") return "tmux 3.4"; if (args.includes("has-session")) { if (liveRuns.has(String(args.at(-1)).replace(/^=/, ""))) return ""; throw Object.assign(new Error("no"), { status: 1 }); } return ""; }; before(async () => { for (const d of [path.join(MIA, "proj"), path.join(MIA, "extra"), path.join(BOB, "private")]) fs.mkdirSync(d, { recursive: true }); fs.writeFileSync( process.env.AGENTHUB_PASSWD, ["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n" ); runAs.__resetCache(); fs.writeFileSync( process.env.AGENTHUB_SERVER_CONF, `HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n` ); linux.__resetConfCache(); linux.__setRunImpl(() => "ok"); runAs.__setExecImpl(fakeSudo().exec); tmux.__setExecImpl(fakeTmux); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; process.env.AGENTHUB_MODE = "server"; for (const [n, role] of [ ["mia", "developer"], ["bob", "developer"], ["ana", "analyst"], ["boss", "admin"], ]) { 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]; } }); 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 member's Antigravity conversation", () => { it("is read from a private copy: a FIFO beside it blocks nothing", () => { const Database = require("better-sqlite3"); const dir = path.join(MIA, ".gemini", "antigravity-cli", "conversations"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "7481e37e-9157-4145-b985-2ea9a2117f52.db"); const c = new Database(file); c.exec(`CREATE TABLE gen_metadata (idx integer, data blob, PRIMARY KEY (idx)); CREATE TABLE trajectory_metadata_blob (id text, data blob, PRIMARY KEY (id));`); c.close(); // A "hot journal" SQLite would open by name — and block on, as a FIFO. assert.equal(spawnSync("mkfifo", [`${file}-journal`]).status, 0); // In a child: the old open blocked its whole thread, which no test timeout interrupts. const child = spawnSync( process.execPath, [ "-e", `const r = require(${JSON.stringify(path.join(__dirname, "..", "lib", "antigravity-usage"))}).readConversation(${JSON.stringify(file)}); process.stdout.write(JSON.stringify(r));`, ], { env: { ...process.env, AGENTHUB_MODE: "server" }, timeout: 15_000, encoding: "utf8" } ); assert.equal(child.signal, null, "the read never blocked"); assert.equal(child.status, 0, child.stderr); assert.deepEqual(JSON.parse(child.stdout), { cwd: null, usage: {}, model: null }); assert.ok(fs.statSync(`${file}-journal`).isFIFO(), "the member's files are left alone"); }); }); describe("a developer's extra workspace folder", () => { it("is refused outside their own home before anything probes it", async () => { const lane = addLane(path.join(MIA, "proj"), "mia"); const add = (p) => call("mia", "POST", `/api/lanes/${lane}/sources/folder`, { path: p }); // Bob's folder answers exactly like a folder that does not exist. for (const p of [path.join(BOB, "private"), path.join(BOB, "nope")]) { const r = await add(p); assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`); assert.equal(r.body.error.code, "EFOLDER"); } // A link in her own home that points out is refused too. fs.symlinkSync(path.join(BOB, "private"), path.join(MIA, "to-bob")); const linked = await add(path.join(MIA, "to-bob")); assert.equal(linked.status, 403, JSON.stringify(linked.body)); // Her own folder is fine. const mine = await add(path.join(MIA, "extra")); assert.equal(mine.status, 201, JSON.stringify(mine.body)); assert.deepEqual( mine.body.sources.map((s) => s.path), [path.join(MIA, "extra")] ); }); }); describe("the Claude config file reader", () => { it("never serves AgentHub's data dir: pane tokens, push keys, the database", async () => { fs.mkdirSync(path.join(DATA, "proxy-settings"), { recursive: true }); const token = path.join(DATA, "proxy-settings", "gw--agenthub-lane-4.json"); fs.writeFileSync(token, '{"env":{"ANTHROPIC_AUTH_TOKEN":"ahpane_SECRET"}}'); fs.writeFileSync(path.join(DATA, "vapid-keys.json"), '{"privateKey":"VAPID-SECRET"}'); // Through a link elsewhere in CLAUDE_HOME too. fs.symlinkSync(token, path.join(TMP, "host-claude", "innocent.json")); for (const who of ["ana", "boss"]) { for (const f of [ token, path.join(DATA, "vapid-keys.json"), path.join(DATA, "dashboard.db"), path.join(TMP, "host-claude", "innocent.json"), ]) { const r = await call(who, "GET", `/api/cc-config/file?path=${encodeURIComponent(f)}`); assert.equal(r.status, 400, `${who} ${f}: ${JSON.stringify(r.body).slice(0, 200)}`); assert.ok(!/SECRET|SQLite/.test(JSON.stringify(r.body))); } } fs.writeFileSync(path.join(TMP, "host-claude", "settings.json"), '{"ok":true}'); const ok = await call( "ana", "GET", `/api/cc-config/file?path=${encodeURIComponent(path.join(TMP, "host-claude", "settings.json"))}` ); assert.equal(ok.status, 200); }); }); // ── Robustness: what a member can do to the dashboard's one thread ───────── const LIB = path.join(__dirname, "..", "lib"); /** Run `code` in a child (the old code hung or crashed the whole process). */ const inChild = (code, env = {}) => spawnSync(process.execPath, ["-e", code], { env: { ...process.env, AGENTHUB_MODE: "server", ...env }, timeout: 40_000, encoding: "utf8", }); /** A sparse file of `size` bytes (costs no disk), optionally with text at both ends. */ function sparse(file, size, { head = "", tail = "" } = {}) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, head); fs.truncateSync(file, size - Buffer.byteLength(tail)); if (tail) fs.appendFileSync(file, tail); } describe("a member's stopped tmux server or planted FIFO", () => { it("never blocks the dashboard: every sudo into a member is bounded", () => { const lane = addLane(path.join(MIA, "tm"), "mia"); const net = require("node:net"); const sock = path.join(MIA, ".agenthub", "tmux", "sock"); fs.mkdirSync(path.dirname(sock), { recursive: true }); fs.rmSync(sock, { force: true }); const srv = net.createServer().listen(sock); // sudo that never returns: a tmux client talking to a SIGSTOPped server. const bin = path.join(TMP, "fakebin"); fs.mkdirSync(bin, { recursive: true }); const spawned = path.join(TMP, "sudo-spawns"); fs.rmSync(spawned, { force: true }); fs.writeFileSync(path.join(bin, "sudo"), `#!/bin/sh\necho x >> '${spawned}'\nexec sleep 60\n`, { mode: 0o755, }); try { // Polls like the heal beat / GET /api/lanes for 4 s — past the 3 s // timeout — while timing every poll and the event loop's worst stall. const child = inChild( `const runAs = require(${JSON.stringify(`${LIB}/run-as`)}); const tmux = require(${JSON.stringify(`${LIB}/tmux`)}); const { monitorEventLoopDelay } = require("node:perf_hooks"); const proj = ${JSON.stringify(path.join(MIA, "proj"))}; const time = (f) => { const t = Date.now(); try { f(); return { ms: Date.now() - t }; } catch (e) { return { code: e.code, ms: Date.now() - t }; } }; (async () => { runAs.accountForSession("agenthub-lane-${lane}"); // opens the DB: not what is timed const loop = monitorEventLoopDelay({ resolution: 10 }); loop.enable(); let live; const first = time(() => (live = tmux.liveSessions())); let slowest = 0; const beat = setInterval(() => { slowest = Math.max(slowest, time(() => tmux.liveSessions()).ms); }, 50); await new Promise((r) => setTimeout(r, 4000)); clearInterval(beat); loop.disable(); const after = tmux.liveSessions(); process.stdout.write(JSON.stringify({ isSet: live instanceof Set, keeps: live.has("agenthub-lane-${lane}"), other: live.has("agenthub-lane-999999"), keepsAfter: after.has("agenthub-lane-${lane}"), first, slowest, stallMs: loop.max / 1e6, sudos: require("node:fs").readFileSync(${JSON.stringify(spawned)}, "utf8").trim().split("\\n").length, git: time(() => runAs.execIn(proj, "git", ["status"])), write: time(() => runAs.writeAs(runAs.accountForPath(proj), proj + "/f.txt", "", "x")), })); })();`, { PATH: `${bin}:${process.env.PATH}` } ); assert.equal(child.signal, null, "nothing hung"); assert.equal(child.status, 0, child.stderr); const r = JSON.parse(child.stdout); // Her panes are UNKNOWN, not dead: nothing of hers is ended for a stall. assert.equal(r.isSet, true, "the host's answer still counts"); assert.equal(r.keeps, true); assert.equal(r.other, false); assert.equal(r.keepsAfter, true, "still unknown once the hung list was killed"); // Listing a member never waits for them: no poll, no event-loop turn // takes anything like the 3 s a hung sudo is allowed. assert.ok(r.first.ms < 100, `first list: ${r.first.ms} ms`); assert.ok(r.slowest < 100, `slowest poll: ${r.slowest} ms`); assert.ok(r.stallMs < 500, `event loop stalled ${r.stallMs} ms`); // ONE sudo, killed at the timeout, then backed off — not one per poll. assert.equal(r.sudos, 1); assert.equal(r.git.code, "ETIMEDOUT"); assert.equal(r.write.code, "ETIMEDOUT"); } finally { srv.close(); } }); it("one member's failing server never blinds the dashboard to everyone else's", () => { const net = require("node:net"); const sock = path.join(MIA, ".agenthub", "tmux", "sock"); fs.mkdirSync(path.dirname(sock), { recursive: true }); fs.rmSync(sock, { force: true }); const srv = net.createServer().listen(sock); const lane = addLane(path.join(MIA, "proj2"), "mia"); let memberCalls = 0; let hostFails = false; tmux.__reset(); tmux.__setExecImpl(() => { if (hostFails) throw Object.assign(new Error("fork"), { code: "EAGAIN" }); return "agenthub-lane-1\n"; }); tmux.__setAccountImpl( (name) => runAs.accountForSession(name), () => { memberCalls += 1; throw Object.assign(new Error("spawnSync sudo ENOBUFS"), { code: "ENOBUFS" }); } ); try { const live = tmux.liveSessions(); assert.ok(live, "not null for everyone"); assert.equal(live.has("agenthub-lane-1"), true); assert.equal(live.has(`agenthub-lane-${lane}`), true, "hers: unknown, kept"); assert.equal(live.has("agenthub-lane-999999"), false); tmux.__setExecImpl(() => "agenthub-lane-1\n"); tmux.liveSessions(); assert.equal(memberCalls, 1, "a flooding server is not asked again at once"); hostFails = true; tmux.__setExecImpl(() => { throw Object.assign(new Error("fork"), { code: "EAGAIN" }); }); assert.equal(tmux.liveSessions(), null, "our own failure still proves nothing"); } finally { srv.close(); tmux.__reset(); tmux.__setExecImpl(fakeTmux); } }); }); describe("a member's huge or sparse files", () => { it("a 600 MB line without a newline never crashes the transcript reader", () => { const big = path.join(TMP, "one-line.jsonl"); sparse(big, 600 * 1024 * 1024, { head: '{"type":"user"' }); try { const child = inChild( `require(${JSON.stringify(path.join(__dirname, "..", "..", "scripts", "import-history"))}) .parseSessionFile(${JSON.stringify(big)}) .then(() => process.stdout.write("survived"));` ); assert.equal(child.status, 0, child.stderr.slice(-400)); assert.equal(child.stdout, "survived"); } finally { fs.rmSync(big, { force: true }); } }); it("readLines keeps line numbers and never builds an over-long line", async () => { const f = path.join(TMP, "lines.jsonl"); fs.writeFileSync(f, `a\r\n\n${"x".repeat(3 << 20)}\nlast`); const out = []; for await (const l of runAs.readLines(f, { maxLine: 1 << 20 })) out.push(l); assert.deepEqual(out, ["a", "", "", "last"]); fs.rmSync(f); }); it("a member's file is read only up to its cap", async () => { const f = path.join(MIA, ".codex", "auth.json"); sparse(f, 70 * 1024 * 1024); assert.throws(() => runAs.readFileSafe(f, "utf8"), { code: "ETOOBIG" }); await assert.rejects(runAs.readFileSafeAsync(f), { code: "ETOOBIG" }); fs.writeFileSync(f, `{"pad":"${"x".repeat(2 << 20)}"}`); assert.throws(() => runAs.readFileSafe(f, "utf8", { max: 1 << 20 }), { code: "ETOOBIG" }); fs.writeFileSync(f, '{"ok":1}'); assert.equal(runAs.readFileSafe(f, "utf8"), '{"ok":1}'); // A member transcript past the cap is not read on the event loop. const t = path.join(MIA, ".claude", "projects", "p", "big.jsonl"); sparse(t, 300 * 1024 * 1024); const real = fs.readSync; let reads = 0; fs.readSync = (...a) => { reads += 1; return real(...a); }; try { const TranscriptCache = require("../lib/transcript-cache"); new TranscriptCache()._streamRange(t, 0, fs.statSync(t).size); } finally { fs.readSync = real; } assert.equal(reads, 0); fs.rmSync(t); }); it("the transcript snapshot copy yields to the event loop between chunks", async () => { const { snapshotTranscript } = require("../../scripts/import-history"); const { getTranscriptSnapshotDir } = require("../lib/claude-home"); const proj = path.join(MIA, ".claude", "projects", "-p"); const sid = "11111111-2222-4333-8444-777777777777"; const src = path.join(proj, `${sid}.jsonl`); fs.mkdirSync(proj, { recursive: true }); fs.writeFileSync(src, Buffer.alloc(64 * 1024 * 1024, 0x61)); let ticks = 0; let maxGap = 0; let last = performance.now(); let done = false; const tick = () => { const now = performance.now(); maxGap = Math.max(maxGap, now - last); last = now; ticks += 1; if (!done) setImmediate(tick); }; setImmediate(tick); try { await snapshotTranscript(src, sid); } finally { done = true; } assert.ok(ticks >= 8, `only ${ticks} event-loop ticks ran during the copy`); assert.ok(maxGap < 50, `the event loop was blocked for ${maxGap.toFixed(1)} ms`); const copy = path.join(getTranscriptSnapshotDir(), `${sid}.jsonl`); assert.ok(fs.readFileSync(copy).equals(fs.readFileSync(src))); fs.rmSync(src); fs.rmSync(copy); }); it("two overlapping snapshots copy a file once, and a failed copy only logs", async () => { const { snapshotTranscript } = require("../../scripts/import-history"); const { getTranscriptSnapshotDir } = require("../lib/claude-home"); const proj = path.join(MIA, ".claude", "projects", "-p"); const sid = "11111111-2222-4333-8444-888888888888"; const src = path.join(proj, `${sid}.jsonl`); fs.mkdirSync(path.join(proj, sid, "subagents"), { recursive: true }); fs.writeFileSync(src, '{"a":1}\n'.repeat(1 << 18)); fs.writeFileSync(path.join(proj, sid, "subagents", "agent-x.jsonl"), '{"b":1}\n'); // The subagent's snapshot folder cannot be made: its parent is a file. fs.mkdirSync(getTranscriptSnapshotDir(), { recursive: true }); fs.writeFileSync(path.join(getTranscriptSnapshotDir(), sid), ""); const realRename = fs.promises.rename; const realWarn = console.warn; let renames = 0; const warned = []; fs.promises.rename = (...a) => { renames += 1; return realRename(...a); }; console.warn = (...a) => warned.push(a.join(" ")); try { await Promise.all([snapshotTranscript(src, sid), snapshotTranscript(src, sid)]); } finally { fs.promises.rename = realRename; console.warn = realWarn; } assert.equal(renames, 1); const copy = path.join(getTranscriptSnapshotDir(), `${sid}.jsonl`); assert.ok(fs.readFileSync(copy).equals(fs.readFileSync(src))); assert.ok( warned.some((w) => w.includes("agent-x.jsonl")), JSON.stringify(warned) ); assert.deepEqual( fs.readdirSync(getTranscriptSnapshotDir()).filter((f) => f.endsWith(".tmp")), [] ); }); it("the transcript snapshot never fills the host's disk from a sparse file", async () => { const { snapshotTranscript } = require("../../scripts/import-history"); const { getTranscriptSnapshotDir } = require("../lib/claude-home"); const proj = path.join(MIA, ".claude", "projects", "-p"); const huge = "11111111-2222-4333-8444-555555555555"; sparse(path.join(proj, `${huge}.jsonl`), 300 * 1024 * 1024); await snapshotTranscript(path.join(proj, `${huge}.jsonl`), huge); assert.equal(fs.existsSync(path.join(getTranscriptSnapshotDir(), `${huge}.jsonl`)), false); // Under the cap: copied, and its hole stays a hole. const holey = "11111111-2222-4333-8444-666666666666"; sparse(path.join(proj, `${holey}.jsonl`), 32 * 1024 * 1024, { head: '{"a":1}\n', tail: '{"z":1}\n', }); await snapshotTranscript(path.join(proj, `${holey}.jsonl`), holey); const copy = path.join(getTranscriptSnapshotDir(), `${holey}.jsonl`); const st = fs.statSync(copy); assert.equal(st.size, 32 * 1024 * 1024); assert.ok(st.blocks * 512 < 4 * 1024 * 1024, `copy allocated ${st.blocks * 512} bytes`); const text = fs.readFileSync(copy); assert.equal(text.subarray(0, 8).toString(), '{"a":1}\n'); assert.equal(text.subarray(-8).toString(), '{"z":1}\n'); }); }); describe("a request that makes an async route throw", () => { it("answers an error instead of taking the server down", async () => { const r = await call("mia", "POST", "/api/lanes/worktree", { sourceRepo: path.join(USERS, "ghost", "x"), branch: "b", }); assert.equal(r.status, 403, JSON.stringify(r.body)); assert.equal(r.body.error.code, "EMEMBERLANE"); // Any async handler that rejects: a 500, not an exit. const express = require("express"); const app = express(); app.get("/boom", async () => { await null; throw new Error("boom"); }); const s = await new Promise((ok) => { const h = app.listen(0, () => ok(h)); }); try { const res = await fetch(`http://127.0.0.1:${s.address().port}/boom`); assert.equal(res.status, 500); } finally { s.close(); } assert.equal((await call("mia", "GET", "/api/lanes")).status, 200, "still serving"); }); }); describe("who a pane runs as", () => { const pty = require("../lib/pty-run"); const HOST = path.join(TMP, "hostdir"); before(() => fs.mkdirSync(HOST, { recursive: true })); it("a non-admin owns no workspace outside their own home: no host pane for them", () => { const lane = addLane(HOST, "boss"); assert.throws(() => auth.assignWorkspace(lane, "mia"), { code: "EOWNERFOLDER" }); assert.throws(() => auth.assignWorkspace(lane, "ana"), { code: "EOWNERFOLDER" }); // One that got there anyway (assigned before this rule, or a demoted admin's). const theirs = addLane(path.join(HOST, "x"), "mia"); assert.throws( () => pty.spawnRun({ laneId: theirs, cwd: path.join(HOST, "x"), tool: "claude" }), { code: "EOWNERFOLDER", } ); assert.throws(() => pty.spawnShell({ laneId: theirs, cwd: path.join(HOST, "x") }), { code: "EOWNERFOLDER", }); fs.mkdirSync(path.join(HOST, "y"), { recursive: true }); const viaRoute = addLane(path.join(HOST, "y"), "mia"); return call("mia", "POST", "/api/run", { laneId: viaRoute, cwd: path.join(HOST, "y") }).then( (r) => { assert.equal(r.status, 409, JSON.stringify(r.body)); assert.equal(r.body.error.code, "EOWNERFOLDER"); } ); }); it("demoting or disabling a user ends their panes that run as the host", () => { const lane = addLane(path.join(HOST, "z"), "boss"); const run = pty.runIdForLane(lane, 0); liveRuns = new Set([run]); tmuxCalls = []; try { auth.setRole("boss", "developer"); assert.ok( tmuxCalls.some((a) => a[0] === "kill-session" && String(a.at(-1)).includes(run)), "the host pane was killed" ); } finally { auth.setRole("boss", "admin"); liveRuns = new Set(); } }); it("a demoted admin can still READ their host-folder workspace but no longer WRITE to it", async () => { fs.mkdirSync(path.join(HOST, "w"), { recursive: true }); const lane = addLane(path.join(HOST, "w"), "boss"); auth.setRole("boss", "developer"); try { assert.equal((await call("boss", "GET", `/api/lanes/${lane}`)).status, 200); for (const [method, url, body] of [ ["POST", `/api/lanes/${lane}/up`, {}], ["POST", `/api/lanes/${lane}/git/commit`, { message: "x" }], ["PUT", `/api/lanes/${lane}/git/file`, { path: "a.txt", content: "x" }], ["PATCH", `/api/workspaces/${lane}`, { title: "t" }], ]) { const r = await call("boss", method, url, body); assert.equal(r.status, 409, `${method} ${url} ${JSON.stringify(r.body)}`); assert.equal(r.body.error.code, "EOWNERFOLDER"); } } finally { auth.setRole("boss", "admin"); } // Back to admin: the same write passes the guard again. const ok = await call("boss", "PATCH", `/api/lanes/${lane}`, { title: "back" }); assert.equal(ok.status, 200, JSON.stringify(ok.body)); }); it("a member's own workspace writes are untouched by that check", async () => { const r = await call("mia", "GET", "/api/lanes"); const mine = (r.body.lanes || []).find((l) => String(l.cwd).startsWith(path.join(USERS, "mia")) ); if (!mine) return; // no member workspace in this fixture const w = await call("mia", "PATCH", `/api/lanes/${mine.id}`, { title: "still mine" }); assert.notEqual(w.status, 409, JSON.stringify(w.body)); }); it("a pane's cwd must be its workspace's account's (POST /api/run names the cwd)", async () => { const mine = addLane(path.join(MIA, "own"), "mia"); assert.throws( () => pty.spawnRun({ laneId: mine, cwd: path.join(BOB, "private"), tool: "claude" }), { code: "ECWDACCOUNT" } ); const host = addLane(path.join(HOST, "h"), "boss"); const r = await call("boss", "POST", "/api/run", { laneId: host, cwd: path.join(MIA, "proj") }); assert.equal(r.status, 409, JSON.stringify(r.body)); assert.equal(r.body.error.code, "ECWDACCOUNT"); }); }); describe("a pane token", () => { const hookAuth = require("../lib/hook-auth"); const crypto = require("node:crypto"); it("is accepted only from the Linux account its pane runs as", async () => { const lane = addLane(path.join(MIA, "pt"), "mia"); const run = `agenthub-lane-${lane}`; const token = "ahpane_review5-token-value-0123456789"; db.prepare( `INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash) VALUES (?, 'interactive', ?, 'running', ?, ?)` ).run(run, path.join(MIA, "pt"), lane, crypto.createHash("sha256").update(token).digest("hex")); tmux.__setAccountImpl( (name) => runAs.accountForSession(name), (acct, args) => fakeTmux(args) ); liveRuns = new Set([run]); const asPane = () => fetch(`${BASE}/api/sessions`, { headers: { "x-agenthub-pane-token": token } }).then( (r) => r.status ); try { hookAuth.__setPeerUid(() => UID); // mia's uid in this test's passwd assert.equal(await asPane(), 200); hookAuth.__setPeerUid(() => UID + 7); // another local account assert.equal(await asPane(), 401); hookAuth.__setPeerUid(() => null); // a peer the kernel table does not show assert.equal(await asPane(), 401); // The AI Proxy gateway takes the same token: refused from another uid. hookAuth.__setPeerUid(() => UID + 7); const gw = await fetch(`${BASE}/api/proxy-gw/x/v1/messages`, { method: "POST", headers: { authorization: `Bearer ${token}` }, }); assert.equal(gw.status, 401); } finally { hookAuth.__setPeerUid(null); liveRuns = new Set(); db.prepare("DELETE FROM dashboard_runs WHERE id = ?").run(run); tmux.__reset(); tmux.__setExecImpl(fakeTmux); } }); }); describe("an analyst's GET that names a path", () => { it("stays in their own folder or workspaces, like any non-admin's request", async () => { fs.mkdirSync(path.join(USERS, "ana", "work"), { recursive: true }); const q = encodeURIComponent(path.join(BOB, "private")); for (const p of [ `/api/lanes/browse?path=${q}`, `/api/lanes/branches?repo=${q}`, `/api/run/files?cwd=${q}`, ]) { const r = await call("ana", "GET", p); assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`); assert.equal(r.body.error.code, "EFOLDER"); } const own = await call( "ana", "GET", `/api/lanes/browse?path=${encodeURIComponent(path.join(USERS, "ana"))}` ); assert.equal(own.status, 200, JSON.stringify(own.body)); assert.equal((await call("boss", "GET", `/api/lanes/browse?path=${q}`)).status, 200); }); }); describe("a dropped file", () => { const drop = (who, laneId) => fetch(`${BASE}/api/run/drop-file?name=a.txt${laneId ? `&laneId=${laneId}` : ""}`, { method: "POST", headers: { "Content-Type": "application/octet-stream", cookie: cookies[who] }, body: Buffer.from("hello"), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); it("goes where the workspace's panes can read it, not to the requester's account", async () => { fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true }); const miaLane = addLane(path.join(MIA, "drop"), "mia"); const byAdmin = await drop("boss", miaLane); assert.equal(byAdmin.status, 200, JSON.stringify(byAdmin.body)); assert.ok( byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep), byAdmin.body.path ); // A host workspace's pane runs as the host: the host's own 0700 drops dir. const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss"); const host = await drop("boss", hostLane); assert.equal(host.status, 200, JSON.stringify(host.body)); assert.equal(path.dirname(host.body.path), path.join(DATA, "drops")); // …and the admin's chat shows it back, though the GET names no workspace. fs.mkdirSync(path.join(USERS, "boss", ".agenthub", "handoff"), { recursive: true }); const shown = await fetch(`${BASE}/api/run/drop/${path.basename(host.body.path)}`, { headers: { cookie: cookies.boss }, }); assert.equal(shown.status, 200); assert.equal(await shown.text(), "hello"); // Another member's workspace is not one to name. assert.equal((await drop("bob", miaLane)).status, 404); }); }); describe("an image a session read (the Chat pane's Files read preview)", () => { const readBy = (sid, cwd, file) => { db.prepare("INSERT OR IGNORE INTO sessions (id, cwd) VALUES (?, ?)").run(sid, cwd); db.prepare( "INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PostToolUse', 'Read', ?)" ).run(sid, JSON.stringify({ tool_input: { file_path: file } })); }; const get = (sid, file) => fetch(`${BASE}/api/sessions/${sid}/image?path=${encodeURIComponent(file)}`, { headers: { cookie: cookies.boss }, }); it("is served for the host's own session and a member's file in that member's home", async () => { const hostPng = path.join(TMP, "hostdir", "shot.png"); const miaPng = path.join(MIA, "proj", "shot.png"); fs.mkdirSync(path.dirname(hostPng), { recursive: true }); fs.writeFileSync(hostPng, "HOSTPNG"); fs.writeFileSync(miaPng, "MIAPNG"); readBy("img-host", path.join(TMP, "hostdir"), hostPng); readBy("img-mia", path.join(MIA, "proj"), miaPng); const host = await get("img-host", hostPng); assert.equal(host.status, 200); assert.equal(await host.text(), "HOSTPNG"); const mia = await get("img-mia", miaPng); assert.equal(mia.status, 200); assert.equal(await mia.text(), "MIAPNG"); }); it("is refused when a member's session names a file outside that member's home", async () => { readBy("img-mia-out", path.join(MIA, "proj"), path.join(TMP, "hostdir", "shot.png")); readBy("img-mia-bob", path.join(MIA, "proj"), path.join(BOB, "b.png")); fs.writeFileSync(path.join(BOB, "b.png"), "BOBPNG"); for (const f of [path.join(TMP, "hostdir", "shot.png"), path.join(BOB, "b.png")]) { const r = await get(f.includes("bob") ? "img-mia-bob" : "img-mia-out", f); assert.equal(r.status, 404, f); assert.ok(!/PNG/.test(await r.text())); } }); });