From ab063880bd887a3ea0b3af8fca4461dfd283cd97 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 23 May 2026 23:39:58 +0800 Subject: [PATCH] feat: add outbound gateway demo --- AGENTS.md | 4 +- cmd/hwlab-gateway/main.mjs | 460 +++++++++++++++++- docs/reference/README.md | 47 -- docs/reference/chinese-first-documentation.md | 2 +- docs/reference/documentation-governance.md | 8 +- docs/reference/gateway-outbound-demo.md | 77 +++ internal/cloud/gateway-demo-registry.mjs | 169 +++++++ internal/cloud/json-rpc.mjs | 68 ++- internal/cloud/server.mjs | 164 ++++++- package.json | 6 +- scripts/gateway-outbound-demo-smoke.mjs | 209 ++++++++ 11 files changed, 1149 insertions(+), 65 deletions(-) delete mode 100644 docs/reference/README.md create mode 100644 docs/reference/gateway-outbound-demo.md create mode 100644 internal/cloud/gateway-demo-registry.mjs create mode 100644 scripts/gateway-outbound-demo-smoke.mjs diff --git a/AGENTS.md b/AGENTS.md index 38faf63c..5e2c6435 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 ## 长期参考 -- 长期参考入口:[docs/reference/README.md](docs/reference/README.md) +- 唯一入口纪律:`AGENTS.md` 是 agent、指挥官和 runner 的唯一入口;不要新增、维护或引用 `README.md`、`docs/reference/README.md` 作为入口或索引,长期参考直接在本节索引。 - 中文优先规则:[docs/reference/chinese-first-documentation.md](docs/reference/chinese-first-documentation.md) - 用户反馈分流规则:[docs/reference/user-feedback-triage.md](docs/reference/user-feedback-triage.md) - 文档治理与 docs-spec 本地权威:[docs/reference/documentation-governance.md](docs/reference/documentation-governance.md) @@ -36,6 +36,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - DEV 运行态、端口、k3s 和 DB DNS 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md) - 部署正规化、`deploy.json` DEV CD 路径、SecretRef preflight、runner/host 边界和镜像发布:[docs/reference/deployment-publish.md](docs/reference/deployment-publish.md) - Code Agent 对话就绪与真实回复判定:[docs/reference/code-agent-chat-readiness.md](docs/reference/code-agent-chat-readiness.md) +- Gateway 主动出站 demo、poll/result 和本地 smoke:[docs/reference/gateway-outbound-demo.md](docs/reference/gateway-outbound-demo.md) - MVP E2E 验收测试与带编号测试报告 issue 规则:[docs/reference/MVP-e2e-acceptance.md](docs/reference/MVP-e2e-acceptance.md) - 指挥官协作、PR 和 runner 交接:[docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md) - M3 闭环发布运行手册:[docs/reference/m3-loop-rollout-runbook.md](docs/reference/m3-loop-rollout-runbook.md) @@ -59,6 +60,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - DEV CD 单事务发布/应用/验证:`npm run dev-cd:apply` - runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight` - D601 k3s 只读观测:`npm run d601:k3s:readonly` +- Gateway 主动出站本地 smoke:`npm run gateway:demo:smoke`;经本地 edge-proxy 验证用 `npm run gateway:demo:edge-smoke`。 ## D601 k3s 只读观测 diff --git a/cmd/hwlab-gateway/main.mjs b/cmd/hwlab-gateway/main.mjs index bdc0ed47..c9bebb8d 100644 --- a/cmd/hwlab-gateway/main.mjs +++ b/cmd/hwlab-gateway/main.mjs @@ -1,23 +1,471 @@ #!/usr/bin/env node +import os from "node:os"; +import { spawn } from "node:child_process"; + +import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs"; import { createGatewayState, createHealth } from "../../internal/sim/model.mjs"; -import { createJsonServer, listen, parsePort } from "../../internal/sim/http.mjs"; +import { createJsonServer, fetchJson, listen, parsePort } from "../../internal/sim/http.mjs"; +import { ERROR_CODES, JSON_RPC_VERSION, createRpcErrorBody } from "../../internal/protocol/index.mjs"; const gatewayId = process.env.HWLAB_GATEWAY_ID ?? "gateway_dev_stub"; +const gatewaySessionId = process.env.HWLAB_GATEWAY_SESSION_ID ?? `gws_${gatewayId}`; const port = parsePort(process.env.PORT, 7001); +const cloudUrl = trimUrl(process.env.HWLAB_GATEWAY_CLOUD_URL); +const pollIntervalMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_POLL_INTERVAL_MS, 500); +const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 10000); +const outputLimitBytes = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES, 65536); +const resourceId = process.env.HWLAB_GATEWAY_RESOURCE_ID ?? "res_windows_host"; +const boxId = process.env.HWLAB_GATEWAY_BOX_ID ?? "box_windows_host"; +const commandExecutionEnabled = truthy(process.env.HWLAB_GATEWAY_CMD_EXEC_ENABLED) || truthy(process.env.HWLAB_GATEWAY_DEMO_OPEN); const state = createGatewayState({ gatewayId, serviceId: "hwlab-gateway", - endpoint: `http://127.0.0.1:${port}`, - boxes: [] + endpoint: process.env.HWLAB_GATEWAY_ENDPOINT ?? `http://127.0.0.1:${port}`, + boxes: [resourceId] }); -state.session.status = "starting"; +state.session.gatewaySessionId = gatewaySessionId; +state.session.status = "connected"; +state.registry = { + gatewayId, + gatewaySessionId, + resourceId, + boxId, + capabilities: shellCapabilities() +}; +state.outbound = { + enabled: Boolean(cloudUrl), + cloudUrl, + pollIntervalMs, + commandExecutionEnabled, + lastPollAt: null, + lastPollError: null, + lastResultAt: null +}; const routes = new Map(); -routes.set("GET /health/live", () => createHealth({ serviceId: "hwlab-gateway", name: gatewayId })); +routes.set("GET /health/live", () => ({ + ...createHealth({ serviceId: "hwlab-gateway", name: gatewayId }), + gatewayId, + gatewaySessionId, + outboundConnected: Boolean(state.outbound.lastPollAt && !state.outbound.lastPollError) +})); routes.set("GET /status", () => ({ ...state, - note: "L2/L3 skeleton only; real hardware integration is intentionally absent for MVP simulator work." + note: "Outbound poll demo gateway; cloud dispatches through a gateway-initiated connection." +})); +routes.set("GET /capabilities", () => ({ + serviceId: "hwlab-gateway", + gatewayId, + gatewaySessionId, + resourceId, + boxId, + capabilities: shellCapabilities() })); listen(createJsonServer({ routes }), port); + +if (cloudUrl) { + ensureNoProxyForLocalCloud(cloudUrl); + schedulePoll(0); +} + +function schedulePoll(delayMs = pollIntervalMs) { + setTimeout(async () => { + await pollCloudOnce(); + schedulePoll(); + }, delayMs).unref?.(); +} + +async function pollCloudOnce() { + try { + const response = await fetchJson(new URL("/v1/gateway/poll", ensureTrailingSlash(cloudUrl)), { + method: "POST", + body: registrationPayload() + }); + if (!response.ok) { + throw new Error(`cloud poll failed: HTTP ${response.status}`); + } + state.outbound.lastPollAt = new Date().toISOString(); + state.outbound.lastPollError = null; + state.session.lastSeenAt = state.outbound.lastPollAt; + if (response.body?.request) { + await handleCloudRequest(response.body.request); + } + } catch (error) { + state.outbound.lastPollError = error instanceof Error ? error.message : String(error); + } +} + +async function handleCloudRequest(request) { + const context = operationContext({ + operationId: request.params?.operationId, + traceId: request.meta?.traceId, + requestId: request.id, + actorId: request.meta?.actorId ?? "svc_hwlab-cloud-api" + }, { + serviceId: "hwlab-gateway", + actorId: "svc_hwlab-gateway" + }); + + let response; + try { + if (request.method !== "hardware.invoke.shell") { + throw rpcError(ERROR_CODES.methodNotFound, `Gateway method ${request.method ?? "unknown"} is not implemented`, { + method: request.method ?? null + }); + } + const result = await invokeShell(request.params ?? {}, context); + response = { + jsonrpc: JSON_RPC_VERSION, + id: request.id, + result, + meta: responseMeta(context.traceId) + }; + } catch (error) { + const body = error?.body ?? createRpcErrorBody({ + code: error?.code ?? ERROR_CODES.hwlabUnknown, + message: error instanceof Error ? error.message : String(error), + data: error?.data ?? {} + }); + response = { + jsonrpc: JSON_RPC_VERSION, + id: request.id, + error: body.error, + meta: responseMeta(context.traceId) + }; + } + + await fetchJson(new URL("/v1/gateway/result", ensureTrailingSlash(cloudUrl)), { + method: "POST", + body: { + gatewayId, + gatewaySessionId, + response + } + }); + state.outbound.lastResultAt = new Date().toISOString(); +} + +async function invokeShell(params, context) { + if (!commandExecutionEnabled) { + throw rpcError(ERROR_CODES.operationRejected, "Gateway command execution is disabled", { + reason: "cmd_exec_disabled", + requiredEnv: "HWLAB_GATEWAY_CMD_EXEC_ENABLED=1" + }); + } + + const shellInput = normalizeShellInput(params); + const startedAt = Date.now(); + const execution = await executeCommand(shellInput, { + timeoutMs: shellInput.timeoutMs ?? commandTimeoutMs, + outputLimitBytes + }); + const durationMs = Date.now() - startedAt; + const now = new Date().toISOString(); + const status = execution.timedOut ? "timed_out" : execution.exitCode === 0 ? "succeeded" : "failed"; + const audit = createAuditEvent({ + serviceId: "hwlab-gateway", + action: "hardware.invoke.shell", + targetType: "box_resource", + targetId: params.resourceId ?? resourceId, + projectId: params.projectId, + gatewaySessionId, + operationId: context.operationId, + traceId: context.traceId, + actorType: "service", + actorId: "svc_hwlab-gateway", + outcome: status === "succeeded" ? "succeeded" : "failed", + metadata: { + gatewayId, + resourceId: params.resourceId ?? resourceId, + capabilityId: params.capabilityId ?? "cap_windows_cmd_exec", + command: redactCommand(shellInput.command), + cwd: execution.cwd, + exitCode: execution.exitCode, + timedOut: execution.timedOut, + durationMs + }, + occurredAt: now + }); + const evidence = createEvidenceRecord({ + serviceId: "hwlab-gateway", + projectId: params.projectId ?? "prj_mvp_topology", + operationId: context.operationId, + traceId: context.traceId, + kind: "trace", + payload: { + gatewayId, + gatewaySessionId, + resourceId: params.resourceId ?? resourceId, + capabilityId: params.capabilityId ?? "cap_windows_cmd_exec", + shell: execution + }, + metadata: { + gatewaySessionId, + producerServiceId: "hwlab-gateway" + }, + createdAt: now + }); + + state.lastDispatch = { + operationId: context.operationId, + traceId: context.traceId, + resourceId: params.resourceId ?? resourceId, + observedAt: now, + accepted: true, + status + }; + state.updatedAt = now; + + return { + accepted: true, + status, + serviceId: "hwlab-gateway", + gatewayId, + gatewaySessionId, + operationId: context.operationId, + traceId: context.traceId, + shellExecuted: true, + dispatchStatus: status, + ...execution, + durationMs, + auditId: audit.auditId, + evidenceId: evidence.evidenceId, + audit, + evidence, + gateway: { + gatewayId, + gatewaySessionId + } + }; +} + +function executeCommand(input, { timeoutMs, outputLimitBytes: limitBytes }) { + return new Promise((resolve) => { + const startedAt = new Date().toISOString(); + const shell = shellCommand(input.command); + const child = spawn(shell.command, shell.args, { + cwd: input.cwd, + windowsHide: true, + env: { + ...process.env, + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy + }, + stdio: ["ignore", "pipe", "pipe"] + }); + const stdout = createLimitedCollector(limitBytes); + const stderr = createLimitedCollector(limitBytes); + let timedOut = false; + let settled = false; + const timer = setTimeout(() => { + timedOut = true; + killProcessTree(child); + }, timeoutMs); + + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + shell: shell.command, + command: input.command, + cwd: input.cwd ?? process.cwd(), + startedAt, + completedAt: new Date().toISOString(), + exitCode: null, + signal: null, + timedOut, + stdout: stdout.text(), + stderr: stderr.text() || error.message, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated + }); + }); + child.on("close", (exitCode, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + shell: shell.command, + command: input.command, + cwd: input.cwd ?? process.cwd(), + startedAt, + completedAt: new Date().toISOString(), + exitCode, + signal, + timedOut, + stdout: stdout.text(), + stderr: stderr.text(), + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated + }); + }); + }); +} + +function shellCommand(command) { + if (process.platform === "win32") { + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", String(command)] + }; + } + return { + command: "sh", + args: ["-lc", String(command)] + }; +} + +function killProcessTree(child) { + if (!child.pid) return; + if (process.platform === "win32") { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + return; + } + child.kill("SIGTERM"); +} + +function createLimitedCollector(limitBytes) { + const chunks = []; + let size = 0; + let truncated = false; + return { + get truncated() { + return truncated; + }, + push(chunk) { + if (size >= limitBytes) { + truncated = true; + return; + } + const buffer = Buffer.from(chunk); + const available = limitBytes - size; + if (buffer.byteLength > available) { + chunks.push(buffer.subarray(0, available)); + size += available; + truncated = true; + return; + } + chunks.push(buffer); + size += buffer.byteLength; + }, + text() { + return Buffer.concat(chunks).toString("utf8"); + } + }; +} + +function registrationPayload() { + return { + serviceId: "hwlab-gateway", + projectId: process.env.HWLAB_PROJECT_ID ?? "prj_mvp_topology", + gatewayId, + gatewaySessionId, + endpoint: state.session.endpoint, + resourceId, + boxId, + capabilities: shellCapabilities(), + system: { + platform: process.platform, + arch: process.arch, + hostname: os.hostname(), + release: os.release() + } + }; +} + +function shellCapabilities() { + return [ + { + capabilityId: process.env.HWLAB_GATEWAY_CMD_CAPABILITY_ID ?? "cap_windows_cmd_exec", + resourceId, + projectId: process.env.HWLAB_PROJECT_ID ?? "prj_mvp_topology", + name: "shell.exec", + description: "Execute a local shell command through an outbound HWLAB gateway demo connection.", + direction: "bidirectional", + valueType: "object", + constraints: { + gatewayOutboundOnly: true, + demoOpen: truthy(process.env.HWLAB_GATEWAY_DEMO_OPEN) + }, + mutatesState: true + } + ]; +} + +function normalizeShellInput(params = {}) { + const input = params.input && typeof params.input === "object" && !Array.isArray(params.input) ? params.input : {}; + const command = params.command ?? input.command ?? input.shell?.command; + if (!command) { + throw rpcError(ERROR_CODES.invalidParams, "hardware.invoke.shell requires input.command", { + required: ["input.command"] + }); + } + return { + command: Array.isArray(command) ? command.map(String).join(" ") : String(command), + cwd: input.cwd ? String(input.cwd) : undefined, + timeoutMs: parsePositiveInteger(input.timeoutMs, commandTimeoutMs) + }; +} + +function responseMeta(traceId) { + return { + traceId, + serviceId: "hwlab-gateway", + environment: "dev" + }; +} + +function rpcError(code, message, data = {}) { + const error = new Error(message); + error.code = code; + error.data = data; + error.body = createRpcErrorBody({ code, message, data }); + return error; +} + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function truthy(value) { + return ["1", "true", "yes", "on", "enabled"].includes(String(value ?? "").trim().toLowerCase()); +} + +function trimUrl(value) { + const text = String(value ?? "").trim(); + return text ? text : null; +} + +function ensureTrailingSlash(url) { + return url.endsWith("/") ? url : `${url}/`; +} + +function redactCommand(command) { + const text = String(command ?? ""); + return text.length <= 200 ? text : `${text.slice(0, 200)}...`; +} + +function ensureNoProxyForLocalCloud(url) { + let host = ""; + try { + host = new URL(url).hostname; + } catch { + return; + } + const additions = new Set(["localhost", "127.0.0.1", "::1", host].filter(Boolean)); + for (const key of ["NO_PROXY", "no_proxy"]) { + const current = String(process.env[key] ?? ""); + const values = new Set(current.split(",").map((item) => item.trim()).filter(Boolean)); + for (const addition of additions) values.add(addition); + process.env[key] = [...values].join(","); + } +} diff --git a/docs/reference/README.md b/docs/reference/README.md deleted file mode 100644 index f8233271..00000000 --- a/docs/reference/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# HWLAB 长期参考入口 - -`docs/reference/` 是 HWLAB agent、指挥官和 runner 的长期参考目录,只记录长期稳定、可复用的规则、边界、入口、命令和验收标准。issue、日报、简报、一次性报告和执行流水账只能作为来源,不混入长期参考文档。 - -## 权威分工 - -| 规则 | 唯一权威出处 | 边界 | -| --- | --- | --- | -| `docs-spec` 本地权威、长期参考入库和过程文档蒸馏 | [documentation-governance.md](documentation-governance.md) | 该文件等价承担 `docs/reference/docs-spec.md` 的职责;其他 reference 只交叉引用,不另写一套文档治理规则。 | -| 中文优先的 issue、PR 和文档规则 | [chinese-first-documentation.md](chinese-first-documentation.md) | 英文标识可保真,解释、约束和验收口径必须中文化。 | -| 用户和参谋反馈默认高优先级分流 | [user-feedback-triage.md](user-feedback-triage.md) | `blocked`、等待上游或环境限制只描述状态,不能替代反馈优先级。 | -| 指挥官一手事实、真实推进和 PR 协作边界 | [commander-collaboration.md](commander-collaboration.md) | 本目录的文档治理规则不覆盖 [pikasTech/HWLAB#131](https://github.com/pikasTech/HWLAB/issues/131) 的指挥作风边界。 | - -## 参考索引 - -| 主题 | 参考文档 | -| --- | --- | -| 中文优先规则 | [chinese-first-documentation.md](chinese-first-documentation.md) | -| 用户反馈分流规则 | [user-feedback-triage.md](user-feedback-triage.md) | -| 文档治理与 docs-spec 本地权威 | [documentation-governance.md](documentation-governance.md) | -| 架构和 M3 上位约束 | [architecture.md](architecture.md) | -| DEV 运行态、端口、k3s、DB readiness 和环境边界 | [dev-runtime-boundary.md](dev-runtime-boundary.md) | -| 部署正规化、`deploy.json` DEV CD 路径、SecretRef preflight、runner/host 边界、artifact 发布和 Cloud Web rollout | [deployment-publish.md](deployment-publish.md) | -| Cloud Workbench 默认界面和 UX 边界 | [cloud-workbench.md](cloud-workbench.md) | -| Code Agent chat 同源通道 readiness 与真实回复判定 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) | -| MVP E2E 验收测试与带编号测试报告 issue 规则 | [MVP-e2e-acceptance.md](MVP-e2e-acceptance.md) | -| 指挥官/runner 协作、PR 和 prompt handoff | [commander-collaboration.md](commander-collaboration.md) | -| M3 闭环 rollout runbook | [m3-loop-rollout-runbook.md](m3-loop-rollout-runbook.md) | -| runner GitHub 可见性与 prompt handoff | [runner-issue-visibility-handoff.md](runner-issue-visibility-handoff.md) | - -## 当前稳定来源 - -- [pikasTech/HWLAB#7](https://github.com/pikasTech/HWLAB/issues/7):指挥官看板、优先级顺序和当前 issue 状态;用户反馈必须挂到这里的醒目位置。 -- [pikasTech/HWLAB#78](https://github.com/pikasTech/HWLAB/issues/78):`DC-DCSN-P0-2026-003`,M3 虚拟硬件可信闭环方向和上位约束。 -- [pikasTech/HWLAB#121](https://github.com/pikasTech/HWLAB/issues/121):issue 和长期文档中文化要求。 -- [pikasTech/HWLAB#122](https://github.com/pikasTech/HWLAB/issues/122):用户和参谋反馈默认按高优先级用户反馈处理,并挂到 `#7`。 -- [pikasTech/HWLAB#123](https://github.com/pikasTech/HWLAB/issues/123):docs-spec 规则必须固化进 HWLAB 长期参考文档;本仓库由 [documentation-governance.md](documentation-governance.md) 作为等价本地权威。 -- [pikasTech/HWLAB#131](https://github.com/pikasTech/HWLAB/issues/131):指挥作风纠偏、一手事实优先和真实推进规则。 -- [pikasTech/HWLAB#99](https://github.com/pikasTech/HWLAB/issues/99):Cloud Workbench 默认前端方向。 -- [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108):禁止外层页面纵向滚动、中文 UI 和内部 Markdown 帮助页。 -- [pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61):DEV 手动 rollout 复盘,以及走向 CLI 加 `deploy/deploy.json` 自动化的路径。 -- [pikasTech/HWLAB#109](https://github.com/pikasTech/HWLAB/issues/109):文档治理和长期参考体系。 -- [pikasTech/HWLAB#116](https://github.com/pikasTech/HWLAB/issues/116):服务部署正规化三阶段:长期参考、受控 CLI/脚本入口、UniDesk CI/CD 加镜像化交付。 -- [pikasTech/HWLAB#235](https://github.com/pikasTech/HWLAB/issues/235):Cloud API、DB、部署与运行态专题,承载 deploy.json、DEV CD、runtime 和 16666/16667 运行态收敛关系。 -- [pikasTech/HWLAB#340](https://github.com/pikasTech/HWLAB/issues/340):`deploy.json` DEV CD 路径长期化、master CLI wrapper、Secret preflight 和恢复后健康审计。 - -当 issue、报告或旧文档与本目录 reference 文档冲突时,先更新 reference 文档,再让 `AGENTS.md` 保持短索引。过程记录不得被改写;只能把稳定结论蒸馏进这里。 diff --git a/docs/reference/chinese-first-documentation.md b/docs/reference/chinese-first-documentation.md index 480dbcb7..f422f9d9 100644 --- a/docs/reference/chinese-first-documentation.md +++ b/docs/reference/chinese-first-documentation.md @@ -29,7 +29,7 @@ ## 验收标准 - `AGENTS.md` 能索引中文优先规则。 -- `docs/reference/README.md` 的索引和来源说明中文主导。 +- 不再维护 `README.md` / `docs/reference/README.md` 入口;`AGENTS.md` 作为唯一入口时,其索引和来源说明必须中文主导。 - 新增或更新长期参考时,中文解释覆盖“做什么、为什么、怎么判定、禁止什么”。 - 必要英文术语保留精确拼写,但不能让文档主体变回英文。 diff --git a/docs/reference/documentation-governance.md b/docs/reference/documentation-governance.md index 6f2d2a81..af91c5a3 100644 --- a/docs/reference/documentation-governance.md +++ b/docs/reference/documentation-governance.md @@ -6,12 +6,12 @@ ## AGENTS.md 规则 -- `AGENTS.md` 只作为项目级顶级索引,用于快速定位命令、入口和长期参考文档。 -- 与 `AGENTS.md` 同等作用的文档,例如 `CLAUDE.md`,只保留 `@AGENTS.md` 引导,避免多套口径漂移。 +- `AGENTS.md` 是 agent、指挥官和 runner 的唯一入口,用于快速定位命令、入口和长期参考文档。 +- 与 `AGENTS.md` 同等作用的文档,例如 `README.md`、`docs/reference/README.md`、`CLAUDE.md`,不得作为入口或索引;如果必须存在,只能说明应回到 `AGENTS.md`,避免多套口径漂移。 - 每个命令在 `AGENTS.md` 中只保留一条主索引;参数、背景、判定标准写入链接的 reference 文档。 - `AGENTS.md` 的主标题、章节名和列表摘要必须中文优先;`Agent`、`runner`、`Cloud Workbench`、命令和路径等可保留原文,但要放在中文语境中解释。 - 每个列表项只描述一个功能点,用一句中文概括,不在顶层展开实现细节。 -- `AGENTS.md` 必须索引中文优先、用户反馈分流、PR 工作流、`#78` 上位约束和 `docs/reference/` 入口。 +- `AGENTS.md` 必须直接索引中文优先、用户反馈分流、PR 工作流、`#78` 上位约束和各专项 `docs/reference/*.md`,不得再通过 README.md 二级入口跳转。 ## docs/reference 长期参考规则 @@ -44,7 +44,7 @@ - 本文保留外部 `docs-spec` 的完整核心规则,使没有 skill 可见性的 runner 仍能执行 HWLAB 文档治理;它是 `#123` 要求的 repo 内权威落点。 - 每次任务涉及 `AGENTS.md`、`docs/reference/*.md` 或过程文档蒸馏时,runner 应先读取外部 `docs-spec` skill;如果外部规则与本文不同,必须在同一 PR 中同步更新本文并说明差异。 - 如果外部 skill 不可读,按本文执行,并在 PR body 中说明“使用仓库内 docs-spec 固化规则,外部 skill 不可用或未校验”。 -- `docs/reference/README.md` 是长期参考入口;新增 reference 后必须更新该索引和 `AGENTS.md`。 +- 本仓库覆盖外部通用 docs-spec 中的 README 入口口径:新增 reference 后只更新对应 reference 和 `AGENTS.md`,不得新增或维护 `README.md` / `docs/reference/README.md` 入口。 ## HWLAB 当前应用 diff --git a/docs/reference/gateway-outbound-demo.md b/docs/reference/gateway-outbound-demo.md new file mode 100644 index 00000000..311f05c3 --- /dev/null +++ b/docs/reference/gateway-outbound-demo.md @@ -0,0 +1,77 @@ +# Gateway 主动出站 demo + +本文是 `hwlab-gateway` 最小主动出站闭环的长期参考。该 demo 只验证 gateway 主动连接 cloud、cloud 下发命令、gateway 执行并回传结果;不得把它当作 M3 `DEV-LIVE` 虚拟硬件可信闭环,也不得替代 `BOX-SIMU / Gateway-SIMU / hwlab-patch-panel` 的 M3 验收链路。 + +## 边界 + +- `hwlab-gateway` 运行在用户 PC 或本地环境,主动访问 `hwlab-cloud-api`;cloud 不需要也不应入站访问 gateway。 +- demo 采用普通 HTTP poll/result:gateway 调 `POST /v1/gateway/poll` 拉取命令,执行后调 `POST /v1/gateway/result` 回传 JSON-RPC response。 +- `hardware.invoke.shell` 仍是 cloud-api 对外 RPC 方法;有在线 gateway 时通过主动出站链路派发,没有在线 gateway 时保留 `not_connected` 降级返回。 +- 当前命令执行能力只用于受限 demo;正式真实硬件控制仍应沿用统一 capability、audit、evidence 和后续 WebSocket/设备身份设计。 + +## Cloud API 入口 + +- `GET /v1/gateway/sessions`:只读查看在线 gateway、队列深度和 last-seen。 +- `POST /v1/gateway/poll`:gateway 主动注册、心跳并领取下一条待执行 JSON-RPC 请求。 +- `POST /v1/gateway/result`:gateway 主动回传 JSON-RPC response,cloud-api 用 request id 完成等待中的 `hardware.invoke.shell`。 +- `POST /json-rpc` 或 `POST /v1/rpc/hardware.invoke.shell`:用户、agent 或 smoke 仍只调用 cloud-api,不直连 gateway。 + +## Gateway 环境变量 + +| 变量 | 作用 | +| --- | --- | +| `HWLAB_GATEWAY_CLOUD_URL` | cloud-api 或 edge-proxy 地址;本地可用 `http://127.0.0.1:6667`,DEV 可用 `http://74.48.78.17:16667`。 | +| `HWLAB_GATEWAY_ID` | gateway 稳定身份,例如 `gtw_windows_1`。 | +| `HWLAB_GATEWAY_SESSION_ID` | gateway session id;不填时默认为 `gws_${HWLAB_GATEWAY_ID}`。 | +| `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` | 允许执行 shell 命令;未设置时 gateway 拒绝 `hardware.invoke.shell`。 | +| `HWLAB_GATEWAY_DEMO_OPEN=1` | demo 明确打开标记;本地 smoke 会设置。 | +| `HWLAB_GATEWAY_POLL_INTERVAL_MS` | poll 间隔,默认 500ms。 | +| `HWLAB_GATEWAY_CMD_TIMEOUT_MS` | 单条命令超时,默认 10000ms。 | +| `HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES` | stdout/stderr 单路输出上限,默认 65536 bytes。 | + +本地或内网存在代理时,必须显式设置: + +```powershell +$env:NO_PROXY="localhost,127.0.0.1,::1" +$env:no_proxy="localhost,127.0.0.1,::1" +``` + +## 本地验证 + +优先使用一键 smoke,避免手动多终端状态不一致: + +```powershell +npm run gateway:demo:smoke +npm run gateway:demo:edge-smoke +``` + +- `gateway:demo:smoke` 启动本地 `hwlab-cloud-api` 和 `hwlab-gateway`,验证 `hardware.invoke.shell` 返回 `stdout=hwlab-demo`。 +- `gateway:demo:edge-smoke` 额外启动本地 `hwlab-edge-proxy`,验证普通 HTTP proxy 能转发 `/v1/gateway/poll`、`/v1/gateway/result` 和 `/json-rpc`。 +- 两个 smoke 都会设置 `NO_PROXY/no_proxy`,用于规避本地代理误触发。 + +## DEV 使用 + +DEV cloud-api 部署包含 `/v1/gateway/*` 后,Windows gateway 可用以下方式主动连接: + +```powershell +$env:HWLAB_GATEWAY_CLOUD_URL="http://74.48.78.17:16667" +$env:HWLAB_GATEWAY_ID="gtw_windows_1" +$env:HWLAB_GATEWAY_SESSION_ID="gws_gtw_windows_1" +$env:HWLAB_GATEWAY_CMD_EXEC_ENABLED="1" +$env:HWLAB_GATEWAY_DEMO_OPEN="1" +node .\cmd\hwlab-gateway\main.mjs +``` + +验证时从 cloud-api 侧调用 `hardware.invoke.shell`,不要尝试从 cloud 入站访问 Windows gateway。若 `GET /v1/gateway/sessions` 能看到对应 `gatewaySessionId` 且 JSON-RPC 返回 `dispatch.shellExecuted=true`,说明主动出站 demo 链路成立。 + +## 后续替换为 WebSocket + +HTTP poll 是最小 demo 形态。后续替换成 WebSocket 时,应保留: + +- `hardware.invoke.shell` 对外 RPC 方法名; +- JSON-RPC request/response envelope; +- gateway 执行器的 stdout/stderr/exitCode/timedOut 返回形态; +- audit/evidence 由 gateway/cloud 硬件通道产生的原则; +- cloud-web、agent 和 CLI 不直连 gateway 的边界。 + +只替换传输层:`/v1/gateway/poll` 和 `/v1/gateway/result` 合并到 gateway 主动建立的 `/v1/gateway/ws` 长连接。 diff --git a/internal/cloud/gateway-demo-registry.mjs b/internal/cloud/gateway-demo-registry.mjs new file mode 100644 index 00000000..68030aa8 --- /dev/null +++ b/internal/cloud/gateway-demo-registry.mjs @@ -0,0 +1,169 @@ +import { randomUUID } from "node:crypto"; + +import { ENVIRONMENT_DEV, JSON_RPC_VERSION } from "../protocol/index.mjs"; +import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; + +const DEFAULT_STALE_MS = 30000; +const DEFAULT_DISPATCH_TIMEOUT_MS = 30000; + +export function createGatewayDemoRegistry({ + now = () => new Date().toISOString(), + clock = () => Date.now(), + staleMs = DEFAULT_STALE_MS, + dispatchTimeoutMs = DEFAULT_DISPATCH_TIMEOUT_MS +} = {}) { + const sessions = new Map(); + const pending = new Map(); + + function updateSession(input = {}) { + const gatewaySessionId = input.gatewaySessionId || `gws_${input.gatewayId || "gateway_demo"}`; + const gatewayId = input.gatewayId || gatewaySessionId.replace(/^gws_/, "") || "gateway_demo"; + const previous = sessions.get(gatewaySessionId); + const observedAt = now(); + const session = { + ...(previous ?? {}), + serviceId: input.serviceId || "hwlab-gateway", + projectId: input.projectId || previous?.projectId || "prj_mvp_topology", + gatewayId, + gatewaySessionId, + status: "connected", + endpoint: input.endpoint ?? previous?.endpoint ?? null, + resourceId: input.resourceId ?? previous?.resourceId ?? "res_windows_host", + boxId: input.boxId ?? previous?.boxId ?? "box_windows_host", + capabilities: Array.isArray(input.capabilities) ? input.capabilities : previous?.capabilities ?? [], + system: input.system ?? previous?.system ?? {}, + firstSeenAt: previous?.firstSeenAt ?? observedAt, + lastSeenAt: observedAt, + lastSeenEpochMs: clock(), + queue: previous?.queue ?? [] + }; + sessions.set(gatewaySessionId, session); + return session; + } + + function isOnline(gatewaySessionId, { maxAgeMs = staleMs } = {}) { + const session = sessions.get(gatewaySessionId); + if (!session) return false; + return clock() - session.lastSeenEpochMs <= maxAgeMs; + } + + function enqueue({ gatewaySessionId, request, timeoutMs = dispatchTimeoutMs }) { + const session = sessions.get(gatewaySessionId); + if (!session || !isOnline(gatewaySessionId)) { + return Promise.resolve({ + ok: false, + status: "not_connected", + error: `gatewaySessionId ${gatewaySessionId} is not connected` + }); + } + + const requestId = request.id ?? `req_gateway_demo_${randomUUID()}`; + const outbound = { + ...request, + id: requestId + }; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(requestId); + resolve({ + ok: false, + status: "timed_out", + error: `gateway dispatch timed out after ${timeoutMs}ms`, + request: outbound + }); + }, timeoutMs); + + pending.set(requestId, { + gatewaySessionId, + resolve, + timer, + request: outbound, + createdAt: now() + }); + session.queue.push(outbound); + }); + } + + function nextRequest(gatewaySessionId) { + const session = sessions.get(gatewaySessionId); + return session?.queue.shift() ?? null; + } + + function complete(input = {}) { + const response = input.response ?? input; + const requestId = response.id; + const waiter = pending.get(requestId); + if (!waiter) { + return { + accepted: false, + status: "unknown_request", + requestId: requestId ?? null + }; + } + + clearTimeout(waiter.timer); + pending.delete(requestId); + waiter.resolve({ + ok: !response.error, + status: response.error ? "failed" : "completed", + response + }); + + return { + accepted: true, + status: "completed", + requestId + }; + } + + function describe() { + return { + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV, + mode: "gateway-outbound-poll-demo", + staleMs, + dispatchTimeoutMs, + sessions: [...sessions.values()].map((session) => ({ + serviceId: session.serviceId, + projectId: session.projectId, + gatewayId: session.gatewayId, + gatewaySessionId: session.gatewaySessionId, + status: isOnline(session.gatewaySessionId) ? "online" : "stale", + resourceId: session.resourceId, + boxId: session.boxId, + capabilityCount: session.capabilities.length, + queueDepth: session.queue.length, + firstSeenAt: session.firstSeenAt, + lastSeenAt: session.lastSeenAt, + system: session.system + })), + pendingCount: pending.size + }; + } + + return { + updateSession, + isOnline, + enqueue, + nextRequest, + complete, + describe, + getSession: (gatewaySessionId) => sessions.get(gatewaySessionId) ?? null + }; +} + +export function createGatewayShellRequest({ id, params = {}, meta = {} }) { + return { + jsonrpc: JSON_RPC_VERSION, + id: id ?? `req_gateway_shell_${randomUUID()}`, + method: "hardware.invoke.shell", + params, + meta: { + traceId: meta.traceId ?? params.traceId ?? `trc_gateway_shell_${randomUUID()}`, + actorId: meta.actorId, + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV + } + }; +} diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index 3559d4a9..df287dd3 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -19,6 +19,7 @@ import { describeCodeAgentAvailability } from "./code-agent-chat.mjs"; import { buildCloudApiReadiness } from "./health-contract.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; import { M3_IO_RPC_METHODS, handleM3IoRpc } from "./m3-io-control.mjs"; +import { createGatewayShellRequest } from "./gateway-demo-registry.mjs"; export const SUPPORTED_RPC_METHODS = Object.freeze([ "system.health", @@ -45,7 +46,7 @@ const rpcHandlers = new Map([ ["box.resource.register", async (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)], ["box.capability.report", async (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)], ["hardware.operation.request", async (params, envelope, context) => getRuntimeStore(context).requestHardwareOperation(params, envelope.meta)], - ["hardware.invoke.shell", async (params, envelope, context) => getRuntimeStore(context).invokeHardwareShell(params, envelope.meta)], + ["hardware.invoke.shell", handleHardwareInvokeShell], ["audit.event.write", async (params, envelope, context) => getRuntimeStore(context).writeAuditEvent(params, envelope.meta)], ["audit.event.query", async (params, envelope, context) => getRuntimeStore(context).queryAuditEvents(params)], ["evidence.record.write", async (params, envelope, context) => getRuntimeStore(context).writeEvidenceRecord(params, envelope.meta)], @@ -271,6 +272,71 @@ function getRuntimeStore(context = {}) { return context.runtimeStore || defaultRuntimeStore; } +async function handleHardwareInvokeShell(params, envelope, context) { + const registry = context.gatewayRegistry; + const gatewaySessionId = params.gatewaySessionId; + if (!registry?.isOnline?.(gatewaySessionId)) { + return getRuntimeStore(context).invokeHardwareShell(params, envelope.meta); + } + + const dispatch = await registry.enqueue({ + gatewaySessionId, + request: createGatewayShellRequest({ + id: envelope.id, + params, + meta: envelope.meta + }), + timeoutMs: parseDispatchTimeout(context.env ?? process.env) + }); + + if (!dispatch.ok) { + return { + accepted: true, + status: dispatch.status === "timed_out" ? "timed_out" : "accepted", + operationId: params.operationId ?? null, + gatewaySessionId, + resourceId: params.resourceId ?? null, + capabilityId: params.capabilityId ?? null, + dispatch: { + shellExecuted: false, + dispatchStatus: dispatch.status, + message: dispatch.error ?? dispatch.response?.error?.message ?? "gateway dispatch failed", + error: dispatch.response?.error ?? null + } + }; + } + + const gatewayResult = dispatch.response?.result ?? {}; + return { + accepted: gatewayResult.accepted !== false, + status: gatewayResult.status ?? statusFromShellResult(gatewayResult), + operationId: gatewayResult.operationId ?? params.operationId ?? null, + gatewaySessionId: gatewayResult.gatewaySessionId ?? gatewaySessionId, + resourceId: params.resourceId ?? null, + capabilityId: params.capabilityId ?? null, + dispatch: { + shellExecuted: gatewayResult.shellExecuted === true, + dispatchStatus: gatewayResult.dispatchStatus ?? statusFromShellResult(gatewayResult), + ...gatewayResult + }, + auditId: gatewayResult.auditId ?? null, + evidenceId: gatewayResult.evidenceId ?? null, + gateway: gatewayResult.gateway ?? null + }; +} + +function statusFromShellResult(result = {}) { + if (result.timedOut) return "timed_out"; + if (result.shellExecuted === false) return "rejected"; + if (Number.isInteger(result.exitCode)) return result.exitCode === 0 ? "succeeded" : "failed"; + return "completed"; +} + +function parseDispatchTimeout(env) { + const parsed = Number.parseInt(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 30000; +} + async function runtimeReadiness(runtimeStore) { if (typeof runtimeStore?.readiness === "function") { return runtimeStore.readiness(); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 8dd75d0c..ae8c65b5 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -32,15 +32,20 @@ import { handleM3IoControl } from "./m3-io-control.mjs"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs"; +import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; export function createCloudApiServer(options = {}) { const env = options.env ?? process.env; const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); + const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ + staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), + dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 30000) + }); return createServer(async (request, response) => { try { - await routeRequest(request, response, { ...options, env, runtimeStore }); + await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry }); } catch (error) { sendJson(response, 500, { error: { @@ -166,7 +171,8 @@ async function handleRpcHttpRequest(request, response, options) { runtimeStore: options.runtimeStore, env: options.env, dbProbe: options.dbProbe, - m3IoRequestJson: options.m3IoRequestJson + m3IoRequestJson: options.m3IoRequestJson, + gatewayRegistry: options.gatewayRegistry }); sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } @@ -212,6 +218,21 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") { + sendJson(response, 200, options.gatewayRegistry.describe()); + return; + } + + if (request.method === "POST" && url.pathname === "/v1/gateway/poll") { + await handleGatewayPollHttp(request, response, options); + return; + } + + if (request.method === "POST" && url.pathname === "/v1/gateway/result") { + await handleGatewayResultHttp(request, response, options); + return; + } + if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) { await handleM3IoControlHttp(request, response, options); return; @@ -282,13 +303,145 @@ async function handleRestAdapter(request, response, url, options) { runtimeStore: options.runtimeStore, env: options.env, dbProbe: options.dbProbe, - m3IoRequestJson: options.m3IoRequestJson + m3IoRequestJson: options.m3IoRequestJson, + gatewayRegistry: options.gatewayRegistry } ); sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } +async function handleGatewayPollHttp(request, response, options) { + const body = await readBody(request, options.bodyLimitBytes); + let params = {}; + + try { + params = body ? JSON.parse(body) : {}; + } catch (error) { + sendJson(response, 400, { + accepted: false, + error: { + code: "parse_error", + message: "Invalid JSON body", + reason: error.message + } + }); + return; + } + + if (!params || typeof params !== "object" || Array.isArray(params)) { + sendJson(response, 400, { + accepted: false, + error: { + code: "invalid_params", + message: "gateway poll body must be a JSON object" + } + }); + return; + } + + const session = options.gatewayRegistry.updateSession(params); + await persistGatewayDemoSession({ + runtimeStore: options.runtimeStore, + session, + request, + env: options.env + }); + const outbound = options.gatewayRegistry.nextRequest(session.gatewaySessionId); + sendJson(response, 200, { + accepted: true, + serviceId: CLOUD_API_SERVICE_ID, + mode: "gateway-outbound-poll-demo", + gatewayId: session.gatewayId, + gatewaySessionId: session.gatewaySessionId, + queueDepth: session.queue.length, + request: outbound, + type: outbound ? "request" : "noop", + observedAt: new Date().toISOString() + }); +} + +async function handleGatewayResultHttp(request, response, options) { + const body = await readBody(request, options.bodyLimitBytes); + let params = {}; + + try { + params = body ? JSON.parse(body) : {}; + } catch (error) { + sendJson(response, 400, { + accepted: false, + error: { + code: "parse_error", + message: "Invalid JSON body", + reason: error.message + } + }); + return; + } + + const result = options.gatewayRegistry.complete(params); + sendJson(response, result.accepted ? 200 : 404, { + ...result, + serviceId: CLOUD_API_SERVICE_ID, + mode: "gateway-outbound-poll-demo", + observedAt: new Date().toISOString() + }); +} + +async function persistGatewayDemoSession({ runtimeStore, session, request, env = process.env }) { + if (!runtimeStore) return; + const refreshMs = parsePositiveInteger(env.HWLAB_GATEWAY_REGISTRATION_REFRESH_MS, 60000); + if (session.runtimeRegisteredEpochMs && Date.now() - session.runtimeRegisteredEpochMs < refreshMs) { + return; + } + const meta = { + traceId: getHeader(request, "x-trace-id") || `trc_gateway_poll_${randomUUID()}`, + actorId: `svc_${session.serviceId}`, + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV + }; + + try { + await runtimeStore.registerGatewaySession?.({ + projectId: session.projectId, + gatewaySessionId: session.gatewaySessionId, + serviceId: "hwlab-gateway", + gatewayId: session.gatewayId, + endpoint: session.endpoint, + status: "connected", + labels: { + demoOutboundPoll: "true", + os: String(session.system?.platform ?? "unknown") + } + }, meta); + await runtimeStore.registerBoxResource?.({ + projectId: session.projectId, + gatewaySessionId: session.gatewaySessionId, + resourceId: session.resourceId, + boxId: session.boxId, + resourceType: "simulator_endpoint", + state: "available", + metadata: { + serviceId: "hwlab-gateway", + demoShellHost: true + } + }, meta); + if (session.capabilities.length > 0) { + await runtimeStore.reportBoxCapabilities?.({ + capabilities: session.capabilities.map((capability) => ({ + ...capability, + resourceId: capability.resourceId ?? session.resourceId, + projectId: capability.projectId ?? session.projectId + })) + }, meta); + } + session.runtimeRegisteredAt = new Date().toISOString(); + session.runtimeRegisteredEpochMs = Date.now(); + } catch { + // Demo polling must keep the gateway online even when durable runtime writes are not ready. + } +} + async function handleCodeAgentChatHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {}; @@ -499,6 +652,11 @@ function getHeader(request, name) { return value; } +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + async function runtimeReadiness(runtimeStore) { if (typeof runtimeStore?.readiness === "function") { return runtimeStore.readiness(); diff --git a/package.json b/package.json index 2f54bcea..7e7631b8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -43,7 +43,9 @@ "m3:io:e2e": "node scripts/m3-io-control-e2e.mjs", "web:layout": "node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json", "web:layout:build": "node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json", - "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json" + "web:layout:live": "node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json", + "gateway:demo:smoke": "node scripts/gateway-outbound-demo-smoke.mjs", + "gateway:demo:edge-smoke": "node scripts/gateway-outbound-demo-smoke.mjs --edge" }, "dependencies": { "@openai/codex": "^0.128.0", diff --git a/scripts/gateway-outbound-demo-smoke.mjs b/scripts/gateway-outbound-demo-smoke.mjs new file mode 100644 index 00000000..8bd78452 --- /dev/null +++ b/scripts/gateway-outbound-demo-smoke.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import assert from "node:assert/strict"; + +const cwd = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/u, "$1"); +const useEdgeProxy = process.argv.includes("--edge"); +const cloudPort = await freePort(); +const gatewayPort = await freePort(); +const edgePort = useEdgeProxy ? await freePort() : null; +const noProxy = mergeNoProxy(process.env.NO_PROXY, process.env.no_proxy, [ + "localhost", + "127.0.0.1", + "::1" +]); +const commonEnv = { + ...process.env, + NO_PROXY: noProxy, + no_proxy: noProxy, + HWLAB_CLOUD_RUNTIME_ADAPTER: "memory", + HWLAB_CLOUD_RUNTIME_DURABLE: "false" +}; +const children = []; + +try { + const cloud = startNode("cmd/hwlab-cloud-api/main.mjs", { + ...commonEnv, + HWLAB_CLOUD_API_HOST: "127.0.0.1", + HWLAB_CLOUD_API_PORT: String(cloudPort), + HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS: "10000" + }); + children.push(cloud); + await waitForJson(`http://127.0.0.1:${cloudPort}/health/live`); + + if (useEdgeProxy) { + const edge = startNode("cmd/hwlab-edge-proxy/main.mjs", { + ...commonEnv, + HWLAB_EDGE_HOST: "127.0.0.1", + HWLAB_EDGE_PORT: String(edgePort), + HWLAB_EDGE_UPSTREAM: `http://127.0.0.1:${cloudPort}` + }); + children.push(edge); + await waitForJson(`http://127.0.0.1:${edgePort}/health`); + } + + const apiBaseUrl = useEdgeProxy ? `http://127.0.0.1:${edgePort}` : `http://127.0.0.1:${cloudPort}`; + const gateway = startNode("cmd/hwlab-gateway/main.mjs", { + ...commonEnv, + PORT: String(gatewayPort), + HWLAB_GATEWAY_CLOUD_URL: apiBaseUrl, + HWLAB_GATEWAY_ID: "gtw_local_demo", + HWLAB_GATEWAY_SESSION_ID: "gws_gtw_local_demo", + HWLAB_GATEWAY_CMD_EXEC_ENABLED: "1", + HWLAB_GATEWAY_DEMO_OPEN: "1", + HWLAB_GATEWAY_POLL_INTERVAL_MS: "100", + HWLAB_GATEWAY_CMD_TIMEOUT_MS: "5000" + }); + children.push(gateway); + await waitForGatewaySession(apiBaseUrl, "gws_gtw_local_demo"); + + const rpc = await postJson(`${apiBaseUrl}/json-rpc`, { + jsonrpc: "2.0", + id: "req_gateway_outbound_demo_shell", + method: "hardware.invoke.shell", + params: { + projectId: "prj_mvp_topology", + gatewaySessionId: "gws_gtw_local_demo", + resourceId: "res_windows_host", + capabilityId: "cap_windows_cmd_exec", + input: { + command: "echo hwlab-demo", + cwd, + timeoutMs: 5000 + } + }, + meta: { + traceId: "trc_gateway_outbound_demo_shell", + actorId: "usr_gateway_outbound_demo", + serviceId: "hwlab-cloud-api", + environment: "dev" + } + }); + + assert.equal(rpc.error, undefined, JSON.stringify(rpc.error ?? {}, null, 2)); + assert.equal(rpc.result?.dispatch?.shellExecuted, true); + assert.equal(rpc.result?.dispatch?.exitCode, 0); + assert.match(rpc.result?.dispatch?.stdout ?? "", /hwlab-demo/u); + console.log(JSON.stringify({ + status: "pass", + mode: useEdgeProxy ? "edge-proxy" : "direct-cloud-api", + cloudUrl: `http://127.0.0.1:${cloudPort}`, + apiBaseUrl, + gatewaySessionId: "gws_gtw_local_demo", + stdout: rpc.result.dispatch.stdout.trim() + }, null, 2)); +} finally { + for (const child of children.reverse()) { + stopChild(child); + } +} + +function startNode(entrypoint, env) { + const child = spawn(process.execPath, [entrypoint], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"] + }); + child.output = ""; + child.stderrOutput = ""; + child.stdout.on("data", (chunk) => { + child.output += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + child.stderrOutput += chunk.toString("utf8"); + }); + child.on("exit", (code, signal) => { + child.exitInfo = { code, signal }; + }); + return child; +} + +async function waitForGatewaySession(apiBaseUrl, gatewaySessionId) { + const deadline = Date.now() + 8000; + let last; + while (Date.now() < deadline) { + try { + const body = await getJson(`${apiBaseUrl}/v1/gateway/sessions`); + last = body; + const session = body.sessions?.find((item) => item.gatewaySessionId === gatewaySessionId); + if (session?.status === "online") return session; + } catch (error) { + last = error; + } + await delay(100); + } + throw new Error(`gateway session ${gatewaySessionId} did not become online: ${stringifyLast(last)}`); +} + +async function waitForJson(url) { + const deadline = Date.now() + 8000; + let last; + while (Date.now() < deadline) { + try { + return await getJson(url); + } catch (error) { + last = error; + await delay(100); + } + } + throw new Error(`endpoint did not become ready: ${url}; last=${stringifyLast(last)}`); +} + +async function getJson(url) { + const response = await fetch(url, { + headers: { accept: "application/json" } + }); + const text = await response.text(); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`); + return text ? JSON.parse(text) : null; +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json" + }, + body: JSON.stringify(body) + }); + const text = await response.text(); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`); + return text ? JSON.parse(text) : null; +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + server.close(() => resolve(port)); + }); + }); +} + +function mergeNoProxy(...parts) { + const values = new Set(); + for (const part of parts.flat()) { + for (const item of String(part ?? "").split(",")) { + const trimmed = item.trim(); + if (trimmed) values.add(trimmed); + } + } + return [...values].join(","); +} + +function stopChild(child) { + if (!child.killed && child.exitCode === null) { + child.kill(); + } +} + +function stringifyLast(value) { + if (value instanceof Error) return value.message; + return JSON.stringify(value); +}