fix(browse): handoff() + launchHeaded() spread the shared STEALTH_LAUNCH_ARGS

handoff() built its launch args from only ['--hide-crash-restore-bubble',
...buildGStackLaunchArgs()], omitting STEALTH_LAUNCH_ARGS — so a handed-off
browser kept the --disable-blink-features=AutomationControlled tell that
launch() and launchHeaded() strip. launchHeaded() also hardcoded the flag as a
literal. Both now spread the shared constant, so the AutomationControlled flag
lives in one place across all three launch paths.

Tripwires: STEALTH_LAUNCH_ARGS spread into >= 3 sites (no inline literal) and
STEALTH_IGNORE_DEFAULT_ARGS wired into both persistent-context paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-18 01:26:48 -07:00
parent e4c372ed98
commit 21b37cca9f
2 changed files with 46 additions and 6 deletions
+10 -6
View File
@@ -437,12 +437,13 @@ export class BrowserManager {
// Find the gstack extension directory for auto-loading
const extensionPath = this.findExtensionPath();
const { buildGStackLaunchArgs } = await import('./stealth');
const { STEALTH_LAUNCH_ARGS, 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',
// Anti-bot-detection: --disable-blink-features=AutomationControlled (and any
// future blink-level tells) via the shared STEALTH_LAUNCH_ARGS constant — the
// same flag launch() and handoff() use, kept in one place instead of a literal.
...STEALTH_LAUNCH_ARGS,
// 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
@@ -1541,8 +1542,11 @@ export class BrowserManager {
const fs = require('fs');
const path = require('path');
const extensionPath = this.findExtensionPath();
const { buildGStackLaunchArgs } = await import('./stealth');
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...buildGStackLaunchArgs()];
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
// Same blink-level stealth flags as launch()/launchHeaded(). Without
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
// tell that the other two paths strip.
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
+36
View File
@@ -253,4 +253,40 @@ describe('stealth injected on every launch path', () => {
const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000);
expect(handoffBody).toContain('applyStealth(');
});
it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => {
// Same silent-drop regression class as applyStealth: a launch path that
// omits buildGStackLaunchArgs() loses the per-install GPU/UA/hardware
// cmdline spoof. launch(), launchHeaded(), and handoff() must all call it.
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
const callSites = src.match(/buildGStackLaunchArgs\(\)/g) || [];
expect(callSites.length).toBeGreaterThanOrEqual(3);
});
it('STEALTH_LAUNCH_ARGS is spread into all 3 launch sites (no hardcoded literal)', async () => {
// The --disable-blink-features=AutomationControlled flag must come from the
// shared constant on launch(), launchHeaded(), AND handoff(). handoff()
// previously omitted it, leaving the AutomationControlled tell on the
// handed-off browser. No path may inline the literal instead.
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
const spreads = src.match(/\.\.\.STEALTH_LAUNCH_ARGS/g) || [];
expect(spreads.length).toBeGreaterThanOrEqual(3);
// The literal must not be reintroduced in a launchArgs array (it belongs in
// the STEALTH_LAUNCH_ARGS constant in stealth.ts, not inline here).
expect(src).not.toContain("'--disable-blink-features=AutomationControlled'");
});
it('STEALTH_IGNORE_DEFAULT_ARGS is wired into both persistent-context paths', async () => {
// launchHeaded() and handoff() both launchPersistentContext and must strip
// Playwright's automation-tell defaults via the shared constant.
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
const sites = src.match(/ignoreDefaultArgs:\s*STEALTH_IGNORE_DEFAULT_ARGS/g) || [];
expect(sites.length).toBeGreaterThanOrEqual(2);
});
});