diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 2c9ed3f35f..614ecff971 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -9,7 +9,10 @@ liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path. The minimal request contains only browser launch/connect selection, `start_url`, and at least one `actions` entry. Launch `headless` defaults to `true` and can -be set to `false` where the host supports a visible browser. Navigations follow +be set to `false` where the host supports a visible browser. Launched Chromium +keeps its sandbox; on hosts that cannot provide one, such as root in a +container, set `REA_BROWSER_NO_SANDBOX=true` for the REA process. Termux on +Android always launches without it. Navigations follow the destinations named by the scenario and the page's ordinary browser requests, and [request steps](#request-steps) send only to the start URL's origin; there is no separate origin allowlist. Request steps use a temporary dispatch diff --git a/docs/website-module-trace.md b/docs/website-module-trace.md index f5e55d3e07..7612bcdfd1 100644 --- a/docs/website-module-trace.md +++ b/docs/website-module-trace.md @@ -86,8 +86,10 @@ and import map have 32 MiB, 16 MiB and 4 MiB input limits. Native resolution has one 20-second deadline; cleanup may extend that deadline. One renderer is requested, and there is no aggregate host memory-limit claim. Cancellation closes only the owned browser; cleanup failure cannot return a successful trace. -`REA_BROWSER_NO_SANDBOX=true` passes Chromium's corresponding launch option when -the host requires it; it does not enable an REA security boundary. +Chromium keeps its sandbox, except under Termux on Android, which always +launches it without one. On hosts that cannot provide one, such as root in a +container, `REA_BROWSER_NO_SANDBOX=true` launches it without the sandbox; the +variable does not enable an REA security boundary. See [testing](testing.md) for the native loading oracle and CLI/MCP real lane. diff --git a/src/browser/ChromiumSandbox.ts b/src/browser/ChromiumSandbox.ts new file mode 100644 index 0000000000..e5949c11ec --- /dev/null +++ b/src/browser/ChromiumSandbox.ts @@ -0,0 +1,32 @@ +/** Host opt-out for Chromium launches where the host cannot provide its sandbox. */ +export const CHROMIUM_NO_SANDBOX_VARIABLE = "REA_BROWSER_NO_SANDBOX"; + +/** + * Decide whether REA-launched Chromium keeps its sandbox. + * + * Playwright disables the Chromium sandbox unless the launch asks for it, so + * every launch passes this decision explicitly. Termux's Chromium has no usable + * sandbox; other hosts opt out only through {@link CHROMIUM_NO_SANDBOX_VARIABLE}. + */ +export const chromiumSandboxEnabled = ( + environment: Readonly>, + platform: NodeJS.Platform = process.platform, +): boolean => + platform !== "android" && + environment[CHROMIUM_NO_SANDBOX_VARIABLE] !== "true"; + +const LAUNCH_REMEDIATION = + "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying."; + +/** + * Remediation for a Chromium launch that failed before the browser connected. + * + * Chromium reports an unusable sandbox through several unrelated messages (no + * namespace support, a misconfigured setuid helper, a refused namespace move, + * a root user), so REA does not classify the startup log. A sandboxed launch + * adds the opt-out; the retained diagnostics show whether it applies. + */ +export const chromiumLaunchRemediation = (sandbox: boolean): string => + sandbox + ? `${LAUNCH_REMEDIATION} If the startup diagnostics show that Chromium could not start its sandbox on this host, enable the host's sandbox support or set ${CHROMIUM_NO_SANDBOX_VARIABLE}=true to launch it without the sandbox.` + : LAUNCH_REMEDIATION; diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 3af1166b3f..b36b3a7462 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -172,7 +172,8 @@ it("keeps Termux execution support when launching Chromium on Android", async () diagnostics: { startup_error: "launch boundary reached" }, }); - expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]); + expect(launchOptions?.chromiumSandbox).toBe(false); + expect(launchOptions?.args).toEqual(["--single-process"]); expect(launchOptions?.env).toEqual({ LANG: "selected-language", LD_PRELOAD: "/data/data/com.termux/files/usr/lib/libtermux-exec.so", @@ -180,6 +181,51 @@ it("keeps Termux execution support when launching Chromium on Android", async () }); }); +const launchScenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: process.execPath }, + start_url: { url: "https://example.test" }, + actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }], +}); + +it.each([ + { environment: {}, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "false" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "1" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "true" }, sandbox: false }, +])( + "launches Chromium with sandbox $sandbox for $environment", + async ({ environment, sandbox }) => { + Object.defineProperty(process, "platform", { value: "linux" }); + let launchOptions: Parameters< + typeof import("playwright-core").chromium.launchPersistentContext + >[1]; + + await expect( + openPlaywrightScenarioBrowser(launchScenario, environment, { + launcher: { + connectOverCDP: async () => { + throw new Error("Unexpected connection"); + }, + launchPersistentContext: async (_profile, options) => { + launchOptions = options; + throw new Error("launch boundary reached"); + }, + }, + }), + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + // Chromium's startup log, not REA, says whether the sandbox caused it. + remediationAction: sandbox + ? expect.stringContaining("REA_BROWSER_NO_SANDBOX=true") + : "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying.", + }); + + expect(launchOptions?.chromiumSandbox).toBe(sandbox); + expect(launchOptions?.args).toEqual([]); + expect(launchOptions?.env).toEqual({}); + }, +); + it("waits for bounded event finalization and owned browser teardown", async () => { vi.useFakeTimers(); try { diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 3df1e7b87b..9b525daffe 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -1,4 +1,8 @@ import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { + chromiumLaunchRemediation, + chromiumSandboxEnabled, +} from "./ChromiumSandbox.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { @@ -869,6 +873,7 @@ export const openPlaywrightScenarioBrowser = async ( let context: BrowserContext; const executablePath = scenario.browser.executable_path; const headless = scenario.browser.headless; + const chromiumSandbox = chromiumSandboxEnabled(environment); let launchStarted = false; try { await chmod(profilePath, 0o700); @@ -894,13 +899,11 @@ export const openPlaywrightScenarioBrowser = async ( handleSIGINT: false, handleSIGTERM: false, timeout: commandTimeoutMs, + chromiumSandbox, // Termux's Chromium cannot create its renderer process through Android's // executable boundary. Single-process mode keeps the browser and page in // the Termux-launched process so scenario navigation remains available. - args: - process.platform === "android" - ? ["--no-sandbox", "--single-process"] - : [], + args: process.platform === "android" ? ["--single-process"] : [], }), commandTimeoutMs, "launch", @@ -923,8 +926,7 @@ export const openPlaywrightScenarioBrowser = async ( { cause, userMessage: "The selected browser could not start.", - remediationAction: - "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying.", + remediationAction: chromiumLaunchRemediation(chromiumSandbox), diagnostics: { executable_path: executablePath, startup_error: diff --git a/src/browser/modules/NativeModuleResolver.test.ts b/src/browser/modules/NativeModuleResolver.test.ts index 1619e8fc44..1a11a15f9c 100644 --- a/src/browser/modules/NativeModuleResolver.test.ts +++ b/src/browser/modules/NativeModuleResolver.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; -import { NativeModuleResolver } from "./NativeModuleResolver.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + NativeModuleResolver, + type ModuleResolverLauncher, +} from "./NativeModuleResolver.js"; import { webModuleResolutionObservationSchema } from "../../domain/webModuleTrace.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; @@ -218,6 +221,61 @@ describe("optional native resolution acquisition", () => { }); }); +describe("native resolution Chromium sandbox", () => { + const hostPlatform = process.platform; + afterEach(() => { + Object.defineProperty(process, "platform", { value: hostPlatform }); + }); + + it.each([ + { environment: {}, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "false" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "true" }, sandbox: false }, + ])( + "launches Chromium with sandbox $sandbox for $environment", + async ({ environment, sandbox }) => { + Object.defineProperty(process, "platform", { value: "linux" }); + let launchOptions: Parameters[0] | undefined; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser", ...environment }, + (options) => { + launchOptions = options; + return Promise.reject(new Error("launch boundary reached")); + }, + ); + const result = await provider.resolve(input); + if (result.ok) throw new Error("expected startup failure"); + expect(launchOptions?.chromiumSandbox).toBe(sandbox); + expect(launchOptions?.args).not.toContain("--no-sandbox"); + expect(result.error).toMatchObject({ + remediationAction: sandbox + ? expect.stringContaining("REA_BROWSER_NO_SANDBOX=true") + : undefined, + }); + }, + ); + + it("keeps the default recovery for engine failures after a sandboxed launch", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + () => + Promise.resolve({ + version: () => "fixture", + newContext: () => + Promise.reject(new Error("fixture context failure")), + close: () => Promise.resolve(), + }), + ); + const result = await provider.resolve(input); + if (result.ok) throw new Error("expected engine failure"); + expect(result.error).toMatchObject({ + remediationAction: undefined, + diagnostics: { error_message: "fixture context failure" }, + }); + }); +}); + describe("completed native resolution finalization", () => { it.each([false, true])( "retains the completed batch when final close is cancelled (close fails: %s)", diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index 3cc45301e4..0e2215f542 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -1,4 +1,8 @@ import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; +import { + chromiumLaunchRemediation, + chromiumSandboxEnabled, +} from "../ChromiumSandbox.js"; import { isAbsolute } from "node:path"; import { z } from "zod"; import type { @@ -73,6 +77,20 @@ export type ModuleResolverLauncher = ( const launch: ModuleResolverLauncher = async (options) => (await import("playwright-core")).chromium.launch(options); +const moduleResolverFailure = ( + cause: unknown, + executable: string, + remediationAction: string | undefined, +): ProviderAdapterError => + new ProviderAdapterError("native-module-resolver", OPERATION, { + cause, + ...(remediationAction === undefined ? {} : { remediationAction }), + diagnostics: { + executable, + error_message: cause instanceof Error ? cause.message : String(cause), + }, + }); + /** Use native import.meta.resolve in an owned context with locally fulfilled requests. */ export class NativeModuleResolver implements WebModuleResolutionPort { #tail: Promise = Promise.resolve(); @@ -132,6 +150,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { }, ), ); + const chromiumSandbox = chromiumSandboxEnabled(this.environment); const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal); let activeCall: | { settled: boolean; interruption?: AnalysisError } @@ -166,6 +185,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { ); }; let lease: AnalysisCleanupLease | undefined; + let launched = false; let cleanup: Promise | undefined; const close = (): Promise => { if (cleanup === undefined && lease !== undefined) { @@ -197,17 +217,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort { handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + chromiumSandbox, args: [ "--disable-dev-shm-usage", "--disable-background-networking", "--renderer-process-limit=1", "--js-flags=--max-old-space-size=128", - ...(this.environment.REA_BROWSER_NO_SANDBOX === "true" - ? ["--no-sandbox"] - : []), ], }), ); + launched = true; lease = new AnalysisCleanupLease(async () => { if ( deadline.signal.aborted && @@ -239,14 +258,15 @@ export class NativeModuleResolver implements WebModuleResolutionPort { cause instanceof AnalysisCapabilityUnavailableError || cause instanceof BrowserObservationError ? cause - : new ProviderAdapterError("native-module-resolver", OPERATION, { + : moduleResolverFailure( cause, - diagnostics: { - executable, - error_message: - cause instanceof Error ? cause.message : String(cause), - }, - }), + executable, + // Only an unresolved sandboxed launch can be a sandbox failure; + // later engine failures keep the default recovery guidance. + launched || !chromiumSandbox + ? undefined + : chromiumLaunchRemediation(true), + ), ); } finally { deadline.signal.removeEventListener("abort", onAbort);