fix(browser)!: keep the Chromium sandbox for REA-launched browsers (#8738)

* fix(browser)!: keep the Chromium sandbox for REA-launched browsers

Playwright appends --no-sandbox unless a launch passes
chromiumSandbox: true. Neither the browser scenario launch nor the native
module resolver passed it, so every REA-launched Chromium ran without its
sandbox and REA_BROWSER_NO_SANDBOX had no effect.

Both launch sites now take the sandbox decision from ChromiumSandbox.ts:
off on Android or when REA_BROWSER_NO_SANDBOX=true, on otherwise. When
Chromium reports that it cannot start its sandbox, the launch failure's
remediation names the opt-out.

Fixes #8722

BREAKING CHANGE: hosts where Chromium cannot create its sandbox, such as
root users and Linux containers without user namespaces or a setuid
sandbox, now fail to launch until REA_BROWSER_NO_SANDBOX=true is set.

* fix(browser): name the sandbox opt-out on any sandboxed launch failure

Chromium reports an unusable sandbox through several unrelated startup
messages, including a misconfigured setuid helper and a refused namespace
move, and Playwright recognizes only some of them. Matching individual
messages missed real failures, so a sandboxed launch failure now always
names REA_BROWSER_NO_SANDBOX=true as a conditional remediation and the
retained startup diagnostics show whether the sandbox was the cause.

* fix(browser): keep default recovery for resolver failures after launch

The module resolver's outer catch also covers context, navigation and
evaluation failures after the browser launched. Attaching the sandbox
remediation there replaced their default recovery guidance. Attach the
shared launch remediation only when a sandboxed launch itself fails.

* docs(browser): note the Android sandbox exception for module traces
This commit is contained in:
김준호 authored and GitHub committed 2026-11-04 23:54:29 +08:00
1 parent b1b6cb64c2
commit 30a11af923
7 files changed
+185 -22

No files matched your search

+4 -1
View File
@@ -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
+4 -2
View File
@@ -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.
+32
View File
@@ -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<Record<string, string | undefined>>,
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;
+47 -1
View File
@@ -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 {
+8 -6
View File
@@ -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:
@@ -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<ModuleResolverLauncher>[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)",
+30 -10
View File
@@ -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<void> = 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<void> | undefined;
const close = (): Promise<void> => {
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);