// server/__tests__/run.test.js /** * @file run.test.js * @description Route tests for the terminal-run feature: same-origin guard, * laneId/cwd validation, spawn/kill/list against a mocked tmux backend, a * dropped image served back, and a live pane's permission mode. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after, beforeEach } = 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 http = require("node:http"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); const { createApp } = require("../index"); const tmux = require("../lib/tmux"); let server; let BASE; function fetchJson(p, opts = {}) { return new Promise((resolve, reject) => { const url = new URL(p, BASE); const headers = { ...(opts.headers || {}) }; let body; if (opts.raw) { body = opts.raw.buf; headers["Content-Type"] = opts.raw.type; headers["Content-Length"] = body.length; } else if (opts.body !== undefined) { body = Buffer.from(JSON.stringify(opts.body)); headers["Content-Type"] = "application/json"; headers["Content-Length"] = body.length; } const req = http.request( { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: opts.method || "GET", headers, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { const raw = Buffer.concat(chunks).toString("utf8"); let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; } resolve({ status: res.statusCode, body: parsed, headers: res.headers }); }); } ); req.on("error", reject); if (body) req.write(body); req.end(); }); } describe("/api/run", () => { before(async () => { const app = createApp(); server = http.createServer(app); await new Promise((r) => server.listen(0, r)); BASE = `http://127.0.0.1:${server.address().port}`; }); after(async () => { await new Promise((r) => server.close(r)); try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best-effort */ } }); beforeEach(() => { tmux.__reset(); }); it("rejects cross-origin browser requests", async () => { const { status, body } = await fetchJson("/api/run", { headers: { Origin: "http://evil.example.com" }, }); assert.equal(status, 403); assert.equal(body.error.code, "EBADORIGIN"); }); it("allows requests with no Origin (CLI/curl)", async () => { tmux.__setExecImpl((args) => { if (args[0] === "list-sessions") { const e = new Error("no server running"); e.status = 1; throw e; } return ""; }); const { status, body } = await fetchJson("/api/run"); assert.equal(status, 200); assert.deepEqual(body.items, []); }); it("POST / rejects a missing laneId", async () => { const { status, body } = await fetchJson("/api/run", { method: "POST", body: { cwd: TMP } }); assert.equal(status, 400); assert.equal(body.error.code, "EBADLANE"); }); it("POST / rejects a non-existent cwd", async () => { const { status, body } = await fetchJson("/api/run", { method: "POST", body: { laneId: 1, cwd: "/definitely/not/a/real/path" }, }); assert.equal(status, 400); assert.equal(body.error.code, "EBADCWD"); }); it("POST / answers 503, not 400, when the host has no tmux", async () => { // The request is fine; the dependency is absent. 400 would tell the user to // fix their input, and the body must carry the install line the UI shows. tmux.__setExecImpl(() => { const e = new Error("spawnSync tmux ENOENT"); e.code = "ENOENT"; throw e; }); const { status, body } = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP }, }); assert.equal(status, 503); assert.equal(body.error.code, "ETMUXMISSING"); assert.match(body.error.message, /install/i); }); it("POST / spawns a tmux session and GET /:id finds it", async () => { let hasSessionCalls = 0; tmux.__setExecImpl((args) => { if (args[0] === "has-session") { hasSessionCalls++; // First call (inside spawnRun): not yet running. Every call after // (GET /:id) sees it as running. if (hasSessionCalls === 1) { const e = new Error("gone"); e.status = 1; throw e; } return ""; } return ""; }); const spawned = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP } }); assert.equal(spawned.status, 201); assert.equal(spawned.body.id, "agenthub-lane-9"); const fetched = await fetchJson(`/api/run/${spawned.body.id}`); assert.equal(fetched.status, 200); assert.equal(fetched.body.status, "running"); }); it("DELETE /:id kills a live tmux session", async () => { tmux.__setExecImpl(() => ""); // has-session succeeds; kill-session succeeds const { status, body } = await fetchJson("/api/run/agenthub-lane-9", { method: "DELETE" }); assert.equal(status, 200); assert.deepEqual(body, { ok: true }); }); it("DELETE /:id?archive=1 archives the run's session; a plain DELETE (Sleep) keeps it", async () => { const { db } = require("../db"); db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-stop", TMP); db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-sleep", TMP); const run = db.prepare( "INSERT OR REPLACE INTO dashboard_runs (id, session_id, cwd, status) VALUES (?, ?, ?, 'running')" ); const archivedAt = (id) => db.prepare("SELECT archived_at FROM sessions WHERE id = ?").get(id).archived_at; tmux.__setExecImpl(() => ""); run.run("agenthub-lane-9-1", "sess-sleep", TMP); const slept = await fetchJson("/api/run/agenthub-lane-9-1", { method: "DELETE" }); assert.deepEqual(slept.body, { ok: true }); assert.equal(archivedAt("sess-sleep"), null); run.run("agenthub-lane-9-2", "sess-stop", TMP); const stopped = await fetchJson("/api/run/agenthub-lane-9-2?archive=1", { method: "DELETE" }); assert.equal(stopped.status, 200); assert.deepEqual(stopped.body, { ok: true, archived: "sess-stop" }); assert.ok(archivedAt("sess-stop")); // A pane with no known session (a shell) archives nothing. run.run("agenthub-lane-9-3", null, TMP); const shell = await fetchJson("/api/run/agenthub-lane-9-3?archive=1", { method: "DELETE" }); assert.deepEqual(shell.body, { ok: true, archived: null }); }); it("DELETE /:id returns 404 for a session that doesn't exist", async () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } return ""; }); const { status } = await fetchJson("/api/run/agenthub-lane-999", { method: "DELETE" }); assert.equal(status, 404); }); it("GET /tmux reports availability from the tmux wrapper", async () => { tmux.__setExecImpl(() => "tmux 3.4"); const { body } = await fetchJson("/api/run/tmux"); assert.equal(body.available, true); // The version and this platform's upgrade advice (null when new enough). assert.equal(body.version, "3.4"); assert.equal(body.upgrade, tmux.upgradeAdvice("3.4")); }); it("GET /tmux says to upgrade a version older than the platform asks for", async () => { tmux.__setExecImpl(() => "tmux 2.9"); const { body } = await fetchJson("/api/run/tmux"); assert.match(body.upgrade, /older than/); }); it("GET /cwds still returns suggested directories (unchanged behavior)", async () => { const { status, body } = await fetchJson("/api/run/cwds"); assert.equal(status, 200); assert.ok(Array.isArray(body.items)); }); it("GET /api/run/binary?tool=codex probes codex, not claude", async () => { const res = await fetchJson("/api/run/binary?tool=codex"); assert.equal(res.status, 200); assert.equal(typeof res.body.found, "boolean"); }); it("GET /api/run/binary rejects an unknown tool", async () => { const res = await fetchJson("/api/run/binary?tool=rm"); assert.equal(res.status, 400); }); }); describe("GET /api/run/files", () => { let srv; const DIR = path.join(TMP, "files-proj"); before(async () => { fs.mkdirSync(path.join(DIR, "src", "lib"), { recursive: true }); fs.writeFileSync(path.join(DIR, "src", "lib", "a.js"), ""); srv = http.createServer(createApp()); await new Promise((r) => srv.listen(0, r)); BASE = `http://127.0.0.1:${srv.address().port}`; }); after(() => new Promise((r) => srv.close(r))); const list = async (qs) => (await fetchJson(`/api/run/files?cwd=${encodeURIComponent(DIR)}${qs}`)).body.items; it("lists files only, unless dirs=1 adds the folders with a trailing slash", async () => { assert.deepEqual(await list(""), ["src/lib/a.js"]); assert.deepEqual(await list("&dirs=1"), ["src/", "src/lib/", "src/lib/a.js"]); assert.deepEqual(await list("&dirs=1&q=lib"), ["src/lib/", "src/lib/a.js"]); }); }); describe("POST /api/run/drop-image", () => { let srv; before(async () => { srv = http.createServer(createApp()); await new Promise((r) => srv.listen(0, r)); BASE = `http://127.0.0.1:${srv.address().port}`; }); after(() => new Promise((r) => srv.close(r))); const PNG = Buffer.from("89504e470d0a1a0a0000", "hex"); it("saves the image under a random name, owner-only, and returns its path", async () => { const r = await fetchJson("/api/run/drop-image", { method: "POST", raw: { type: "image/png", buf: PNG }, }); assert.equal(r.status, 200); assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/); // The host's own data dir, never a predictable name in the shared tmp. assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops")); assert.equal(fs.statSync(path.dirname(r.body.path)).mode & 0o777, 0o700); assert.deepEqual(fs.readFileSync(r.body.path), PNG); assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600); fs.unlinkSync(r.body.path); }); it("refuses anything that is not one of the image types", async () => { const r = await fetchJson("/api/run/drop-image", { method: "POST", raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") }, }); assert.equal(r.status, 400); }); it("refuses a cross-origin browser request", async () => { const r = await fetchJson("/api/run/drop-image", { method: "POST", headers: { Origin: "https://evil.example" }, raw: { type: "image/png", buf: PNG }, }); assert.equal(r.status, 403); }); it("drop-file saves any file under a random prefix and its sanitized name", async () => { const name = encodeURIComponent("../../báo cáo Q3.pdf"); const r = await fetchJson(`/api/run/drop-file?name=${name}`, { method: "POST", raw: { type: "application/pdf", buf: Buffer.from("%PDF") }, }); assert.equal(r.status, 200); assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops")); assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/); assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF"); assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600); fs.unlinkSync(r.body.path); }); it("drop-file without a name or a body is refused", async () => { const r = await fetchJson("/api/run/drop-file", { method: "POST", raw: { type: "text/plain", buf: Buffer.alloc(0) }, }); assert.equal(r.status, 400); }); }); describe("GET /api/run/drop/:name", () => { let srv; before(async () => { srv = http.createServer(createApp()); await new Promise((r) => srv.listen(0, r)); BASE = `http://127.0.0.1:${srv.address().port}`; }); after(() => new Promise((r) => srv.close(r))); const PNG = Buffer.from("89504e470d0a1a0a0000", "hex"); it("serves a dropped image back as an image that runs nothing", async () => { const saved = await fetchJson("/api/run/drop-image", { method: "POST", raw: { type: "image/png", buf: PNG }, }); const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`); assert.equal(r.status, 200); assert.equal(r.headers["content-type"], "image/png"); assert.equal(r.headers["x-content-type-options"], "nosniff"); assert.match(r.headers["content-security-policy"], /sandbox/); fs.unlinkSync(saved.body.path); }); it("serves a dropped text file back as plain text that runs nothing", async () => { const saved = await fetchJson("/api/run/drop-file?name=pasted-text.txt", { method: "POST", raw: { type: "text/plain", buf: Buffer.from(" long paste") }, }); const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`); assert.equal(r.status, 200); assert.equal(r.headers["content-type"], "text/plain; charset=utf-8"); assert.equal(r.headers["x-content-type-options"], "nosniff"); assert.match(r.headers["content-security-policy"], /sandbox/); fs.unlinkSync(saved.body.path); }); it("serves no file that is not a dropped image or text file", async () => { const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", { method: "POST", raw: { type: "application/pdf", buf: Buffer.from("%PDF") }, }); // Planted in the real drop dir (the one this drop just made), not a // guessed path: `/drops` replaced `/tmp/agenthub-drops-`. const link = path.join(path.dirname(pdf.body.path), "fedcba9876543210.png"); fs.rmSync(link, { force: true }); fs.symlinkSync("/etc/passwd", link); for (const name of [ path.basename(pdf.body.path), // a drop, but no image "0123456789abcdef.png", // the right shape, never dropped "..%2F..%2Fetc%2Fpasswd", "passwd.png", // no random prefix "fedcba9876543210.png", // a link planted under a drop's name ]) { const r = await fetchJson(`/api/run/drop/${name}`); assert.equal(r.status, 404, name); } fs.unlinkSync(pdf.body.path); fs.unlinkSync(link); }); }); describe("/api/run/:id/permission-mode", () => { let srv; before(async () => { srv = http.createServer(createApp()); await new Promise((r) => srv.listen(0, r)); BASE = `http://127.0.0.1:${srv.address().port}`; }); after(() => new Promise((r) => srv.close(r))); beforeEach(() => tmux.__reset()); // A live claude pane whose mode line follows Shift+Tab around Claude Code's cycle. const LINES = { default: " ⏸ manual mode on · ← for agents", acceptEdits: " ⏵⏵ accept edits on (shift+tab to cycle) · ← for agents", plan: " ⏸ plan mode on (shift+tab to cycle) · ← for agents", auto: " ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents", }; function fakePane(start) { const cycle = ["default", "acceptEdits", "plan", "auto"]; const state = { mode: start, presses: 0 }; tmux.__setExecImpl((args) => { if (args[0] === "capture-pane") return `❯ \n${"─".repeat(20)}\n${LINES[state.mode]}\n`; if (args[0] === "send-keys" && args.at(-1) === "BTab") { state.presses++; state.mode = cycle[(cycle.indexOf(state.mode) + 1) % cycle.length]; } return ""; }); return state; } it("GET reads the mode off the pane", async () => { fakePane("plan"); const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode"); assert.equal(r.status, 200); assert.equal(r.body.mode, "plan"); }); it("POST presses Shift+Tab until the pane shows the mode asked for", async () => { const state = fakePane("default"); const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", { method: "POST", body: { mode: "auto" }, }); assert.equal(r.status, 200); assert.equal(r.body.mode, "auto"); assert.equal(state.presses, 3); }); it("POST of a mode not in the cycle ends where it started, and says so", async () => { const state = fakePane("acceptEdits"); const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", { method: "POST", body: { mode: "bypassPermissions" }, }); assert.equal(r.status, 409); assert.equal(r.body.error.code, "EMODEUNAVAILABLE"); assert.equal(state.mode, "acceptEdits"); }); it("POST presses nothing when the mode line cannot be read", async () => { let presses = 0; tmux.__setExecImpl((args) => { if (args[0] === "capture-pane") return "Select model\n❯ 1. Default\n"; if (args[0] === "send-keys") presses++; return ""; }); const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", { method: "POST", body: { mode: "plan" }, }); assert.equal(r.status, 409); assert.equal(r.body.error.code, "EMODEUNKNOWN"); assert.equal(presses, 0); }); it("POST refuses a mode outside the allowlist", async () => { fakePane("default"); const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", { method: "POST", body: { mode: "dontAsk; rm -rf ~" }, }); assert.equal(r.status, 400); assert.equal(r.body.error.code, "EBADMODE"); }); it("answers 404 for a pane that is not running", async () => { tmux.__setExecImpl((args) => { if (args[0] === "has-session") { const e = new Error("gone"); e.status = 1; throw e; } return ""; }); const r = await fetchJson("/api/run/agenthub-lane-999/permission-mode"); assert.equal(r.status, 404); }); }); describe("interrupted sessions", () => { let srv; before(async () => { srv = http.createServer(createApp()); await new Promise((r) => srv.listen(0, r)); BASE = `http://127.0.0.1:${srv.address().port}`; }); after(() => new Promise((r) => srv.close(r))); it("lists and dismisses, refusing a body that is not a list of ids", async () => { const list = await fetchJson("/api/run/interrupted"); assert.equal(list.status, 200); assert.ok(Array.isArray(list.body.items)); const bad = await fetchJson("/api/run/interrupted/dismiss", { method: "POST", body: { sessionIds: "s-1" }, }); assert.equal(bad.status, 400); assert.equal(bad.body.error.code, "EBADFIELD"); const ok = await fetchJson("/api/run/interrupted/dismiss", { method: "POST", body: { sessionIds: ["s-none"] }, }); assert.equal(ok.status, 200); assert.equal(ok.body.dismissed, 0); }); });