fix(browse): recreateContext() re-applies stealth (closes 4th un-stealth path)

useragent and viewport --scale route through recreateContext(), which rebuilds
the BrowserContext via newContext() — a fresh context with no init scripts. It
never called applyStealth, so a routine useragent/viewport-scale command
silently dropped webdriver masking, window.chrome.* shape, hardware spoof, and
the cdc/Permissions cleanup on every restored page. Caught by the cross-model
adversarial review (Codex) after the Claude pass and eng review missed it.

Both the main and fallback paths now call applyStealth before any page is
created. The launch-path tripwire is raised to >= 4 sites and now asserts the
recreateContext() body specifically, so the regression class can't recur.

Also documents the load-bearing trust assumption on buildGStackLaunchArgs /
readHostProfile (GSTACK_* must be gbd-sourced, never page/remote data — the
injection-safety argument depends on it) and the notifications-permission
spoof tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-18 01:39:23 -07:00
parent d63f2adb6f
commit 1067b12e96
3 changed files with 46 additions and 9 deletions
+19 -6
View File
@@ -235,23 +235,36 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => {
// cmdline args but never calling applyStealth, so a handed-off browser had
// no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
// This static check fails CI if any launch path drops the call again.
describe('stealth injected on every launch path', () => {
it('handoff() calls applyStealth and there are >= 3 call sites', async () => {
describe('stealth injected on every context-creation path', () => {
it('every context-creation path calls applyStealth (>= 4 call sites)', async () => {
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');
// >= 3 total applyStealth call sites (launch, launchHeaded, handoff).
// Every path that builds a BrowserContext must apply stealth: launch()
// (headless), launchHeaded(), handoff(), and recreateContext() (the
// useragent / viewport --scale rebuild, main + fallback). A path that
// creates a context without applyStealth silently un-stealths its pages.
const callSites = src.match(/applyStealth\(/g) || [];
expect(callSites.length).toBeGreaterThanOrEqual(3);
expect(callSites.length).toBeGreaterThanOrEqual(4);
// The handoff() method body specifically must call applyStealth, before
// the resume() JSDoc that follows it.
// handoff() body specifically must call applyStealth, before the resume() JSDoc.
const handoffStart = src.indexOf('async handoff(');
expect(handoffStart).toBeGreaterThan(0);
const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart);
const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000);
expect(handoffBody).toContain('applyStealth(');
// recreateContext() body must call applyStealth too — useragent and
// viewport --scale route through it and would otherwise drop all stealth.
const recreateStart = src.indexOf('recreateContext');
expect(recreateStart).toBeGreaterThan(0);
// Find the method definition (not just the JSDoc/caller references).
const recreateDef = src.indexOf('async recreateContext(');
expect(recreateDef).toBeGreaterThan(0);
const setUaAnchor = src.indexOf('async setDeviceScaleFactor(', recreateDef);
const recreateBody = src.slice(recreateDef, setUaAnchor > 0 ? setUaAnchor : recreateDef + 6000);
expect(recreateBody).toContain('applyStealth(');
});
it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => {