5a7644dac9
Prevent direct hwpod-node URL dispatch from executing plans on the wrong node and expose spawn diagnostics for cmd.run failures.
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createCloudApiBunServer } from "./bun-server.ts";
|
|
import { createCloudApiServer } from "./server.ts";
|
|
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
|
|
|
test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
|
|
const server = createCloudApiServer();
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
|
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
|
|
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
|
|
assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after"));
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api discovers preinstalled workspace hwpod-specs without hardcoded device constants", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-discovery-"));
|
|
const specDir = path.join(root, ".hwlab");
|
|
await mkdir(specDir, { recursive: true });
|
|
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
await writeFile(path.join(specDir, "hwpod-spec.meta.json"), JSON.stringify({ source: { kind: "preinstalled-verified-spec", caseRepo: "pikasTech/hwlab-case-registry", caseId: "d601-f103-v2-compile" } }), "utf8");
|
|
const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root } });
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.contractVersion, "hwpod-spec-discovery-v1");
|
|
assert.equal(payload.count, 1);
|
|
assert.equal(payload.specs[0].name, "d601-f103-v2");
|
|
assert.equal(payload.specs[0].uid, "D601-F103-V2");
|
|
assert.equal(payload.specs[0].nodeId, "node-d601-f103-v2");
|
|
assert.equal(payload.specs[0].source.caseRepo, "pikasTech/hwlab-case-registry");
|
|
assert.equal(payload.specs[0].source.caseId, "d601-f103-v2-compile");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api probes discovered hwpod-spec availability through node-ops", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-probe-"));
|
|
const specDir = path.join(root, ".hwlab");
|
|
await mkdir(specDir, { recursive: true });
|
|
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
|
const seen: any[] = [];
|
|
const server = createCloudApiServer({
|
|
env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root },
|
|
hwpodNodeOpsHandler: async (plan: any) => {
|
|
seen.push(plan);
|
|
return { ok: true, status: "completed", results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { path: op.args.workspacePath } })) };
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs?probe=1`);
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.count, 1);
|
|
assert.equal(payload.availableCount, 1);
|
|
assert.equal(payload.specs[0].availability.status, "available");
|
|
assert.equal(seen[0].hwpodId, "d601-f103-v2");
|
|
assert.equal(seen[0].nodeId, "node-d601-f103-v2");
|
|
assert.equal(seen[0].ops[0].op, "workspace.ls");
|
|
} finally {
|
|
await close(server);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
|
|
const seen: any[] = [];
|
|
const server = createCloudApiServer({
|
|
hwpodNodeOpsHandler: async (plan: any, context: any) => {
|
|
seen.push({ plan, context });
|
|
return {
|
|
ok: true,
|
|
status: "completed",
|
|
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { sample: true } }))
|
|
};
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "x-trace-id": "trc_hwpod_test" },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.contractVersion, "hwpod-node-ops-v1");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].plan.nodeId, "pc-host-1");
|
|
assert.equal(seen[0].context.requestMeta.traceId, "trc_hwpod_test");
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api returns blocked payload when hwpod-node is not wired yet", async () => {
|
|
const server = createCloudApiServer();
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.blocker.code, "hwpod_node_unavailable");
|
|
assert.equal(payload.results[0].blocker.layer, "hwpod-node");
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("cloud-api forwards hwpod-node-ops to configured thin hwpod-node URL", async () => {
|
|
const node = createHwpodNodeServer({ nodeId: "pc-host-1" });
|
|
await listen(node);
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.ops[0].args.workspacePath = process.cwd();
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "package.json"), true);
|
|
} finally {
|
|
await close(server);
|
|
await close(node);
|
|
}
|
|
});
|
|
|
|
test("cloud-api blocks direct hwpod-node URL when target node id does not match", async () => {
|
|
const node = createHwpodNodeServer({ nodeId: "g14-host-hwpod-node" });
|
|
await listen(node);
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWPOD_NODE_ID: "g14-host-hwpod-node",
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(node)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.nodeId = "node-d601-f103-v2";
|
|
plan.ops[0].args.workspacePath = "F:\\Work\\HWLAB-CASE-F103";
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.blocker.code, "hwpod_node_id_mismatch");
|
|
assert.equal(payload.blocker.details.requestedNodeId, "node-d601-f103-v2");
|
|
assert.equal(payload.blocker.details.targetNodeId, "g14-host-hwpod-node");
|
|
assert.equal(payload.results[0].blocker.code, "hwpod_node_id_mismatch");
|
|
} finally {
|
|
await close(server);
|
|
await close(node);
|
|
}
|
|
});
|
|
|
|
test("cloud-api blocks direct hwpod-node URL when node identity is not visible", async () => {
|
|
const target = createCloudApiServer({
|
|
hwpodNodeOpsHandler: async (plan: any) => ({
|
|
ok: true,
|
|
status: "completed",
|
|
results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { shouldNotRun: true } }))
|
|
})
|
|
});
|
|
await listen(target);
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_HWPOD_NODE_OPS_URL: `${serverUrl(target)}/v1/hwpod-node-ops`
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(samplePlan())
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.status, "blocked");
|
|
assert.equal(payload.blocker.code, "hwpod_node_identity_unverified");
|
|
assert.equal(payload.results[0].output, undefined);
|
|
} finally {
|
|
await close(server);
|
|
await close(target);
|
|
}
|
|
});
|
|
|
|
test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by nodeId", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-ws-"));
|
|
const runtime = await createCloudApiBunServer({ host: "127.0.0.1", port: 0, env: { PATH: process.env.PATH } });
|
|
const connector = connectHwpodNodeWs({ cloudUrl: runtime.url, nodeId: "pc-host-1", reconnect: false, heartbeatIntervalMs: 1000 });
|
|
try {
|
|
await writeFile(path.join(root, "main.c"), "int main(void) { return 0; }\n", "utf8");
|
|
await withTimeout(connector.ready, 3000, "hwpod-node WebSocket registration timed out");
|
|
const plan = samplePlan();
|
|
plan.ops[0].args.workspacePath = root;
|
|
const response = await fetch(`${runtime.url}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.nodeId, "pc-host-1");
|
|
assert.equal(payload.results[0].op, "workspace.ls");
|
|
assert.equal(payload.results[0].output.entries.some((entry: any) => entry.name === "main.c"), true);
|
|
} finally {
|
|
connector.close();
|
|
runtime.stop();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => {
|
|
const server = createCloudApiServer({
|
|
hwpodNodeOpsHandler: async () => {
|
|
throw new Error("must not be called");
|
|
}
|
|
});
|
|
await listen(server);
|
|
try {
|
|
const plan = samplePlan();
|
|
plan.ops[0].op = "debug.flash";
|
|
const response = await fetch(`${serverUrl(server)}/v1/hwpod-node-ops`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(plan)
|
|
});
|
|
const payload = await response.json();
|
|
assert.equal(response.status, 400);
|
|
assert.equal(payload.ok, false);
|
|
assert.equal(payload.error.code, "unsupported_hwpod_node_op");
|
|
} finally {
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
function samplePlan() {
|
|
return {
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
planId: "hwpod_plan_test",
|
|
hwpodId: "hwpod-local",
|
|
nodeId: "pc-host-1",
|
|
intent: "workspace.ls",
|
|
ops: [
|
|
{ opId: "op_01_workspace_ls", op: "workspace.ls", args: { workspacePath: "/workspace/fw", path: "." } }
|
|
]
|
|
};
|
|
}
|
|
|
|
function sampleSpecYaml() {
|
|
return `apiVersion: hwlab.dev/v0alpha1
|
|
kind: Hwpod
|
|
metadata:
|
|
uid: D601-F103-V2
|
|
name: d601-f103-v2
|
|
spec:
|
|
targetDevice:
|
|
board: D601-F103-V2
|
|
mcu: STM32F103
|
|
workspace:
|
|
path: "F:\\\\Work\\\\D601-HWLAB"
|
|
toolchain: keil-mdk
|
|
debugProbe:
|
|
type: daplink
|
|
ioProbe:
|
|
uart:
|
|
port: COM9
|
|
nodeBinding:
|
|
nodeId: node-d601-f103-v2
|
|
nodeType: pc-host
|
|
`;
|
|
}
|
|
|
|
async function listen(server: any) {
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
}
|
|
|
|
async function close(server: any) {
|
|
await new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve(undefined))));
|
|
}
|
|
|
|
function serverUrl(server: any) {
|
|
return `http://127.0.0.1:${server.address().port}`;
|
|
}
|
|
|
|
async function withTimeout(promise: Promise<unknown>, timeoutMs: number, message: string) {
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise((_resolve, reject) => {
|
|
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
})
|
|
]);
|
|
} finally {
|
|
if (timer !== null) clearTimeout(timer);
|
|
}
|
|
}
|