feat: add outbound gateway demo

This commit is contained in:
lyon
2026-05-23 23:39:58 +08:00
parent 194ca572c7
commit ab063880bd
11 changed files with 1149 additions and 65 deletions
+3 -1
View File
@@ -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 只读观测
+454 -6
View File
@@ -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(",");
}
}
-47
View File
@@ -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` 保持短索引。过程记录不得被改写;只能把稳定结论蒸馏进这里。
@@ -29,7 +29,7 @@
## 验收标准
- `AGENTS.md` 能索引中文优先规则。
- `docs/reference/README.md` 索引和来源说明中文主导。
- 不再维护 `README.md` / `docs/reference/README.md` 入口;`AGENTS.md` 作为唯一入口时,其索引和来源说明必须中文主导。
- 新增或更新长期参考时,中文解释覆盖“做什么、为什么、怎么判定、禁止什么”。
- 必要英文术语保留精确拼写,但不能让文档主体变回英文。
+4 -4
View File
@@ -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 当前应用
+77
View File
@@ -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/resultgateway 调 `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 responsecloud-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` 长连接。
+169
View File
@@ -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
}
};
}
+67 -1
View File
@@ -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();
+161 -3
View File
@@ -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();
+4 -2
View File
File diff suppressed because one or more lines are too long
+209
View File
@@ -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);
}