Files
AgentHub/server/__tests__/run.test.js
T
nntrivi2001 f6f1bf4a76 feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default,
  saved per account): the session view's tab bar holds every workspace's
  live sessions, a combo may mix them, and focusing a pane selects its
  workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once
  under `all` in agenthub.workspace.paneLayout.
- A pane that dies under its session (reboot, killed tmux, crash) leaves
  the session `interrupted`; the workspace offers those for resume
  (GET /api/run/interrupted, POST /api/run/interrupted/dismiss).
- Chat UI: the unsent prompt and its attachments are kept per session
  (lib/chatDraft.ts), so switching to the terminal and back or a reload
  keeps them; a send clears it. Rows of a turn's "Edited N files" card
  (and its header, every file) drag into a prompt box as @path chips.
- Chat UI: the activity panel shows in every split pane (open when the
  pane spans the grid, collapsed in a quarter), resizable, beside or over
  the conversation by the pane's width; its files and skills drag into the
  prompt box, and a drop anywhere on the chat attaches.
- Chat UI: a screenshot the session read outside the workspace previews
  (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text
  files are served back as text/plain; long prompts and answers are no
  longer cut at 10 KB (tool output still is); a new live session shows an
  empty chat until its first hook instead of "Session not found".
- Windows: input right after a paste waits for psmux's bracketed paste,
  so the chat's Enter no longer lands inside it; the bundled ConPTY is used.
- Changes panel: a Workspace context fold (instructions, files, folders).

Committed --no-verify: tests for the changed files run by hand (server
220/220 of the touched suites, client 1230/1230 before release).
2026-10-10 15:10:13 +07:00

491 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 link = path.join(
os.tmpdir(),
`agenthub-drops-${os.userInfo().uid}`,
"fedcba9876543210.png"
);
fs.rmSync(link, { force: true });
fs.symlinkSync("/etc/passwd", link);
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
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);
});
});