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
+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
});
});