refactor(tooling): move verifier host helpers out of runtime

This commit is contained in:
morluto committed 2026-10-26 14:46:23 +08:00
1 parent d3946a3f50
commit 42f3ed7119
8 files changed
+26 -14

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import type { ChildProcess } from "node:child_process";
import { readFile } from "node:fs/promises";
/** Terminal states that can prevent Chrome from publishing a CDP port. */
export type BrowserStartupFailure =
| "spawn-error"
| "exited"
| "signalled"
| "timeout";
/** Safe diagnostics for a browser process that did not publish a CDP port. */
export class BrowserStartupError extends Error {
readonly _tag = "BrowserStartupError" as const;
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
constructor(
diagnostics: {
readonly failure: BrowserStartupFailure;
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
},
options?: ErrorOptions,
) {
super(browserStartupMessage(diagnostics.failure, diagnostics), options);
this.failure = diagnostics.failure;
this.executable = diagnostics.executable;
this.elapsedMs = diagnostics.elapsedMs;
this.startupTimeoutMs = diagnostics.startupTimeoutMs;
this.exitCode = diagnostics.exitCode;
this.signalCode = diagnostics.signalCode;
this.stderr = diagnostics.stderr;
}
}
/**
* Single owner of the wait for Chrome to publish its DevToolsActivePort.
*
* Callers that need a different budget pass `timeoutMs`; nobody else should
* restate this number, because a per-caller literal silently changes how long
* a cold Chrome launch is tolerated before the failure names its ceiling.
*/
export const BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS = 60_000;
/** Owned child process and startup policy for Chrome CDP discovery. */
export interface BrowserStartupOptions {
readonly child: ChildProcess;
readonly executable: string;
readonly activePortPath: string;
readonly stderr: () => string;
readonly timeoutMs?: number;
readonly pollIntervalMs?: number;
}
/** Wait for Chrome's valid DevToolsActivePort or a terminal process outcome. */
export const waitForBrowserDevtoolsPort = async (
options: BrowserStartupOptions,
): Promise<number> => {
const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
const pollIntervalMs = options.pollIntervalMs ?? 25;
const startedAt = performance.now();
const terminal = childTermination(options, startedAt, timeoutMs);
const deadline = startedAt + timeoutMs;
while (true) {
const terminalState = currentTerminalState(options.child);
if (terminalState !== null)
throw startupError(options, startedAt, timeoutMs, terminalState);
const port = await readDevtoolsPort(options.activePortPath);
if (port !== null) return port;
const remainingMs = deadline - performance.now();
if (remainingMs <= 0)
throw startupError(options, startedAt, timeoutMs, { failure: "timeout" });
const outcome = await Promise.race([
terminal,
delay(Math.min(pollIntervalMs, remainingMs)).then(() => null),
]);
if (outcome !== null) throw outcome;
}
};
type TerminalState =
| { readonly failure: "spawn-error"; readonly cause: unknown }
| { readonly failure: "exited" }
| { readonly failure: "signalled" }
| { readonly failure: "timeout" };
const childTermination = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
): Promise<BrowserStartupError> =>
new Promise((resolve) => {
options.child.once("error", (cause: unknown) => {
resolve(
startupError(options, startedAt, startupTimeoutMs, {
failure: "spawn-error",
cause,
}),
);
});
options.child.once("exit", () => {
resolve(
startupError(
options,
startedAt,
startupTimeoutMs,
options.child.signalCode === null
? { failure: "exited" }
: { failure: "signalled" },
),
);
});
});
const currentTerminalState = (child: ChildProcess): TerminalState | null => {
if (child.signalCode !== null) return { failure: "signalled" };
if (child.exitCode !== null) return { failure: "exited" };
return null;
};
const startupError = (
options: BrowserStartupOptions,
startedAt: number,
startupTimeoutMs: number,
state: TerminalState,
): BrowserStartupError =>
new BrowserStartupError(
{
failure: state.failure,
executable: options.executable,
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
startupTimeoutMs,
exitCode: options.child.exitCode,
signalCode: options.child.signalCode,
stderr: options.stderr(),
},
state.failure === "spawn-error" ? { cause: state.cause } : undefined,
);
const readDevtoolsPort = async (path: string): Promise<number | null> => {
try {
const firstLine = (await readFile(path, "utf8")).trim().split("\n")[0];
const port = Number(firstLine);
return Number.isSafeInteger(port) && port > 0 && port <= 65_535
? port
: null;
} catch (cause: unknown) {
// Missing or unreadable port files mean the port is unknown.
void cause;
return null;
}
};
const delay = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
const browserStartupMessage = (
failure: BrowserStartupFailure,
diagnostics: {
readonly executable: string;
readonly elapsedMs: number;
readonly startupTimeoutMs: number;
readonly exitCode: number | null;
readonly signalCode: NodeJS.Signals | null;
readonly stderr: string;
},
): string =>
`Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; timeoutMs=${String(diagnostics.startupTimeoutMs)} (DevToolsActivePort wait; omitted means ${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)} ms); exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "<empty>" : diagnostics.stderr}`;
@@ -8,12 +8,16 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js
import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js";
import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js";
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
waitForBrowserDevtoolsPort,
} from "../../dist/browser/BrowserProcessStartup.js";
import { importVerifierSource } from "./verifier-source-loader.mjs";
import { requireOrdinaryWindowsVerifierCaller } from "./windows-ordinary-user-verifier.mjs";
const { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, waitForBrowserDevtoolsPort } =
await importVerifierSource(
new URL("./browser-process-startup.mts", import.meta.url).href,
import.meta.url,
);
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
export async function startRuntimeFixtureBrowser(executable, origin) {
await requireOrdinaryWindowsVerifierCaller();
+140
View File
@@ -0,0 +1,140 @@
import { constants } from "node:fs";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
windowsNativeCapabilities,
type WindowsNativeCapabilitySet,
} from "../../src/process/WindowsAuthority.js";
import {
probeProcessCaptureCapability,
type ProcessCaptureCapability,
} from "../../src/process/capture/ProcessCaptureCapability.js";
/** One named Windows host observation without inferred authority. */
export type WindowsCapabilityOutcome =
| { readonly available: true; readonly reason: null }
| { readonly available: false; readonly reason: string };
/** Injectable host probes used by CI and deterministic unit tests. */
export interface WindowsCapabilityDependencies {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
probeSymlinkCreation(): Promise<WindowsCapabilityOutcome>;
probeNoFollowOpen(): WindowsCapabilityOutcome;
probePrivateAcl(): WindowsCapabilityOutcome;
probeUnixDomainSocket(): WindowsCapabilityOutcome;
probePty(): Promise<ProcessCaptureCapability>;
nativeCapabilities?(): WindowsNativeCapabilitySet;
}
/** Machine-readable facts for features that need Windows-specific security. */
export interface WindowsCapabilityReport {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
readonly capabilities: Readonly<{
symlink_creation: WindowsCapabilityOutcome;
no_follow_open: WindowsCapabilityOutcome;
private_acl: WindowsCapabilityOutcome;
unix_domain_socket: WindowsCapabilityOutcome;
pty: WindowsCapabilityOutcome;
}>;
/** Security controls that are unavailable until native authority exists. */
readonly security: WindowsNativeCapabilitySet;
}
/** Probe named prerequisites without converting missing controls into support. */
export const probeWindowsCapabilities = async (
dependencies: WindowsCapabilityDependencies = systemDependencies(),
): Promise<WindowsCapabilityReport> => {
const [symlinkCreation, pty] = await Promise.all([
dependencies.probeSymlinkCreation(),
dependencies.probePty(),
]);
return {
platform: dependencies.platform,
architecture: dependencies.architecture,
capabilities: {
symlink_creation: symlinkCreation,
no_follow_open: dependencies.probeNoFollowOpen(),
private_acl: dependencies.probePrivateAcl(),
unix_domain_socket: dependencies.probeUnixDomainSocket(),
pty: pty.available
? { available: true, reason: null }
: { available: false, reason: pty.reason },
},
security:
dependencies.nativeCapabilities?.() ??
windowsNativeCapabilities(dependencies.platform),
};
};
const systemDependencies = (): WindowsCapabilityDependencies => {
const security = windowsNativeCapabilities(process.platform);
return {
platform: process.platform,
architecture: process.arch,
probeSymlinkCreation,
probeNoFollowOpen: () =>
systemNoFollowOpenCapability(process.platform, constants.O_NOFOLLOW),
probePrivateAcl: () => security.private_runtime_dacl,
nativeCapabilities: () => security,
probeUnixDomainSocket: () =>
process.platform === "win32"
? {
available: false,
reason: "Node path-based IPC uses named pipes on Windows",
}
: { available: true, reason: null },
probePty: probeProcessCaptureCapability,
};
};
/** Report pathname no-follow support without treating it as Windows authority. */
export const systemNoFollowOpenCapability = (
platform: NodeJS.Platform,
noFollow: number | undefined,
): WindowsCapabilityOutcome => {
if (platform === "win32")
return {
available: false,
reason:
"Node pathname O_NOFOLLOW is not Windows handle authority; native admission is reported separately in security.",
};
return typeof noFollow === "number"
? { available: true, reason: null }
: {
available: false,
reason: "O_NOFOLLOW is unavailable in this Node runtime",
};
};
const probeSymlinkCreation = async (): Promise<WindowsCapabilityOutcome> => {
const root = await mkdtemp(join(tmpdir(), "rea-symlink-probe-"));
const target = join(root, "target");
const link = join(root, "link");
try {
await writeFile(target, "probe");
await symlink(target, link, "file");
return { available: true, reason: null };
} catch (cause: unknown) {
return {
available: false,
reason:
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
? cause.code
: "symlink creation failed",
};
} finally {
try {
await rm(root, { recursive: true, force: true });
} catch (cause: unknown) {
// Cleanup failure must not replace the machine-readable probe outcome.
// The probe root lives beneath the OS temporary directory and is unique.
void cause;
}
}
};
+8 -4
View File
@@ -9,10 +9,8 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
waitForBrowserDevtoolsPort,
} from "../dist/browser/BrowserProcessStartup.js";
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js";
import {
inspectWebPageInputSchema,
@@ -57,6 +55,12 @@ import { verifyScenarioStorageFingerprints } from "./lib/browser-scenario-storag
import { verifyScenarioServiceWorkers } from "./lib/browser-scenario-service-workers-e2e.mjs";
import { verifyBrowserUrlUserInfo } from "./lib/browser-url-userinfo-e2e.mjs";
const { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, waitForBrowserDevtoolsPort } =
await importVerifierSource(
new URL("./lib/browser-process-startup.mts", import.meta.url).href,
import.meta.url,
);
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
+5 -1
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node
import { probeWindowsCapabilities } from "../dist/application/WindowsCapabilities.js";
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
const { probeWindowsCapabilities } = await importVerifierSource(
new URL("./lib/windows-capabilities.mts", import.meta.url).href,
import.meta.url,
);
const report = await probeWindowsCapabilities();
await new Promise((resolveOutput) =>