feat: add outbound gateway demo
This commit is contained in:
@@ -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
@@ -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(",");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 作为唯一入口时,其索引和来源说明必须中文主导。
|
||||
- 新增或更新长期参考时,中文解释覆盖“做什么、为什么、怎么判定、禁止什么”。
|
||||
- 必要英文术语保留精确拼写,但不能让文档主体变回英文。
|
||||
|
||||
|
||||
@@ -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 当前应用
|
||||
|
||||
|
||||
@@ -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` 长连接。
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user