Files
AgentHub/server/__tests__/run.test.js
T
nntrivi2001 5d8fc9bb98 docs+test: the young-run window in SERVER-MODE/LANES; run.test plants its link in the real drop dir
run.test.js hardcoded /tmp/agenthub-drops-<uid>, which drops left for
<data dir>/drops long ago — it passed only while an old build's folder
survived in /tmp (gone after a reboot). Full suites: server 2887/2887,
client 1250/1250.
2026-10-12 14:36:22 +07:00

489 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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ĩ <vinnt@smartgift.vn>
*/
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 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);
});
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("<script>x</script> 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: `<data dir>/drops` replaced `/tmp/agenthub-drops-<uid>`.
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);
});
});