保留原生浏览器探针失败阶段与清理证据(09-06-release-gate-integrity)

这个提交包含在:
2026-09-19 15:43:44 +08:00
父节点 16b9cad98c
当前提交 6e57d33715
共修改 8 个文件,包含 395 行新增和 27 行删除
@@ -6,6 +6,7 @@
- `release-gate-result.mjs` owns an exclusive empty directory under `artifacts/release-gate/`, records INCOMPLETE before execution, and derives final results from fixed ordered phases, source/index/tracked and untracked input digests, current policy/toolchain, owned environment/cleanup, G19 full discovery and every local result plus witnesses/acceptance specs. Missing, truncated, linked, changed, interrupted or partial evidence is not PASS.
- `qa-stage-record.mjs` records full-local phases through failure cleanup. Keep plain shell calls under errexit: wrapping compound helpers in `if`/`||` can suppress their internal failures. Preserve the original failing exit code if evidence cleanup also fails.
- Persist actual browser selection separately from discovery: exact derived spec arguments, supplementary arguments, mode, retries, forbid-only and worker count. Ordinary release verification rejects supplementary filters, shards, missing projects, changed budgets, skips, retries, expected failures and inconsistent statistics/time ranges.
- Native environment probes retain separate Chromium/WebKit observations even on launch or cleanup failure. Record the failing stage and allowlisted error category, never raw browser messages, endpoints or paths. Cleanup failures append without replacing the primary cause; every failure still rejects the environment. A Chromium trace cannot diagnose WebKit. A standalone blank-page diagnostic is not a managed gate receipt or evidence of business coverage.
- A dirty development tree may have `localChecksPassed=true`, but returns PARTIAL/exit 2 and `deploymentEligible=false`. Offline `verify` re-reads and hashes evidence and recomputes against the current checkout. Local proof does not discharge the 12 same-candidate public obligations, independent reproduction, or operator approval.
- Existing project-wide skip flags accept only exact 0/1. Each or both require nonempty reason/operator/approval reference, persist affected stages, and end PARTIAL/exit 2. Other release modes reject them before stage side effects. These metadata do not grant authorization; no approval time is invented.
- Validate with pure receipt counterexamples, actual shell/fake-tool invocation, source mutations, and existing deployment lifecycle/safety regressions. Fake tools must intercept every external action. Evidence is auditable, not a cryptographic signature.
@@ -1,3 +1,28 @@
## 2026-09-19 WebKit固定三组对照(本地根因诊断)
原30项探针测试与补充7项环境测试覆盖同一未改代码,独立复核通过,现有观测补丁可按既有授权先本地提交。真实native诊断仍FAIL,绝不把观测修正当成浏览器修复。
静态发现:headless走WPE;MiniBrowser包装器使用自己的lib/sys/lib,原包131个相对符号链接在物理复制后变成独立文件,含libWPEBackend-fdo.so与.so.1别名。这里只是可验证线索,不能宣称已确认动态加载冲突;另一线索是绝对缓存路径长短。Playwright公开BrowserServer.process()可监听stderr/exit,无需DEBUG/协议payload或改业务源码。
固定A/B/C,均新独占副本,不从旧缓存执行:A长缓存路径、全物化;B短缓存路径、全物化;C与B等长路径、只在本轮副本内恢复原bundle相对链接。三份展开后的字节/模式清单完全相同;C每条链接须与旧物理文件摘要相同且解析在本轮缓存内。禁止外部链接、硬链接和跨样本共享inode;源和旧证据前后不变。运行时HOME/TMP/XDG均短独占,namespace拓扑相同,保持Chromium存活时观测WebKit。因环境条件与旧门禁仍不同,只报告当前对照结论,不能倒推历史根因唯一性。
每组一次,外层每组180秒沿用当前诊断预算;不得改引擎参数/禁用sandbox/重试到绿。分类记录WebKit实际stderr的白名单关键词与父进程退出事件(含观测是否在cleanup前);原始stderr不落盘。仅增加ignored诊断封装器,已审probe/helper/测试不变。新实验独立marker登记A/B/C最多三次,旧一次marker不删除或复用。失败若正常清理仍可继续计划内下一对照;完整性/隔离/清理无法确认则停止剩余组。
## 2026-09-19 定向执行临时路径增量
原私有TMPDIR放在长证据目录下,IPC用例missing路径拒绝失败。改用`/tmp`下mkdtemp生成的短独占目录(0700),与共享/tmp内容隔离;先验证包括最长missing/supervisor.sock后缀的字节长度小于100。测试源码、断言、5秒IPC预算、零重试不变。诊断wrapper亦使用短TMPDIR,避免把同一执行条件缺陷带入下一组;其余HOME/XDG、缓存副本、拓扑、180秒外层终止预算和清理合同不变,披露这个环境差异。
原wrapper与输入清单保存;新wrapper复用原BASE/原一次性marker,只消费新冻结输入。单文件定向补验与原通过探针文件按输入分列,可支持继续独立空白页诊断,不宣称两文件同次全绿,更不代表完整门禁通过。
## 2026-09-19 native探针观测修复(现有范围内)
已证实:WebKit异常使engines数组赋值整体丢失,native-trace仅覆盖Chromium;尚未证实:物理副本或系统依赖是根因。旧缓存131个WebKit内部链接被物化是待验线索,不作为修复依据。
1. 原生引擎生命周期把launch纳入try,按launch/namespace/connect/context/trace/page/online/cleanup登记失败;错误只输出受控类别、标准系统码/信号/退出码,不输出message/stack/URL/路径。清理继续执行,首因与追加清理错误分别保留。
2. Chromium与WebKit结果逐项保留;正常路径维持Chromium存活期间观测WebKit的原顺序。Chromium自身失败也保留两引擎诊断。原native-trace仍属Chromium,WebKit使用独立trace。引擎任何失败或收尾环境重验失败均保持整体false/exit1,现有回执校验不放宽。
3. 主会话仅做语法/差异检查;Claude运行探针与环境校验两份Node测试、治理检查和新独占缓存的单次空白页诊断。不起DB、应用、业务用例或完整门禁,不伪造managed receipt。诊断采用原封闭veth拓扑,缓存用失败轮副本再复制、逐份摘要/模式和非共享inode核对,源前后不变。另设独占HOME/XDG/TMPDIR以隔离用户配置,明确登记为对旧门禁的环境差异;一次通过不能证明缓存或HOME是历史根因。
4. 通过定向测试及独立复核后再决定提交和完整候选时机;当前不能称根因已解决,旧失败及例外不改写。
## 2026-09-19 F1诊断处置及最终冻结
未改执行输入的单文件52项一次通过;独立复核承认三次R1校验不在该测试执行范围内。保留原失败与根因未知,不做推测性修复/预算扩容,不合并分阶段重验。定向证据足以支持本地提交,不能替代完整候选验收。
@@ -1,3 +1,43 @@
## 2026-09-19 观测补丁定向通过:本地提交,Claude固定三组定位WebKit(当前)
已实核34条输入无漂移。环境文件短TMPDIR补验7/7,原探针文件30项通过、源码未改;独立审查与治理/格式通过。原37项FAIL保留,不说同轮37通过。真实诊断约12.6秒、exit1:Chromium145.0.7632.6在线通过;WebKit26.0在page阶段报browser_process_closed,trace_stop追加同类;拓扑/namespace/清理及源缓存完整性通过。未采集真实父进程退出事件,根因仍UNKNOWN。
- [x] 接收补验/独立复核与诊断证据,旧失败/marker/缓存保持。
- [x] 确定三组有界对照方案(见design顶部),不改受测代码/预算/验收要求。
- [ ] 按既有明确授权本地提交已验证的8路径观测修正;SHA/范围以本轮local-commit.json为准,无推送部署。
- [ ] Claude只审新ignored封装器、核输入,运行A/B/C空白页对照;不重复30/7单测、已通过静态检查或完整门禁。
- [ ] 根据各组stderr安全分类/真实退出事件与副本对照结果决定具体根因修复和后续完整候选;不预判已修好。
证据/后续请求:artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/webkit-comparison-20260919/。本次主会话仅语法/治理/格式静态检查和本地提交;无测试启动、生产ARM/真实配置密钥备份访问或下载。旧整轮FAIL、registry FAIL、CSV NOT_RUN、docs22提示、12公网义务保持,deploymentEligible=false。
## 2026-09-19 探针补丁复核通过;修正定向执行的临时路径(当前)
Claude两文件37项36通过/1失败、零跳过;治理/格式与补丁独立复核通过。主会话接收时核8份增量和27条输入零漂移。唯一失败在原IPC用例第80行missing/supervisor.sock应拒绝,非报告所称字段校验循环(实际第77行)。正向与四个错字段校验已通过;原报告与FAIL保留,更正只追加。
长TMPDIR让socket路径超过Linux AF_UNIX长度限制,与missing后缀被截断造成同地址别名相符,尚未运行补充实验。决策只调整执行器:短的独占临时目录不与隐私隔离冲突;不改生产helper/测试断言、不skip、不增加预算。旧WebKit根因继续UNKNOWN,不将本轮IPC线索移作旧根因。
- [x] 核原始日志并追加接收/行号更正;原证据不改。
- [x] 写定向续行方案:短TMPDIR只补验full-local-environment.test.mjs一文件;复用原browser-environment-probe.test.mjs已通过且字节未变的结果,不拼成同次37全绿。
- [ ] Claude一文件补验、三份记录的治理/格式检查;全部通过后继续尚未启动的一次双引擎诊断。
- [ ] 接回结果再决定必要根因修复和本地提交,完整门禁不自动重跑。
续行入口:artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/short-runtime-20260919/claude-request.md。新诊断wrapper仅换短独占TMPDIR和当前输入清单,旧wrapper/manifest/validation-inputs/报告/缓存/marker全部原样保留;新wrapper共享原诊断marker,不能获得额外一次运行。主会话只做语法/格式检查,不启动测试。deploymentEligible=false。
## 2026-09-19 最终候选FAIL:先修观测,Claude做最小诊断(当前)
16b9cad9已本地提交且本次运行前后clean。完整尝试2026-09-19T06:33:39Z至06:38:33Z,exit1;static PASS(定向74项/前端构建),full-local环境校验失败,12子阶段未开始。失败回执与接收摘要见artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/failure-reception.json。WebKit观测抛错已证实,实际抛错阶段/根因未知;现有trace无WebKit错误。历史5轮通过不证明当前副本必为原因。
- [x] 接收并核对原报告/回执/native JSON/trace;不改旧失败、marker、浏览器缓存。
- [x] 在现有task记录限定方案,维持发布与真实数据边界。
- [x] 修native观测与失败清理;增加假引擎故障/隐私/回执拒绝测试。
- [x] 主会话仅语法/差异自查通过,冻结本批增量和Claude执行说明。
- [ ] Claude执行两份定向Node测试、治理检查和一次隔离双引擎空白页诊断并只读复核。
- [ ] 接回实际失败阶段及安全分类后决定必要根因修复;测试通过前不提交本批、不启动完整门禁。
交接:artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/claude-request.md。诊断有独占HOME/XDG/TMPDIR,与旧门禁未传HOME不完全相同;即使通过也只表明该隔离条件下可启动,不能据此宣布旧故障已定位。主会话未执行测试/浏览器,没有第二轮完整门禁。
保留原F1时序超时、WebKit registry baseline FAIL、CSV NOT_RUN、docs22提示和12公网义务。deploymentEligible=false;无推送部署/生产ARM/真实配置密钥备份/旧恢复/编号启用/差旅解冻。用户分工不变:Codex决策与实施,Claude测试。
## 2026-09-19 R1定向验收完成,保存本地候选后进入一次完整门禁(最新)
Claude单次诊断52/52通过、exit0、57.515秒,原三例2.027/1.837/1.940秒,5秒预算不变;27条执行输入前后零漂移。主会话实算与原125项及另两份46项所依赖代码一致,六项独立代码review均PASS。F1原“三次require导致这52项超时”的归因被独立更正;原exit1/3次超时保留,实际成因仍未确定,不宣称已根治或整组98曾一次全绿。
@@ -1,3 +1,15 @@
## 2026-09-19 native观测补丁已定向验收,WebKit根因继续本地诊断
探针文件原30项通过,环境文件短TMPDIR补充7/7通过;补丁独立复核及治理/格式通过。旧37项FAIL不改写,非同次37全绿。真实双引擎诊断Chromium通过、WebKit在page阶段报browser_process_closed,底层父进程是否退出及原因未采集,不能仅凭类别断定系统进程退出原因。
允许现有本地故障诊断范围内做固定三组对照:长路径物理副本、短路径物理副本、短路径保留内部安全相对链接的独占副本;每组一次、无重试、无下载或系统/生产设置改动。双引擎原生online/namespace约束不放宽。对照只帮助归因,不能作完整发布回执;实际缓存策略修复需以观测为据。记录安全stderr类别及真实进程退出码/信号,不保留原始输出、端点、配置或完整路径。
## 2026-09-19 最终候选环境失败:限定诊断与观测修复
候选16b9cad9的单次完整尝试FAIL/exit1:static通过;full-local在native WebKit观测抛错时停止,12个内部阶段全部NOT_RUN,未获得发布资格。旧回执、marker和缓存保持。继续现有QA范围,仅修复错误观测丢失并安排Claude定向验证;双引擎、namespace、online、预算、完整集合及零重试要求不变,不改业务或部署入口。
本批验收:每种引擎失败必须保留名称、失败阶段、安全分类及清理结果;失败仍拒绝,清理错误不抹掉首因;不得记录原始Playwright错误/端点/完整路径。以假引擎验证故障路径,以新独占缓存/无外联网namespace进行一次双引擎空白页诊断;诊断不是完整门禁或发布回执。根因未证实前不修改缓存策略、不下载、不自动重跑全量。
## 2026-09-19 R1本地补丁可提交,最终完整验收待执行
独立代码复核六项及125项定向检查通过;生命周期52项在原5秒预算下补充通过,原3项超时与未知成因保持记录。没有已证实的R1缺陷需要改代码,按已有授权保存最终候选。本地定向完成不勾销完整验收:冻结clean提交后一次verify-candidate,外部部署/公网义务仍分别授权与验收。
@@ -2,7 +2,7 @@
"id": "release-gate-integrity",
"name": "release-gate-integrity",
"title": "部署门禁完整性与候选验收回执",
"description": "R1本地接入、125项定向测试及六项独立复核通过;生命周期52项补充通过,原3项超时保留且归因更正。按授权保存最终本地提交,等待同一clean候选一次完整门禁及独立授权后的公网义务。",
"description": "native观测修正已定向验证及独立复核;按授权本地提交。真实WebKit在page阶段失败,根因待固定三组本地对照,完整门禁未通过。",
"status": "in_progress",
"dev_type": null,
"scope": "release-gate",
@@ -172,7 +172,7 @@
"commitResultReceipt": "artifacts/qa-governance/local-commits-20260919/result.json"
},
"r1IntegrationPlan": {
"status": "LOCAL_REVIEWED_FINAL_FULL_GATE_PENDING",
"status": "FINAL_GATE_FAIL_NATIVE_PROBE_DIAGNOSTIC_PENDING",
"planningApproved": true,
"implementationApproved": true,
"enforcementEnabled": true,
@@ -190,7 +190,7 @@
"pushAuthorized": false,
"deploymentAuthorized": false,
"approval": "用户确认本地R1接入与先实施再最终一次完整门禁的顺序;不含推送或部署",
"enforcementScope": "local uncommitted code only; no real deployment executed",
"enforcementScope": "local committed code; no real deployment executed",
"localEnforcementImplemented": true,
"testStatus": "SCOPED_PASS_WITH_RECORDED_PRIOR_TIMEOUT",
"independentReview": "PASS_6_POINTS_CAUSE_CORRECTION_RECEIVED",
@@ -198,7 +198,7 @@
"evidenceDirectory": "artifacts/qa-governance/r1-enforcement-20260919",
"handoff": "artifacts/qa-governance/r1-enforcement-20260919/claude-request.md",
"reviewReception": "artifacts/qa-governance/r1-enforcement-20260919/review-reception.json",
"nextHandoff": "artifacts/qa-governance/r1-enforcement-20260919/final-gate-claude-request.md",
"nextHandoff": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/claude-request.md",
"f1": {
"discoveryStatus": "THREE_TIMEOUTS_CONFIRMED",
"attribution": "R1_DIRECT_CAUSE_DISPROVED",
@@ -213,7 +213,61 @@
"productionChecksPreserved": true
},
"diagnosticReception": "artifacts/qa-governance/r1-enforcement-20260919/f1-triage-20260919/diagnostic-reception.json",
"localCommitEvidence": "artifacts/qa-governance/r1-enforcement-20260919/local-commit/result.json"
"localCommitEvidence": "artifacts/qa-governance/r1-enforcement-20260919/local-commit/result.json",
"localCommit": "16b9cad98c4687b8328a69a47f192077f0c63dab",
"finalGateAttempt": {
"candidate": "16b9cad98c4687b8328a69a47f192077f0c63dab",
"run": "r1-final-gate-llu75eza",
"exitCode": 1,
"static": "PASS",
"fullLocal": "ENVIRONMENT_INVALID",
"internalStagesRun": 0,
"deploymentEligible": false
}
},
"nativeProbeRepair": {
"status": "SCOPED_VERIFIED_READY_FOR_LOCAL_COMMIT",
"failureReception": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/failure-reception.json",
"historicalGateStatus": "FAIL",
"fullLocalStagesRun": 0,
"rootCause": "UNDETERMINED",
"testExecutor": "Claude independent session",
"deploymentEligible": false,
"testsRunByMainSession": false,
"actualRootCauseResolved": false,
"handoff": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/webkit-comparison-20260919/claude-request.md",
"environmentDifferences": [
"private HOME/XDG/TMPDIR",
"fresh copy of failed cache",
"no database or managed receipt"
],
"independentReview": "PASS",
"unit": {
"tests": 37,
"passed": 36,
"failed": 1,
"skipped": 0,
"exitCode": 1
},
"ipcFailure": "MISSING_SOCKET_ASSERTION_LINE_80",
"ipcHypothesis": "LONG_TMPDIR_SOCKET_ALIASING_NOT_RUNTIME_CONFIRMED",
"diagnostic": "FAIL_WEBKIT_PAGE_PROCESS_CLOSED",
"reviewReception": "artifacts/qa-governance/r1-enforcement-20260919/native-probe-20260919/webkit-comparison-20260919/review-reception.json",
"validationByFile": {
"probePriorPassed": 30,
"environmentSupplementalPassed": 7,
"singleRun37Pass": false
}
},
"webkitComparison": {
"status": "PLANNED",
"cases": [
"deep-materialized",
"short-materialized",
"short-internal-links"
],
"maxAttemptsPerCase": 1,
"deploymentEligible": false
}
}
}
+119 -21
查看文件
@@ -66,21 +66,119 @@ export async function observeGuestEntry(context, baseURL, timeout = 30000) {
return observed;
}
async function observeNativeEngine(engine, name, owner) {
const server = await engine.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
try {
const browserNamespace = namespaceFingerprint(server.process().pid);
if (browserNamespace !== owner.ownedNamespace) throw new Error("environment_browser_namespace_mismatch");
browser = await engine.connect(server.wsEndpoint());
const context = await browser.newContext({ serviceWorkers: "block" });
const page = await context.newPage();
const online = await page.evaluate(() => navigator.onLine);
return { name, version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild: true, noNavigatorOverride: true };
} finally {
await browser?.close().catch(() => {});
await server.close();
// Browser errors may include executable paths, websocket endpoints and process
// environments. Persist only classifications, never the message or stack.
function nativeFailure(error, stage) {
const message = typeof error?.message === "string" ? error.message : "";
const failure = { stage, errorKind: "browser_operation_failed" };
if (message === "environment_browser_namespace_mismatch") failure.errorKind = message;
else if (error?.name === "TimeoutError" || /Timeout \d+ms exceeded/i.test(message)) failure.errorKind = "probe_timeout";
else if (/error while loading shared libraries|cannot open shared object file|host system is missing dependencies/i.test(message)) failure.errorKind = "browser_dependency_missing";
else if (/executable doesn.t exist|spawn .* ENOENT/i.test(message)) failure.errorKind = "browser_executable_missing";
else if (/permission denied|operation not permitted|EACCES|EPERM/i.test(message)) failure.errorKind = "browser_permission_denied";
else if (/Target .*closed|browser.*closed|process did exit/i.test(message)) failure.errorKind = "browser_process_closed";
if (["ENOENT", "EACCES", "EPERM", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(error?.code)) failure.systemCode = error.code;
const exit = /<process did exit: exitCode=(null|-?\d+), signal=(null|SIG[A-Z0-9]+)>/.exec(message);
if (exit) {
failure.processExitCode = exit[1] === "null" ? null : Number(exit[1]);
// Only standard names; do not echo arbitrary text resembling a signal.
if (["null", "SIGABRT", "SIGSEGV", "SIGILL", "SIGBUS", "SIGKILL", "SIGTERM", "SIGTRAP"].includes(exit[2])) failure.processSignal = exit[2] === "null" ? null : exit[2];
}
return failure;
}
export async function observeNativeEngine(engine, name, owner, { fingerprint = namespaceFingerprint, tracePath, observeSibling } = {}) {
const result = { name, version: null, valid: false, online: null, browserNamespace: null, browserMatchesOwnedChild: false, noNavigatorOverride: true, failures: [] };
let stage = "launch";
let server;
let browser;
let context;
let tracing = false;
const fail = failure => {
result.valid = false;
result.failures.push(failure);
result.errorKind ??= failure.errorKind;
result.failureStage ??= failure.stage;
};
try {
server = await engine.launchServer({ headless: true, host: "127.0.0.1" });
stage = "namespace";
result.browserNamespace = fingerprint(server.process().pid);
result.browserMatchesOwnedChild = result.browserNamespace === owner.ownedNamespace;
if (!result.browserMatchesOwnedChild) throw new Error("environment_browser_namespace_mismatch");
stage = "connect";
browser = await engine.connect(server.wsEndpoint());
stage = "version";
result.version = browser.version();
stage = "context";
context = await browser.newContext({ serviceWorkers: "block" });
if (tracePath) {
stage = "trace_start";
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
tracing = true;
}
stage = "page";
const page = await context.newPage();
stage = "online";
const observedOnline = await page.evaluate(() => navigator.onLine);
result.online = typeof observedOnline === "boolean" ? observedOnline : null;
result.valid = result.online === true;
if (!result.valid) fail({ stage, errorKind: result.online === false ? "native_online_observed_false" : "native_online_observation_missing" });
// Retain the existing successful lifecycle: Chromium is still alive while
// WebKit is observed, so diagnostics do not silently change that condition.
stage = "sibling";
await observeSibling?.();
} catch (error) {
fail(nativeFailure(error, stage));
} finally {
for (const [cleanupStage, close] of [
["trace_stop", tracing ? () => context.tracing.stop({ path: tracePath }) : null],
["context_close", context ? () => context.close() : null],
["browser_close", browser ? () => browser.close() : null],
["server_close", server ? () => server.close() : null],
]) {
if (!close) continue;
try { await close(); }
catch (error) {
fail(nativeFailure(error, cleanupStage));
if (cleanupStage === "server_close") {
try { await server.kill(); }
catch (killError) { fail(nativeFailure(killError, "server_kill")); }
}
}
}
}
return result;
}
export async function observeNativeProbe({ chromium, webkit }, owner, { fingerprint = namespaceFingerprint } = {}) {
let webkitResult;
const observeWebkit = async () => {
webkitResult = await observeNativeEngine(webkit, "webkit", owner, { fingerprint, tracePath: path.join(owner.environmentDir, "native-webkit-trace.zip") });
};
const chromiumResult = await observeNativeEngine(chromium, "chromium", owner, {
fingerprint, tracePath: path.join(owner.environmentDir, "native-trace.zip"), observeSibling: observeWebkit,
});
if (!webkitResult) await observeWebkit();
const engines = [chromiumResult, webkitResult];
return {
valid: engines.every(engine => engine.valid), scope: "blank-page-native-probe",
online: chromiumResult.online, browserNamespace: chromiumResult.browserNamespace,
browserMatchesOwnedChild: chromiumResult.browserMatchesOwnedChild, noNavigatorOverride: true,
errorKind: engines.every(engine => engine.valid) ? null : "native_engine_probe_failed", engines,
};
}
export async function recordNativeProbe(engines, owner, { verify = verifyManagedEnvironment, write = atomicEnvironmentJson, fingerprint = namespaceFingerprint } = {}) {
const result = await observeNativeProbe(engines, owner, { fingerprint });
try { verify(); }
catch (error) {
result.valid = false;
result.errorKind ??= "environment_recheck_failed";
result.environmentFailure = nativeFailure(error, "environment_recheck");
}
write(path.join(owner.environmentDir, "native-online.json"), result);
return result;
}
async function main(mode, baseURL) {
@@ -88,6 +186,12 @@ async function main(mode, baseURL) {
const { context: owner } = verifyManagedEnvironment();
const require = createRequire(path.join(root, "client/package.json"));
const { chromium, webkit } = require("@playwright/test");
if (mode === "native") {
const result = await recordNativeProbe({ chromium, webkit }, owner);
console.log(`[environment] native probe ${result.valid ? "PASS" : "FAIL"}`);
if (!result.valid) process.exitCode = 1;
return;
}
const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
let context;
@@ -102,13 +206,7 @@ async function main(mode, baseURL) {
context = await browser.newContext({ serviceWorkers: "block" });
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
tracing = true;
if (mode === "native") {
const page = await context.newPage();
const online = await page.evaluate(() => navigator.onLine);
result = { valid: online === true, scope: "blank-page-native-probe", online, errorKind: online === true ? null : online === false ? "native_online_observed_false" : "native_online_observation_missing", browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
result.engines = [{ name: "chromium", version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true }, await observeNativeEngine(webkit, "webkit", owner)];
result.valid = result.engines.every(engine => engine.valid);
} else result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
verifyManagedEnvironment();
} catch (error) {
result.valid = false;
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { observeGuestEntry } from "../browser-environment-probe.mjs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { observeGuestEntry, observeNativeEngine, observeNativeProbe, recordNativeProbe } from "../browser-environment-probe.mjs";
function fixture({ refresh = false, failed = false, online = true, documentOnline = true, cookies = [], status = 200 } = {}) {
const handlers = {};
@@ -56,3 +59,118 @@ test("非候选 HTTP 200 不合格,禁止公网探针", async () => {
assert.equal((await observeGuestEntry(fixture({ status: 500 }).context, "http://127.0.0.1")).valid, false);
await assert.rejects(observeGuestEntry(fixture().context, "https://example.invalid"), /requires_loopback/);
});
function nativeFixture({ faults = {}, online = true, events = [], name = "webkit" } = {}) {
const step = stage => {
events.push(`${name}:${stage}`);
if (faults[stage]) throw faults[stage];
};
const context = {
tracing: { async start() { step("trace_start"); }, async stop() { step("trace_stop"); } },
async newPage() { step("page"); return { async evaluate() { step("online"); return online; } }; },
async close() { step("context_close"); },
};
const browser = {
version() { step("version"); return "26.0"; },
async newContext(options) { step("context"); assert.equal(options.serviceWorkers, "block"); return context; },
async close() { step("browser_close"); },
};
return {
engine: {
async launchServer(options) {
step("launch"); assert.deepEqual(options, { headless: true, host: "127.0.0.1" });
return {
process() { step("namespace"); return { pid: 123 }; },
wsEndpoint() { return "ws://127.0.0.1/synthetic-private-endpoint"; },
async close() { step("server_close"); }, async kill() { step("server_kill"); },
};
},
async connect() { step("connect"); return browser; },
}, events,
};
}
const nativeOwner = { ownedNamespace: "child", environmentDir: "synthetic-output" };
const nativeOptions = { fingerprint: () => "child", tracePath: "synthetic-trace.zip" };
for (const stage of ["launch", "namespace", "connect", "version", "context", "trace_start", "page", "online", "trace_stop", "context_close", "browser_close", "server_close"]) {
test(`native ${stage} 失败保留引擎、阶段和安全原因,继续清理`, async () => {
const error = Object.assign(new Error("synthetic-private-endpoint /private/synthetic/path secret=do-not-record"), { code: "EACCES" });
const fake = nativeFixture({ faults: { [stage]: error } });
const result = await observeNativeEngine(fake.engine, "webkit", nativeOwner, nativeOptions);
assert.equal(result.name, "webkit");
assert.equal(result.valid, false);
assert.equal(result.failureStage, stage);
assert.equal(result.failures[0].systemCode, "EACCES");
assert.doesNotMatch(JSON.stringify(result), /private|secret|do-not-record|stack|ws:\/\//);
assert.equal(fake.events.includes("webkit:server_close"), stage !== "launch");
assert.equal(fake.events.includes("webkit:server_kill"), stage === "server_close");
});
}
test("清理失败不能覆盖首因,kill失败也有记录", async () => {
const fault = new Error("synthetic failure");
const fake = nativeFixture({ faults: { online: fault, trace_stop: fault, context_close: fault, browser_close: fault, server_close: fault, server_kill: fault } });
const result = await observeNativeEngine(fake.engine, "webkit", nativeOwner, nativeOptions);
assert.equal(result.failureStage, "online");
assert.deepEqual(result.failures.map(failure => failure.stage), ["online", "trace_stop", "context_close", "browser_close", "server_close", "server_kill"]);
});
for (const [error, expected] of [
[Object.assign(new Error("private timeout"), { name: "TimeoutError" }), { errorKind: "probe_timeout" }],
[new Error("/private/MiniBrowser: error while loading shared libraries: private.so"), { errorKind: "browser_dependency_missing" }],
[new Error("Executable doesn't exist at /private/engine"), { errorKind: "browser_executable_missing" }],
[new Error("Permission denied /private/engine"), { errorKind: "browser_permission_denied" }],
[new Error("Target page, context or browser has been closed\n<process did exit: exitCode=null, signal=SIGSEGV>"), { errorKind: "browser_process_closed", processExitCode: null, processSignal: "SIGSEGV" }],
]) test(`native 安全错误分类 ${expected.errorKind}`, async () => {
const result = await observeNativeEngine(nativeFixture({ faults: { launch: error } }).engine, "webkit", nativeOwner, nativeOptions);
assert.deepEqual(result.failures, [{ stage: "launch", ...expected }]);
});
test("错误namespace不连接;false和缺失online不通过", async () => {
const fake = nativeFixture();
const mismatch = await observeNativeEngine(fake.engine, "webkit", nativeOwner, { fingerprint: () => "other" });
assert.equal(mismatch.valid, false);
assert.equal(mismatch.errorKind, "environment_browser_namespace_mismatch");
assert.equal(fake.events.includes("webkit:connect"), false);
for (const online of [false, null, "synthetic-sensitive-value"]) {
const result = await observeNativeEngine(nativeFixture({ online }).engine, "webkit", nativeOwner, nativeOptions);
assert.equal(result.valid, false);
assert.equal(result.failureStage, "online");
assert.equal(result.online, online === false ? false : null);
}
});
test("两引擎通过且Chromium保持存活直到WebKit清理", async () => {
const events = [];
const chromium = nativeFixture({ name: "chromium", events }).engine;
const webkit = nativeFixture({ events }).engine;
const result = await observeNativeProbe({ chromium, webkit }, nativeOwner, nativeOptions);
assert.equal(result.valid, true);
assert.deepEqual(result.engines.map(engine => engine.name), ["chromium", "webkit"]);
assert.ok(events.indexOf("chromium:online") < events.indexOf("webkit:launch"));
assert.ok(events.indexOf("webkit:server_close") < events.indexOf("chromium:context_close"));
});
for (const failedEngine of ["chromium", "webkit"]) test(`${failedEngine}启动失败仍落盘两个引擎,不虚报通过`, async t => {
const directory = mkdtempSync(path.join(os.tmpdir(), "hyb-native-probe-test-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const engines = Object.fromEntries(["chromium", "webkit"].map(name => [name, nativeFixture({ name, faults: name === failedEngine ? { launch: new Error("synthetic launch failure") } : {} }).engine]));
let rechecked = false;
const result = await recordNativeProbe(engines, { ...nativeOwner, environmentDir: directory }, { ...nativeOptions, verify() { rechecked = true; } });
assert.equal(rechecked, true);
assert.equal(result.valid, false);
assert.deepEqual(result.engines.map(engine => [engine.name, engine.valid]), ["chromium", "webkit"].map(name => [name, name !== failedEngine]));
assert.deepEqual(JSON.parse(readFileSync(path.join(directory, "native-online.json"))), result);
});
test("双引擎成功后环境重验失败仍落盘拒绝", async () => {
let persisted;
const result = await recordNativeProbe({ chromium: nativeFixture().engine, webkit: nativeFixture().engine }, nativeOwner, {
...nativeOptions, verify() { throw new Error("environment_private_detail"); }, write(file, value) { persisted = value; },
});
assert.equal(result.valid, false);
assert.equal(result.engines.every(engine => engine.valid), true);
assert.equal(persisted.environmentFailure.stage, "environment_recheck");
assert.equal(persisted.errorKind, "environment_recheck_failed");
assert.doesNotMatch(JSON.stringify(persisted), /private_detail/);
});
@@ -43,6 +43,26 @@ test("环境回执必须消费事实而非 valid 标记", () => {
}
});
test("两引擎逐项必需:诊断记录不构成失败引擎的豁免", () => {
for (const index of [0, 1]) {
for (const mutate of [
engine => { engine.valid = false; engine.failureStage = "launch"; engine.failures = [{ stage: "launch", errorKind: "browser_process_closed" }]; },
engine => { engine.online = false; },
engine => { engine.browserNamespace = "other"; },
engine => { engine.browserMatchesOwnedChild = false; },
engine => { engine.noNavigatorOverride = false; },
engine => { delete engine.version; },
]) {
const value = receipt(); mutate(value.nativeOnlineProbe.engines[index]);
assert.ok(validateEnvironmentReceipt(value, "policy").includes("native_engine_probe_invalid"));
}
}
for (const engines of [[], [receipt().nativeOnlineProbe.engines[0]], receipt().nativeOnlineProbe.engines.toReversed()]) {
const value = receipt(); value.nativeOnlineProbe.engines = engines;
assert.ok(validateEnvironmentReceipt(value, "policy").includes("native_engine_probe_invalid"));
}
});
test("父 runner 实时 IPC 绑定本轮、进程和当前策略,错误响应拒绝", async context => {
const scratch = mkdtempSync(path.join(os.tmpdir(), "hyb-env-ipc-test-"));
const owner = { scratch, runId: "current", supervisorPid: process.pid, policyDigest: "policy" };