From 7f030e0c4eb9569470e564a9a90921e4d8518f2c Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 04:30:54 +0000 Subject: [PATCH] feat: add M3 IO control surface path --- deploy/deploy.json | 4 + deploy/k8s/base/workloads.yaml | 16 + internal/cloud/m3-io-control.mjs | 1277 +++++++++++++++++ internal/cloud/m3-io-control.test.mjs | 442 ++++++ internal/cloud/server.mjs | 90 +- internal/cloud/server.test.mjs | 90 ++ package.json | 2 +- scripts/dev-artifact-publish.mjs | 8 +- web/hwlab-cloud-web/app.mjs | 268 +++- web/hwlab-cloud-web/help.md | 6 +- web/hwlab-cloud-web/index.html | 37 +- web/hwlab-cloud-web/scripts/check.mjs | 13 + .../scripts/m3-readonly-contract.mjs | 9 + web/hwlab-cloud-web/styles.css | 50 + 14 files changed, 2291 insertions(+), 21 deletions(-) create mode 100644 internal/cloud/m3-io-control.mjs create mode 100644 internal/cloud/m3-io-control.test.mjs diff --git a/deploy/deploy.json b/deploy/deploy.json index 42b58aec..c8372e3f 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -198,6 +198,10 @@ "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only", "HWLAB_CLOUD_RUNTIME_ADAPTER": "postgres", "HWLAB_CLOUD_RUNTIME_DURABLE": "true", + "HWLAB_M3_IO_CONTROL_ENABLED": "true", + "HWLAB_M3_GATEWAY_SIMU_1_URL": "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101", + "HWLAB_M3_GATEWAY_SIMU_2_URL": "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101", + "HWLAB_M3_PATCH_PANEL_URL": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301", "HWLAB_CODE_AGENT_PROVIDER": "openai", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 6022ca45..b38234cd 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -89,6 +89,22 @@ "name": "HWLAB_CLOUD_RUNTIME_DURABLE", "value": "true" }, + { + "name": "HWLAB_M3_IO_CONTROL_ENABLED", + "value": "true" + }, + { + "name": "HWLAB_M3_GATEWAY_SIMU_1_URL", + "value": "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101" + }, + { + "name": "HWLAB_M3_GATEWAY_SIMU_2_URL", + "value": "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101" + }, + { + "name": "HWLAB_M3_PATCH_PANEL_URL", + "value": "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" + }, { "name": "HWLAB_CODE_AGENT_PROVIDER", "value": "openai" diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs new file mode 100644 index 00000000..0170ad1e --- /dev/null +++ b/internal/cloud/m3-io-control.mjs @@ -0,0 +1,1277 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { + ENVIRONMENT_DEV, + ERROR_CODES, + HwlabProtocolError, + assertProtocolRecord +} from "../protocol/index.mjs"; +import { + CLOUD_API_SERVICE_ID, + createProtocolAuditEvent +} from "../audit/index.mjs"; + +export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1"; +export const M3_IO_CONTROL_ROUTE = "/v1/m3/io"; + +export const M3_IO_CHAIN = Object.freeze({ + projectId: "prj_m3_hardware_loop", + sourceGatewayId: "gwsimu_1", + sourceGatewaySessionId: "gws_gwsimu_1", + sourceResourceId: "res_boxsimu_1", + sourceBoxId: "boxsimu_1", + sourcePort: "DO1", + targetGatewayId: "gwsimu_2", + targetGatewaySessionId: "gws_gwsimu_2", + targetResourceId: "res_boxsimu_2", + targetBoxId: "boxsimu_2", + targetPort: "DI1", + patchPanelServiceId: "hwlab-patch-panel" +}); + +export const M3_IO_BLOCKER_CODES = Object.freeze({ + gatewayUnavailable: "m3_gateway_session_unavailable", + gatewayIdentityMismatch: "m3_gateway_identity_mismatch", + boxUnavailable: "m3_box_resource_unavailable", + portDirectionInvalid: "m3_port_direction_invalid", + wiringMissing: "m3_wiring_missing", + patchPanelUnavailable: "m3_patch_panel_unavailable", + dispatchFailed: "m3_gateway_dispatch_failed", + runtimeDurableBlocked: "runtime_durable_not_green" +}); + +const DEFAULT_TIMEOUT_MS = 2200; +const DEV_SERVICE_ENDPOINTS = Object.freeze({ + gateway1: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101", + gateway2: "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101", + patchPanel: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" +}); +const ACTIONS = new Set(["do.write", "di.read"]); + +export function describeM3IoControl(options = {}) { + const env = options.env ?? process.env; + const config = buildM3IoConfig(env); + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + route: M3_IO_CONTROL_ROUTE, + status: config.enabled ? "available" : "blocked", + sourceKind: "SOURCE", + chain: M3_IO_CHAIN, + actions: [ + { + action: "do.write", + label: "box-simu-1 DO1 write", + gatewayId: M3_IO_CHAIN.sourceGatewayId, + resourceId: M3_IO_CHAIN.sourceResourceId, + boxId: M3_IO_CHAIN.sourceBoxId, + port: M3_IO_CHAIN.sourcePort, + valueType: "boolean" + }, + { + action: "di.read", + label: "box-simu-2 DI1 read", + gatewayId: M3_IO_CHAIN.targetGatewayId, + resourceId: M3_IO_CHAIN.targetResourceId, + boxId: M3_IO_CHAIN.targetBoxId, + port: M3_IO_CHAIN.targetPort, + valueType: "boolean" + } + ], + boundaries: { + frontendCallsOnly: M3_IO_CONTROL_ROUTE, + cloudApiDispatchesToGateway: true, + patchPanelOwnsPropagation: true, + directFrontendGatewayOrBoxAccess: false, + genericHardwareRpcExposedToFrontend: false + }, + endpointsConfigured: { + gateway1: Boolean(config.gateway1Url), + gateway2: Boolean(config.gateway2Url), + patchPanel: Boolean(config.patchPanelUrl) + }, + blockedReason: config.enabled ? null : "M3 IO 控制未启用;前端保持 blocked,不直连 gateway/box-simu。" + }; +} + +export async function handleM3IoControl(params = {}, context = {}) { + const env = context.env ?? process.env; + const now = context.now?.() ?? new Date().toISOString(); + const config = buildM3IoConfig(env); + const action = normalizeAction(params.action ?? params.operation); + const meta = buildRequestMeta(params, context); + const runtimeBefore = await safeRuntimeSummary(context.runtimeStore); + + if (!config.enabled) { + return blockedResult({ + action, + meta, + runtime: runtimeBefore, + code: "m3_control_disabled", + reason: "M3 IO 控制未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。", + httpStatus: 503, + now + }); + } + + if (!ACTIONS.has(action)) { + throw new HwlabProtocolError("M3 IO action must be do.write or di.read", { + code: ERROR_CODES.invalidParams, + data: { + action, + allowedActions: [...ACTIONS] + } + }); + } + + const command = normalizeCommand(action, params); + const directionIssue = validateM3Command(command); + if (directionIssue) { + return blockedResult({ + action, + meta, + runtime: runtimeBefore, + code: M3_IO_BLOCKER_CODES.portDirectionInvalid, + reason: directionIssue, + httpStatus: 400, + now + }); + } + + const gatewayUrl = action === "do.write" ? config.gateway1Url : config.gateway2Url; + const gatewayRole = action === "do.write" ? "source" : "target"; + const gateway = await readGatewaySession(gatewayUrl, { + gatewayRole, + expectedGatewayId: command.gatewayId, + expectedGatewaySessionId: command.gatewaySessionId, + requestJson: context.requestJson, + timeoutMs: config.timeoutMs + }); + if (!gateway.available) { + return blockedResult({ + action, + command, + meta, + runtime: runtimeBefore, + code: gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable, + reason: gateway.reason, + httpStatus: 200, + now, + details: { + gateway + } + }); + } + + const box = findGatewayBox(gateway.status, command); + if (!box) { + return blockedResult({ + action, + command, + meta, + runtime: runtimeBefore, + code: M3_IO_BLOCKER_CODES.boxUnavailable, + reason: `${gatewayRole} gateway 已响应,但目标 box resource 未注册/不可用:${command.resourceId}`, + httpStatus: 200, + now, + details: { + gateway: publicGatewayStatus(gateway.status) + } + }); + } + + const capability = capabilityFor(command, box); + const operation = createOperationRecord({ + action, + command, + capability, + gatewaySessionId: gateway.gatewaySessionId, + meta, + now + }); + const registration = await persistRuntimeRegistrations({ + runtimeStore: context.runtimeStore, + gatewayStatus: gateway.status, + box, + capability, + command, + meta + }); + const operationWrite = await tryRuntimeWrite( + "hardware.operation.request", + () => context.runtimeStore?.requestHardwareOperation?.(operation, meta) + ); + + if (action === "di.read") { + return finalizeControlResult(await readDi({ + config, + command, + capability, + gateway, + operation, + meta, + runtimeStore: context.runtimeStore, + runtimeBefore, + registration, + operationWrite, + requestJson: context.requestJson, + now + })); + } + + return finalizeControlResult(await writeDo({ + config, + command, + capability, + gateway, + operation, + meta, + runtimeStore: context.runtimeStore, + runtimeBefore, + registration, + operationWrite, + requestJson: context.requestJson, + now + })); +} + +export function buildM3IoConfig(env = process.env) { + const enabledValue = String(env.HWLAB_M3_IO_CONTROL_ENABLED ?? env.HWLAB_M3_CONTROL_ENABLED ?? "true").toLowerCase(); + return { + enabled: !["0", "false", "no", "disabled"].includes(enabledValue), + gateway1Url: trimUrl(env.HWLAB_M3_GATEWAY_SIMU_1_URL) ?? DEV_SERVICE_ENDPOINTS.gateway1, + gateway2Url: trimUrl(env.HWLAB_M3_GATEWAY_SIMU_2_URL) ?? DEV_SERVICE_ENDPOINTS.gateway2, + patchPanelUrl: trimUrl(env.HWLAB_M3_PATCH_PANEL_URL) ?? DEV_SERVICE_ENDPOINTS.patchPanel, + timeoutMs: parseTimeout(env.HWLAB_M3_CONTROL_TIMEOUT_MS) + }; +} + +async function writeDo({ + config, + command, + capability, + gateway, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + requestJson, + now +}) { + const dispatch = await invokeGateway(gateway.url, { + operation: "hardware.port.write", + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId: gateway.gatewaySessionId, + resourceId: command.resourceId, + boxId: command.boxId, + capabilityId: capability.capabilityId, + port: command.port, + value: command.value, + operationId: operation.operationId, + traceId: meta.traceId, + requestId: meta.requestId, + actorType: "service", + actorId: `svc_${CLOUD_API_SERVICE_ID}`, + requestedBy: meta.actorId, + source: "cloud-api-m3-io-control" + }, { requestJson, timeoutMs: config.timeoutMs }); + + if (!dispatch.ok || dispatch.body?.accepted !== true) { + return blockedControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + code: M3_IO_BLOCKER_CODES.dispatchFailed, + reason: `gateway-simu DO 写入失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`, + gateway, + dispatch, + now + }); + } + + const patchPanel = await tickPatchPanel(config.patchPanelUrl, { + signals: [ + { + fromResourceId: M3_IO_CHAIN.sourceResourceId, + fromPort: M3_IO_CHAIN.sourcePort, + value: command.value, + operationId: operation.operationId, + traceId: meta.traceId + } + ] + }, { requestJson, timeoutMs: config.timeoutMs }); + const patchIssue = classifyPatchPanelResult(patchPanel); + if (patchIssue) { + return blockedControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + code: patchIssue.code, + reason: patchIssue.reason, + gateway, + dispatch, + patchPanel, + now + }); + } + + const targetReadback = await readTargetDiAfterWrite({ + config, + operation, + meta, + requestJson + }); + if (targetReadback.status !== "succeeded" || targetReadback.value !== command.value) { + return blockedControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + code: targetReadback.code ?? M3_IO_BLOCKER_CODES.wiringMissing, + reason: targetReadback.reason ?? `DI1 回读值 ${String(targetReadback.value)} 与 DO1 写入值 ${String(command.value)} 不一致;不能证明 M3 DO->DI 闭环。`, + gateway, + dispatch, + patchPanel, + targetReadback, + now + }); + } + const tracePayload = { + action: command.action, + command, + gateway: gatewayResultSummary(gateway), + dispatch: dispatch.body, + patchPanel: patchPanel.body, + targetReadback + }; + + return successControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + gateway, + dispatch, + patchPanel, + targetReadback, + tracePayload, + now + }); +} + +async function readDi({ + config, + command, + capability, + gateway, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + requestJson, + now +}) { + const dispatch = await invokeGateway(gateway.url, { + operation: "hardware.port.read", + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId: gateway.gatewaySessionId, + resourceId: command.resourceId, + boxId: command.boxId, + capabilityId: capability.capabilityId, + port: command.port, + operationId: operation.operationId, + traceId: meta.traceId, + requestId: meta.requestId, + actorType: "service", + actorId: `svc_${CLOUD_API_SERVICE_ID}`, + requestedBy: meta.actorId, + source: "cloud-api-m3-io-control" + }, { requestJson, timeoutMs: config.timeoutMs }); + + if (!dispatch.ok || dispatch.body?.accepted !== true) { + return blockedControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + code: M3_IO_BLOCKER_CODES.dispatchFailed, + reason: `gateway-simu DI 读取失败:${dispatch.error ?? dispatch.body?.error?.message ?? "unknown"}`, + gateway, + dispatch, + now + }); + } + + const tracePayload = { + action: command.action, + command, + gateway: gatewayResultSummary(gateway), + dispatch: dispatch.body, + value: readValueFromGatewayResult(dispatch.body) + }; + + return successControlResult({ + action: command.action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + gateway, + dispatch, + tracePayload, + now + }); +} + +async function readTargetDiAfterWrite({ config, operation, meta, requestJson }) { + const gateway = await readGatewaySession(config.gateway2Url, { + gatewayRole: "target-readback", + expectedGatewayId: M3_IO_CHAIN.targetGatewayId, + expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId, + requestJson, + timeoutMs: config.timeoutMs + }); + if (!gateway.available) { + return { + status: "blocked", + code: gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable, + reason: gateway.reason + }; + } + const command = normalizeCommand("di.read", {}); + const box = findGatewayBox(gateway.status, command); + if (!box) { + return { + status: "blocked", + code: M3_IO_BLOCKER_CODES.boxUnavailable, + reason: `target gateway 已响应,但 ${M3_IO_CHAIN.targetResourceId} 未注册/不可用` + }; + } + const capability = capabilityFor(command, box); + const dispatch = await invokeGateway(gateway.url, { + operation: "hardware.port.read", + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId: gateway.gatewaySessionId, + resourceId: command.resourceId, + boxId: command.boxId, + capabilityId: capability.capabilityId, + port: command.port, + operationId: operation.operationId, + traceId: meta.traceId, + requestId: meta.requestId, + actorType: "service", + actorId: `svc_${CLOUD_API_SERVICE_ID}`, + requestedBy: meta.actorId, + source: "cloud-api-m3-io-control" + }, { requestJson, timeoutMs: config.timeoutMs }); + + return dispatch.ok && dispatch.body?.accepted === true + ? { + status: "succeeded", + value: readValueFromGatewayResult(dispatch.body), + gatewaySessionId: gateway.gatewaySessionId, + resourceId: command.resourceId, + port: command.port, + result: dispatch.body + } + : { + status: "blocked", + code: M3_IO_BLOCKER_CODES.dispatchFailed, + reason: dispatch.error ?? dispatch.body?.error?.message ?? "target DI readback failed" + }; +} + +function normalizeAction(action) { + return String(action ?? "").trim().toLowerCase(); +} + +function normalizeCommand(action, params = {}) { + if (action === "do.write") { + return { + action, + gatewayId: params.gatewayId ?? params.sourceGatewayId ?? M3_IO_CHAIN.sourceGatewayId, + gatewaySessionId: params.gatewaySessionId ?? M3_IO_CHAIN.sourceGatewaySessionId, + resourceId: params.resourceId ?? M3_IO_CHAIN.sourceResourceId, + boxId: params.boxId ?? M3_IO_CHAIN.sourceBoxId, + port: normalizePort(params.port ?? M3_IO_CHAIN.sourcePort), + value: normalizeBoolean(params.value) + }; + } + + return { + action, + gatewayId: params.gatewayId ?? params.targetGatewayId ?? M3_IO_CHAIN.targetGatewayId, + gatewaySessionId: params.gatewaySessionId ?? M3_IO_CHAIN.targetGatewaySessionId, + resourceId: params.resourceId ?? M3_IO_CHAIN.targetResourceId, + boxId: params.boxId ?? M3_IO_CHAIN.targetBoxId, + port: normalizePort(params.port ?? M3_IO_CHAIN.targetPort) + }; +} + +function validateM3Command(command) { + if (command.action === "do.write") { + if (command.gatewayId !== M3_IO_CHAIN.sourceGatewayId || command.resourceId !== M3_IO_CHAIN.sourceResourceId || command.port !== M3_IO_CHAIN.sourcePort) { + return `M3 只允许 ${M3_IO_CHAIN.sourceGatewayId}/${M3_IO_CHAIN.sourceResourceId}/${M3_IO_CHAIN.sourcePort} 执行 DO write;当前请求会造成端口方向或资源越界。`; + } + if (typeof command.value !== "boolean") { + return "M3 DO1 写入值必须是 boolean。"; + } + return null; + } + + if (command.action === "di.read") { + if (command.gatewayId !== M3_IO_CHAIN.targetGatewayId || command.resourceId !== M3_IO_CHAIN.targetResourceId || command.port !== M3_IO_CHAIN.targetPort) { + return `M3 只允许 ${M3_IO_CHAIN.targetGatewayId}/${M3_IO_CHAIN.targetResourceId}/${M3_IO_CHAIN.targetPort} 执行 DI read;当前请求会造成端口方向或资源越界。`; + } + } + return null; +} + +function normalizePort(port) { + return String(port ?? "").trim().toUpperCase(); +} + +function normalizeBoolean(value) { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return value; +} + +function buildRequestMeta(params = {}, context = {}) { + return { + traceId: params.traceId || context.traceId || `trc_${randomUUID()}`, + requestId: params.requestId || context.requestId || `req_${randomUUID()}`, + actorType: "user", + actorId: params.actorId || context.actorId || "usr_hwlab_cloud_web", + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV + }; +} + +function createOperationRecord({ action, command, capability, gatewaySessionId, meta, now }) { + const operationId = command.operationId || `op_m3_${action.replace(".", "_")}_${randomUUID()}`; + const operation = { + operationId, + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId, + resourceId: command.resourceId, + capabilityId: capability.capabilityId, + requestedBy: meta.actorId, + input: { + action, + port: command.port, + ...(Object.hasOwn(command, "value") ? { value: command.value } : {}), + route: { + from: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}`, + via: M3_IO_CHAIN.patchPanelServiceId, + to: `${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}` + }, + frontendBypass: false + }, + status: "accepted", + environment: ENVIRONMENT_DEV, + requestedAt: now, + updatedAt: now + }; + assertProtocolRecord("hardwareOperation", operation); + return operation; +} + +function capabilityFor(command, box = {}) { + const suffix = command.action === "do.write" ? "write" : "read"; + const portPart = command.port.toLowerCase(); + const boxId = box.boxId ?? command.boxId; + return { + capabilityId: `cap_${boxId}_${portPart}_${suffix}`, + resourceId: command.resourceId, + projectId: M3_IO_CHAIN.projectId, + name: command.action, + description: `M3 ${command.action} ${command.resourceId}:${command.port}`, + direction: command.action === "do.write" ? "output" : "input", + valueType: "boolean", + constraints: { + port: command.port, + portFamily: command.action === "do.write" ? "DO" : "DI", + patchPanelOnlyPropagation: true, + frontendBypassAllowed: false + }, + mutatesState: command.action === "do.write", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; +} + +async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expectedGatewaySessionId, requestJson, timeoutMs }) { + if (!url) { + return { + available: false, + role: gatewayRole, + code: M3_IO_BLOCKER_CODES.gatewayUnavailable, + reason: "gateway 未注册/不可用:cloud-api 未配置 gateway-simu endpoint" + }; + } + const response = await requestRuntimeJson(new URL("/status", ensureTrailingSlash(url)).toString(), { + method: "GET", + requestJson, + timeoutMs + }); + if (!response.ok) { + return { + available: false, + role: gatewayRole, + url: redactUrl(url), + code: M3_IO_BLOCKER_CODES.gatewayUnavailable, + reason: `gateway 未注册/不可用:${response.error ?? `HTTP ${response.status}`}` + }; + } + const status = response.body ?? {}; + const gatewaySessionId = status.session?.gatewaySessionId ?? status.gatewaySessionId ?? status.registry?.gatewaySessionId; + const gatewayId = status.gatewayId ?? status.session?.gatewayId ?? status.registry?.gatewayId; + if (!gatewaySessionId || !gatewayId) { + return { + available: false, + role: gatewayRole, + url: redactUrl(url), + status, + code: M3_IO_BLOCKER_CODES.gatewayUnavailable, + reason: "gateway 未注册/不可用:/status 未返回 gatewaySessionId/gatewayId" + }; + } + if (expectedGatewayId && gatewayId !== expectedGatewayId) { + return { + available: false, + role: gatewayRole, + url: redactUrl(url), + status, + code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch, + reason: `gateway-simu identity mismatch:expected gatewayId=${expectedGatewayId},actual=${gatewayId};indexed simulator identity drift must fail closed.` + }; + } + if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) { + return { + available: false, + role: gatewayRole, + url: redactUrl(url), + status, + code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch, + reason: `gateway-simu identity mismatch:expected gatewaySessionId=${expectedGatewaySessionId},actual=${gatewaySessionId};indexed simulator identity drift must fail closed.` + }; + } + return { + available: true, + role: gatewayRole, + url, + gatewayId, + gatewaySessionId, + status + }; +} + +function findGatewayBox(gatewayStatus, command) { + const boxes = [ + ...(Array.isArray(gatewayStatus?.registry?.boxes) ? gatewayStatus.registry.boxes : []), + ...(Array.isArray(gatewayStatus?.boxes) + ? gatewayStatus.boxes.map((item) => typeof item === "string" ? { resourceId: item } : item) + : []) + ]; + return boxes.find((box) => box?.resourceId === command.resourceId || box?.boxId === command.boxId) ?? null; +} + +async function invokeGateway(gatewayUrl, body, options) { + return requestRuntimeJson(new URL("/invoke", ensureTrailingSlash(gatewayUrl)).toString(), { + method: "POST", + body, + requestJson: options.requestJson, + timeoutMs: options.timeoutMs + }); +} + +async function tickPatchPanel(patchPanelUrl, body, options) { + if (!patchPanelUrl) { + return { + ok: false, + status: 0, + error: "patch-panel endpoint is not configured" + }; + } + return requestRuntimeJson(new URL("/sync/tick", ensureTrailingSlash(patchPanelUrl)).toString(), { + method: "POST", + body, + requestJson: options.requestJson, + timeoutMs: options.timeoutMs + }); +} + +function classifyPatchPanelResult(patchPanel) { + if (!patchPanel.ok) { + return { + code: M3_IO_BLOCKER_CODES.patchPanelUnavailable, + reason: `hwlab-patch-panel 不可用:${patchPanel.error ?? `HTTP ${patchPanel.status}`}` + }; + } + if (patchPanel.body?.accepted !== true) { + const diagnostic = firstDiagnostic(patchPanel.body); + return { + code: diagnostic?.code === "target_endpoint_missing" ? M3_IO_BLOCKER_CODES.boxUnavailable : M3_IO_BLOCKER_CODES.patchPanelUnavailable, + reason: diagnostic?.message ?? "hwlab-patch-panel 拒绝同步;请检查 wiring 和 endpointMap。" + }; + } + if (!hasExpectedPatchDelivery(patchPanel.body)) { + return { + code: M3_IO_BLOCKER_CODES.wiringMissing, + reason: `hwlab-patch-panel 未路由 ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort};wiring 不存在或不是 active。` + }; + } + return null; +} + +function hasExpectedPatchDelivery(body) { + const routes = Array.isArray(body?.routes) ? body.routes : []; + return routes.some((route) => + (route.deliveries ?? []).some((delivery) => + delivery.sourceResourceId === M3_IO_CHAIN.sourceResourceId && + delivery.sourcePort === M3_IO_CHAIN.sourcePort && + delivery.resourceId === M3_IO_CHAIN.targetResourceId && + delivery.port === M3_IO_CHAIN.targetPort && + delivery.deliveryStatus !== "failed" + ) + ); +} + +function firstDiagnostic(body) { + return [ + ...(Array.isArray(body?.diagnostics) ? body.diagnostics : []), + ...(Array.isArray(body?.routes) ? body.routes.flatMap((route) => route.diagnostics ?? []) : []) + ][0] ?? null; +} + +async function requestRuntimeJson(url, { method = "GET", body, requestJson, timeoutMs = DEFAULT_TIMEOUT_MS }) { + if (requestJson) { + try { + const result = await requestJson(url, { method, body, timeoutMs }); + return normalizeRuntimeJsonResponse(result); + } catch (error) { + return { + ok: false, + status: 0, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method, + headers: body === undefined + ? { accept: "application/json" } + : { + accept: "application/json", + "content-type": "application/json" + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const text = await response.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + return { + ok: response.ok, + status: response.status, + body: parsed, + error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText + }; + } catch (error) { + return { + ok: false, + status: 0, + error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message + }; + } finally { + clearTimeout(timer); + } +} + +function normalizeRuntimeJsonResponse(result = {}) { + if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) { + return { + ok: result.ok ?? (Number(result.status ?? 200) >= 200 && Number(result.status ?? 200) < 300), + status: result.status ?? 200, + body: result.body ?? result.json ?? null, + error: result.error ?? result.body?.error?.message ?? null + }; + } + if (Object.hasOwn(result, "status") || Object.hasOwn(result, "json")) { + const status = result.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + body: result.json ?? null, + error: result.error ?? null + }; + } + return { + ok: true, + status: 200, + body: result + }; +} + +async function persistRuntimeRegistrations({ runtimeStore, gatewayStatus, box, capability, command, meta }) { + const gatewaySessionId = gatewayStatus.session?.gatewaySessionId ?? gatewayStatus.gatewaySessionId ?? gatewayStatus.registry?.gatewaySessionId ?? command.gatewaySessionId; + const gatewayId = gatewayStatus.gatewayId ?? gatewayStatus.session?.gatewayId ?? gatewayStatus.registry?.gatewayId ?? command.gatewayId; + const gatewaySession = { + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId, + gatewayId, + serviceId: "hwlab-gateway-simu", + endpoint: gatewayStatus.registry?.endpoint ?? gatewayStatus.session?.endpoint, + status: "connected", + labels: { + m3ControlSurface: "true" + } + }; + const resource = { + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId, + resourceId: command.resourceId, + boxId: box.boxId ?? command.boxId, + resourceType: "simulator_endpoint", + state: "available", + metadata: { + serviceId: "hwlab-box-simu", + patchPanelOnlyPropagation: true, + frontendBypassAllowed: false + } + }; + const writes = []; + writes.push(await tryRuntimeWrite("gateway.session.register", () => runtimeStore?.registerGatewaySession?.(gatewaySession, meta))); + writes.push(await tryRuntimeWrite("box.resource.register", () => runtimeStore?.registerBoxResource?.(resource, meta))); + writes.push(await tryRuntimeWrite("box.capability.report", () => runtimeStore?.reportBoxCapabilities?.({ capability }, meta))); + return writes; +} + +async function tryRuntimeWrite(label, write) { + if (typeof write !== "function") { + return { + label, + written: false, + skipped: true, + reason: "runtimeStore method unavailable" + }; + } + try { + const result = await write(); + return { + label, + written: true, + result + }; + } catch (error) { + return { + label, + written: false, + reason: error instanceof Error ? error.message : String(error), + code: error?.code ?? null, + data: error?.data ?? null + }; + } +} + +async function successControlResult(input) { + const records = await writeOutcomeRecords({ + ...input, + outcome: "succeeded", + reason: null + }); + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + status: "succeeded", + accepted: true, + action: input.action, + chain: M3_IO_CHAIN, + command: input.command, + operationId: input.operation.operationId, + traceId: input.meta.traceId, + auditId: records.auditEvent.auditId, + evidenceId: records.evidenceRecord.evidenceId, + operation: { + ...input.operation, + status: "succeeded", + completedAt: input.now, + updatedAt: input.now + }, + result: { + value: input.command.action === "di.read" + ? readValueFromGatewayResult(input.dispatch.body) + : input.command.value, + dispatch: gatewayResultSummary(input.gateway), + patchPanel: input.patchPanel?.body ?? null, + targetReadback: input.targetReadback ?? null + }, + controlPath: { + status: "succeeded", + cloudApi: true, + gatewaySimu: true, + boxSimu: true, + patchPanel: input.action === "do.write", + frontendBypass: false + }, + auditEvent: records.auditEvent, + evidenceRecord: records.evidenceRecord, + evidenceState: evidenceState(records.runtimeAfter, records), + persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), + observedAt: input.now + }; +} + +async function blockedControlResult(input) { + const records = await writeOutcomeRecords({ + ...input, + outcome: "failed", + reason: input.reason + }); + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + status: "blocked", + accepted: false, + action: input.action, + chain: M3_IO_CHAIN, + command: input.command, + operationId: input.operation.operationId, + traceId: input.meta.traceId, + auditId: records.auditEvent.auditId, + evidenceId: records.evidenceRecord.evidenceId, + blocker: { + code: input.code, + message: input.reason, + zh: input.reason + }, + operation: { + ...input.operation, + status: "failed", + completedAt: input.now, + updatedAt: input.now, + error: { + code: input.code, + message: input.reason + } + }, + result: { + dispatch: input.gateway ? gatewayResultSummary(input.gateway) : null, + patchPanel: input.patchPanel?.body ?? null, + targetReadback: input.targetReadback ?? null + }, + controlPath: { + status: "blocked", + cloudApi: true, + gatewaySimu: Boolean(input.gateway), + boxSimu: input.code !== M3_IO_BLOCKER_CODES.gatewayUnavailable, + patchPanel: Boolean(input.patchPanel?.ok), + frontendBypass: false + }, + auditEvent: records.auditEvent, + evidenceRecord: records.evidenceRecord, + evidenceState: evidenceState(records.runtimeAfter, records), + persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), + observedAt: input.now + }; +} + +function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) { + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + status: "blocked", + accepted: false, + action: action || "unknown", + chain: M3_IO_CHAIN, + command, + operationId: null, + traceId: meta.traceId, + auditId: null, + evidenceId: null, + blocker: { + code, + message: reason, + zh: reason + }, + controlPath: { + status: "blocked", + cloudApi: true, + gatewaySimu: false, + boxSimu: false, + patchPanel: false, + frontendBypass: false + }, + evidenceState: evidenceState(runtime, { + auditWrite: { written: false, reason: "operation was blocked before audit write" }, + evidenceWrite: { written: false, reason: "operation was blocked before evidence write" } + }), + persistence: { + runtime, + writes: [] + }, + observedAt: now, + httpStatus, + ...details + }; +} + +async function writeOutcomeRecords({ + action, + command, + operation, + meta, + runtimeStore, + runtimeBefore, + registration, + operationWrite, + tracePayload, + outcome, + reason, + now +}) { + const auditEvent = createProtocolAuditEvent({ + auditId: `aud_${operation.operationId.slice(3)}_${outcome}`, + traceId: meta.traceId, + actorType: "user", + actorId: meta.actorId, + action: action === "do.write" ? "m3.io.do.write" : "m3.io.di.read", + targetType: "hardware_operation", + targetId: operation.operationId, + projectId: M3_IO_CHAIN.projectId, + gatewaySessionId: operation.gatewaySessionId, + operationId: operation.operationId, + serviceId: CLOUD_API_SERVICE_ID, + outcome, + reason: reason ?? undefined, + metadata: { + route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`, + command, + frontendBypass: false, + runtimeDurableBefore: runtimeBefore?.status ?? "unknown" + }, + occurredAt: now + }); + const evidenceRecord = createEvidenceRecord({ + evidenceId: `evd_${operation.operationId.slice(3)}_${outcome}`, + projectId: M3_IO_CHAIN.projectId, + operationId: operation.operationId, + traceId: meta.traceId, + createdAt: now, + payload: { + outcome, + reason, + tracePayload, + command, + frontendBypass: false, + patchPanelOnlyPropagation: true + } + }); + const auditWrite = await tryRuntimeWrite("audit.event.write", () => runtimeStore?.writeAuditEvent?.({ event: auditEvent }, meta)); + const evidenceWrite = await tryRuntimeWrite("evidence.record.write", () => runtimeStore?.writeEvidenceRecord?.({ record: evidenceRecord }, meta)); + const runtimeAfter = await safeRuntimeSummary(runtimeStore); + return { + auditEvent, + evidenceRecord, + auditWrite, + evidenceWrite, + runtimeAfter, + registration, + operationWrite + }; +} + +function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, payload, createdAt }) { + const metadata = { + traceId, + serviceId: CLOUD_API_SERVICE_ID, + route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`, + frontendBypass: false, + patchPanelOnlyPropagation: true + }; + const serialized = JSON.stringify({ + serviceId: CLOUD_API_SERVICE_ID, + projectId, + operationId, + traceId, + payload, + metadata, + createdAt + }); + const record = { + evidenceId, + projectId, + operationId, + kind: "trace", + uri: `memory://hwlab-cloud-api/${operationId}/m3-io-control.json`, + mimeType: "application/json", + sha256: createHash("sha256").update(serialized).digest("hex"), + sizeBytes: Buffer.byteLength(serialized), + serviceId: CLOUD_API_SERVICE_ID, + environment: ENVIRONMENT_DEV, + metadata, + createdAt + }; + assertProtocolRecord("evidenceRecord", record); + return record; +} + +function evidenceState(runtime, records) { + const durableReady = isDurableRuntimeReady(runtime); + const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true; + if (durableReady && recordsWritten) { + return { + status: "green", + sourceKind: "DEV-LIVE", + durable: true, + reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。" + }; + } + const blocker = runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked; + return { + status: "blocked", + sourceKind: "BLOCKED", + durable: runtime?.durable === true, + blocker, + reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。` + }; +} + +function persistenceSummary(runtime, registration, operationWrite, records) { + return { + runtime, + writes: [ + ...(registration ?? []), + operationWrite, + records.auditWrite, + records.evidenceWrite + ].filter(Boolean) + }; +} + +async function safeRuntimeSummary(runtimeStore) { + try { + if (typeof runtimeStore?.readiness === "function") { + return await runtimeStore.readiness(); + } + if (typeof runtimeStore?.summary === "function") { + return runtimeStore.summary(); + } + } catch (error) { + return { + adapter: "unknown", + durable: false, + ready: false, + status: "blocked", + blocker: "runtime_summary_unavailable", + reason: error instanceof Error ? error.message : String(error), + liveRuntimeEvidence: false + }; + } + return { + adapter: "none", + durable: false, + ready: false, + status: "blocked", + blocker: "runtime_store_missing", + reason: "runtime store is not available", + liveRuntimeEvidence: false + }; +} + +function isDurableRuntimeReady(runtime) { + if (!runtime || typeof runtime !== "object") return false; + if (runtime.durable !== true) return false; + if (runtime.ready === false) return false; + if (runtime.liveRuntimeEvidence !== true) return false; + return !["blocked", "degraded", "failed"].includes(String(runtime.status ?? "").toLowerCase()); +} + +function readValueFromGatewayResult(result) { + return result?.result?.value ?? result?.value ?? null; +} + +function gatewayResultSummary(gateway) { + return { + gatewayId: gateway.gatewayId, + gatewaySessionId: gateway.gatewaySessionId, + url: redactUrl(gateway.url), + role: gateway.role + }; +} + +function publicGatewayStatus(status) { + return { + gatewayId: status?.gatewayId ?? status?.session?.gatewayId ?? null, + gatewaySessionId: status?.session?.gatewaySessionId ?? status?.gatewaySessionId ?? null, + boxCount: Array.isArray(status?.registry?.boxes) ? status.registry.boxes.length : null + }; +} + +function finalizeControlResult(result) { + return result; +} + +function ensureTrailingSlash(url) { + return url.endsWith("/") ? url : `${url}/`; +} + +function trimUrl(value) { + const text = String(value ?? "").trim(); + return text ? text : null; +} + +function parseTimeout(value) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS; +} + +function redactUrl(url) { + try { + const parsed = new URL(url); + parsed.username = parsed.username ? "***" : ""; + parsed.password = parsed.password ? "***" : ""; + return parsed.toString(); + } catch { + return "invalid-url"; + } +} diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs new file mode 100644 index 00000000..509462cc --- /dev/null +++ b/internal/cloud/m3-io-control.test.mjs @@ -0,0 +1,442 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; +import { createBoxState, createGatewayState } from "../sim/model.mjs"; +import { writeBoxPort } from "../sim/l2-runtime.mjs"; +import { + M3_IO_BLOCKER_CODES, + M3_IO_CHAIN, + describeM3IoControl, + handleM3IoControl +} from "./m3-io-control.mjs"; + +const fixedNow = "2026-05-23T00:00:00.000Z"; + +test("M3 IO control describes a cloud-api-only control surface contract", () => { + const contract = describeM3IoControl({ + env: { + HWLAB_M3_IO_CONTROL_ENABLED: "true" + } + }); + + assert.equal(contract.status, "available"); + assert.equal(contract.route, "/v1/m3/io"); + assert.equal(contract.boundaries.frontendCallsOnly, "/v1/m3/io"); + assert.equal(contract.boundaries.cloudApiDispatchesToGateway, true); + assert.equal(contract.boundaries.patchPanelOwnsPropagation, true); + assert.equal(contract.boundaries.directFrontendGatewayOrBoxAccess, false); + assert.equal(contract.boundaries.genericHardwareRpcExposedToFrontend, false); + assert.deepEqual(contract.chain, M3_IO_CHAIN); +}); + +test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, and records audit/evidence fields", async () => { + const fixture = createM3ControlFixture(); + const runtimeStore = createCloudRuntimeStore({ + now: () => fixedNow + }); + + const result = await handleM3IoControl( + { + action: "do.write", + gatewayId: "gwsimu_1", + resourceId: "res_boxsimu_1", + boxId: "boxsimu_1", + port: "DO1", + value: true, + traceId: "trc_m3_control_write_true", + requestId: "req_m3_control_write_true", + actorId: "usr_m3_operator" + }, + { + runtimeStore, + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "succeeded"); + assert.equal(result.accepted, true); + assert.equal(result.action, "do.write"); + assert.equal(result.command.port, "DO1"); + assert.equal(result.command.value, true); + assert.match(result.operationId, /^op_m3_do_write_/u); + assert.equal(result.traceId, "trc_m3_control_write_true"); + assert.match(result.auditId, /^aud_m3_do_write_/u); + assert.match(result.evidenceId, /^evd_m3_do_write_/u); + assert.equal(result.controlPath.cloudApi, true); + assert.equal(result.controlPath.gatewaySimu, true); + assert.equal(result.controlPath.boxSimu, true); + assert.equal(result.controlPath.patchPanel, true); + assert.equal(result.controlPath.frontendBypass, false); + assert.equal(result.result.value, true); + assert.equal(result.result.targetReadback.value, true); + assert.equal(result.evidenceState.status, "blocked"); + assert.equal(result.evidenceState.sourceKind, "BLOCKED"); + assert.match(result.evidenceState.reason, /runtime durable 未 green/u); + assert.equal(fixture.box1.ports.DO1.value, true); + assert.equal(fixture.box2.ports.DI1.value, true); + assert.deepEqual(fixture.calls.map((call) => call.path), [ + "/status", + "/invoke", + "/sync/tick", + "/status", + "/invoke" + ]); + assert.equal(fixture.calls.every((call) => call.url.startsWith("http://gateway-") || call.url.startsWith("http://patch-panel")), true); + + const audit = runtimeStore.queryAuditEvents({ projectId: M3_IO_CHAIN.projectId }); + assert.ok(audit.events.some((event) => event.action === "m3.io.do.write")); + const evidence = runtimeStore.queryEvidenceRecords({ projectId: M3_IO_CHAIN.projectId }); + assert.equal(evidence.count, 1); + assert.equal(evidence.records[0].operationId, result.operationId); +}); + +test("M3 DI read returns structured value through gateway-simu without patch-panel mutation", async () => { + const fixture = createM3ControlFixture(); + writeBoxPort({ + state: fixture.box2, + port: "DI1", + value: true, + source: "patch-panel" + }); + + const result = await handleM3IoControl( + { + action: "di.read", + gatewayId: "gwsimu_2", + resourceId: "res_boxsimu_2", + boxId: "boxsimu_2", + port: "DI1", + traceId: "trc_m3_control_read_di", + requestId: "req_m3_control_read_di", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "succeeded"); + assert.equal(result.accepted, true); + assert.equal(result.action, "di.read"); + assert.equal(result.command.port, "DI1"); + assert.equal(result.result.value, true); + assert.equal(result.controlPath.patchPanel, false); + assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]); +}); + +test("M3 IO control blocks with Chinese reason when gateway session is unavailable", async () => { + const result = await handleM3IoControl( + { + action: "do.write", + value: true, + traceId: "trc_m3_gateway_missing", + requestId: "req_m3_gateway_missing", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: async () => ({ + ok: false, + status: 503, + body: { + error: { + message: "unreachable" + } + } + }) + } + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.accepted, false); + assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable); + assert.match(result.blocker.zh, /gateway 未注册\/不可用/u); + assert.equal(result.controlPath.cloudApi, true); + assert.equal(result.controlPath.frontendBypass, false); +}); + +test("M3 IO control blocks invalid port direction instead of allowing generic writes", async () => { + const result = await handleM3IoControl( + { + action: "do.write", + gatewayId: "gwsimu_1", + resourceId: "res_boxsimu_1", + port: "DI1", + value: true, + traceId: "trc_m3_invalid_port", + requestId: "req_m3_invalid_port", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: async () => { + throw new Error("gateway must not be called for invalid port direction"); + } + } + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid); + assert.match(result.blocker.zh, /端口方向|资源越界/u); +}); + +test("M3 IO control fails closed when indexed gateway identity drifts", async () => { + const fixture = createM3ControlFixture({ + gateway1Id: "gwsimu_2" + }); + + const result = await handleM3IoControl( + { + action: "do.write", + gatewayId: "gwsimu_1", + resourceId: "res_boxsimu_1", + boxId: "boxsimu_1", + port: "DO1", + value: true, + traceId: "trc_m3_gateway_identity_drift", + requestId: "req_m3_gateway_identity_drift", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.accepted, false); + assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayIdentityMismatch); + assert.match(result.blocker.zh, /identity mismatch/u); + assert.deepEqual(fixture.calls.map((call) => call.path), ["/status"]); + assert.equal(fixture.box1.ports.DO1.value, false); + assert.equal(fixture.box2.ports.DI1.value, false); +}); + +test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", async () => { + const fixture = createM3ControlFixture({ + patchPanelBody: { + accepted: true, + routes: [ + { + accepted: true, + deliveries: [] + } + ] + } + }); + + const result = await handleM3IoControl( + { + action: "do.write", + value: false, + traceId: "trc_m3_wiring_missing", + requestId: "req_m3_wiring_missing", + actorId: "usr_m3_operator" + }, + { + runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), + now: () => fixedNow, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + requestJson: fixture.requestJson + } + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.accepted, false); + assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); + assert.match(result.blocker.zh, /wiring 不存在|未路由/u); +}); + +function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) { + const calls = []; + const box1 = createBoxState({ + boxId: "boxsimu_1", + gatewaySessionId: "gws_gwsimu_1", + ports: ["DO1", "DI1"], + now: fixedNow + }); + box1.resource.resourceId = "res_boxsimu_1"; + box1.capabilities = box1.capabilities.map((capability) => ({ + ...capability, + resourceId: "res_boxsimu_1", + projectId: M3_IO_CHAIN.projectId + })); + box1.projectId = M3_IO_CHAIN.projectId; + box1.resource.projectId = M3_IO_CHAIN.projectId; + + const box2 = createBoxState({ + boxId: "boxsimu_2", + gatewaySessionId: "gws_gwsimu_2", + ports: ["DO1", "DI1"], + now: fixedNow + }); + box2.resource.resourceId = "res_boxsimu_2"; + box2.capabilities = box2.capabilities.map((capability) => ({ + ...capability, + resourceId: "res_boxsimu_2", + projectId: M3_IO_CHAIN.projectId + })); + box2.projectId = M3_IO_CHAIN.projectId; + box2.resource.projectId = M3_IO_CHAIN.projectId; + + const gateway1 = createGatewayStatus(gateway1Id, "http://gateway-1", [box1]); + const gateway2 = createGatewayStatus(gateway2Id, "http://gateway-2", [box2]); + + async function requestJson(url, { method = "GET", body } = {}) { + const parsed = new URL(url); + calls.push({ + url, + method, + path: parsed.pathname, + body + }); + + if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") { + return { ok: true, status: 200, body: gateway1 }; + } + if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") { + return { ok: true, status: 200, body: gateway2 }; + } + if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") { + return { + ok: true, + status: 200, + body: writeBoxPort({ + state: box1, + port: body.port, + capabilityId: body.capabilityId, + value: body.value, + source: "gateway-simu" + }) + }; + } + if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") { + return { + ok: true, + status: 200, + body: { + accepted: true, + operationId: body.operationId, + traceId: body.traceId, + boxId: box2.boxId, + resourceId: box2.resource.resourceId, + capabilityId: body.capabilityId, + port: body.port, + value: box2.ports[body.port].value, + state: box2.ports[body.port], + auditId: "aud_box2_read", + evidenceId: "evd_box2_read" + } + }; + } + if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") { + if (patchPanelBody) { + return { ok: true, status: 200, body: patchPanelBody }; + } + box2.ports.DI1 = { + ...box2.ports.DI1, + value: body.signals[0].value, + source: "patch-panel", + sourceResourceId: "res_boxsimu_1", + sourcePort: "DO1", + propagatedBy: "hwlab-patch-panel", + updatedAt: fixedNow + }; + return { + ok: true, + status: 200, + body: { + accepted: true, + propagatedBy: "hwlab-patch-panel", + routeCount: 1, + deliveryCount: 1, + routes: [ + { + accepted: true, + deliveryCount: 1, + deliveries: [ + { + resourceId: "res_boxsimu_2", + port: "DI1", + value: body.signals[0].value, + sourceResourceId: "res_boxsimu_1", + sourcePort: "DO1", + deliveryStatus: "applied" + } + ] + } + ] + } + }; + } + + throw new Error(`unexpected request ${method} ${url}`); + } + + return { + calls, + box1, + box2, + requestJson + }; +} + +function createGatewayStatus(gatewayId, endpoint, boxes) { + const state = createGatewayState({ + gatewayId, + endpoint, + boxes: boxes.map((box) => box.resource.resourceId), + now: fixedNow + }); + state.projectId = M3_IO_CHAIN.projectId; + state.session.projectId = M3_IO_CHAIN.projectId; + state.registry = { + gatewayId, + gatewaySessionId: state.session.gatewaySessionId, + endpoint, + boxes: boxes.map((box) => ({ + boxId: box.boxId, + resourceId: box.resource.resourceId, + url: `http://${box.boxId}`, + state: "registered", + capabilities: box.capabilities + })) + }; + return state; +} diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 52352a63..05e92e0a 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -15,6 +15,11 @@ import { import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; import { buildDbRuntimeReadiness } from "./db-contract.mjs"; import { buildCloudApiReadiness } from "./health-contract.mjs"; +import { + M3_IO_CONTROL_ROUTE, + describeM3IoControl, + handleM3IoControl +} from "./m3-io-control.mjs"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -172,11 +177,22 @@ async function handleRestAdapter(request, response, url, options) { runtime, readiness, blockers: readiness.blockers, - blockerCodes: readiness.blockerCodes + blockerCodes: readiness.blockerCodes, + m3IoControl: describeM3IoControl(options) }); return; } + if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) { + sendJson(response, 200, describeM3IoControl(options)); + return; + } + + if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) { + await handleM3IoControlHttp(request, response, options); + return; + } + if (request.method === "POST" && url.pathname === "/v1/agent/chat") { await handleCodeAgentChatHttp(request, response, options); return; @@ -310,6 +326,78 @@ async function handleCodeAgentChatHttp(request, response, options) { sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload); } +async function handleM3IoControlHttp(request, response, options) { + const body = await readBody(request, options.bodyLimitBytes); + let params = {}; + + try { + params = body ? JSON.parse(body) : {}; + } catch (error) { + sendJson(response, 400, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "m3-io-control-v1", + status: "blocked", + accepted: false, + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + error: { + code: "parse_error", + message: "Invalid JSON body", + reason: error.message + } + }); + return; + } + + if (!params || typeof params !== "object" || Array.isArray(params)) { + sendJson(response, 400, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "m3-io-control-v1", + status: "blocked", + accepted: false, + traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + error: { + code: "invalid_params", + message: "M3 IO control body must be a JSON object" + } + }); + return; + } + + try { + const payload = await handleM3IoControl( + { + ...params, + traceId: getHeader(request, "x-trace-id") || params.traceId, + requestId: getHeader(request, "x-request-id") || params.requestId, + actorId: getHeader(request, "x-actor-id") || params.actorId + }, + { + runtimeStore: options.runtimeStore, + env: options.env, + requestJson: options.m3IoRequestJson, + traceId: getHeader(request, "x-trace-id"), + requestId: getHeader(request, "x-request-id"), + actorId: getHeader(request, "x-actor-id") + } + ); + sendJson(response, payload.httpStatus ?? 200, payload); + } catch (error) { + const protocolCode = Number.isInteger(error?.code) ? error.code : ERROR_CODES.internalError; + sendJson(response, protocolCode === ERROR_CODES.invalidParams ? 400 : 200, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "m3-io-control-v1", + status: "blocked", + accepted: false, + traceId: getHeader(request, "x-trace-id") || params.traceId || "trc_unassigned", + error: { + code: protocolCode, + message: error.message, + data: error.data ?? {} + } + }); + } +} + function sendRestError(request, response, statusCode, code, message, context = {}) { const requestId = getHeader(request, "x-request-id") || "req_unassigned"; sendJson(response, statusCode, { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index e2731ac6..b03ee4a4 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -4,6 +4,7 @@ import { createServer as createTcpServer } from "node:net"; import test from "node:test"; import { createCloudApiServer } from "./server.mjs"; +import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; import { RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, @@ -969,3 +970,92 @@ test("cloud api /v1/agent/chat does not complete on empty provider text", async }); } }); + +test("cloud api /v1 exposes M3 IO control contract without generic frontend hardware RPC", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_M3_IO_CONTROL_ENABLED: "true" + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const index = await fetch(`http://127.0.0.1:${port}/v1`); + assert.equal(index.status, 200); + const indexPayload = await index.json(); + assert.equal(indexPayload.m3IoControl.route, "/v1/m3/io"); + assert.equal(indexPayload.m3IoControl.boundaries.directFrontendGatewayOrBoxAccess, false); + + const contract = await fetch(`http://127.0.0.1:${port}/v1/m3/io`); + assert.equal(contract.status, 200); + const payload = await contract.json(); + assert.equal(payload.status, "available"); + assert.equal(payload.chain.sourceResourceId, "res_boxsimu_1"); + assert.equal(payload.chain.targetResourceId, "res_boxsimu_2"); + assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io"); + assert.equal(payload.boundaries.patchPanelOwnsPropagation, true); + assert.equal(payload.boundaries.genericHardwareRpcExposedToFrontend, false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/m3/io returns structured blocked payload when gateway is unavailable", async () => { + const runtimeStore = createCloudRuntimeStore({ + now: () => "2026-05-23T00:00:00.000Z" + }); + const server = createCloudApiServer({ + runtimeStore, + env: { + HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", + HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", + HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" + }, + m3IoRequestJson: async () => ({ + ok: false, + status: 503, + body: { + error: { + message: "gateway offline" + } + } + }) + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/m3/io`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": "trc_http_m3_gateway_missing", + "x-actor-id": "usr_http_m3" + }, + body: JSON.stringify({ + action: "do.write", + value: true + }) + }); + + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.status, "blocked"); + assert.equal(payload.accepted, false); + assert.equal(payload.traceId, "trc_http_m3_gateway_missing"); + assert.equal(payload.operationId, null); + assert.equal(payload.auditId, null); + assert.equal(payload.evidenceId, null); + assert.match(payload.blocker.zh, /gateway 未注册\/不可用/u); + assert.equal(payload.controlPath.cloudApi, true); + assert.equal(payload.controlPath.frontendBypass, false); + assert.equal(payload.evidenceState.sourceKind, "BLOCKED"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/package.json b/package.json index 8ce95052..6007f293 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 59fe1931..5f8acaaa 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -459,10 +459,10 @@ async function serveCloudWeb() { return; } - if ( - (request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) || - (request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat")) - ) { + if ( + (request.method === "GET" && (url.pathname === "/v1" || url.pathname.startsWith("/v1/"))) || + (request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io")) + ) { await proxyCloudApi(request, response, url); return; } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index aff22325..5ae9f55a 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -13,6 +13,7 @@ const rpcReadMethods = Object.freeze([ const STATUS_LABELS = Object.freeze({ active: "活动", available: "可用", + blocked_after_cloud_api: "cloud-api 后阻塞", blocked: "阻塞 BLOCKED", completed: "完成", connected: "已连接", @@ -86,6 +87,14 @@ const el = { recordsList: byId("records-list"), diagnosticsList: byId("diagnostics-list"), methodList: byId("method-list"), + m3ControlForm: byId("m3-control-form"), + m3ControlStatus: byId("m3-control-status"), + m3GatewaySelect: byId("m3-gateway-select"), + m3BoxSelect: byId("m3-box-select"), + m3PortSelect: byId("m3-port-select"), + m3ValueSelect: byId("m3-value-select"), + m3WriteDo: byId("m3-write-do"), + m3ReadDi: byId("m3-read-di"), helpContent: byId("help-content"), helpStatus: byId("help-status") }; @@ -95,7 +104,12 @@ const state = { codeAgentAvailability: null, chatMessages: [], chatPending: false, - liveSurface: null + liveSurface: null, + m3Control: { + contract: null, + operation: null, + pending: false + } }; initRoutes(); @@ -104,6 +118,7 @@ syncMobileExplorer(); initSideTabs(); initQuickActions(); initCommandBar(); +initM3Control(); renderStaticWorkbench(); renderProbePending(); loadHelpSurface(); @@ -354,6 +369,16 @@ function initCommandBar() { }); } +function initM3Control() { + el.m3ControlForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runM3IoAction("do.write"); + }); + el.m3ReadDi.addEventListener("click", async () => { + await runM3IoAction("di.read"); + }); +} + function renderStaticWorkbench() { el.explorerStatus.textContent = "只读工作区"; el.explorerStatus.className = "status-dot tone-source"; @@ -367,6 +392,7 @@ function renderStaticWorkbench() { renderUnblockList(); renderHardwareStatus(null); renderDrafts(); + renderM3ControlStatus(); renderWiringList(null); renderRecords(null); renderDiagnostics(null); @@ -411,9 +437,10 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; - const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([ + const [healthLive, restIndex, m3Control, health, adapter, audit, evidence] = await Promise.all([ fetchJson("/health/live"), fetchJson("/v1"), + fetchJson("/v1/m3/io"), callRpc("system.health"), callRpc("cloud.adapter.describe"), callRpc("audit.event.query", { projectId, limit: 6 }), @@ -424,6 +451,7 @@ async function loadLiveSurface() { observedAt: new Date().toISOString(), healthLive, restIndex, + m3Control, health, adapter, audit, @@ -522,6 +550,76 @@ async function callRpc(method, params = {}) { }; } +async function runM3IoAction(action) { + if (state.m3Control.pending) return; + const traceId = nextProtocolId("trc"); + state.m3Control.pending = true; + state.m3Control.operation = { + status: "running", + action, + traceId, + observedAt: new Date().toISOString() + }; + renderM3ControlStatus(); + renderDrafts(); + renderRecords(state.liveSurface); + + const body = action === "do.write" + ? { + action, + gatewayId: el.m3GatewaySelect.value, + resourceId: el.m3BoxSelect.value, + boxId: "boxsimu_1", + port: el.m3PortSelect.value, + value: el.m3ValueSelect.value === "true", + projectId: gateSummary.topology.projectId, + traceId + } + : { + action, + gatewayId: "gwsimu_2", + resourceId: M3_TRUSTED_ROUTE.toResourceId, + boxId: "boxsimu_2", + port: M3_TRUSTED_ROUTE.toPort, + projectId: gateSummary.topology.projectId, + traceId + }; + + const response = await fetchJson("/v1/m3/io", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Trace-Id": traceId, + "X-Actor-Id": "usr_hwlab_cloud_web" + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + state.m3Control.operation = { + status: "blocked", + action, + traceId, + blocker: { + zh: response.error || "M3 IO 控制请求失败" + }, + observedAt: new Date().toISOString(), + sourceKind: "BLOCKED" + }; + } else { + state.m3Control.operation = { + ...response.data, + sourceKind: response.data?.status === "succeeded" ? "DEV-LIVE" : "BLOCKED" + }; + } + state.m3Control.pending = false; + renderM3ControlStatus(); + renderHardwareStatus(runtimeSummaryFrom(state.liveSurface)); + renderWiringList(runtimeSummaryFrom(state.liveSurface)); + renderRecords(state.liveSurface); + renderDrafts(); +} + function nextProtocolId(prefix) { rpcSequence += 1; return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`; @@ -529,6 +627,7 @@ function nextProtocolId(prefix) { function renderLiveSurface(live) { state.liveSurface = live; + state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null; const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]; const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes); const runtimeSummary = runtimeSummaryFrom(live); @@ -545,6 +644,7 @@ function renderLiveSurface(live) { renderRecords(live); renderDiagnostics(live); renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone); + renderM3ControlStatus(); renderConversation(); renderDrafts(); } @@ -798,7 +898,7 @@ function renderHardwareStatus(summary) { const counts = summary?.counts ?? null; const patchPanel = gateSummary.topology.patchPanel; const sourceLink = trustedM3Link(patchPanel.activeConnections); - const liveM3Evidence = trustedM3LiveEvidenceFrom(summary); + const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence(); const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence); const runtimeCountsSourceKind = "SOURCE"; const runtimeCountsTone = "source"; @@ -885,10 +985,12 @@ function renderHardwareStatus(summary) { rows: [ { label: "硬件控制", - value: "已禁用", - detail: "Web 不提供硬件写 RPC;不会新增伪硬件写操作。", - sourceKind: "SOURCE", - tone: "source" + value: state.m3Control.contract?.status === "available" ? "cloud-api 受控" : "阻塞", + detail: state.m3Control.contract?.status === "available" + ? "M3 控制只调用 same-origin /v1/m3/io,由 cloud-api 转发 gateway-simu/box-simu 并触发 patch-panel;Web 不直连 gateway/box-simu。Web 不提供硬件写 RPC;不会新增伪硬件写操作。" + : "M3 控制路径未就绪;Web 不提供直连 gateway/box-simu 或伪硬件状态写入。", + sourceKind: state.m3Control.contract?.status === "available" ? "SOURCE" : "BLOCKED", + tone: state.m3Control.contract?.status === "available" ? "source" : "blocked" } ] }) @@ -973,6 +1075,35 @@ function trustedM3LiveEvidenceFrom(summary) { return candidates.find(hasTrustedM3LiveEvidence) ?? null; } +function m3ControlLiveEvidence() { + const operation = state.m3Control.operation; + if (!operation || operation.status !== "succeeded") return null; + return { + status: operation.evidenceState?.status === "green" ? "verified" : "blocked", + sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED", + live: operation.evidenceState?.status === "green", + operationId: operation.operationId, + traceId: operation.traceId, + auditId: operation.auditId, + evidenceId: operation.evidenceId, + patchPanelLiveReport: { + serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId, + sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED", + live: operation.evidenceState?.status === "green", + reportId: operation.evidenceId, + activeConnections: [ + { + fromResourceId: M3_TRUSTED_ROUTE.fromResourceId, + fromPort: M3_TRUSTED_ROUTE.fromPort, + toResourceId: M3_TRUSTED_ROUTE.toResourceId, + toPort: M3_TRUSTED_ROUTE.toPort, + patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId + } + ] + } + }; +} + function liveRecordTone(record, live) { const runtimeSummary = runtimeSummaryFrom(live); return isDurableRuntimeReady(runtimeSummary) && hasTrustedM3LiveEvidence(record) ? "dev-live" : "blocked"; @@ -1034,11 +1165,69 @@ function m3LiveDetail(evidence) { return `operation=${evidence.operationId} / trace=${evidence.traceId} / audit=${evidence.auditId} / evidence=${evidence.evidenceId}`; } +function renderM3ControlStatus() { + const contract = state.m3Control.contract; + const operation = state.m3Control.operation; + const contractAvailable = contract?.status === "available"; + const pending = state.m3Control.pending; + const operationSucceeded = operation?.status === "succeeded"; + const operationBlocked = operation?.status === "blocked"; + const label = pending + ? "执行中" + : operationSucceeded + ? operation.evidenceState?.status === "green" + ? "控制可达 / 证据 green" + : "控制可达 / 证据 blocked" + : operationBlocked + ? "操作 BLOCKED" + : contractAvailable + ? "cloud-api 受控" + : "BLOCKED"; + const tone = pending + ? "dry-run" + : operationSucceeded + ? operation.evidenceState?.status === "green" ? "dev-live" : "degraded" + : operationBlocked || !contractAvailable + ? "blocked" + : "source"; + + el.m3ControlStatus.textContent = label; + el.m3ControlStatus.className = `state-tag tone-${toneClass(tone)}`; + el.m3ControlStatus.title = operation?.evidenceState?.reason ?? contract?.blockedReason ?? ""; + const disabled = pending || !contractAvailable; + for (const input of [el.m3GatewaySelect, el.m3BoxSelect, el.m3PortSelect, el.m3ValueSelect, el.m3WriteDo, el.m3ReadDi]) { + input.disabled = disabled; + } + el.m3WriteDo.textContent = pending ? "执行中" : "写入 DO1"; +} + function controlRows() { + const operation = state.m3Control.operation; + const operationCards = operation + ? [ + { + title: `M3 ${operationActionLabel(operation.action)} ${statusLabel(operation.status)}`, + detail: m3OperationDetail(operation), + tone: operation.status === "succeeded" + ? operation.evidenceState?.status === "green" ? "dev-live" : "degraded" + : operation.status === "running" + ? "dry-run" + : "blocked" + } + ] + : []; return [ { - title: "硬件操作", - detail: "此 Web 工作台不开放硬件写入;需要硬件动作时交由受控后端流程处理。", + title: "cloud-api 控制路径", + detail: state.m3Control.contract?.status === "available" + ? "按钮只调用 /v1/m3/io;cloud-api 再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。" + : state.m3Control.contract?.blockedReason ?? "等待 /v1/m3/io 合同探测;不可用时不会直连 gateway/box-simu。", + tone: state.m3Control.contract?.status === "available" ? "source" : "blocked" + }, + ...operationCards, + { + title: "读取目标", + detail: `${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 只能通过 /v1/m3/io 的 di.read 读取;不能从浏览器直连 box-simu。`, tone: "source" }, { @@ -1054,6 +1243,30 @@ function controlRows() { ]; } +function operationActionLabel(action) { + return action === "do.write" ? "DO 写入" : action === "di.read" ? "DI 读取" : "操作"; +} + +function m3OperationDetail(operation) { + if (operation.status === "running") { + return `trace=${operation.traceId} / 正在通过 cloud-api 提交。`; + } + const ids = [ + recordField("operation", operation.operationId), + recordField("trace", operation.traceId), + recordField("audit", operation.auditId), + recordField("evidence", operation.evidenceId) + ].filter(Boolean).join(" / "); + const value = operation.result?.value !== undefined && operation.result?.value !== null + ? ` / value=${String(operation.result.value)}` + : ""; + const blocker = operation.blocker?.zh || operation.blocker?.message + ? ` / 阻塞原因=${operation.blocker.zh || operation.blocker.message}` + : ""; + const evidence = operation.evidenceState?.reason ? ` / evidence=${operation.evidenceState.reason}` : ""; + return `${ids}${value}${blocker}${evidence}`; +} + function renderDrafts() { const rows = [ ...state.chatMessages.slice(-3).reverse().map((message) => ({ @@ -1153,6 +1366,27 @@ function codeAgentRecordCards() { function operationRecordCards() { const wiring = gateSummary.topology.patchPanel; + const controlOperation = state.m3Control.operation; + const controlCards = controlOperation + ? [ + infoCard({ + title: controlOperation.operationId ?? controlOperation.traceId ?? "m3-control-operation", + detail: [ + sourceKindLabel(controlOperation.evidenceState?.status === "green" ? "dev-live" : "blocked_after_cloud_api"), + "via=/v1/m3/io", + recordField("action", controlOperation.action), + recordField("trace", controlOperation.traceId), + recordField("audit", controlOperation.auditId), + recordField("evidence", controlOperation.evidenceId), + controlOperation.controlPath?.frontendBypass === false ? "frontendBypass=false" : null, + controlOperation.evidenceState?.reason + ].filter(Boolean).join(" / "), + tone: controlOperation.status === "succeeded" + ? controlOperation.evidenceState?.status === "green" ? "dev-live" : "degraded" + : "blocked" + }) + ] + : []; const wiringCards = wiring.activeConnections.map((link) => infoCard({ title: `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`, @@ -1178,11 +1412,18 @@ function operationRecordCards() { tone: operation.dryRun ? "dry-run" : "source" }) ); - return [...wiringCards, ...operationCards]; + return [...controlCards, ...wiringCards, ...operationCards]; } function auditRecordCards(live) { const cards = []; + if (state.m3Control.operation?.auditEvent) { + cards.push(auditCard( + state.m3Control.operation.auditEvent, + state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked", + state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason + )); + } if (live) { if (live.audit.ok) { const liveEvents = live.audit.data?.events ?? []; @@ -1212,6 +1453,13 @@ function auditRecordCards(live) { function evidenceRecordCards(live) { const cards = []; + if (state.m3Control.operation?.evidenceRecord) { + cards.push(evidenceCard( + state.m3Control.operation.evidenceRecord, + state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked", + state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason + )); + } if (live) { if (live.evidence.ok) { const liveRecords = live.evidence.data?.records ?? []; diff --git a/web/hwlab-cloud-web/help.md b/web/hwlab-cloud-web/help.md index 66816990..7d243a9f 100644 --- a/web/hwlab-cloud-web/help.md +++ b/web/hwlab-cloud-web/help.md @@ -13,14 +13,14 @@ - Agent 对话区显示用户工作台范围、建议起步方式和可查看内容,不展示 Gate、诊断、验收、BLOCKED 或 M0-M5 执行轨迹。 - 工作清单区提供描述目标、核对接线、留存记录三类用户动作。 -- 底部输入栏只把文字保存为浏览器本地草稿,不调用硬件写 API,不提交 patch-panel 变更,也不写入审计或证据记录。 +- 底部输入栏用于 Code Agent 对话;右侧 M3 基本 IO 控制只调用 same-origin `/v1/m3/io`,由 `hwlab-cloud-api` 通过 gateway-simu、box-simu、`hwlab-patch-panel` 执行,不从浏览器直连 gateway/box-simu,也不调用泛化硬件写 RPC。 ## 右侧硬件与状态面板 - BOX-SIMU / Gateway-SIMU / Patch Panel 卡片展示只读计数、source fallback 与硬件写入边界。 - 接线:`hwlab-patch-panel` 面板使用表格展示源设备、源端口、接线盘、目标设备、目标端口、状态、证据来源和 trace/evidence。 - M3 虚拟硬件可信闭环的上位约束是:`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`。只有观察到这条完整链路并带有可信记录时,才可作为 M3 DEV-LIVE 依据。 -- Web 前端不会新增 `hardware.*` 写 RPC、patch-panel 写接口、audit 写接口或 evidence 写接口。 +- Web 前端不会新增 `hardware.*` 写 RPC、patch-panel 写接口、audit 写接口或 evidence 写接口;M3 控制的 operation/audit/evidence 字段由 `/v1/m3/io` 响应返回,runtime durable 未 green 时必须显示 BLOCKED。 ## 右侧工作面板 @@ -33,7 +33,7 @@ - Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,作为验收复核二级页面保留。 - 解阻路径:先完成 DB live readiness,让 same-origin `/health/live` 能证明 db.ready=true、db.connected=true;再通过 `hwlab-patch-panel` 建立 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`,并附带可信 trace/evidence。 -- 诊断探测只检查 same-origin `/health/live`、same-origin `/v1` 与只读 `/json-rpc` 方法。允许的方法包括 `system.health`、`cloud.adapter.describe`、`audit.event.query`、`evidence.record.query`。 +- 诊断探测检查 same-origin `/health/live`、same-origin `/v1`、`/v1/m3/io` 合同与只读 `/json-rpc` 方法。允许的 JSON-RPC 方法包括 `system.health`、`cloud.adapter.describe`、`audit.event.query`、`evidence.record.query`。 ## 来源标签含义 diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index b1746487..f16e8663 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -255,9 +255,42 @@
-

控制草稿

- 演练 DRY-RUN 仅本地 +

M3 基本 IO 控制

+ 探测中
+
+
+ + + + +
+
+ + +
+
diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index b01c14d1..2654ac31 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -319,6 +319,17 @@ assert.doesNotMatch(html, />添加草稿<\/button>/); assert.doesNotMatch(html, /主流程[\s\S]*添加草稿/u); assert.match(app, /sendAgentMessage/); assert.match(app, /fetchJson\("\/v1\/agent\/chat"/); +assert.match(app, /fetchJson\("\/v1\/m3\/io"/); +assert.match(html, /id="m3-control-form"/); +assert.match(html, /id="m3-control-status"/); +assert.match(html, />写入 DO1<\/button>/); +assert.match(html, />读取 DI1<\/button>/); +assert.match(app, /function runM3IoAction/); +assert.match(app, /function renderM3ControlStatus/); +assert.match(app, /frontendBypass=false/); +assert.match(app, /按钮只调用 \/v1\/m3\/io/); +assert.match(app, /Web 不直连 gateway\/box-simu/); +assert.match(styles, /\.m3-control-form\s*{/); assert.match(app, /Code Agent 调用失败/); assert.match(app, /normalizeBlockedAgentResult/); assert.match(app, /isBlockedAgentResponse/); @@ -406,6 +417,7 @@ assert.match(artifactPublisher, /requestUpstream/); assert.match(artifactPublisher, /from "node:http"/); assert.doesNotMatch(artifactPublisher, /await fetch\(target/); assert.match(artifactPublisher, /url\.pathname === "\/v1\/agent\/chat"/); +assert.match(artifactPublisher, /url\.pathname === "\/v1\/m3\/io"/); assert.match(artifactPublisher, /"system\.health"/); assert.match(artifactPublisher, /"cloud\.adapter\.describe"/); assert.match(artifactPublisher, /"audit\.event\.query"/); @@ -423,6 +435,7 @@ assert.doesNotMatch(app, /hardware\.operation\.request/); assert.doesNotMatch(app, /hardware\.invoke\.shell/); assert.doesNotMatch(app, /audit\.event\.write/); assert.doesNotMatch(app, /evidence\.record\.write/); +assert.doesNotMatch(app, /https?:\/\/[^"']*(?:gateway-simu|box-simu|patch-panel)[^"']*/iu); assert.doesNotMatch(frontendSource, /--live --confirm-dev --confirmed-non-production/); assert.doesNotMatch(frontendSource, /74\.48\.78\.17:666[67]\b|:666[67]\b/); diff --git a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs index fae26589..c4b3a42f 100644 --- a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs +++ b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs @@ -13,6 +13,7 @@ const READ_ONLY_RPC_METHODS = Object.freeze([ "audit.event.query", "evidence.record.query" ]); +const CONTROL_ROUTE = "/v1/m3/io"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = path.resolve(rootDir, "../.."); @@ -56,8 +57,10 @@ export function runCloudWebM3ReadonlyContract() { "Cloud Web must call only read-only JSON-RPC diagnostics methods" ); assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index"); + assert.match(app, /fetchJson\("\/v1\/m3\/io"/u, "Cloud Web M3 control must use only the same-origin cloud-api M3 IO route"); assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics"); assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/u, "Cloud Web must render the observed route contract"); + assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass"); for (const [pattern, label] of [ [/DEV Control Console/iu, "generic control console label"], @@ -69,6 +72,7 @@ export function runCloudWebM3ReadonlyContract() { [/audit\.event\.write/iu, "audit write RPC"], [/evidence\.record\.write/iu, "evidence write RPC"], [/\/v1\/rpc\//iu, "direct REST RPC bridge"], + [/https?:\/\/[^"']*(?:gateway-simu|box-simu|patch-panel)[^"']*/iu, "direct simulator endpoint"], [/\/wiring\/apply/iu, "patch-panel apply endpoint"], [/\/wiring\/reload/iu, "patch-panel reload endpoint"], [/method-pill[\s\S]{0,80}write/iu, "write method pill"] @@ -76,6 +80,11 @@ export function runCloudWebM3ReadonlyContract() { assert.doesNotMatch(frontendSource, pattern, `Cloud Web frontend must not expose ${label}`); } + const mutationFetches = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,220}?method:\s*["']POST["']/gu)] + .map((match) => match[1]) + .filter((path) => path !== "/v1/agent/chat" && path !== CONTROL_ROUTE && path !== "/json-rpc"); + assert.deepEqual(mutationFetches, [], "Cloud Web must not add POST mutation fetches outside Code Agent chat and M3 cloud-api control route"); + const m3Milestone = gateSummary.milestones.find((item) => item.id === "M3"); assert.equal(m3Milestone?.status, "blocked", "Cloud Web must not present edge-only M3 as DEV-LIVE"); assert.match(m3Milestone.summary, /blocked by hardware loop topology/u, "M3 summary must explain the live blocker"); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 349b0007..5cef3a8f 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -915,6 +915,54 @@ h3 { display: none; } +.m3-control-form { + min-width: 0; + display: grid; + gap: 9px; + padding: 9px; + background: var(--surface); + border: 1px solid var(--line); +} + +.m3-control-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.m3-control-grid label { + min-width: 0; + display: grid; + gap: 4px; + color: var(--muted); + font-size: 10px; + font-weight: 760; + text-transform: uppercase; +} + +.m3-control-grid select { + width: 100%; + min-width: 0; + min-height: 30px; + padding: 5px 7px; + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--text); + font: 11px/1.2 var(--mono); +} + +.m3-control-grid select:disabled, +.m3-control-actions button:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.m3-control-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + .info-card { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -1283,6 +1331,8 @@ tbody tr:last-child td { .hardware-list, .milestone-grid, .gate-grid, + .m3-control-grid, + .m3-control-actions, .quick-actions { grid-template-columns: 1fr; }