/** * @file server-mode-review-fixes.test.js * @description Regressions for the server-mode review's confirmed defects: a * member cannot read another user's agent through `POST /api/agents`, cannot * PATCH the pane/session columns of a workspace, cannot open the account pool; * a transcript `agent_id`/`run_id` cannot walk out of its folder (any mode); * push subscriptions belong to the user who made them, and a member's quota * view names no 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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-fixes-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; // Named locks live under LANES_ROOT/.locks — never the real one. process.env.LANES_ROOT = path.join(TMP, "lanes"); const { createApp, startServer } = require("../index"); const { db } = require("../db"); const auth = require("../lib/auth"); let server; let BASE; let laneMia; let laneMax; async function call(method, p, { cookie, body } = {}) { const res = await fetch(BASE + p, { method, headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json().catch(() => null) }; } async function login(username, password) { const res = await fetch(BASE + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200); return res.headers.get("set-cookie").split(";")[0]; } before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; const mia = await auth.createUser("mia", "member-pass-1", "developer"); const max = await auth.createUser("max", "member-pass-2", "developer"); await auth.createUser("boss", "admin-pass-1", "admin"); const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)"); laneMia = lane.run("/w/mia", mia.id).lastInsertRowid; laneMax = lane.run("/w/max", max.id).lastInsertRowid; db.prepare( "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s-max', 'max', 'active', '/w/max', ?)" ).run(laneMax); db.prepare( "INSERT INTO agents (id, session_id, name, type) VALUES ('a-max', 's-max', 'Main', 'main')" ).run(); }); after(() => { delete process.env.AGENTHUB_MODE; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("server mode review fixes", () => { before(() => { process.env.AGENTHUB_MODE = "server"; }); it("does not hand back another user's agent from POST /api/agents", async () => { const mia = await login("mia", "member-pass-1"); const r = await call("POST", "/api/agents", { cookie: mia, body: { id: "a-max", session_id: `s-mia-${laneMia}`, name: "x" }, }); assert.equal(r.status, 404); assert.ok(!JSON.stringify(r.body).includes("s-max")); }); it("lets a member PATCH only a workspace's typed fields", async () => { const mia = await login("mia", "member-pass-1"); const bad = await call("PATCH", `/api/lanes/${laneMia}`, { cookie: mia, body: { run_id: `agenthub-lane-${laneMax}` }, }); assert.equal(bad.status, 403); assert.equal(db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(laneMia).run_id, null); const ok = await call("PATCH", `/api/lanes/${laneMia}`, { cookie: mia, body: { title: "Mine" }, }); assert.equal(ok.status, 200); const admin = await login("boss", "admin-pass-1"); const staff = await call("PATCH", `/api/lanes/${laneMia}`, { cookie: admin, body: { notes: "x", status: "idle" }, }); assert.equal(staff.status, 200); }); it("keeps the account pool and account names from members", async () => { const mia = await login("mia", "member-pass-1"); assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403); const q = await call("GET", "/api/pricing/quota", { cookie: mia }); assert.equal(q.status, 200); for (const t of q.body.tools || []) assert.equal(t.account, null); }); it("scopes push subscriptions to their user", async () => { const mia = await login("mia", "member-pass-1"); const max = await login("max", "member-pass-2"); const sub = (endpoint, cookie) => call("POST", "/api/push/subscribe", { cookie, body: { endpoint, keys: { p256dh: "p", auth: "a" } }, }); await sub("https://push.example/mia", mia); await sub("https://push.example/max", max); const owners = db .prepare("SELECT endpoint, user_id FROM push_subscriptions ORDER BY endpoint") .all() .map((r) => [r.endpoint.split("/").pop(), r.user_id]); assert.deepEqual( owners.map(([e]) => e), ["max", "mia"] ); assert.notEqual(owners[0][1], owners[1][1]); // max cannot unsubscribe mia's browser. await call("DELETE", "/api/push/subscribe", { cookie: max, body: { endpoint: "https://push.example/mia" }, }); assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 2); }); }); describe("transcript ids (every mode)", () => { it("refuses an agent_id or run_id that is a path", async () => { delete process.env.AGENTHUB_MODE; for (const q of ["agent_id=../../x", "run_id=..%2F..%2Fx", "agent_id=a%2Fb"]) { const r = await call("GET", `/api/sessions/s-max/transcript?${q}`); assert.equal(r.status, 400, q); assert.equal(r.body.error.code, "EBADID"); } const ok = await call("GET", "/api/sessions/s-max/transcript?agent_id=a-max"); assert.equal(ok.status, 200); }); }); describe("server mode review fixes, second round", () => { before(() => { process.env.AGENTHUB_MODE = "server"; }); it("refuses a write whose Origin is not the dashboard's own", async () => { const mia = await login("mia", "member-pass-1"); const res = await fetch(`${BASE}/api/lanes/${laneMia}`, { method: "PATCH", headers: { "Content-Type": "application/json", cookie: mia, origin: "https://wiki.example.com", }, body: JSON.stringify({ title: "csrf" }), }); assert.equal(res.status, 403); assert.equal((await res.json()).error.code, "EBADORIGIN"); const own = await fetch(`${BASE}/api/lanes/${laneMia}`, { method: "PATCH", headers: { "Content-Type": "application/json", cookie: mia, origin: BASE }, body: JSON.stringify({ title: "fine" }), }); assert.equal(own.status, 200); }); it("counts parallel failed logins against the limit", async () => { const tries = await Promise.all( Array.from({ length: 25 }, () => fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", "x-forwarded-for": "192.0.2.55" }, body: JSON.stringify({ username: "max", password: "wrong" }), }).then((r) => r.status) ) ); assert.ok(tries.filter((s) => s === 401).length <= 10, JSON.stringify(tries)); assert.ok(tries.includes(429)); }); it("refuses a member a workspace that overlaps another user's, for any tool", async () => { const mia = await login("mia", "member-pass-1"); for (const body of [{ cwd: "/w/max", tool: "codex" }, { cwd: "/w/max/sub" }, { cwd: "/w" }]) { const r = await call("POST", "/api/lanes", { cookie: mia, body }); assert.equal(r.status, 409, JSON.stringify(body)); assert.equal(r.body.error.code, "EOWNED"); } }); it("keeps members from planting sessions and from the shared pet store", async () => { const mia = await login("mia", "member-pass-1"); const s = await call("POST", "/api/sessions", { cookie: mia, body: { id: "fake-1", name: "x" }, }); assert.equal(s.status, 403); assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE id = 'fake-1'").get().n, 0); const p = await call("DELETE", "/api/pets/some-pet", { cookie: mia }); assert.equal(p.status, 403); }); it("lets a live pane's CLI act as its workspace owner, and nothing else", async () => { const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const run = `agenthub-lane-${laneMia}`; const token = "ahpane_test-token-value-0123456789"; db.prepare( `INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash) VALUES (?, 'interactive', '/w/mia', 'running', ?, ?)` ).run(run, laneMia, crypto.createHash("sha256").update(token).digest("hex")); const asPane = (p, extra = {}) => fetch(BASE + p, { headers: { "x-agenthub-pane-token": token, ...extra } }).then( (r) => r.status ); // No live tmux session yet: the credential is dead. assert.equal(await asPane("/api/sessions"), 401); execFileSync("tmux", ["new-session", "-d", "-s", run, "sleep", "120"]); try { assert.equal(await asPane("/api/sessions"), 200); assert.equal(await asPane(`/api/lanes/${laneMax}`), 404); // still a member assert.equal(await asPane("/api/sessions", { "x-forwarded-for": "203.0.113.1" }), 401); assert.equal( await fetch(BASE + "/api/sessions", { headers: { "x-agenthub-pane-token": "ahpane_wrong" }, }).then((r) => r.status), 401 ); } finally { execFileSync("tmux", ["kill-session", "-t", `=${run}`]); } }); it("sends a member the first frames of a session their own pane starts", async () => { const WebSocket = require("ws"); const cookie = await login("mia", "member-pass-1"); const run = `agenthub-lane-${laneMia}-3`; db.prepare( `INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id) VALUES (?, 'interactive', '/w/mia', 'running', ?)` ).run(run, laneMia); const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie } }); const seen = []; ws.on("message", (m) => seen.push(JSON.parse(String(m)))); await new Promise((resolve, reject) => ws.on("open", resolve).on("error", reject)); try { // The session is new: it has no workspace until bindRunSession stamps // it, so frames sent before that stamp reached no member. const res = await call("POST", "/api/hooks/event", { body: { hook_type: "SessionStart", run_id: run, data: { session_id: "s-new-mia", cwd: "/w/mia", source: "startup" }, }, }); assert.equal(res.status, 200); await new Promise((r) => setTimeout(r, 300)); const mine = seen.filter((m) => JSON.stringify(m.data || {}).includes("s-new-mia")); assert.ok( mine.some((m) => m.type === "new_event"), `got ${seen.map((m) => m.type).join(", ")}` ); } finally { ws.close(); } }); it("sends members the config-change signal without its payload", () => { const scope = require("../lib/scope"); const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" }); assert.equal(a.strip, true); assert.equal(scope.audienceIncludes(a, { id: 1, role: "developer" }), true); }); }); describe("named locks", () => { it("never takes a path for a lock name (any mode)", () => { const namedLock = require("../lib/named-lock"); for (const bad of ["../x", "..", ".", "a/b", "a\\b", "", "x".repeat(256)]) { assert.throws(() => namedLock.tryAcquire(bad, "me"), { code: "EBADNAME" }, bad); assert.throws(() => namedLock.release(bad, "me"), { code: "EBADNAME" }, bad); } assert.throws(() => namedLock.tryAcquire("ok", " "), { code: "EBADHOLDER" }); // A holder with spaces keeps working — and now really holds the lock. assert.deepEqual(namedLock.tryAcquire("ci", "ci bot"), { acquired: true }); assert.equal(namedLock.tryAcquire("ci", "other").acquired, false); namedLock.release("ci", "ci bot"); // Names that were always valid directory names keep working. for (const good of ["db:migrate", "build web", "-x"]) { assert.deepEqual(namedLock.tryAcquire(good, "me"), { acquired: true }, good); namedLock.release(good, "me"); } }); it("gives each member their own lock namespace", async () => { process.env.AGENTHUB_MODE = "server"; const mia = await login("mia", "member-pass-1"); const max = await login("max", "member-pass-2"); const take = (cookie, holder) => call("POST", "/api/locks/deploy/acquire", { cookie, body: { holder } }); assert.equal((await take(mia, "mia-agent")).status, 200); assert.equal((await take(max, "max-agent")).status, 200); // not mia's lock const seen = (await call("GET", "/api/locks", { cookie: max })).body.locks; assert.deepEqual( seen.map((l) => [l.name, l.holder]), [["deploy", "max-agent"]] ); const bad = await call("POST", "/api/locks/..%2F..%2Fx/acquire", { cookie: mia, body: { holder: "h" }, }); assert.ok([400, 404].includes(bad.status)); await call("POST", "/api/locks/deploy/release", { cookie: mia, body: { holder: "mia-agent" } }); await call("POST", "/api/locks/deploy/release", { cookie: max, body: { holder: "max-agent" } }); }); }); describe("data export", () => { it("never writes a pane credential's hash into a bundle", async () => { delete process.env.AGENTHUB_MODE; db.prepare( `INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, pane_token_hash) VALUES ('agenthub-lane-999@x', 'interactive', '/w', 'killed', 'deadbeefhash')` ).run(); const text = await (await fetch(`${BASE}/api/settings/export`)).text(); assert.ok(text.includes("agenthub-lane-999@x")); assert.ok(!text.includes("deadbeefhash")); const { buildExportBundle } = require("../lib/data-transfer"); const bundle = buildExportBundle(db, require("../db").stmts); assert.ok(bundle.dashboard_runs.some((r) => r.id === "agenthub-lane-999@x")); assert.ok(!JSON.stringify(bundle).includes("deadbeefhash")); }); });