/** * @file server-s3-review3.test.js * @description S3 plan, third review: a workspace in a member's home is owned * by that member only (create, assign and every pane start refuse another * owner, whose pane token the folder's member could read); a long first * prompt in a member's pane travels in its launch file, never through the * host's private run-prompts dir; the workspace memory view, the transcript * snapshot and the startup AGENTS.md cleanup never follow a member's planted * link with the dashboard's rights; a non-admin's `?cwd=` on the config * readers names only their own workspaces, and no credential file is served * by name; a member's quiet Codex session keeps the name from their own * index; `agenthub users provision --all` goes on past a user it cannot * provision. A fake passwd (every member = this test's uid, or another uid * when a test needs the file to be someone else's) 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 { spawnSync } = require("node:child_process"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review3-"))); const USERS = path.join(TMP, "users"); const MIA = path.join(USERS, "mia"); const BOB = path.join(USERS, "bob"); const ANA = path.join(USERS, "ana"); const HOSTDIR = path.join(TMP, "hostdir"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); 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 pty = require("../lib/pty-run"); const { fakeSudo } = require("./fixtures/fake-sudo"); let server; let BASE; const cookies = {}; let memberCalls = []; // tmux calls run as a member: [login, ...args, stdin] 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, tool = "claude") => Number( db .prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')") .run(cwd, tool, owner ? idOf(owner) : null).lastInsertRowid ); const UID = process.getuid(); const passwd = (miaUid = UID) => { fs.writeFileSync( process.env.AGENTHUB_PASSWD, [ `ah-mia:x:${miaUid}:${miaUid}::/:/bin/sh`, ...["bob", "ana"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`), ].join("\n") + "\n" ); runAs.__resetCache(); }; /** Run `fn` while mia is another uid: every file this test made is not hers. */ const asForeign = async (fn) => { passwd(UID + 1); try { return await fn(); } finally { passwd(); } }; before(async () => { fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true }); for (const d of [path.join(MIA, "proj"), path.join(BOB, "proj"), path.join(ANA, "proj")]) fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(HOSTDIR, { recursive: true }); fs.writeFileSync(path.join(HOSTDIR, "secret.txt"), "HOST-SECRET\n"); fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true }); fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"BOB-SECRET"}\n'); passwd(); 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((args) => { if (args[0] === "-V") return "tmux 3.4"; if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 }); return ""; }); tmux.__setAccountImpl( (name) => runAs.accountForSession(name), (acct, args, input) => { memberCalls.push([acct.login, ...args, input]); if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 }); return ""; } ); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; process.env.AGENTHUB_MODE = "server"; // A user made before S3 whose name fits no Linux login — first in id order. db.prepare("INSERT INTO users (username, password_hash) VALUES ('Old.Name', 'x')").run(); for (const [n, role] of [ ["boss", "admin"], ["mia", "developer"], ["bob", "developer"], ["ana", "analyst"], ]) { 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 workspace in a member's home is that member's only", () => { it("refuses an admin creating one there, and lets the member", async () => { const byBoss = await call("boss", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") }); assert.equal(byBoss.status, 409, JSON.stringify(byBoss.body)); assert.equal(byBoss.body.error.code, "EOWNERFOLDER"); const ensured = await call("boss", "POST", "/api/lanes/ensure", { cwd: path.join(MIA, "proj"), }); assert.equal(ensured.status, 409, JSON.stringify(ensured.body)); const byMia = await call("mia", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") }); assert.equal(byMia.status, 201, JSON.stringify(byMia.body)); // Nor can it be given to someone else afterwards. assert.throws(() => auth.assignWorkspace(byMia.body.lane.id, "bob"), { code: "EOWNERFOLDER", }); auth.assignWorkspace(byMia.body.lane.id, "mia"); }); it("starts no pane carrying another owner's token there", () => { const lane = addLane(path.join(MIA, "proj", "sub"), "boss"); memberCalls = []; assert.throws( () => pty.spawnRun({ laneId: lane, cwd: path.join(MIA, "proj"), tool: "claude" }), { code: "EOWNERFOLDER" } ); assert.throws(() => pty.spawnShell({ laneId: lane, cwd: path.join(MIA, "proj") }), { code: "EOWNERFOLDER", }); assert.ok(!memberCalls.some((a) => a.includes("new-session")), "no pane started"); }); }); describe("a long first prompt in a member's pane", () => { it("travels in the launch file, never the host's run-prompts dir", () => { const lane = addLane(path.join(MIA, "long"), "mia"); fs.mkdirSync(path.join(MIA, "long"), { recursive: true }); memberCalls = []; const prompt = `spec: ${"x".repeat(6000)}`; pty.spawnRun({ laneId: lane, cwd: path.join(MIA, "long"), tool: "claude", initialPrompt: prompt, }); const create = memberCalls.find((a) => a.includes("new-session")); assert.ok(create, "started on mia's server"); const launch = create[create.indexOf("/opt/agenthub/libexec/pane-launch") + 1]; const body = fs.readFileSync(launch, "utf8").split("\0"); assert.equal(body.at(-2), prompt, "the prompt is the CLI's last argument"); assert.ok( !body.some((x) => x.includes("run-prompts") || x.includes('cat "$1"')), "no sh reading a host file" ); assert.equal(fs.existsSync(path.join(TMP, "data", "run-prompts")), false); }); }); describe("a member's planted link is never followed with the dashboard's rights", () => { it("the memory view reads only the member's own CLAUDE.md", async () => { const dir = path.join(MIA, "mem"); fs.mkdirSync(dir, { recursive: true }); const lane = addLane(dir, "mia"); fs.writeFileSync(path.join(dir, "AGENTS.md"), "mia's own notes\n"); fs.symlinkSync(path.join(HOSTDIR, "secret.txt"), path.join(dir, "CLAUDE.md")); const mine = await call("mia", "GET", `/api/lanes/${lane}/memory`); assert.equal(mine.status, 200); assert.ok(JSON.stringify(mine.body).includes("mia's own notes")); await asForeign(async () => { const r = await call("mia", "GET", `/api/lanes/${lane}/memory`); assert.equal(r.status, 200); assert.ok(!JSON.stringify(r.body).includes("HOST-SECRET"), JSON.stringify(r.body)); }); }); it("the transcript snapshot copies only the member's own files", async () => { const { snapshotTranscript } = require("../../scripts/import-history"); const { getTranscriptSnapshotDir } = require("../lib/claude-home"); const proj = path.join(MIA, ".claude", "projects", "-p"); const sid = "aaaaaaaa-1111-4222-8333-444444444444"; fs.mkdirSync(path.join(proj, sid, "subagents"), { recursive: true }); fs.writeFileSync(path.join(proj, `${sid}.jsonl`), '{"own":1}\n'); fs.writeFileSync(path.join(proj, sid, "subagents", "agent-own.jsonl"), '{"own":2}\n'); await snapshotTranscript(path.join(proj, `${sid}.jsonl`), sid); const snap = getTranscriptSnapshotDir(); assert.equal(fs.readFileSync(path.join(snap, `${sid}.jsonl`), "utf8"), '{"own":1}\n'); assert.ok(fs.existsSync(path.join(snap, sid, "subagents", "agent-own.jsonl"))); const sid2 = "bbbbbbbb-1111-4222-8333-444444444444"; fs.mkdirSync(path.join(proj, sid2, "subagents"), { recursive: true }); fs.writeFileSync(path.join(proj, `${sid2}.jsonl`), '{"own":3}\n'); fs.symlinkSync( path.join(HOSTDIR, "secret.txt"), path.join(proj, sid2, "subagents", "agent-x.jsonl") ); await asForeign(() => snapshotTranscript(path.join(proj, `${sid2}.jsonl`), sid2)); assert.equal(fs.existsSync(path.join(snap, sid2, "subagents", "agent-x.jsonl")), false); }); it("the startup AGENTS.md cleanup never touches a member's folder", () => { const { removeStageDoc } = require("../lib/lane-agents"); const doc = "keep\n\n\nx\n\n"; const target = path.join(HOSTDIR, "victim.txt"); fs.writeFileSync(target, doc); const dir = path.join(MIA, "cx"); fs.mkdirSync(dir, { recursive: true }); fs.symlinkSync(target, path.join(dir, "AGENTS.md")); assert.equal(removeStageDoc(dir), false); assert.equal(fs.readFileSync(target, "utf8"), doc); // A host folder is still cleaned as before. const host = path.join(HOSTDIR, "lane"); fs.mkdirSync(host); fs.writeFileSync(path.join(host, "AGENTS.md"), doc); assert.equal(removeStageDoc(host), true); assert.equal(fs.readFileSync(path.join(host, "AGENTS.md"), "utf8"), "keep\n"); }); }); describe("the config readers", () => { it("take a non-admin's ?cwd= only inside their own workspaces", async () => { addLane(path.join(ANA, "proj"), "ana"); const q = (cwd, p) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(p)}`; const bobs = await call("ana", "GET", q(BOB, path.join(BOB, ".claude", ".credentials.json"))); assert.equal(bobs.status, 403); assert.equal(bobs.body.error.code, "EFOLDER"); // A link in a folder of the caller's own: `/tmp/a/.claude -> /`. const out = path.join(TMP, "a"); fs.mkdirSync(out); fs.symlinkSync("/", path.join(out, ".claude")); const root = await call("ana", "GET", q(out, path.join(out, ".claude", "etc", "hostname"))); assert.equal(root.status, 403); const codex = await call( "ana", "GET", `/api/codex-config/memory?cwd=${encodeURIComponent(BOB)}` ); assert.equal(codex.status, 403); const own = await call( "ana", "GET", `/api/cc-config/hooks?cwd=${encodeURIComponent(path.join(ANA, "proj"))}` ); assert.equal(own.status, 200); }); it("serve no credential file by name, to anyone", async () => { const home = process.env.CLAUDE_HOME; fs.mkdirSync(path.join(home, "agenthub-proxies"), { recursive: true }); fs.writeFileSync(path.join(home, ".credentials.json"), '{"t":"HOST-TOKEN"}'); fs.writeFileSync(path.join(home, "agenthub-proxies", "gw.json"), '{"apiKey":"sk-x"}'); fs.writeFileSync(path.join(home, "settings.json"), '{"ok":true}'); const get = (p) => call("boss", "GET", `/api/cc-config/file?path=${encodeURIComponent(p)}`); for (const f of [".credentials.json", path.join("agenthub-proxies", "gw.json")]) { const r = await get(path.join(home, f)); assert.equal(r.status, 400, f); assert.ok(!JSON.stringify(r.body).includes("HOST-TOKEN")); } assert.equal((await get(path.join(home, "settings.json"))).status, 200); fs.mkdirSync(process.env.CODEX_HOME, { recursive: true }); fs.writeFileSync(path.join(process.env.CODEX_HOME, "auth.json"), '{"t":"CODEX-TOKEN"}'); const cx = await call( "boss", "GET", `/api/codex-config/file?path=${encodeURIComponent(path.join(process.env.CODEX_HOME, "auth.json"))}` ); assert.equal(cx.status, 400); }); }); describe("a member's quiet Codex session", () => { it("keeps the name from the member's own index", async () => { const { ingestCodexUsage, settleActive } = require("../lib/codex-usage"); const tid = "cccccccc-1111-4222-8333-444444444444"; const codexHome = path.join(MIA, ".codex"); const day = path.join(codexHome, "sessions", "2026", "10", "09"); fs.mkdirSync(day, { recursive: true }); const file = path.join(day, `rollout-2026-10-09T00-00-00-${tid}.jsonl`); const ts = new Date().toISOString(); fs.writeFileSync( file, [ { timestamp: ts, type: "session_meta", payload: { id: tid, timestamp: ts, cwd: path.join(MIA, "proj") }, }, { timestamp: ts, type: "event_msg", payload: { type: "user_message", message: "hello" } }, ] .map((l) => JSON.stringify(l)) .join("\n") + "\n" ); fs.writeFileSync( path.join(codexHome, "session_index.jsonl"), `${JSON.stringify({ id: tid, thread_name: "Mia's named thread" })}\n` ); const now = Date.now(); await ingestCodexUsage({ home: codexHome, now }); const row = () => db.prepare("SELECT name, status FROM sessions WHERE id = ?").get(tid); assert.deepEqual(row(), { name: "Mia's named thread", status: "active" }); settleActive(() => {}, now + 60 * 60_000); assert.deepEqual(row(), { name: "Mia's named thread", status: "completed" }); }); }); describe("agenthub users provision --all", () => { it("goes on past a user it cannot provision", () => { const bin = path.join(TMP, "fakebin"); fs.mkdirSync(bin); // Stands in for `sudo -n /opt/agenthub/sbin/agenthub-provision create `. fs.writeFileSync(path.join(bin, "sudo"), '#!/bin/sh\necho "made $4"\n', { mode: 0o755 }); const r = spawnSync( process.execPath, [path.join(__dirname, "..", "..", "bin", "agenthub.js"), "users", "provision", "--all"], { encoding: "utf8", // AGENTHUB_FILE_KEYS: what this process loaded from a settings file, // which the CLI would take back out of ITS env (AGENTHUB_MODE among them). env: { ...process.env, AGENTHUB_FILE_KEYS: "", PATH: `${bin}:${process.env.PATH}`, NO_COLOR: "1", }, } ); assert.equal(r.status, 1, r.stderr); assert.match(r.stderr, /Old\.Name/); for (const n of ["boss", "mia", "bob", "ana"]) assert.match(r.stdout, new RegExp(`made ${n}`)); }); });