diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index 751c851c..f2ad063f 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -98,6 +98,29 @@ export function describeM3IoControl(options = {}) { }; } +export async function describeM3IoControlLive(options = {}) { + const contract = describeM3IoControl(options); + const env = options.env ?? process.env; + const config = buildM3IoConfig(env); + const readiness = await buildM3IoReadiness({ + config, + requestJson: options.m3IoRequestJson ?? options.requestJson, + now: options.now + }); + const controlReady = readiness.status === "ready" && readiness.controlReady === true; + + return { + ...contract, + status: controlReady ? "available" : "blocked", + sourceKind: controlReady ? "DEV-LIVE" : "BLOCKED", + readiness, + controlReady, + blockedReason: controlReady + ? null + : readiness.blocker?.zh ?? contract.blockedReason ?? "M3 IO 只读 readiness 未 green;控制面板保持阻塞。" + }; +} + export async function handleM3IoControl(params = {}, context = {}) { const env = context.env ?? process.env; const now = context.now?.() ?? new Date().toISOString(); @@ -239,6 +262,137 @@ export async function handleM3IoControl(params = {}, context = {}) { })); } +export async function buildM3IoReadiness({ config, requestJson, now } = {}) { + const observedAt = now?.() ?? new Date().toISOString(); + const checks = []; + + if (!config?.enabled) { + return blockedReadiness({ + checks, + code: "m3_control_disabled", + layer: "cloud-api", + reason: "M3 IO 控制未启用;前端不能绕过 cloud-api 直连 gateway/box-simu。", + observedAt + }); + } + + const sourceGateway = await readGatewaySession(config.gateway1Url, { + gatewayRole: "source", + expectedGatewayId: M3_IO_CHAIN.sourceGatewayId, + expectedGatewaySessionId: M3_IO_CHAIN.sourceGatewaySessionId, + requestJson, + timeoutMs: config.timeoutMs + }); + checks.push(readinessCheckFromGateway("source-gateway", sourceGateway)); + if (!sourceGateway.available) { + return blockedReadiness({ + checks, + code: sourceGateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable, + layer: "gateway-simu-1", + reason: sourceGateway.reason, + observedAt + }); + } + + const sourceCommand = normalizeCommand("do.write", {}); + const sourceBox = findGatewayBox(sourceGateway.status, sourceCommand); + checks.push(readinessCheckFromBox("source-box", sourceBox, sourceCommand)); + if (!sourceBox) { + return blockedReadiness({ + checks, + code: M3_IO_BLOCKER_CODES.boxUnavailable, + layer: "box-simu-1", + reason: `gateway-simu-1 已响应,但 ${M3_IO_CHAIN.sourceResourceId} 未注册/不可用;控制面板保持阻塞。`, + observedAt + }); + } + + const targetGateway = await readGatewaySession(config.gateway2Url, { + gatewayRole: "target", + expectedGatewayId: M3_IO_CHAIN.targetGatewayId, + expectedGatewaySessionId: M3_IO_CHAIN.targetGatewaySessionId, + requestJson, + timeoutMs: config.timeoutMs + }); + checks.push(readinessCheckFromGateway("target-gateway", targetGateway)); + if (!targetGateway.available) { + return blockedReadiness({ + checks, + code: targetGateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable, + layer: "gateway-simu-2", + reason: targetGateway.reason, + observedAt + }); + } + + const targetCommand = normalizeCommand("di.read", {}); + const targetBox = findGatewayBox(targetGateway.status, targetCommand); + checks.push(readinessCheckFromBox("target-box", targetBox, targetCommand)); + if (!targetBox) { + return blockedReadiness({ + checks, + code: M3_IO_BLOCKER_CODES.boxUnavailable, + layer: "box-simu-2", + reason: `gateway-simu-2 已响应,但 ${M3_IO_CHAIN.targetResourceId} 未注册/不可用;控制面板保持阻塞。`, + observedAt + }); + } + + const patchStatus = await requestRuntimeJson(new URL("/status", ensureTrailingSlash(config.patchPanelUrl)).toString(), { + method: "GET", + requestJson, + timeoutMs: config.timeoutMs + }); + checks.push(readinessCheckFromPatchPanelStatus(patchStatus)); + if (!patchStatus.ok) { + return blockedReadiness({ + checks, + code: M3_IO_BLOCKER_CODES.patchPanelUnavailable, + layer: "hwlab-patch-panel", + reason: `hwlab-patch-panel 不可用:${patchStatus.error ?? `HTTP ${patchStatus.status}`}`, + observedAt + }); + } + + const wiring = await requestRuntimeJson(new URL("/wiring", ensureTrailingSlash(config.patchPanelUrl)).toString(), { + method: "GET", + requestJson, + timeoutMs: config.timeoutMs + }); + checks.push(readinessCheckFromPatchPanelWiring(wiring)); + if (!wiring.ok) { + return blockedReadiness({ + checks, + code: M3_IO_BLOCKER_CODES.wiringMissing, + layer: "hwlab-patch-panel", + reason: `hwlab-patch-panel 接线读取失败:${wiring.error ?? `HTTP ${wiring.status}`}`, + observedAt + }); + } + + if (!hasExpectedPatchPanelWiring(patchStatus.body) || !hasExpectedPatchPanelWiring(wiring.body)) { + return blockedReadiness({ + checks, + code: M3_IO_BLOCKER_CODES.wiringMissing, + layer: "hwlab-patch-panel", + reason: `hwlab-patch-panel 未确认 active ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} 接线;控制面板保持阻塞。`, + observedAt + }); + } + + return { + status: "ready", + controlReady: true, + sourceKind: "DEV-LIVE", + evidenceLevel: "DEV-LIVE", + observedAt, + summary: "cloud-api 只读 readiness 已确认 gateway-simu、box-simu 与 hwlab-patch-panel 目标接线;这只表示控制面板可执行受控操作,不等于 M3 验收通过。", + chain: M3_IO_CHAIN, + checks, + blocker: null + }; +} + export async function handleM3IoRpc(action, params = {}, envelope = {}, context = {}) { return handleM3IoControl( { @@ -269,6 +423,89 @@ export function buildM3IoConfig(env = process.env) { }; } +function blockedReadiness({ checks, code, layer, reason, observedAt }) { + return { + status: "blocked", + controlReady: false, + sourceKind: "BLOCKED", + evidenceLevel: "BLOCKED", + observedAt, + summary: `M3 IO 只读 readiness 未 green:${reason}`, + chain: M3_IO_CHAIN, + blocker: { + code, + layer, + zh: reason + }, + checks + }; +} + +function readinessCheckFromGateway(id, gateway) { + return { + id, + status: gateway.available ? "pass" : "blocked", + layer: gateway.role, + sourceKind: gateway.available ? "DEV-LIVE" : "BLOCKED", + summary: gateway.available + ? `${gateway.role} gateway ${gateway.gatewayId}/${gateway.gatewaySessionId} 只读状态可用。` + : gateway.reason, + gatewayId: gateway.gatewayId ?? null, + gatewaySessionId: gateway.gatewaySessionId ?? null, + url: gateway.url ? redactUrl(gateway.url) : null, + blocker: gateway.available ? null : gateway.code ?? M3_IO_BLOCKER_CODES.gatewayUnavailable + }; +} + +function readinessCheckFromBox(id, box, command) { + return { + id, + status: box ? "pass" : "blocked", + sourceKind: box ? "DEV-LIVE" : "BLOCKED", + summary: box + ? `${command.resourceId}/${command.port} 已在 gateway registry 中可见。` + : `${command.resourceId}/${command.port} 未在 gateway registry 中可见。`, + resourceId: box?.resourceId ?? command.resourceId, + boxId: box?.boxId ?? command.boxId, + port: command.port, + blocker: box ? null : M3_IO_BLOCKER_CODES.boxUnavailable + }; +} + +function readinessCheckFromPatchPanelStatus(response) { + const expected = response.ok && hasExpectedPatchPanelWiring(response.body); + return { + id: "patch-panel-status", + status: expected ? "pass" : "blocked", + sourceKind: expected ? "DEV-LIVE" : "BLOCKED", + summary: expected + ? "hwlab-patch-panel status 包含目标 active 接线。" + : response.ok + ? "hwlab-patch-panel status 未包含目标 active 接线。" + : `hwlab-patch-panel status 不可读:${response.error ?? `HTTP ${response.status}`}`, + serviceId: response.body?.serviceId ?? null, + state: response.body?.state ?? null, + blocker: expected ? null : M3_IO_BLOCKER_CODES.wiringMissing + }; +} + +function readinessCheckFromPatchPanelWiring(response) { + const expected = response.ok && hasExpectedPatchPanelWiring(response.body); + return { + id: "patch-panel-wiring", + status: expected ? "pass" : "blocked", + sourceKind: expected ? "DEV-LIVE" : "BLOCKED", + summary: expected + ? "hwlab-patch-panel /wiring 包含目标 active 接线。" + : response.ok + ? "hwlab-patch-panel /wiring 未包含目标 active 接线。" + : `hwlab-patch-panel /wiring 不可读:${response.error ?? `HTTP ${response.status}`}`, + wiringConfigId: response.body?.wiringConfigId ?? null, + wiringStatus: response.body?.status ?? null, + blocker: expected ? null : M3_IO_BLOCKER_CODES.wiringMissing + }; +} + async function writeDo({ config, command, @@ -697,7 +934,7 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte 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.` + reason: `gateway-simu 身份不匹配:期望 gatewayId=${expectedGatewayId},实际=${gatewayId};控制面板保持阻塞。` }; } if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) { @@ -707,7 +944,7 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte 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.` + reason: `gateway-simu 会话不匹配:期望 gatewaySessionId=${expectedGatewaySessionId},实际=${gatewaySessionId};控制面板保持阻塞。` }; } return { @@ -791,6 +1028,24 @@ function hasExpectedPatchDelivery(body) { ); } +function hasExpectedPatchPanelWiring(body) { + const connections = [ + ...(Array.isArray(body?.activeConnections) ? body.activeConnections : []), + ...(Array.isArray(body?.connections) ? body.connections : []), + ...(Array.isArray(body?.wiringConfig?.connections) ? body.wiringConfig.connections : []) + ]; + const active = body?.state === undefined || !["faulted", "blocked", "disabled"].includes(String(body.state).toLowerCase()); + const wiringActive = body?.status === undefined || String(body.status).toLowerCase() === "active"; + return active && wiringActive && connections.some((connection) => { + const from = connection.from ?? connection; + const to = connection.to ?? connection; + return String(from.resourceId ?? connection.fromResourceId ?? "") === M3_IO_CHAIN.sourceResourceId && + normalizePort(from.port ?? connection.fromPort) === M3_IO_CHAIN.sourcePort && + String(to.resourceId ?? connection.toResourceId ?? "") === M3_IO_CHAIN.targetResourceId && + normalizePort(to.port ?? connection.toPort) === M3_IO_CHAIN.targetPort; + }); +} + function firstDiagnostic(body) { return [ ...(Array.isArray(body?.diagnostics) ? body.diagnostics : []), diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index 4f785733..ad6d9906 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -11,6 +11,7 @@ import { M3_IO_CHAIN, M3_IO_RPC_METHODS, describeM3IoControl, + describeM3IoControlLive, handleM3IoControl } from "./m3-io-control.mjs"; @@ -33,6 +34,62 @@ test("M3 IO control describes a cloud-api-only control surface contract", () => assert.deepEqual(contract.chain, M3_IO_CHAIN); }); +test("M3 IO live descriptor enables controls only after read-only backend readiness is green", async () => { + const fixture = createM3ControlFixture(); + const contract = await describeM3IoControlLive({ + 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" + }, + now: () => fixedNow, + m3IoRequestJson: fixture.requestJson + }); + + assert.equal(contract.status, "available"); + assert.equal(contract.controlReady, true); + assert.equal(contract.readiness.status, "ready"); + assert.equal(contract.readiness.sourceKind, "DEV-LIVE"); + assert.equal(contract.blockedReason, null); + assert.deepEqual(fixture.calls.map((call) => call.path), [ + "/status", + "/status", + "/status", + "/wiring" + ]); +}); + +test("M3 IO live descriptor blocks controls with Chinese reason when readiness is not green", async () => { + const fixture = createM3ControlFixture({ + patchPanelStatusBody: { + serviceId: "hwlab-patch-panel", + state: "active", + activeConnections: [] + }, + patchPanelWiringBody: { + wiringConfigId: "wir_wrong", + status: "active", + connections: [] + } + }); + const contract = await describeM3IoControlLive({ + 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" + }, + now: () => fixedNow, + m3IoRequestJson: fixture.requestJson + }); + + assert.equal(contract.status, "blocked"); + assert.equal(contract.controlReady, false); + assert.equal(contract.readiness.status, "blocked"); + assert.equal(contract.readiness.sourceKind, "BLOCKED"); + assert.match(contract.blockedReason, /未确认 active|控制面板保持阻塞/u); + assert.equal(contract.readiness.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); +}); + 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({ @@ -361,7 +418,7 @@ test("M3 IO control fails closed when indexed gateway identity drifts", async () 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.match(result.blocker.zh, /身份不匹配|会话不匹配/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); @@ -589,7 +646,7 @@ function createBlockedDurableRuntimeStore() { }; } -function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) { +function createM3ControlFixture({ patchPanelBody, patchPanelStatusBody, patchPanelWiringBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) { const calls = []; const box1 = createBoxState({ boxId: "boxsimu_1", @@ -671,6 +728,47 @@ function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gatew } }; } + if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") { + return { + ok: true, + status: 200, + body: patchPanelStatusBody ?? { + serviceId: "hwlab-patch-panel", + state: "active", + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ] + } + }; + } + if (url.startsWith("http://patch-panel") && parsed.pathname === "/wiring") { + return { + ok: true, + status: 200, + body: patchPanelWiringBody ?? { + wiringConfigId: "wir_m3_do1_di1", + status: "active", + connections: [ + { + from: { + resourceId: "res_boxsimu_1", + port: "DO1" + }, + to: { + resourceId: "res_boxsimu_2", + port: "DI1" + }, + mode: "exclusive" + } + ] + } + }; + } if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") { if (patchPanelBody) { return { ok: true, status: 200, body: patchPanelBody }; diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index c2beada0..7307d715 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -21,6 +21,7 @@ import { buildCloudApiReadiness } from "./health-contract.mjs"; import { M3_IO_CONTROL_ROUTE, describeM3IoControl, + describeM3IoControlLive, handleM3IoControl } from "./m3-io-control.mjs"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs"; @@ -190,7 +191,7 @@ async function handleRestAdapter(request, response, url, options) { } if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) { - sendJson(response, 200, describeM3IoControl(options)); + sendJson(response, 200, await describeM3IoControlLive(options)); return; } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 121add06..63373ac7 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -672,6 +672,99 @@ function layerForBlockedCase(blockedLayer) { return blockedLayer; } +async function m3ReadinessRequestJson(url) { + const parsed = new URL(url); + if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") { + return { + ok: true, + status: 200, + body: m3GatewayStatus({ + gatewayId: "gwsimu_1", + gatewaySessionId: "gws_gwsimu_1", + boxId: "boxsimu_1", + resourceId: "res_boxsimu_1" + }) + }; + } + if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") { + return { + ok: true, + status: 200, + body: m3GatewayStatus({ + gatewayId: "gwsimu_2", + gatewaySessionId: "gws_gwsimu_2", + boxId: "boxsimu_2", + resourceId: "res_boxsimu_2" + }) + }; + } + if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") { + return { + ok: true, + status: 200, + body: { + serviceId: "hwlab-patch-panel", + state: "active", + activeConnections: [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ] + } + }; + } + if (url.startsWith("http://patch-panel") && parsed.pathname === "/wiring") { + return { + ok: true, + status: 200, + body: { + wiringConfigId: "wir_m3_do1_di1", + status: "active", + connections: [ + { + from: { + resourceId: "res_boxsimu_1", + port: "DO1" + }, + to: { + resourceId: "res_boxsimu_2", + port: "DI1" + }, + mode: "exclusive" + } + ] + } + }; + } + throw new Error(`unexpected M3 readiness request ${url}`); +} + +function m3GatewayStatus({ gatewayId, gatewaySessionId, boxId, resourceId }) { + return { + serviceId: "hwlab-gateway-simu", + gatewayId, + gatewaySessionId, + session: { + gatewayId, + gatewaySessionId + }, + registry: { + gatewayId, + gatewaySessionId, + boxes: [ + { + boxId, + resourceId, + state: "registered" + } + ] + } + }; +} + test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => { const server = createCloudApiServer({ env: { @@ -1153,8 +1246,12 @@ 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" - } + HWLAB_M3_IO_CONTROL_ENABLED: "true", + 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: m3ReadinessRequestJson }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1172,6 +1269,8 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard assert.equal(contract.status, 200); const payload = await contract.json(); assert.equal(payload.status, "available"); + assert.equal(payload.controlReady, true); + assert.equal(payload.readiness.status, "ready"); assert.equal(payload.chain.sourceResourceId, "res_boxsimu_1"); assert.equal(payload.chain.targetResourceId, "res_boxsimu_2"); assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io"); diff --git a/scripts/m3-io-control-e2e.test.mjs b/scripts/m3-io-control-e2e.test.mjs index b83f1007..3dda815f 100644 --- a/scripts/m3-io-control-e2e.test.mjs +++ b/scripts/m3-io-control-e2e.test.mjs @@ -183,9 +183,78 @@ test("M3 IO E2E classifies trusted green only with durable DEV-LIVE evidence", ( ); }); +test("M3 IO E2E does not claim trusted green for SOURCE, DRY-RUN, or BLOCKED backend evidence", () => { + for (const sourceKind of ["SOURCE", "DRY-RUN", "BLOCKED"]) { + const operations = expectedM3IoLiveSequence().map((step) => ({ + id: step.id, + status: "succeeded", + schema: { status: "pass", issues: [] }, + evidenceState: { + status: "green", + sourceKind, + durable: true + } + })); + assert.deepEqual( + classifyM3IoControlReport({ + checks: [{ id: "source", status: "pass" }], + liveOperations: operations + }), + { + status: "blocked", + classification: controlPathReachablePersistenceBlocked, + trustedGreen: false + } + ); + } +}); + +test("M3 IO E2E frontend guardrail requires readiness before controls unlock", () => { + const appSource = ` + function m3ControlCanOperate(contract) { + return contract?.status === "available" && + contract?.readiness?.status === "ready" && + contract?.readiness?.controlReady === true && + contract?.readiness?.sourceKind === "DEV-LIVE" && + contract?.readiness?.evidenceLevel === "DEV-LIVE"; + } + async function runM3IoAction() { + if (!m3ControlCanOperate()) return; + return fetchJson("/v1/m3/io", { method: "POST" }); + } + `; + const pass = checkFrontendNoDirectRuntimeCalls({ + appSource, + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + }); + assert.equal(pass.status, "pass"); + + const fail = checkFrontendNoDirectRuntimeCalls({ + appSource: ` + function m3ControlCanOperate(contract) { + return contract?.status === "available"; + } + async function runM3IoAction() { + return fetchJson("/v1/m3/io", { method: "POST" }); + } + `, + artifactPublisherSource: 'if (url.pathname === "/v1/m3/io") proxyCloudApi();' + }); + assert.equal(fail.status, "fail"); + assert.ok(fail.issues.some((issue) => /readiness/u.test(issue))); +}); + test("M3 IO E2E frontend guardrail catches direct gateway or patch-panel calls", () => { const appSource = ` + function m3ControlCanOperate(contract) { + return contract?.status === "available" && + contract?.readiness?.status === "ready" && + contract?.readiness?.controlReady === true && + contract?.readiness?.sourceKind === "DEV-LIVE" && + contract?.readiness?.evidenceLevel === "DEV-LIVE"; + } async function runM3IoAction() { + if (!m3ControlCanOperate()) return; return fetchJson("/v1/m3/io", { method: "POST" }); } async function other() { diff --git a/scripts/src/m3-io-control-e2e.mjs b/scripts/src/m3-io-control-e2e.mjs index 0517143b..9eb95a4c 100644 --- a/scripts/src/m3-io-control-e2e.mjs +++ b/scripts/src/m3-io-control-e2e.mjs @@ -364,6 +364,20 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", if (/fetchJson\(["'`](?!\/v1\/m3\/io)/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) { issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\")"); } + if (!/if \(!m3ControlCanOperate\(\)\)/u.test(actionBody)) { + issues.push("runM3IoAction must fail closed before POST when M3 readiness is blocked"); + } + const readinessBody = functionBody(appSource, "m3ControlCanOperate"); + for (const [pattern, label] of [ + [/readiness\?\.status === "ready"/u, "readiness.status=ready"], + [/readiness\?\.controlReady === true/u, "readiness.controlReady=true"], + [/readiness\?\.sourceKind === "DEV-LIVE"/u, "readiness.sourceKind=DEV-LIVE"], + [/readiness\?\.evidenceLevel === "DEV-LIVE"/u, "readiness.evidenceLevel=DEV-LIVE"] + ]) { + if (!pattern.test(readinessBody)) { + issues.push(`m3ControlCanOperate must require ${label}`); + } + } if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) { issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api"); } @@ -438,6 +452,9 @@ export function validateM3IoSourceResponseContract(m3Source) { /durableStatus:\s*durableStatus\(/u, /blockerClassification:\s*redactedBlockerClassification\(/u, /persistence:\s*persistenceSummary\(/u, + /describeM3IoControlLive/u, + /buildM3IoReadiness/u, + /controlReady:\s*false/u, /frontendBypass:\s*false/u, /patchPanelOnlyPropagation:\s*true/u, /sourceKind:\s*"DEV-LIVE"/u, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index ef7a7cb5..b0dbb58f 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -579,6 +579,23 @@ async function callRpc(method, params = {}) { async function runM3IoAction(action) { if (state.m3Control.pending) return; const traceId = nextProtocolId("trc"); + if (!m3ControlCanOperate()) { + state.m3Control.operation = { + status: "blocked", + action, + traceId, + blocker: { + code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked", + zh: m3ControlBlockedReason() + }, + observedAt: new Date().toISOString(), + sourceKind: "BLOCKED" + }; + renderM3ControlStatus(); + renderRecords(state.liveSurface); + renderDrafts(); + return; + } state.m3Control.pending = true; state.m3Control.operation = { status: "running", @@ -926,6 +943,7 @@ function renderHardwareStatus(summary) { const sourceLink = trustedM3Link(patchPanel.activeConnections); const liveM3Evidence = trustedM3LiveEvidenceFrom(summary) ?? m3ControlLiveEvidence(); const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence); + const controlReady = m3ControlCanOperate(); const runtimeCountsSourceKind = "SOURCE"; const runtimeCountsTone = "source"; const patchTone = m3Live ? "live" : patchPanel.state === "active" ? "source" : patchPanel.state; @@ -1011,12 +1029,12 @@ function renderHardwareStatus(summary) { rows: [ { label: "硬件控制", - value: state.m3Control.contract?.status === "available" ? "受控路径" : "等待探测", - detail: state.m3Control.contract?.status === "available" + value: controlReady ? "受控路径" : "受控路径受阻", + detail: controlReady ? "控制只通过受控后端执行,并经由 gateway-simu、box-simu 与 patch-panel;页面不直连模拟器,也不伪造硬件状态。" - : "控制路径未就绪;页面不直连模拟器,也不伪造硬件状态。", - sourceKind: state.m3Control.contract?.status === "available" ? "SOURCE" : "BLOCKED", - tone: state.m3Control.contract?.status === "available" ? "source" : "blocked" + : `${m3ControlBlockedReason()} 页面不直连模拟器,也不伪造硬件状态。`, + sourceKind: controlReady ? "SOURCE" : "BLOCKED", + tone: controlReady ? "source" : "blocked" } ] }) @@ -1206,7 +1224,8 @@ function m3LiveDetail(evidence) { function renderM3ControlStatus() { const contract = state.m3Control.contract; const operation = state.m3Control.operation; - const contractAvailable = contract?.status === "available"; + const controlReady = m3ControlCanOperate(contract); + const contractObserved = Boolean(contract); const pending = state.m3Control.pending; const operationSucceeded = operation?.status === "succeeded"; const operationBlocked = operation?.status === "blocked"; @@ -1218,29 +1237,47 @@ function renderM3ControlStatus() { : "控制可达 / 等待记录" : operationBlocked ? "操作待处理" - : contractAvailable - ? "受控路径可用" + : controlReady + ? "受控路径可执行" + : contractObserved + ? "受控路径受阻" : "等待探测"; const tone = pending ? "dry-run" : operationSucceeded ? operation.evidenceState?.status === "green" ? "dev-live" : "degraded" - : operationBlocked || !contractAvailable + : operationBlocked || !controlReady ? "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; + el.m3ControlStatus.title = operation?.blocker?.zh ?? operation?.evidenceState?.reason ?? m3ControlBlockedReason(contract); + const disabled = pending || !controlReady; 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 m3ControlCanOperate(contract = state.m3Control.contract) { + return contract?.status === "available" && + contract?.readiness?.status === "ready" && + contract?.readiness?.controlReady === true && + contract?.readiness?.sourceKind === "DEV-LIVE" && + contract?.readiness?.evidenceLevel === "DEV-LIVE"; +} + +function m3ControlBlockedReason(contract = state.m3Control.contract) { + if (!contract) return "等待 cloud-api 返回 M3 IO 只读 readiness;未确认前控制保持阻塞。"; + return contract.readiness?.blocker?.zh ?? + contract.blockedReason ?? + "M3 IO 只读 readiness 未 green;控制面板保持阻塞,不直连 gateway/box-simu。"; +} + function controlRows() { const operation = state.m3Control.operation; + const controlReady = m3ControlCanOperate(); const operationCards = operation ? [ { @@ -1257,10 +1294,10 @@ function controlRows() { return [ { title: "控制路径", - detail: state.m3Control.contract?.status === "available" + detail: controlReady ? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。" - : "等待受控路径探测;不可用时不会直连 gateway/box-simu。", - tone: state.m3Control.contract?.status === "available" ? "source" : "blocked" + : `${m3ControlBlockedReason()} 不可用时不会直连 gateway/box-simu,也不会伪造硬件状态。`, + tone: controlReady ? "source" : "blocked" }, ...operationCards, { diff --git a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs index e5009f74..3d68cee3 100644 --- a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs +++ b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs @@ -53,7 +53,10 @@ export function runM3ControlPanelGuard() { for (const copy of [ "M3 IO 控制请求失败", - "等待受控路径探测;不可用时不会直连 gateway/box-simu。", + "M3 IO 只读 readiness 未 green", + "受控路径受阻", + "等待 cloud-api 返回 M3 IO 只读 readiness", + "不可用时不会直连 gateway/box-simu", "浏览器不直连 box-simu", "阻塞原因=", "仍在等待完整可信记录", @@ -109,6 +112,7 @@ function assertControlPanelSource(app) { assert.match(initBody, /el\.m3ReadDi\.addEventListener\("click"[\s\S]*runM3IoAction\("di\.read"\)/u, "DI button must run di.read"); const actionBody = functionBody(app, "runM3IoAction"); + assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked"); assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io"); assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch"); assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/io)/u, "M3 action must not call any route except /v1/m3/io"); @@ -125,9 +129,16 @@ function assertControlPanelSource(app) { assert.match(actionBody, /"X-Actor-Id":\s*"usr_hwlab_cloud_web"/u, "M3 action must attach web actor header"); assert.match(actionBody, /evidenceState\?\.status === "green" \? "DEV-LIVE" : "BLOCKED"/u, "M3 operation sourceKind must not claim DEV-LIVE unless evidence is green"); + const canOperateBody = functionBody(app, "m3ControlCanOperate"); + assert.match(canOperateBody, /readiness\?\.status === "ready"/u, "M3 controls must require readiness.status=ready"); + assert.match(canOperateBody, /readiness\?\.controlReady === true/u, "M3 controls must require readiness.controlReady=true"); + assert.match(canOperateBody, /readiness\?\.sourceKind === "DEV-LIVE"/u, "M3 controls must not unlock on SOURCE/DRY-RUN/BLOCKED readiness"); + assert.match(canOperateBody, /readiness\?\.evidenceLevel === "DEV-LIVE"/u, "M3 controls must require live readiness evidence level"); + const controlRowsBody = functionBody(app, "controlRows"); assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route"); assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path"); + assert.match(controlRowsBody, /m3ControlBlockedReason\(\)/u, "blocked control copy must surface the cloud-api Chinese readiness reason"); assert.match(controlRowsBody, /受控路径读取/u, "control rows must describe DI read affordance"); const operationDetailBody = functionBody(app, "m3OperationDetail"); @@ -191,8 +202,8 @@ function assertNoStaticDevLiveClaim({ html, app }) { assert.match(`${controlPanel}\n${wiringPanel}`, /tone-blocked|待可信记录/u, "static M3 panels must keep blocked/source posture"); assert.doesNotMatch( functionBody(app, "renderM3ControlStatus"), - /operationSucceeded[\s\S]{0,160}\?\s*"dev-live"\s*:\s*"degraded"[\s\S]{0,160}operation\.status === "succeeded"/u, - "M3 status rendering must not treat any succeeded operation as DEV-LIVE without evidenceState green" + /contract\?\.status === "available"[\s\S]{0,120}disabled/u, + "M3 status rendering must not unlock controls from route availability alone" ); }