Files
AgentHub/server/__tests__/run.test.js
T
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00

277 lines
9.1 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");
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 });
});
}
);
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$/);
// 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);
});
});