Merge pull request #257 from pikasTech/test/m3-control-panel-guard

test: add M3 control panel guard
This commit is contained in:
Lyon
2026-05-23 13:21:24 +08:00
committed by GitHub
4 changed files with 249 additions and 2 deletions
+1
View File
@@ -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",
+1 -1
View File
@@ -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;
+3 -1
View File
@@ -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, "\\$&");
@@ -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, /<h2>M3 基本 IO 控制<\/h2>/u, "M3 control panel title must be user-visible");
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="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(`<th>${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(`<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");
}