cd427d218b
- Server tests on Windows no longer reach the user's real psmux: psmux
ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
the runner's kill-server ended every live lane pane — the pre-commit hook
included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
client, so xterm.js never reported the wheel and a pane could not scroll.
A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
-> brew upgrade tmux / build the release on Linux). Used by the doctor,
GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
sticky toast on the Workspace page. Advice only: nothing installs tmux.
Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
// 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);
|
||
// 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("<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);
|
||
});
|
||
});
|