/** * @file server-mode-workspaces.test.js * @description Server mode's per-user row ownership on the workspace routes * (phase S2): the workspace list and its counts, pipeline stats, housekeeping * (`/lanes/gc`), projects, the live and past run lists, folder suggestions, * the `@` file walk, starting and killing a run, and a resume of another * user's session. A member gets only their own rows, another member theirs, * an admin and an analyst everything, and local mode is unchanged. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const fs = require("fs"); const os = require("os"); const TEST_DB = path.join(os.tmpdir(), `server-mode-ws-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.DASHBOARD_LIVENESS_PROBE = "0"; // A private tmux server: nothing here may list or kill the user's real panes. const TMUX_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-tmux-")); process.env.TMUX_TMPDIR = TMUX_DIR; delete process.env.TMUX; const { createApp, startServer } = require("../index"); const { db } = require("../db"); const auth = require("../lib/auth"); let server; let BASE; const cookies = {}; const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-"))); const DIR_A = path.join(ROOT, "alice"); const DIR_B = path.join(ROOT, "bob"); const LANE_A = 9001; const LANE_B = 9002; async function call(method, p, { as, body } = {}) { const res = await fetch(BASE + p, { method, headers: { "Content-Type": "application/json", ...(as ? { cookie: cookies[as] } : {}), }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json().catch(() => null) }; } const get = async (p, as) => { const r = await call("GET", p, { as }); assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`); return r.body; }; const laneIds = async (as) => (await get("/api/lanes", as)).lanes.map((l) => l.id).sort(); before(async () => { for (const d of [DIR_A, DIR_B]) { fs.mkdirSync(d); fs.writeFileSync(path.join(d, `${path.basename(d)}.txt`), "x"); } server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; const alice = await auth.createUser("alice", "alice-pass-1", "developer"); const bob = await auth.createUser("bob", "bob-pass-1", "developer"); await auth.createUser("boss", "admin-pass-1", "admin"); await auth.createUser("ana", "analyst-pass-1", "analyst"); const lane = db.prepare( "INSERT INTO lanes (id, cwd, tool, owner_id, source_repo) VALUES (?, ?, 'claude', ?, ?)" ); lane.run(LANE_A, DIR_A, alice.id, DIR_A); lane.run(LANE_B, DIR_B, bob.id, DIR_B); const session = db.prepare( "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'x', 'completed', ?, ?)" ); session.run("sA-0000001", DIR_A, LANE_A); session.run("sB-0000001", DIR_B, LANE_B); const stage = db.prepare( "INSERT INTO lane_stage_events (lane_id, pipeline, node_id) VALUES (?, 'default', 'x')" ); stage.run(LANE_A); stage.run(LANE_B); const run = db.prepare( "INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, started_at) VALUES (?, 'interactive', ?, 'exited', ?, ?)" ); run.run(`agenthub-lane-${LANE_A}@1`, DIR_A, LANE_A, "2026-01-01T00:00:00Z"); run.run(`agenthub-lane-${LANE_B}@1`, DIR_B, LANE_B, "2026-01-02T00:00:00Z"); for (const [name, pass] of [ ["alice", "alice-pass-1"], ["bob", "bob-pass-1"], ["boss", "admin-pass-1"], ["ana", "analyst-pass-1"], ]) { process.env.AGENTHUB_MODE = "server"; const res = await fetch(BASE + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: name, password: pass }), }); assert.equal(res.status, 200); cookies[name] = res.headers.get("set-cookie").split(";")[0]; } delete process.env.AGENTHUB_MODE; }); after(() => { delete process.env.AGENTHUB_MODE; server?.close(); for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) { fs.rmSync(f, { force: true }); } fs.rmSync(ROOT, { recursive: true, force: true }); fs.rmSync(TMUX_DIR, { recursive: true, force: true }); }); describe("local mode", () => { it("lists every workspace, run and folder as before", async () => { assert.deepEqual(await laneIds(), [LANE_A, LANE_B]); assert.equal((await get("/api/lanes/pipeline-stats")).lanes.length, 2); const hist = (await get("/api/run/history")).items.map((r) => r.lane_id).sort(); assert.deepEqual(hist, [LANE_A, LANE_B]); const kinds = (await get("/api/run/cwds")).items.map((i) => i.kind); assert.ok(kinds.includes("dashboard") && kinds.includes("home")); assert.equal((await get(`/api/run/files?cwd=${encodeURIComponent(DIR_B)}`)).items.length, 1); }); }); describe("server mode", () => { before(() => { process.env.AGENTHUB_MODE = "server"; }); after(() => { delete process.env.AGENTHUB_MODE; }); it("GET /api/lanes lists and counts only a member's own workspaces", async () => { assert.deepEqual(await laneIds("alice"), [LANE_A]); assert.deepEqual(await laneIds("bob"), [LANE_B]); assert.equal((await get("/api/lanes", "alice")).counts.total, 1); assert.deepEqual(await laneIds("boss"), [LANE_A, LANE_B]); assert.deepEqual(await laneIds("ana"), [LANE_A, LANE_B]); // Each carries its owner's name, for the staff sidebar's per-user groups. const owners = (await get("/api/lanes", "ana")).lanes.map((l) => [l.id, l.owner_name]); assert.deepEqual(owners.sort(), [ [LANE_A, "alice"], [LANE_B, "bob"], ]); assert.deepEqual( (await get("/api/workspaces", "bob")).lanes.map((l) => l.id), [LANE_B] ); }); it("a member with no folder of their own may not make one anywhere (browse/mkdir)", async () => { for (const as of ["alice", "ana"]) { const r = await call("POST", "/api/lanes/browse/mkdir", { as, body: { parent: ROOT, name: `by-${as}` }, }); assert.equal(r.status, 403, JSON.stringify(r.body)); assert.equal(r.body.error.code, "EFOLDER"); } assert.ok(!fs.existsSync(path.join(ROOT, "by-alice"))); const admin = await call("POST", "/api/lanes/browse/mkdir", { as: "boss", body: { parent: ROOT, name: "by-boss" }, }); assert.equal(admin.status, 201, JSON.stringify(admin.body)); }); it("pipeline stats cover only a member's own workspaces", async () => { const mine = await get("/api/lanes/pipeline-stats", "alice"); assert.deepEqual( mine.lanes.map((l) => l.id), [LANE_A] ); assert.equal(mine.pipelines[0].runs, 1); const all = await get("/api/lanes/pipeline-stats", "ana"); assert.equal(all.lanes.length, 2); assert.equal(all.pipelines[0].runs, 2); assert.equal( (await call("GET", `/api/lanes/pipeline-stats?lane=${LANE_B}`, { as: "alice" })).status, 404 ); }); it("housekeeping is refused to a member", async () => { const r = await call("POST", "/api/lanes/gc", { as: "alice", body: { dryRun: true } }); assert.equal(r.status, 403); assert.equal(r.body.error.code, "EADMIN"); assert.equal( (await call("POST", "/api/lanes/gc", { as: "boss", body: { dryRun: true } })).status, 200 ); }); it("projects are built from a member's own workspaces", async () => { const ws = (b) => b.projects.flatMap((p) => p.workspaces).sort(); assert.deepEqual(ws(await get("/api/projects", "alice")), [LANE_A]); assert.deepEqual(ws(await get("/api/projects", "bob")), [LANE_B]); assert.deepEqual(ws(await get("/api/projects", "ana")), [LANE_A, LANE_B]); const r = await call("POST", "/api/projects/refresh", { as: "alice" }); assert.deepEqual(ws(r.body), [LANE_A]); }); it("run lists and history show only a member's own runs", async () => { const hist = async (as, q = "") => (await get(`/api/run/history${q}`, as)).items.map((r) => r.lane_id).sort(); assert.deepEqual(await hist("alice"), [LANE_A]); assert.deepEqual(await hist("bob"), [LANE_B]); assert.deepEqual(await hist("boss"), [LANE_A, LANE_B]); assert.deepEqual(await hist("alice", "?limit=1"), [LANE_A]); assert.equal( (await call("GET", `/api/run/history?laneId=${LANE_B}`, { as: "alice" })).status, 404 ); const live = await get("/api/run", "alice"); assert.ok(live.items.every((r) => r.laneId === LANE_A)); }); it("folder suggestions come from a member's own workspaces and sessions", async () => { const paths = async (as) => (await get("/api/run/cwds", as)).items.map((i) => i.path); assert.deepEqual(await paths("alice"), [DIR_A]); assert.deepEqual(await paths("bob"), [DIR_B]); const all = await paths("ana"); assert.ok(all.includes(DIR_A) && all.includes(DIR_B) && all.includes(os.homedir())); }); it("the file walk stays inside a member's own workspaces", async () => { const files = (as, dir) => call("GET", `/api/run/files?cwd=${encodeURIComponent(dir)}`, { as }); assert.deepEqual((await files("alice", DIR_A)).body.items, ["alice.txt"]); assert.equal((await files("alice", DIR_B)).status, 403); assert.equal((await call("GET", "/api/run/files", { as: "alice" })).status, 403); // A symlink inside her folder does not reach bob's. fs.symlinkSync(DIR_B, path.join(DIR_A, "link")); assert.equal((await files("alice", path.join(DIR_A, "link"))).status, 403); assert.deepEqual((await files("boss", DIR_B)).body.items, ["bob.txt"]); }); it("a member's run starts only in their own workspace and folder", async () => { const start = (body) => call("POST", "/api/run", { as: "alice", body }); assert.equal((await start({})).status, 400); assert.equal((await start({ laneId: LANE_B })).status, 404); const r = await start({ laneId: LANE_A, cwd: DIR_B }); assert.equal(r.status, 403); assert.equal(r.body.error.code, "EFOLDER"); }); it("another user's run cannot be read or killed, encoded or not", async () => { const id = `agenthub-lane-${LANE_B}`; assert.equal((await call("DELETE", `/api/run/${id}`, { as: "alice" })).status, 404); const encoded = `%61genthub-lane-${LANE_B}`; assert.equal((await call("DELETE", `/api/run/${encoded}`, { as: "alice" })).status, 404); assert.equal((await call("GET", `/api/run/${encoded}`, { as: "alice" })).status, 404); }); it("a member cannot resume another user's session in their own workspace", async () => { const r = await call("POST", `/api/lanes/${LANE_A}/start`, { as: "alice", body: { resumeSessionId: "sB-0000001" }, }); assert.equal(r.status, 404); }); });