mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
7 files changed
+185
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user