diff --git a/package.json b/package.json index 218344b1..4c12df05 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "l6:smoke": "node scripts/l6-cli-web-smoke.mjs", "web:summary": "node scripts/export-web-gate-summary.mjs", "web:m3-readonly": "node web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs", + "web:m3-control-panel": "node web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs", "web:check": "node web/hwlab-cloud-web/scripts/check.mjs", "web:build": "node web/hwlab-cloud-web/scripts/build.mjs", "artifact-catalog:refresh-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 31265b32..4e791e66 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -635,7 +635,7 @@ async function runM3IoAction(action) { } else { state.m3Control.operation = { ...response.data, - sourceKind: response.data?.status === "succeeded" ? "DEV-LIVE" : "BLOCKED" + sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED" }; } state.m3Control.pending = false; diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index e7d12e0c..6bca6e9b 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -9,6 +9,7 @@ import { runDevCloudWorkbenchStaticSmoke } from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs"; import { gateSummary } from "../gate-summary.mjs"; +import { runM3ControlPanelGuard } from "./m3-control-panel-guard.mjs"; import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs"; import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs"; @@ -509,9 +510,10 @@ assert.ok(gateSummary.evidenceRecords.every((record) => record.traceId && record assert.equal(gateSummary.safety.allowNetwork, false); runCloudWebM3ReadonlyContract(); +runM3ControlPanelGuard(); runWorkbenchHardwarePanelContract(); -console.log("hwlab-cloud-web check ok: workbench shell, hardware evidence panel, and read-only diagnostics contract are present"); +console.log("hwlab-cloud-web check ok: workbench shell, M3 control guard, hardware evidence panel, and read-only diagnostics contract are present"); function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs new file mode 100644 index 00000000..2b45717b --- /dev/null +++ b/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + M3_IO_CHAIN, + M3_IO_CONTROL_ROUTE +} from "../../../internal/cloud/m3-io-control.mjs"; +import { checkFrontendNoDirectRuntimeCalls } from "../../../scripts/src/m3-io-control-e2e.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(rootDir, "../.."); +const exactTrustedRoute = `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.patchPanelServiceId} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`; +const readOnlyRpcMethods = Object.freeze([ + "system.health", + "cloud.adapter.describe", + "audit.event.query", + "evidence.record.query" +]); + +function readText(relativePath) { + return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8"); +} + +export function runM3ControlPanelGuard() { + const html = readText("web/hwlab-cloud-web/index.html"); + const app = readText("web/hwlab-cloud-web/app.mjs"); + const styles = readText("web/hwlab-cloud-web/styles.css"); + const artifactPublisher = readText("scripts/dev-artifact-publish.mjs"); + const browserSource = `${html}\n${app}\n${styles}`; + + assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact"); + assert.equal( + exactTrustedRoute, + "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + "M3 trusted route constant must remain exact" + ); + + assertControlPanelDom(html); + assertControlPanelSource(app); + assertBrowserWriteBoundaries({ app, html, artifactPublisher }); + assertNoStaticDevLiveClaim({ html, app }); + + for (const pattern of [ + /\.m3-control-form\s*{/s, + /\.m3-control-grid\s*{/s, + /\.m3-control-actions\s*{/s + ]) { + assert.match(styles, pattern, `missing M3 control style ${pattern.source}`); + } + + for (const copy of [ + "M3 IO 控制请求失败", + "等待 /v1/m3/io 合同探测;不可用时不会直连 gateway/box-simu。", + "不能从浏览器直连 box-simu", + "阻塞原因=", + "缺少 patch-panel live report 与 operation/trace/audit/evidence IDs", + "Web 不提供直连 gateway/box-simu 或伪硬件状态写入。", + "控制可达 / 证据 blocked", + "阻塞 BLOCKED" + ]) { + assert.match(browserSource, new RegExp(escapeRegExp(copy), "u"), `missing Chinese blocked/error copy: ${copy}`); + } +} + +function assertControlPanelDom(html) { + const controlPanel = elementSectionById(html, "panel-control"); + assert.match(controlPanel, /data-side-panel="control"/u, "right-side control tab panel must be present"); + assert.match(controlPanel, /

M3 基本 IO 控制<\/h2>/u, "M3 control panel title must be user-visible"); + assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中]+id="m3-control-form"[^>]+aria-label="M3 DO1 写入"/u, "DO write form must be labeled"); + assert.match(controlPanel, /id="m3-gateway-select"[\s\S]*value="gwsimu_1"[\s\S]*gateway-simu-1/u, "DO write uses source gateway"); + assert.match(controlPanel, /id="m3-box-select"[\s\S]*value="res_boxsimu_1"[\s\S]*box-simu-1 \/ res_boxsimu_1/u, "DO write uses source box resource"); + assert.match(controlPanel, /id="m3-port-select"[\s\S]*value="DO1"[\s\S]*DO1/u, "DO write exposes only DO1"); + assert.match(controlPanel, /id="m3-value-select"[\s\S]*value="true"[\s\S]*true[\s\S]*value="false"[\s\S]*false/u, "DO write exposes true and false values"); + assert.match(controlPanel, /id="m3-write-do"[^>]*type="submit"[^>]*>写入 DO1<\/button>/u, "DO write affordance must be visible"); + assert.match(controlPanel, /id="m3-read-di"[^>]*type="button"[^>]*>读取 DI1<\/button>/u, "DI read affordance must be visible"); + assert.match(controlPanel, /id="control-list"/u, "control panel must include operation feedback list"); + assert.doesNotMatch(controlPanel, /tone-dev-live|开发实况 DEV-LIVE/u, "static control panel must not claim DEV-LIVE"); + + const wiringPanel = elementSectionById(html, "panel-wiring"); + assert.match(html, new RegExp(escapeRegExp(exactTrustedRoute), "u"), "static fallback must show the exact M3 route"); + for (const value of ["res_boxsimu_1", "DO1", "hwlab-patch-panel", "res_boxsimu_2", "DI1"]) { + assert.match(wiringPanel, new RegExp(escapeRegExp(value), "u"), `wiring panel missing ${value}`); + } + for (const header of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "trace/evidence"]) { + assert.match(wiringPanel, new RegExp(`${escapeRegExp(header)}<\\/th>`, "u"), `wiring table missing ${header}`); + } + assert.match(wiringPanel, /阻塞 BLOCKED/u, "static wiring row must stay blocked"); + assert.match(wiringPanel, /m3-route-required \/ waiting-for-dev-live/u, "static wiring row must require live evidence"); +} + +function assertControlPanelSource(app) { + const trustedRouteSource = constObjectSource(app, "M3_TRUSTED_ROUTE"); + for (const [field, value] of [ + ["fromResourceId", M3_IO_CHAIN.sourceResourceId], + ["fromPort", M3_IO_CHAIN.sourcePort], + ["patchPanelServiceId", M3_IO_CHAIN.patchPanelServiceId], + ["toResourceId", M3_IO_CHAIN.targetResourceId], + ["toPort", M3_IO_CHAIN.targetPort] + ]) { + assert.match(trustedRouteSource, new RegExp(`${field}:\\s*"${escapeRegExp(value)}"`, "u"), `M3 trusted route source missing ${field}`); + } + + const initBody = functionBody(app, "initM3Control"); + assert.match(initBody, /el\.m3ControlForm\.addEventListener\("submit"[\s\S]*runM3IoAction\("do\.write"\)/u, "submit must run do.write"); + 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, /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"); + assert.match(actionBody, /gatewayId:\s*el\.m3GatewaySelect\.value/u, "DO write request must use the selected source gateway"); + assert.match(actionBody, /resourceId:\s*el\.m3BoxSelect\.value/u, "DO write request must use the selected source resource"); + assert.match(actionBody, /boxId:\s*"boxsimu_1"/u, "DO write request must target source boxsimu_1"); + assert.match(actionBody, /port:\s*el\.m3PortSelect\.value/u, "DO write request must use selected DO1 port"); + assert.match(actionBody, /value:\s*el\.m3ValueSelect\.value === "true"/u, "DO write request must convert true/false to boolean"); + assert.match(actionBody, /gatewayId:\s*"gwsimu_2"/u, "DI read request must use target gateway"); + assert.match(actionBody, /resourceId:\s*M3_TRUSTED_ROUTE\.toResourceId/u, "DI read request must use trusted target resource"); + assert.match(actionBody, /boxId:\s*"boxsimu_2"/u, "DI read request must target boxsimu_2"); + assert.match(actionBody, /port:\s*M3_TRUSTED_ROUTE\.toPort/u, "DI read request must use trusted DI1 port"); + assert.match(actionBody, /"X-Trace-Id":\s*traceId/u, "M3 action must attach trace header"); + 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 controlRowsBody = functionBody(app, "controlRows"); + assert.match(controlRowsBody, /按钮只调用 \/v1\/m3\/io/u, "control copy must name same-origin control route"); + assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path"); + assert.match(controlRowsBody, /di\.read/u, "control rows must describe DI read affordance"); + + const operationDetailBody = functionBody(app, "m3OperationDetail"); + for (const field of ["operation", "trace", "audit", "evidence"]) { + assert.match(operationDetailBody, new RegExp(`recordField\\("${field}"`, "u"), `operation detail missing ${field}`); + } + assert.match(operationDetailBody, /operation\.blocker\?\.zh/u, "operation detail must prefer Chinese blocker copy"); + + const operationRecordBody = functionBody(app, "operationRecordCards"); + for (const field of ["action", "trace", "audit", "evidence"]) { + assert.match(operationRecordBody, new RegExp(`recordField\\("${field}"`, "u"), `operation record missing ${field}`); + } + assert.match(operationRecordBody, /via=\/v1\/m3\/io/u, "operation record must show the control route"); + assert.match(operationRecordBody, /frontendBypass=false/u, "operation record must show no frontend bypass"); +} + +function assertBrowserWriteBoundaries({ app, html, artifactPublisher }) { + const browserSource = `${html}\n${app}`; + const frontendGuard = checkFrontendNoDirectRuntimeCalls({ + appSource: app, + htmlSource: html, + artifactPublisherSource: artifactPublisher + }); + assert.equal(frontendGuard.status, "pass", frontendGuard.issues.join("\n")); + assert.deepEqual( + uniqueSorted(rpcCallsFrom(app)), + [...readOnlyRpcMethods].sort(), + "browser JSON-RPC calls must stay read-only" + ); + + const postTargets = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,260}?method:\s*["']POST["']/gu)] + .map((match) => match[1]); + assert.deepEqual( + uniqueSorted(postTargets), + ["/json-rpc", "/v1/agent/chat", M3_IO_CONTROL_ROUTE].sort(), + "browser POST routes must be limited to read-only RPC envelope, agent chat, and M3 IO control" + ); + + const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat"); + assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route"); + + for (const [pattern, label] of [ + [/\bcallRpc\(\s*["']hardware\./u, "generic hardware JSON-RPC"], + [/hardware\.operation\.request/u, "hardware operation RPC"], + [/hardware\.invoke\.shell/u, "hardware shell RPC"], + [/audit\.event\.write/u, "audit write RPC"], + [/evidence\.record\.write/u, "evidence write RPC"], + [/\/v1\/rpc\//u, "direct REST RPC bridge"], + [/\bfetch(?:Json)?\(\s*["'`][^"'`]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator fetch"], + [/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator URL"], + [/\/wiring\/apply|\/wiring\/reload|\/signals\/route/u, "patch-panel write endpoint"] + ]) { + assert.doesNotMatch(browserSource, pattern, `browser source must not expose ${label}`); + } +} + +function assertNoStaticDevLiveClaim({ html, app }) { + const controlPanel = elementSectionById(html, "panel-control"); + const wiringPanel = elementSectionById(html, "panel-wiring"); + assert.doesNotMatch(`${controlPanel}\n${wiringPanel}`, /tone-dev-live|开发实况 DEV-LIVE/u, "static M3 panels must not claim DEV-LIVE"); + assert.match(`${controlPanel}\n${wiringPanel}`, /tone-blocked|阻塞 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" + ); +} + +function rpcCallsFrom(source) { + return [...source.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]); +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} + +function elementSectionById(source, id) { + const start = source.search(new RegExp(`]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "u")); + assert.notEqual(start, -1, `missing section #${id}`); + const end = source.indexOf("", start); + assert.notEqual(end, -1, `unterminated section #${id}`); + return source.slice(start, end + "".length); +} + +function constObjectSource(source, constName) { + const match = source.match(new RegExp(`const\\s+${escapeRegExp(constName)}\\s*=\\s*Object\\.freeze\\(\\{`, "u")); + if (!match) return ""; + const start = match.index; + const end = source.indexOf("});", start); + return end === -1 ? source.slice(start) : source.slice(start, end + 3); +} + +function functionBody(source, functionName) { + const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u")); + if (!match) return ""; + let depth = 0; + for (let index = match.index + match[0].length - 1; index < source.length; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return source.slice(match.index, index + 1); + } + } + return ""; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + runM3ControlPanelGuard(); + console.log("M3 control panel SOURCE/DOM guard passed"); +}