9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
277 lines
8.9 KiB
JavaScript
277 lines
8.9 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.
|
|
* @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");
|
|
|
|
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 });
|
|
});
|
|
}
|
|
);
|
|
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("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$/);
|
|
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
|
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(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
|
|
);
|
|
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);
|
|
});
|
|
});
|