fix(browse): apply stealth on every launch path + share automation-artifact cleanup

handoff() built cmdline args but never called applyStealth, so a handed-off
browser had no JS stealth (no webdriver mask, no chrome.* shape, no toString
proxy). And the cdc_/Permissions cleanup shim lived inline in launchHeaded()
only, so headless launch() reported Notification.permission='default' without
the matching permissions.query='prompt' answer — the exact cross-source
inconsistency the shim exists to prevent.

Move the cleanup into AUTOMATION_ARTIFACT_CLEANUP_SCRIPT inside applyStealth so
all three launch paths (launch, launchHeaded, handoff) get identical stealth,
and call applyStealth(newContext) in handoff() before restoreState() navigates.

A static tripwire in browser-manager-unit.test.ts fails CI if any launch path
drops the applyStealth call again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-18 00:58:34 -07:00
parent 66e1f44a86
commit c389084a64
4 changed files with 119 additions and 59 deletions
+15 -40
View File
@@ -592,48 +592,16 @@ export class BrowserManager {
this.intentionalDisconnect = false;
// ─── Anti-bot-detection patches ───────────────────────────────
// D7 (codex correction): mask navigator.webdriver only. We do NOT fake
// plugins/languages — modern fingerprinters check consistency between
// those and userAgent/platform, and synthesizing fixed values can flag
// MORE bot-like, not less. Let Chromium's natural plugins and languages
// surface unmodified.
//
// What we DO clean up are automation-specific runtime artifacts that
// shouldn't exist in a real browser at all (Permissions API quirks,
// ChromeDriver-injected window globals). Those aren't fingerprint
// synthesis — they're removing leaked automation tells.
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
// restores window.chrome.* shape, aligns Notification.permission, sets
// per-install hardware, and strips automation runtime artifacts (cdc_/
// __webdriver globals + the Permissions notifications 'denied' tell).
// We still do NOT fake navigator.plugins/languages — faking those flags
// more bot-like, not less (D7). The cdc/Permissions cleanup moved into
// applyStealth so headless launch() and handoff() get it too, not just
// this headed path.
const { applyStealth } = await import('./stealth');
await applyStealth(this.context);
await this.context.addInitScript(() => {
// Remove CDP runtime artifacts that automation detectors look for
// cdc_ prefixed vars are injected by ChromeDriver/CDP
const cleanup = () => {
for (const key of Object.keys(window)) {
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
try {
delete (window as any)[key];
} catch (e: any) {
if (!(e instanceof TypeError)) throw e;
}
}
}
};
cleanup();
// Re-clean after a tick in case they're injected late
setTimeout(cleanup, 0);
// Override Permissions API to return 'prompt' for notifications
// (automation browsers return 'denied' which is a fingerprint)
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
(window.navigator.permissions as any).query = (params: any) => {
if (params.name === 'notifications') {
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
}
return originalQuery.call(window.navigator.permissions, params);
};
}
});
// Inject visual indicator — subtle top-edge amber gradient
// Extension's content script handles the floating pill
@@ -1619,6 +1587,13 @@ export class BrowserManager {
this.tabSessions.clear();
this.connectionMode = 'headed';
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
// restoreState() navigates so the init scripts apply to the restored
// pages — without this the handed-off browser had cmdline args but no
// JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
const { applyStealth } = await import('./stealth');
await applyStealth(newContext);
if (Object.keys(this.extraHeaders).length > 0) {
await newContext.setExtraHTTPHeaders(this.extraHeaders);
}
+63 -10
View File
@@ -367,17 +367,69 @@ function extendedModeEnabled(): boolean {
}
/**
* Apply stealth patches to a fresh BrowserContext (or persistent context).
* Called by browser-manager.launch() and launchHeaded().
* Strip automation runtime artifacts that don't belong in a real browser
* and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window
* globals (cdc_*, __webdriver*) and the Permissions API quirk where an
* automated Chromium reports notifications as 'denied' instead of real
* Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's
* Notification.permission = 'default' so the two surfaces stay consistent.
*
* Always applies the always-on Layer C stealth script (built from the
* per-install host profile) — the consistency-first default. When
* GSTACK_STEALTH=extended is set, layers the opt-in EXTENDED_STEALTH_SCRIPT
* on top: its window.chrome.* patches are `if (!...)`-guarded, so Layer C's
* richer shapes win, while the extended-only additions (WebGL spoof, faked
* navigator.plugins, mediaDevices, cdc_* cleanup) apply on top. Extended
* mode actively LIES about the browser and can break sites that reflect on
* these properties, so it stays off by default.
* Runs on EVERY launch path via applyStealth (headless launch(),
* launchHeaded(), handoff()) so the Notification/Permissions pairing never
* diverges by mode. Previously this lived inline in launchHeaded() only,
* which left headless and handoff with the Notification value but not the
* matching Permissions answer.
*/
const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
// cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector
// finds them by iterating window keys. Strip immediately and again after a
// tick in case they are injected late.
const cleanup = () => {
for (const key of Object.keys(window)) {
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; }
}
}
};
cleanup();
setTimeout(cleanup, 0);
// Permissions API: automated Chromium returns 'denied' for notifications,
// a known tell. Return 'prompt' to match real Chrome (and Layer C's
// Notification.permission = 'default').
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
if (originalQuery) {
window.navigator.permissions.query = (params) => {
if (params && params.name === 'notifications') {
return Promise.resolve({ state: 'prompt', onchange: null });
}
return originalQuery.call(window.navigator.permissions, params);
};
}
})();`;
/**
* Apply stealth patches to a fresh BrowserContext (or persistent context).
* Called by browser-manager.launch(), launchHeaded(), AND handoff() so all
* three launch paths get identical stealth.
*
* Injection order (Playwright evaluates init scripts in registration order):
* 1. Layer C (buildStealthScript) — the always-on consistency-first default.
* 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim),
* kept consistent with Layer C's Notification.permission alignment.
* 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by
* default). Its window.chrome.* patches are `if (!...)`-guarded, so
* Layer C's richer shapes win; the extended-only additions (WebGL spoof,
* faked navigator.plugins, mediaDevices) apply on top.
*
* KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT
* wrapped by Layer C's Function.prototype.toString proxy, so they stringify
* as injected code; its prototype-level navigator.webdriver delete is shadowed
* by Layer C's own-property getter (net behavior still matches real Chrome:
* webdriver present and false); and its hardcoded Apple-M1 WebGL string can
* disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple
* hosts. This is acceptable for the documented "actively lies, may break sites"
* escape hatch; the consistency-first default (Layer C alone) has none of these.
*
* Host profile is resolved from process.env at call time so per-install
* values bake into the script before Playwright sends it to Chromium via
@@ -386,6 +438,7 @@ function extendedModeEnabled(): boolean {
export async function applyStealth(context: BrowserContext): Promise<void> {
const hw = readHostProfile();
await context.addInitScript({ content: buildStealthScript(hw) });
await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT });
if (extendedModeEnabled()) {
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
}
+27
View File
@@ -227,3 +227,30 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => {
expect(shutdownCalls).toEqual([0, 2, 2]);
});
});
// ─── Stealth injected on EVERY launch path (regression tripwire) ───
//
// applyStealth must run on launch() (headless), launchHeaded(), AND
// handoff(). The blend of Layer C with extended mode left handoff() building
// 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 () => {
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).
const callSites = src.match(/applyStealth\(/g) || [];
expect(callSites.length).toBeGreaterThanOrEqual(3);
// The handoff() method body specifically must call applyStealth, before
// the resume() JSDoc that follows it.
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(');
});
});
+14 -9
View File
@@ -89,7 +89,7 @@ describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => {
});
describe('applyStealth — script wiring', () => {
test('default mode applies ONLY the Layer C script (not extended)', async () => {
test('default mode applies Layer C + cleanup, not extended', async () => {
delete process.env.GSTACK_STEALTH;
const calls: string[] = [];
const fakeCtx = {
@@ -98,14 +98,19 @@ describe('applyStealth — script wiring', () => {
},
} as unknown as Parameters<typeof applyStealth>[0];
await applyStealth(fakeCtx);
expect(calls).toHaveLength(1);
// Layer C signatures: toString-proxy native-code lie + webdriver mask.
expect(calls).toHaveLength(2);
// [0] = Layer C (toString-proxy native-code lie + webdriver mask).
expect(calls[0]).toContain('[native code]');
expect(calls[0]).toContain('webdriver');
expect(calls[0]).not.toBe(EXTENDED_STEALTH_SCRIPT);
// [1] = automation-artifact cleanup (cdc_ scan + Permissions shim) —
// now applied on EVERY launch path, not just the headed one.
expect(calls[1]).toContain('cdc_');
expect(calls[1]).toContain('setTimeout(cleanup');
// Extended script must NOT be applied by default.
expect(calls).not.toContain(EXTENDED_STEALTH_SCRIPT);
});
test('extended mode applies BOTH scripts in order (Layer C first, extended second)', async () => {
test('extended mode applies Layer C, cleanup, then extended (in order)', async () => {
process.env.GSTACK_STEALTH = 'extended';
const calls: string[] = [];
const fakeCtx = {
@@ -114,9 +119,9 @@ describe('applyStealth — script wiring', () => {
},
} as unknown as Parameters<typeof applyStealth>[0];
await applyStealth(fakeCtx);
expect(calls).toHaveLength(2);
// Layer C first (its native-code lie), extended second.
expect(calls[0]).toContain('[native code]');
expect(calls[1]).toBe(EXTENDED_STEALTH_SCRIPT);
expect(calls).toHaveLength(3);
expect(calls[0]).toContain('[native code]'); // Layer C first
expect(calls[1]).toContain('cdc_'); // cleanup second
expect(calls[2]).toBe(EXTENDED_STEALTH_SCRIPT); // extended last
});
});