mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
E8: headless browse honors GSTACK_CHROMIUM_PATH; extensions load in new headless mode (#2771, #1968, #2281)
The headless launch passes GSTACK_CHROMIUM_PATH as executablePath, scopes the XProtect self-heal out for that bundle, and names the path in a launch failure with the fix. BROWSE_EXTENSIONS_DIR now launches a persistent context under the project state dir with --headless=new, so the extension runs and no window appears (the off-screen window was visible on macOS and the extension never ran in an isolated newContext). The GPU source guard test moves from the removed useHeadless flag to launch() vs launchHeaded(). Co-authored-by: oli548 <312288720+oli548@users.noreply.github.com> Co-authored-by: Slava <slava.istomin@tuta.io> Co-authored-by: ryanlaiwy <129729476+ryanlaiwy@users.noreply.github.com> Co-authored-by: 李翊楠 <yinanli1917@gmail.com>
This commit is contained in:
1 parent
5015a939a9
commit
46f4f52f44
3 files changed
+151
-28
No files matched your search
@@ -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<number, string> = 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<Page, { pending: Set<Promise<void>>; 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');
|
||||
}
|
||||
|
||||
@@ -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));');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>;
|
||||
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: ['<all_urls>'], 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('<p>x</p>', { 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);
|
||||
});
|
||||
Reference in new issue
Block a user