import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; import test from "node:test"; import { setTimeout as delay } from "node:timers/promises"; const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); test("gateway outbound transport registers session and keeps shell execution disabled by default", async () => { const cloud = await startFakeCloud(); const gateway = await startGateway(cloud.port, { HWLAB_GATEWAY_SESSION_ID: "gws_gateway_disabled_test", HWLAB_GATEWAY_CMD_EXEC_ENABLED: "" }); try { const registration = await cloud.waitForRegistration((item) => item.gatewaySessionId === "gws_gateway_disabled_test"); assert.equal(registration.serviceId, "hwlab-gateway"); assert.equal(registration.outbound.commandExecutionEnabled, false); const health = await fetchJson(`http://127.0.0.1:${gateway.port}/health/live`); assert.equal(health.serviceId, "hwlab-gateway"); assert.equal(health.gatewaySessionId, "gws_gateway_disabled_test"); const status = await fetchJson(`http://127.0.0.1:${gateway.port}/status`); assert.equal(status.outbound.commandExecutionEnabled, false); const capabilities = await fetchJson(`http://127.0.0.1:${gateway.port}/capabilities`); assert.equal(capabilities.capabilities[0].constraints.demoOpen, false); cloud.enqueue(shellRequest({ id: "req_disabled_shell", traceId: "trc_disabled_shell", command: "echo should-not-run" })); const result = await cloud.waitForResult("req_disabled_shell"); assert.equal(result.response.error.data.reason, "cmd_exec_disabled"); assert.equal(result.response.meta.environment, "v02"); assert.equal(result.gatewaySessionId, "gws_gateway_disabled_test"); } finally { await gateway.stop(); await cloud.stop(); } }); test("gateway outbound transport executes cloud requests without blocking poll loop", async () => { const cloud = await startFakeCloud(); const gateway = await startGateway(cloud.port, { HWLAB_GATEWAY_SESSION_ID: "gws_gateway_nonblocking_test", HWLAB_GATEWAY_CMD_EXEC_ENABLED: "1", HWLAB_GATEWAY_MAX_INFLIGHT: "2" }); try { await cloud.waitForRegistration((item) => item.gatewaySessionId === "gws_gateway_nonblocking_test"); cloud.enqueue(shellRequest({ id: "req_slow_shell", traceId: "trc_slow_shell", command: "node -e \"setTimeout(()=>console.log('slow-done'),800)\"" })); await cloud.waitForRegistration((item) => item.outbound.inflightCount >= 1); const quickStartedAt = Date.now(); cloud.enqueue(shellRequest({ id: "req_quick_shell", traceId: "trc_quick_shell", command: "echo quick-done" })); const quick = await cloud.waitForResult("req_quick_shell"); const quickDurationMs = Date.now() - quickStartedAt; const slow = await cloud.waitForResult("req_slow_shell"); assert.equal(quick.response.result.shellExecuted, true, JSON.stringify(quick.response, null, 2)); assert.equal(quick.response.result.exitCode, 0, JSON.stringify(quick.response, null, 2)); assert.equal(quick.response.meta.environment, "v02"); assert.equal(quick.response.result.environment, "v02"); assert.equal(quick.response.result.audit.environment, "v02"); assert.equal(quick.response.result.evidence.environment, "v02"); assert.match(quick.response.result.stdout, /quick-done/u); assert.equal(slow.response.result.shellExecuted, true, JSON.stringify(slow.response, null, 2)); assert.equal(slow.response.result.exitCode, 0, JSON.stringify(slow.response, null, 2)); assert.match(slow.response.result.stdout, /slow-done/u); assert.ok(quick.receivedAt < slow.receivedAt, "quick request should complete before the earlier slow request"); assert.ok(quickDurationMs < 700, `quick request was blocked for ${quickDurationMs}ms`); assert.ok(cloud.registrations.some((item) => item.outbound.inflightCount >= 1)); } finally { await gateway.stop(); await cloud.stop(); } }); async function startGateway(cloudPort: number, env: Record) { const port = await freePort(); const child = spawn(bunCommand, ["run", "cmd/hwlab-gateway/main.ts"], { cwd: process.cwd(), env: { ...process.env, ...env, PORT: String(port), HWLAB_GATEWAY_ID: env.HWLAB_GATEWAY_ID || "gtw_gateway_test", HWLAB_GATEWAY_CLOUD_URL: `http://127.0.0.1:${cloudPort}`, HWLAB_GATEWAY_POLL_INTERVAL_MS: "20", HWLAB_GATEWAY_CMD_TIMEOUT_MS: "3000", HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES: "4096", NO_PROXY: mergeNoProxy(process.env.NO_PROXY, "127.0.0.1,localhost,::1"), no_proxy: mergeNoProxy(process.env.no_proxy, "127.0.0.1,localhost,::1") }, stdio: ["ignore", "pipe", "pipe"] }); await waitForListening(child, "hwlab-gateway"); return { port, stop: () => stopChild(child) }; } async function startFakeCloud() { const requests: any[] = []; const registrations: any[] = []; const results: any[] = []; const server = createServer(async (request, response) => { try { const body = await readJson(request); if (request.method === "POST" && request.url === "/v1/gateway/poll") { registrations.push({ ...body, receivedAt: Date.now() }); sendJson(response, 200, { ok: true, request: requests.shift() ?? null }); return; } if (request.method === "POST" && request.url === "/v1/gateway/result") { results.push({ ...body, receivedAt: Date.now() }); sendJson(response, 200, { ok: true }); return; } sendJson(response, 404, { error: "not_found" }); } catch (error) { sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return { port: (server.address() as any).port as number, registrations, enqueue: (rpcRequest: any) => requests.push(rpcRequest), waitForRegistration: (predicate: (item: any) => boolean) => waitFor(() => registrations.find(predicate), "gateway registration"), waitForResult: (id: string) => waitFor(() => results.find((item) => item.response?.id === id), `gateway result ${id}`), stop: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) }; } function shellRequest({ id, traceId, command }: { id: string; traceId: string; command: string }) { return { jsonrpc: "2.0", id, method: "hardware.invoke.shell", params: { projectId: "prj_v02_hwpod", gatewaySessionId: "gws_gateway_test", resourceId: "res_d601_windows_host", capabilityId: "cap_d601_windows_cmd_exec", operationId: `op_${id}`, input: { command, cwd: process.cwd(), timeoutMs: 3000 } }, meta: { traceId, actorId: "svc_hwpod-node", serviceId: "hwlab-cloud-api", environment: "v02" } }; } async function waitFor(read: () => T | undefined, label: string, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = read(); if (value) return value; await delay(20); } throw new Error(`timed out waiting for ${label}`); } async function fetchJson(url: string) { const response = await fetch(url, { headers: { accept: "application/json" } }); assert.equal(response.ok, true, `${url} returned ${response.status}`); return response.json(); } function readJson(request: any) { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; request.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); request.on("end", () => { const text = Buffer.concat(chunks).toString("utf8").trim(); resolve(text ? JSON.parse(text) : {}); }); request.on("error", reject); }); } function sendJson(response: any, statusCode: number, body: any) { const payload = JSON.stringify(body); response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }); response.end(payload); } async function freePort() { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as any).port as number; await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); return port; } async function waitForListening(child: any, label: string) { let stderr = ""; child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} did not start: ${stderr}`)), 5000); child.stdout.on("data", (chunk: Buffer) => { if (chunk.toString("utf8").includes("listening")) { clearTimeout(timer); resolve(); } }); child.on("exit", (code: number) => { clearTimeout(timer); reject(new Error(`${label} exited early code=${code}: ${stderr}`)); }); }); } async function stopChild(child: any) { if (child.exitCode !== null || child.signalCode !== null) return; child.kill("SIGTERM"); await Promise.race([ new Promise((resolve) => child.on("exit", () => resolve())), delay(1000).then(() => child.kill("SIGKILL")) ]); } function mergeNoProxy(...values: Array) { return [...new Set(values.flatMap((value) => String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean)))].join(","); }