diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 1f619218e..2d863bd1b 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -22,7 +22,7 @@ import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type D import { emitActivity } from './activity'; import { validateNavigationUrl, blockedNavigationReason } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; -import { resolveChromiumProfile, cleanSingletonLocks } from './config'; +import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config'; import { launchWithXProtectHeal } from './xprotect-heal'; import { readPidStartTime, shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; @@ -318,6 +318,8 @@ export class BrowserManager { // ─── Tab Ownership (multi-agent isolation) ────────────── // Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes. private tabOwnership: Map = new Map(); + /** Headless launch with BROWSE_EXTENSIONS_DIR: a persistent context (#2281). */ + private extensionContext = false; /** Pending and recorded navigation-guard verdicts per page (D2). */ private navigationGuards = new WeakMap>; blocked: string | null }>(); @@ -530,11 +532,22 @@ export class BrowserManager { this.closing = false; // ─── Extension Support ──────────────────────────────────── // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. - // Extensions only work in headed mode, so we use an off-screen window. + // Extensions only run in a persistent context (launch() + newContext() + // isolates them), so that path uses launchPersistentContext with Chrome's + // new headless mode: no window, unlike the old off-screen window that + // macOS still showed (#2281, #432). const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; - const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); + const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs, STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; - let useHeadless = true; + // #2771/#1968: honor GSTACK_CHROMIUM_PATH headless too (NixOS, macOS 13). + // That bundle belongs to the user, so the XProtect heal never touches it, + // and a launch failure names it. + const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined; + const nameCustomChromium = (err: unknown): never => { + if (!executablePath) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Chromium at GSTACK_CHROMIUM_PATH=${executablePath} failed to launch: ${message}\nFix: point GSTACK_CHROMIUM_PATH at a working Chromium or Chrome binary, or unset it to use Playwright's bundled Chromium.`, { cause: err }); + }; // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which // are typically disabled in containers and are never available for the root @@ -554,24 +567,59 @@ export class BrowserManager { `--load-extension=${extensionsDir}`, ); } - launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1'); - useHeadless = false; // extensions require headed mode; off-screen window simulates headless + launchArgs.push('--headless=new'); console.log(`[browse] Extensions loaded from: ${extensionsDir}`); } - // #2709: headless-only — the extensions path above forces headed mode, - // and headed/GBrowser sessions must keep the GPU. - if (useHeadless) { - launchArgs.push(...headlessGpuArgs(process.platform, process.env)); + // #2709: headless-only GPU flags; headed/GBrowser sessions keep the GPU. + launchArgs.push(...headlessGpuArgs(process.platform, process.env)); + + const contextOptions: BrowserContextOptions = { + viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, + deviceScaleFactor: this.deviceScaleFactor, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + + if (extensionsDir) { + const profileDir = require('path').join(resolveConfig().stateDir, 'chromium-extensions-profile'); + require('fs').mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + cleanSingletonLocks(profileDir); + this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(profileDir, { + headless: false, // --headless=new in launchArgs: windowless, extensions run + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + chromiumSandbox: shouldEnableChromiumSandbox(), + args: launchArgs, + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, + ...contextOptions, + ...(executablePath ? { executablePath } : {}), + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), + }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium); + this.extensionContext = true; + this.browser = this.context.browser(); + this.browser?.on('disconnected', () => { + void handleChromiumDisconnect(this.browser); + }); + for (const page of this.context.pages()) await page.close().catch(() => {}); + if (Object.keys(this.extraHeaders).length > 0) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + const { applyStealth } = await import('./stealth'); + await applyStealth(this.context); + await this.newTab(); + return; } // XProtect self-heal wrapper (P0 #2554): a macOS definition update can // start SIGKILLing the pinned Chromium at spawn. On the classified // signature, clear quarantine on the Playwright cache + force-reinstall - // once, then retry this launch once. This headless path always uses the - // Playwright cache (no executablePath), so the heal is never scoped out. + // once, then retry this launch once. A GSTACK_CHROMIUM_PATH bundle is + // scoped out of the heal. this.browser = await launchWithXProtectHeal(() => chromium.launch({ - headless: useHeadless, + headless: true, // #2220: the daemon owns signal policy, not Playwright. Playwright's // default handlers close Chromium the moment THIS process receives // SIGINT/SIGTERM/SIGHUP — which fights the deliberate headless @@ -589,8 +637,9 @@ export class BrowserManager { // namespaces that aren't available. chromiumSandbox: shouldEnableChromiumSandbox(), ...(launchArgs.length > 0 ? { args: launchArgs } : {}), + ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - })); + }), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium); // Chromium disconnect → distinguish clean user-quit from crash. Both // events look identical to Playwright (one 'disconnected' fires), but @@ -614,13 +663,6 @@ export class BrowserManager { : null; } - const contextOptions: BrowserContextOptions = { - viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, - deviceScaleFactor: this.deviceScaleFactor, - }; - if (this.customUserAgent) { - contextOptions.userAgent = this.customUserAgent; - } this.context = await this.browser.newContext(contextOptions); if (Object.keys(this.extraHeaders).length > 0) { @@ -956,7 +998,7 @@ export class BrowserManager { (t as { unref?: () => void }).unref?.(); }); if (this.browser || ((this.connectionMode === 'headed' || this.handoffPrevious) && this.context)) { - if (this.connectionMode === 'headed' || this.handoffPrevious) { + if (this.connectionMode === 'headed' || this.handoffPrevious || this.extensionContext) { // Headed/persistent context mode: close the context (which closes the browser) this.intentionalDisconnect = true; if (this.browser) this.browser.removeAllListeners('disconnected'); @@ -1681,6 +1723,9 @@ export class BrowserManager { if (this.connectionMode === 'headed') { throw new Error('Cannot recreate context in headed mode. Use disconnect first.'); } + if (this.extensionContext) { + throw new Error('Cannot recreate context while BROWSE_EXTENSIONS_DIR extensions are loaded (persistent context). Restart browse with the new settings instead.'); + } if (!this.browser || !this.context) { throw new Error('Browser not launched'); } diff --git a/browse/test/headless-gpu-and-reap.test.ts b/browse/test/headless-gpu-and-reap.test.ts index d0dbbf0c0..cef5776e1 100644 --- a/browse/test/headless-gpu-and-reap.test.ts +++ b/browse/test/headless-gpu-and-reap.test.ts @@ -189,11 +189,10 @@ describe('stop-reap wiring pins (#2709)', () => { // unguarded push would strip the GPU from headed/GBrowser sessions. const calls = src.match(/headlessGpuArgs\(process\.platform, process\.env\)/g) ?? []; expect(calls.length).toBe(1); - // And that one call site is guarded by the headless flag: the extensions - // path above it forces useHeadless = false, so extension-loaded and headed - // launches never receive the GPU-disable flags. - expect(src).toMatch( - /if \(useHeadless\) \{\s*\n\s*launchArgs\.push\(\.\.\.headlessGpuArgs\(process\.platform, process\.env\)\);\s*\n\s*\}/, - ); + // And that one call site is in the headless launch(), never in the headed + // launchHeaded(). Since #2281 the extensions path is new headless too + // (--headless=new, no window), so it takes the headless GPU flags. + const headless = src.slice(src.indexOf(' async launch() {'), src.indexOf(' async launchHeaded(')); + expect(headless).toContain('launchArgs.push(...headlessGpuArgs(process.platform, process.env));'); }); }); diff --git a/browse/test/headless-launch-options.test.ts b/browse/test/headless-launch-options.test.ts new file mode 100644 index 000000000..f727c31a0 --- /dev/null +++ b/browse/test/headless-launch-options.test.ts @@ -0,0 +1,79 @@ +/** + * E8 (#2771, #1968) and #2281: the headless launch honors GSTACK_CHROMIUM_PATH + * and names it when it fails; BROWSE_EXTENSIONS_DIR loads extensions in new + * headless mode (no window, extension actually runs) instead of an off-screen + * headed window. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { chromium } from 'playwright'; +import { BrowserManager } from '../src/browser-manager'; +import { readPidCmdline } from '../src/xvfb'; +import { handleWriteCommand } from '../src/write-commands'; + +const ENV_KEYS = ['GSTACK_CHROMIUM_PATH', 'BROWSE_EXTENSIONS_DIR', 'BROWSE_STATE_FILE'] as const; +let saved: Record; +let scratch: string; +let bm: BrowserManager | null; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])); + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-launch-opts-')); + process.env.BROWSE_STATE_FILE = path.join(scratch, 'state', 'browse.json'); + bm = null; +}); + +afterEach(async () => { + try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {} + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +describe.skipIf(process.platform !== 'linux')('headless launch options (Linux, real Chromium)', () => { + test('GSTACK_CHROMIUM_PATH is the binary the headless launch runs', async () => { + const custom = chromium.executablePath(); + process.env.GSTACK_CHROMIUM_PATH = custom; + bm = new BrowserManager(); + await bm.launch(); + const children = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d)).filter(d => { + try { return fs.readFileSync(`/proc/${d}/stat`, 'utf-8').split(') ')[1].split(' ')[1] === String(process.pid); } catch { return false; } + }); + const cmdlines = children.map(pid => readPidCmdline(Number(pid))); + expect(cmdlines.some(c => c.startsWith(custom) && c.includes('--headless'))).toBe(true); + expect(cmdlines.some(c => c.includes('headless_shell'))).toBe(false); + }, 60_000); + + test('a broken GSTACK_CHROMIUM_PATH fails with the path named', async () => { + const missing = path.join(scratch, 'no-such-chromium'); + process.env.GSTACK_CHROMIUM_PATH = missing; + bm = new BrowserManager(); + await expect(bm.launch()).rejects.toThrow(`Chromium at GSTACK_CHROMIUM_PATH=${missing} failed to launch`); + }, 60_000); + + test('BROWSE_EXTENSIONS_DIR runs the extension in new headless mode', async () => { + const ext = path.join(scratch, 'ext'); + fs.mkdirSync(ext); + fs.writeFileSync(path.join(ext, 'manifest.json'), JSON.stringify({ + manifest_version: 3, name: 'probe', version: '1', + content_scripts: [{ matches: [''], js: ['c.js'], run_at: 'document_start' }], + })); + fs.writeFileSync(path.join(ext, 'c.js'), 'document.documentElement.setAttribute("data-probe-ext", "1");'); + process.env.BROWSE_EXTENSIONS_DIR = ext; + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('

x

', { headers: { 'Content-Type': 'text/html' } }) }); + try { + bm = new BrowserManager(); + await bm.launch(); + const session = bm.getActiveSession(); + await handleWriteCommand('goto', [`http://127.0.0.1:${server.port}/`], session, bm); + expect(await session.getPage().evaluate(() => document.documentElement.getAttribute('data-probe-ext'))).toBe('1'); + expect(fs.existsSync(path.join(scratch, 'state', 'chromium-extensions-profile'))).toBe(true); + } finally { + server.stop(true); + } + }, 60_000); +});