mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
refactor(tooling): move verifier host helpers out of runtime
This commit is contained in:
1 parent
d3946a3f50
commit
42f3ed7119
8 files changed
+26
-14
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user