diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 272bc7d89..92972144a 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -213,8 +213,8 @@ 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, buildGStackLaunchArgs } = await import('./stealth'); + const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; let useHeadless = true; // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which @@ -295,11 +295,18 @@ export class BrowserManager { // Find the gstack extension directory for auto-loading const extensionPath = this.findExtensionPath(); + const { buildGStackLaunchArgs } = await import('./stealth'); 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', + // GStack Pack 1: per-install hardware/GPU/UA-CH overrides for the + // C++ patches in gbrowser's Chromium build. Each switch is a no-op + // on Chromium builds without the corresponding patch (the patch's + // empty-fallback returns native), so this is safe on stock Playwright + // Chromium too. + ...buildGStackLaunchArgs(), ]; if (extensionPath) { // Skip --load-extension when running against a custom Chromium build @@ -1296,7 +1303,8 @@ export class BrowserManager { const fs = require('fs'); const path = require('path'); const extensionPath = this.findExtensionPath(); - const launchArgs = ['--hide-crash-restore-bubble']; + const { buildGStackLaunchArgs } = await import('./stealth'); + const launchArgs: string[] = ['--hide-crash-restore-bubble', ...buildGStackLaunchArgs()]; 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 b072df291..f4e982f28 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -270,6 +270,62 @@ export const STEALTH_LAUNCH_ARGS = [ '--disable-blink-features=AutomationControlled', ]; +/** + * Build the `--gstack-*=` cmdline switches that the Pack 1 Chromium + * patches read (webgl-vendor-spoof, ua-client-hints-stealth, worker- + * navigator-stealth). Values come from the GSTACK_* env vars that + * gbd populates from host_profile.go at startup. + * + * Each switch is only emitted when its env var is non-empty — empty + * env values fall through to the patch's "no override" path, which + * returns the real Chromium native value. This keeps the helper safe + * on builds that DO NOT have the C++ patches applied (gbrowser + * pre-Pack-1) and on hosts where gbd hasn't yet populated some + * fields (legacy installs). + * + * Mapping (gbd env → Chromium cmdline switch → C++ patch consumer): + * GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch + * GSTACK_GPU_RENDERER → --gstack-gpu-renderer → webgl-vendor-spoof.patch + * GSTACK_PLATFORM → --gstack-ua-platform → ua-client-hints-stealth.patch + * (maps MacARM/MacIntel → "macOS") + * GSTACK_GPU_CHIPSET → --gstack-ua-model → ua-client-hints-stealth.patch + * GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency → worker-navigator-stealth.patch + * GSTACK_DEVICE_MEMORY → --gstack-device-memory → worker-navigator-stealth.patch + */ +export function buildGStackLaunchArgs(): string[] { + const env = (globalThis as any).process?.env ?? {}; + const args: string[] = []; + + const vendor = env.GSTACK_GPU_VENDOR; + if (vendor) args.push(`--gstack-gpu-vendor=${vendor}`); + + const renderer = env.GSTACK_GPU_RENDERER; + if (renderer) args.push(`--gstack-gpu-renderer=${renderer}`); + + // Map gbd's "MacARM"/"MacIntel" classification to the UA-CH "macOS" + // platform string Chromium emits natively. Other future platforms + // would map similarly (Win32 → "Windows", Linux → "Linux"). + const platform = env.GSTACK_PLATFORM; + if (platform === 'MacARM' || platform === 'MacIntel') { + args.push('--gstack-ua-platform=macOS'); + } else if (platform === 'Win32') { + args.push('--gstack-ua-platform=Windows'); + } else if (platform && platform.startsWith('Linux')) { + args.push('--gstack-ua-platform=Linux'); + } + + const chipset = env.GSTACK_GPU_CHIPSET; + if (chipset) args.push(`--gstack-ua-model=${chipset}`); + + const hw = env.GSTACK_HW_CONCURRENCY; + if (hw) args.push(`--gstack-hw-concurrency=${hw}`); + + const memory = env.GSTACK_DEVICE_MEMORY; + if (memory) args.push(`--gstack-device-memory=${memory}`); + + return args; +} + /** * Playwright default args to strip via ignoreDefaultArgs. * diff --git a/browse/test/stealth-layer-c.test.ts b/browse/test/stealth-layer-c.test.ts index 0ace1bd45..444a09313 100644 --- a/browse/test/stealth-layer-c.test.ts +++ b/browse/test/stealth-layer-c.test.ts @@ -11,6 +11,7 @@ import { describe, test, expect } from 'bun:test'; import { buildStealthScript, + buildGStackLaunchArgs, WEBDRIVER_MASK_SCRIPT, STEALTH_LAUNCH_ARGS, STEALTH_IGNORE_DEFAULT_ARGS, @@ -116,6 +117,97 @@ describe('buildStealthScript — T3 Layer C', () => { }); }); +describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => { + // Helper: clear all GSTACK_* env, run test body, restore env. + function withEnv(env: Record, body: () => void) { + const saved: Record = {}; + for (const k of Object.keys(process.env)) { + if (k.startsWith('GSTACK_')) { + saved[k] = process.env[k]; + delete process.env[k]; + } + } + for (const [k, v] of Object.entries(env)) { + if (v !== undefined) process.env[k] = v; + } + try { + body(); + } finally { + for (const k of Object.keys(process.env)) { + if (k.startsWith('GSTACK_')) delete process.env[k]; + } + for (const [k, v] of Object.entries(saved)) { + if (v !== undefined) process.env[k] = v; + } + } + } + + test('empty env produces empty arg list', () => { + withEnv({}, () => { + expect(buildGStackLaunchArgs()).toEqual([]); + }); + }); + + test('all env values populated → all 6 switches emitted', () => { + withEnv({ + GSTACK_GPU_VENDOR: 'Apple Inc.', + GSTACK_GPU_RENDERER: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)', + GSTACK_PLATFORM: 'MacARM', + GSTACK_GPU_CHIPSET: 'Apple M4 Max', + GSTACK_HW_CONCURRENCY: '16', + GSTACK_DEVICE_MEMORY: '8', + }, () => { + const args = buildGStackLaunchArgs(); + expect(args).toContain('--gstack-gpu-vendor=Apple Inc.'); + expect(args).toContain('--gstack-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)'); + expect(args).toContain('--gstack-ua-platform=macOS'); + expect(args).toContain('--gstack-ua-model=Apple M4 Max'); + expect(args).toContain('--gstack-hw-concurrency=16'); + expect(args).toContain('--gstack-device-memory=8'); + expect(args.length).toBe(6); + }); + }); + + test('platform mapping: MacARM and MacIntel both → macOS', () => { + withEnv({ GSTACK_PLATFORM: 'MacARM' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS'); + }); + withEnv({ GSTACK_PLATFORM: 'MacIntel' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS'); + }); + }); + + test('platform mapping: Win32 → Windows, Linux x86_64 → Linux', () => { + withEnv({ GSTACK_PLATFORM: 'Win32' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Windows'); + }); + withEnv({ GSTACK_PLATFORM: 'Linux x86_64' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Linux'); + }); + }); + + test('partial env: only set switches that have values', () => { + withEnv({ GSTACK_HW_CONCURRENCY: '12' }, () => { + const args = buildGStackLaunchArgs(); + expect(args).toEqual(['--gstack-hw-concurrency=12']); + }); + }); + + test('unrecognized platform falls through without --gstack-ua-platform', () => { + withEnv({ GSTACK_PLATFORM: 'OS/2' }, () => { + const args = buildGStackLaunchArgs(); + expect(args.some(a => a.startsWith('--gstack-ua-platform='))).toBe(false); + }); + }); + + test('GPU vendor with spaces survives intact (no quote/escape regression)', () => { + withEnv({ GSTACK_GPU_VENDOR: 'NVIDIA Corporation' }, () => { + const args = buildGStackLaunchArgs(); + expect(args).toContain('--gstack-gpu-vendor=NVIDIA Corporation'); + }); + }); +}); + describe('backwards-compat exports', () => { test('WEBDRIVER_MASK_SCRIPT still exported', () => { expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");