From 5414dbf35cba36420df828335712be9e4d74ed15 Mon Sep 17 00:00:00 2001 From: garrytan-agents Date: Fri, 29 May 2026 21:46:14 +0000 Subject: [PATCH] fix(browse): apply --no-sandbox to headed launch sites (root/container) The headless launch() path already disables the Chromium sandbox when running in CI/container or as root, but launchHeaded() and the extension-reload handoff did not. As a result, headed mode aborts at launch ("Chromium sandboxing failed!" / context closed immediately) whenever the daemon runs as root in a container, even though headless mode works fine in the same environment. Extract the detection into a shared sandboxDisableArgs() helper in stealth.ts and apply it at all three launch sites. Also de-duplicates the inline block that launch() previously carried. Adds a unit test for the helper (env + uid branches). --- browse/src/browser-manager.ts | 19 ++++----- browse/src/stealth.ts | 22 ++++++++++ browse/test/sandbox-disable-args.test.ts | 54 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 browse/test/sandbox-disable-args.test.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2bc1c597db..d8a8881b37 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -342,18 +342,10 @@ export class BrowserManager { // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. // Extensions only work in headed mode, so we use an off-screen window. const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; - const { STEALTH_LAUNCH_ARGS } = await import('./stealth'); - const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS]; + const { STEALTH_LAUNCH_ARGS, sandboxDisableArgs } = await import('./stealth'); + const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...sandboxDisableArgs()]; let useHeadless = true; - // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which - // are typically disabled in containers and are never available for the root - // user on Linux. Detect all three cases and add --no-sandbox automatically. - const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; - if (process.env.CI || process.env.CONTAINER || isRoot) { - launchArgs.push('--no-sandbox'); - } - if (extensionsDir) { // Skip --load-extension when running against a custom Chromium build that // already bakes the extension in (e.g., GBrowser / GStack Browser.app). @@ -435,12 +427,16 @@ export class BrowserManager { this.nextTabId = 1; // Find the gstack extension directory for auto-loading + const { sandboxDisableArgs } = await import('./stealth'); const extensionPath = this.findExtensionPath(); const launchArgs = [ '--hide-crash-restore-bubble', // Anti-bot-detection: remove the navigator.webdriver flag that Playwright sets. // Sites like Google and NYTimes check this to block automation browsers. '--disable-blink-features=AutomationControlled', + // Docker/CI/root: disable the Chromium sandbox when unavailable, otherwise + // headed mode aborts at launch as root even though headless succeeds. + ...sandboxDisableArgs(), ]; if (extensionPath) { // Skip --load-extension when running against a custom Chromium build @@ -1555,8 +1551,9 @@ export class BrowserManager { try { const fs = require('fs'); const path = require('path'); + const { sandboxDisableArgs } = await import('./stealth'); const extensionPath = this.findExtensionPath(); - const launchArgs = ['--hide-crash-restore-bubble']; + const launchArgs = ['--hide-crash-restore-bubble', ...sandboxDisableArgs()]; if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 075c272101..e798c2f286 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -194,6 +194,28 @@ export const STEALTH_LAUNCH_ARGS = [ '--disable-blink-features=AutomationControlled', ]; +/** + * Chromium's setuid sandbox requires unprivileged user namespaces, which are + * typically disabled in containers (Docker/CI) and are NEVER available for the + * root user on Linux. In those cases Chromium aborts at launch with + * "Chromium sandboxing failed!" / the process closes immediately. Detect all + * three cases and disable the sandbox so launches succeed. + * + * Returns `['--no-sandbox']` when sandboxing is known to be unavailable, + * otherwise an empty array. Spread this into a chromium launch's `args`. + * + * This must be applied to EVERY launch site — headless `launch()`, headed + * `launchHeaded()`, and the extension-reload handoff — otherwise headed mode + * crashes as root even though headless works. + */ +export function sandboxDisableArgs(): string[] { + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + if (process.env.CI || process.env.CONTAINER || isRoot) { + return ['--no-sandbox']; + } + return []; +} + /** Test-only helper: report whether extended mode is currently active. */ export function isExtendedStealthEnabled(): boolean { return extendedModeEnabled(); diff --git a/browse/test/sandbox-disable-args.test.ts b/browse/test/sandbox-disable-args.test.ts new file mode 100644 index 0000000000..a505c71df6 --- /dev/null +++ b/browse/test/sandbox-disable-args.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, afterEach } from 'bun:test'; +import { sandboxDisableArgs } from '../src/stealth'; + +// Snapshot + restore the env vars the helper reads, so tests don't leak state. +const ORIG = { + CI: process.env.CI, + CONTAINER: process.env.CONTAINER, +}; + +function clearEnv() { + delete process.env.CI; + delete process.env.CONTAINER; +} + +afterEach(() => { + // restore + if (ORIG.CI === undefined) delete process.env.CI; else process.env.CI = ORIG.CI; + if (ORIG.CONTAINER === undefined) delete process.env.CONTAINER; else process.env.CONTAINER = ORIG.CONTAINER; +}); + +describe('sandboxDisableArgs', () => { + test('returns --no-sandbox when CONTAINER is set', () => { + clearEnv(); + process.env.CONTAINER = '1'; + expect(sandboxDisableArgs()).toContain('--no-sandbox'); + }); + + test('returns --no-sandbox when CI is set', () => { + clearEnv(); + process.env.CI = 'true'; + expect(sandboxDisableArgs()).toContain('--no-sandbox'); + }); + + test('returns --no-sandbox when running as root (uid 0)', () => { + clearEnv(); + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + // Only assert the root branch when the test process is actually root + // (true in the CI/container image). On a normal dev box uid!=0, so we + // assert the negative branch instead. + if (isRoot) { + expect(sandboxDisableArgs()).toContain('--no-sandbox'); + } else { + expect(sandboxDisableArgs()).toEqual([]); + } + }); + + test('returns [] when not container/CI and not root', () => { + clearEnv(); + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + if (!isRoot) { + expect(sandboxDisableArgs()).toEqual([]); + } + }); +});