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, M3_STATUS_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 cloudWebRouteSource = readText("internal/dev-entrypoint/cloud-web-routes.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(M3_STATUS_ROUTE, "/v1/m3/status", "M3 status aggregate 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, cloudWebRouteSource }); assertNoStaticDevLiveClaim({ html, app }); for (const pattern of [ /#panel-control\s*{\s*grid-template-rows:\s*auto auto auto minmax\(0,\s*1fr\);/s, /\.m3-flow-summary\s*{/s, /\.m3-flow-line\s*{/s, /\.m3-flow-kv\s*{/s, /\.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 控制请求失败", "M3 IO 只读 readiness 未 green", "M3 IO 只读 readiness 已确认", "受控路径受阻", "等待 cloud-api 返回 M3 IO 只读 readiness", "不可用时页面不直连模拟器", "浏览器不直连 box-simu", "阻塞原因=", "仍在等待完整可信记录", "页面不直连模拟器,也不伪造硬件状态。", "控制可达 / 等待记录", "待可信记录", "选择 DO1 true/false 后点击“写入 DO1”", "最近 trace 与 operation 可在“可信记录”页签追踪", "DO1目标=", "DI1观测=", "trusted=", "durable=" ]) { 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, /

基础 IO 控制<\/h2>/u, "M3 control panel title must be user-visible without milestone shorthand"); assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中]*aria-label="M3 DO1 到 DI1 操作摘要"/u, "M3 operation summary must be visible above the form"); for (const id of ["m3-flow-state", "m3-action-summary", "m3-do1-target", "m3-di1-observed", "m3-trust-summary", "m3-trace-summary"]) { assert.match(controlPanel, new RegExp(`id="${id}"`, "u"), `M3 flow summary missing #${id}`); } assert.match(controlPanel, /DO1 -> patch-panel -> DI1/u, "M3 summary must state the operator-facing DO1 to DI1 route"); assert.match(controlPanel, /id="m3-records-jump"[\s\S]*查看最近操作\/trace/u, "M3 summary must link recent trace to records tab"); assert.match(controlPanel, /]+id="m3-control-form"[^>]+aria-label="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}`); } assert.match(wiringPanel, /data-wiring-layout="two-column-long-table"/u, "wiring panel must use the #276 two-column long-table layout"); assert.equal((wiringPanel.match(/${escapeRegExp(header)}<\\/th>`, "u"), `wiring table must not restore legacy wide column ${header}`); } assert.match(wiringPanel, /待可信记录/u, "static wiring row must stay blocked"); assert.match(wiringPanel, /等待可信记录/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, /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.match(actionBody, /fetchJson\("\/v1\/m3\/status"\)/u, "M3 action may refresh only through same-origin aggregate status after control"); assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch"); assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/(?:io|status))/u, "M3 action must not call any route except /v1/m3/io and /v1/m3/status"); 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 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 statusBody = functionBody(app, "renderM3ControlStatus"); assert.match(statusBody, /m3ControlStatusTitle\(\{ operation, contract, controlReady \}\)/u, "ready status title must use the control-ready-aware helper"); assert.match(statusBody, /renderM3FlowSummary\(\{ label, tone, operation, contract, controlReady, pending \}\)/u, "control status must keep the operator summary in sync"); const flowSummaryBody = functionBody(app, "renderM3FlowSummary"); for (const term of ["m3ActionSummary", "m3Do1Target", "m3Di1Observed", "m3TrustSummary", "m3TraceSummary"]) { assert.match(flowSummaryBody, new RegExp(`el\\.${term}\\.textContent`, "u"), `M3 flow summary must update ${term}`); } assert.match(flowSummaryBody, /m3OperationDo1Target\(operation/u, "M3 flow summary must show the DO1 target"); assert.match(flowSummaryBody, /m3OperationDi1Observed\(operation/u, "M3 flow summary must show the DI1 observation"); assert.match(flowSummaryBody, /m3TrustSummaryText\(operation, contract\)/u, "M3 flow summary must show trusted/durable status"); assert.match(flowSummaryBody, /m3TraceSummaryText\(operation, status\)/u, "M3 flow summary must show recent operation/trace ids"); const statusTitleBody = functionBody(app, "m3ControlStatusTitle"); assert.match(statusTitleBody, /if \(controlReady\)/u, "ready status title must not fall through to blocked copy"); assert.match(statusTitleBody, /只读 readiness 已确认/u, "ready status title must show confirmed Chinese readiness copy"); assert.match(statusTitleBody, /return m3ControlBlockedReason\(contract\)/u, "blocked status title must still surface Chinese blocked reason"); const controlRowsBody = functionBody(app, "controlRows"); assert.match(controlRowsBody, /选择 DO1 true\/false 后点击/u, "control copy must start with an operator action script"); assert.match(controlRowsBody, /最近 trace 与 operation/u, "control copy must direct users to recent trace records"); 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"); 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"); assert.match(operationDetailBody, /DO1目标=/u, "operation detail must expose DO1 target"); assert.match(operationDetailBody, /DI1观测=/u, "operation detail must expose DI1 observation"); 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, /DO1目标=/u, "operation records must expose DO1 target"); assert.match(operationRecordBody, /DI1观测=/u, "operation records must expose DI1 observation"); assert.match(operationRecordBody, /trusted=/u, "operation records must expose trusted state"); assert.match(operationRecordBody, /durable=/u, "operation records must expose durable state"); 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, cloudWebRouteSource }) { const browserSource = `${html}\n${app}`; const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, ""); const frontendGuard = checkFrontendNoDirectRuntimeCalls({ appSource: app, htmlSource: html, artifactPublisherSource: artifactPublisher, cloudWebRouteSource }); 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", "/v1/agent/chat/cancel", M3_IO_CONTROL_ROUTE].sort(), "browser POST routes must be limited to read-only RPC envelope, agent chat lifecycle, and M3 IO control" ); const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat" && target !== "/v1/agent/chat/cancel"); assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route"); assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status"); 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(sanitizedBrowserSource, 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|待可信记录/u, "static M3 panels must keep blocked/source posture"); assert.doesNotMatch( functionBody(app, "renderM3ControlStatus"), /contract\?\.status === "available"[\s\S]{0,120}disabled/u, "M3 status rendering must not unlock controls from route availability alone" ); } 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"); }