保留原生浏览器探针失败阶段与清理证据(09-06-release-gate-integrity)
这个提交包含在:
@@ -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" };
|
||||
|
||||
在新工单中引用
屏蔽一个用户