diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 2a6d4e65..e87186b8 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -8,6 +8,7 @@ const POST_PROXY_ROUTES = new Set([ "/v1/agent/chat/cancel", "/v1/agent/chat/steer", "/v1/agent/sessions", + "/v1/hwpod-node-ops", "/v1/web-performance", "/v1/m3/io", "/v1/skills/uploads" diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 79496123..e73c1296 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -141,6 +141,15 @@ test("cloud web proxies only supported authenticated device-pod write routes", ( }); }); +test("cloud web proxies authenticated hwpod node-ops route", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/hwpod-node-ops"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "POST /v1/hwpod-node-ops" + }); +}); + test("cloud web proxies authenticated account workbench write routes", () => { assert.deepEqual(cloudWebProxyRoutePolicy("PATCH", "/v1/workbench/workspace/ws_default"), { proxy: true, diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 5b2de712..281e1086 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -125,6 +125,70 @@ test("cloud web proxies Admin Access write routes and leaves legacy grants local } }); +test("cloud web forwards hwpod node-ops plans to cloud-api", async () => { + const upstreamRequests = []; + const upstream = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + upstreamRequests.push({ + method: request.method, + url: request.url, + authorization: request.headers.authorization, + body + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: false, status: "blocked", path: request.url, blocker: { code: "hwpod_node_unavailable" } })); + }); + await listen(upstream); + + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + cloudApiBaseUrl: serverUrl(upstream), + cloudApiProxyTimeoutMs: 1000, + healthPayload: () => ({ status: "ok" }), + roots: [], + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_proxy_test", + hwpodId: "hwpod-test", + nodeId: "node-test", + ops: [{ opId: "op_01", op: "node.health", args: {} }] + }; + const response = await fetch(`${serverUrl(cloudWeb)}/v1/hwpod-node-ops`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer hwl_live_operator" + }, + body: JSON.stringify(plan) + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: false, status: "blocked", path: "/v1/hwpod-node-ops", blocker: { code: "hwpod_node_unavailable" } }); + assert.equal(upstreamRequests.length, 1); + assert.deepEqual(upstreamRequests[0], { + method: "POST", + url: "/v1/hwpod-node-ops", + authorization: "Bearer hwl_live_operator", + body: JSON.stringify(plan) + }); + } finally { + await close(cloudWeb); + await close(upstream); + } +}); + test("cloud web serves access deep link through the React shell", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-")); await writeFile(path.join(root, "index.html"), "
\n", "utf8");