Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
Expand Down
22 changes: 22 additions & 0 deletions browse/src/stealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions browse/test/sandbox-disable-args.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
}
});
});