Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/m3-control-panel-guard.mjs
T
2026-05-23 06:58:52 +00:00

246 lines
13 KiB
JavaScript

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 [
/#panel-control\s*{\s*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/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 控制请求失败",
"等待受控路径探测;不可用时不会直连 gateway/box-simu。",
"浏览器不直连 box-simu",
"阻塞原因=",
"仍在等待完整可信记录",
"页面不直连模拟器,也不伪造硬件状态。",
"控制可达 / 等待记录",
"待可信记录"
]) {
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, /<h2>基础 IO 控制<\/h2>/u, "M3 control panel title must be user-visible without milestone shorthand");
assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中</u, "M3 control status starts as probing, not live");
assert.match(controlPanel, /<form[^>]+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}`);
}
for (const header of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"]) {
assert.match(wiringPanel, new RegExp(`<th>${escapeRegExp(header)}<\\/th>`, "u"), `wiring table missing ${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, /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, /按钮只走受控后端/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, /受控路径读取/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|待可信记录/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(`<section\\b[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "u"));
assert.notEqual(start, -1, `missing section #${id}`);
const end = source.indexOf("</section>", start);
assert.notEqual(end, -1, `unterminated section #${id}`);
return source.slice(start, end + "</section>".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");
}