stealth.ts
574 lines · browse/src/stealth.ts at 3f79d81
1/**
2 * Stealth init script — Layer C of GBrowser's anti-detection plan.
3 *
4 * D7 (codex correction, kept): Layer C (the always-on default built by
5 * buildStealthScript) does NOT fake navigator.plugins or
6 * navigator.languages — modern fingerprinters cross-check those against
7 * userAgent / platform / OS, and synthesizing fixed values flags MORE
8 * bot-like, not less. Plugins and languages surface their native
9 * Chromium values. The opt-in EXTENDED_STEALTH_SCRIPT below (gated on
10 * GSTACK_STEALTH=extended, off by default) DOES fake plugins — that mode
11 * is the documented "actively lies, may break sites" escape hatch, not
12 * the default posture.
13 *
14 * What this script DOES do (the new additions for Phase 1):
15 * 1. Mask navigator.webdriver (the canonical headless tell).
16 * 2. Restore window.chrome.runtime / app / csi / loadTimes — real
17 * Chrome ships them; their absence in headless/automation is a
18 * universally-checked tell. (Vendor research: Cloudflare + DataDome
19 * check chrome.runtime presence + enum shape.)
20 * 3. Align Notification.permission with the Permissions API spoof
21 * that the inline addInitScript already applies — `denied` while
22 * Permissions returns `prompt` is a cross-source inconsistency
23 * detectors flag.
24 * 4. Report per-install hardware values via GSTACK_HW_CONCURRENCY /
25 * GSTACK_DEVICE_MEMORY env vars (set by gbd at startup via
26 * system_profiler + sysctl). Per-install honesty avoids the
27 * cross-user fingerprint cluster a hardcoded default would create.
28 * 5. Install a Function.prototype.toString Proxy that makes every
29 * patched getter report `function ... { [native code] }` at every
30 * recursion depth — defeats the well-known depth-3 detection trick
31 * (`fn.toString.toString.toString().includes('[native code]')`)
32 * that breaks naive stealth tooling.
33 *
34 * Codex caveat (acknowledged): a Proxy on Function.prototype.toString
35 * still has detection surfaces (descriptors, Reflect.ownKeys, cross-
36 * realm identity). Phase 2's C++ patches make this layer obsolete by
37 * pushing the spoofs to native code where toString is truly native.
38 * Until then, this is the best JS-only approach.
39 */
41import type { BrowserContext } from 'playwright';
43/**
44 * Host hardware values resolved at browser-manager startup. Values come
45 * from the gbd `host_profile.go` detection (system_profiler + sysctl
46 * on macOS), passed through the GSTACK_* env vars. Each field falls
47 * back to a documented default if the env var is missing or unparseable.
48 */
49interface HostProfile {
50 hwConcurrency: number;
51 deviceMemory: number;
52}
54// Exported for the clamp/fallback unit test. The platform spoof is owned by
55// the UA-CH cmdline switch in buildGStackLaunchArgs (which reads GSTACK_PLATFORM
56// directly), so this profile only carries the values buildStealthScript bakes
57// into the page-world script.
58export function readHostProfile(): HostProfile {
59 const env = (globalThis as any).process?.env ?? {};
60 const concurrency = Number(env.GSTACK_HW_CONCURRENCY);
61 const memory = Number(env.GSTACK_DEVICE_MEMORY);
62 return {
63 // Clamp to a plausible default: 0/NaN/negative/missing all fall back to 8.
64 // deviceMemory=0 or NaN would be a glaring bot tell, so never report it.
65 hwConcurrency: Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 8,
66 deviceMemory: Number.isFinite(memory) && memory > 0 ? memory : 8,
67 };
68}
70/**
71 * Build the full Layer C stealth init script. The function template-
72 * literal-interpolates the host values so they bake into the script the
73 * page sees — process.env is not accessible from a page-world init script,
74 * so values must be resolved by the browser-manager process before
75 * injection.
76 *
77 * The script is one big self-invoking function so all the patches
78 * happen atomically before any page code runs. Order matters: the
79 * Function.prototype.toString Proxy installs FIRST so all subsequent
80 * defineProperty getters are covered by its native-code lie.
81 */
82export function buildStealthScript(hw: HostProfile): string {
83 return `(() => {
84 // ──── Function.prototype.toString Proxy (must run first) ────
85 // Make every patched getter / function below report
86 // 'function NAME() { [native code] }' at every recursion depth.
87 // Defeats fn.toString.toString.toString() integrity checks.
88 const patchedFns = new WeakSet();
89 const nativeToString = Function.prototype.toString;
90 const toStringProxy = new Proxy(nativeToString, {
91 apply(target, thisArg, args) {
92 if (patchedFns.has(thisArg)) {
93 const name = (thisArg && thisArg.name) || '';
94 return 'function ' + name + '() { [native code] }';
95 }
96 return Reflect.apply(target, thisArg, args);
97 },
98 });
99 Object.defineProperty(Function.prototype, 'toString', {
100 value: toStringProxy, writable: true, configurable: true,
101 });
102 const markNative = (fn, name) => {
103 if (name) {
104 try { Object.defineProperty(fn, 'name', { value: name }); } catch {}
105 }
106 patchedFns.add(fn);
107 return fn;
108 };
110 // ──── navigator.webdriver (canonical mask, kept from D7) ────
111 try {
112 const webdriverGetter = markNative(function() { return false; }, 'get webdriver');
113 Object.defineProperty(navigator, 'webdriver', { get: webdriverGetter, configurable: true });
114 } catch {}
116 // ──── window.chrome.* restoration ────
117 // Real Chrome ships these objects with rich enum / method shape.
118 // Headless Chromium / Playwright's launch strips them. Their absence
119 // is a universally-checked tell (verified in Cloudflare + DataDome
120 // RE catalogs). We don't try to perfectly mimic — we ship plausible
121 // shape with native-code-looking methods.
122 try {
123 if (!('chrome' in window)) {
124 window.chrome = {};
125 }
126 const chrome = window.chrome;
127 if (!chrome.runtime) {
128 chrome.runtime = {
129 OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install',
130 SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' },
131 OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
132 PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64',
133 X86_32: 'x86-32', X86_64: 'x86-64' },
134 PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64',
135 X86_32: 'x86-32', X86_64: 'x86-64' },
136 PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux',
137 MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },
138 RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled',
139 UPDATE_AVAILABLE: 'update_available' },
140 id: undefined,
141 };
142 }
143 if (!chrome.app) {
144 chrome.app = {
145 isInstalled: false,
146 InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
147 RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
148 };
149 }
150 if (typeof chrome.csi !== 'function') {
151 chrome.csi = markNative(function csi() {
152 return {
153 onloadT: Date.now(),
154 pageT: performance.now(),
155 startE: Date.now() - 1000,
156 tran: 15,
157 };
158 }, 'csi');
159 }
160 if (typeof chrome.loadTimes !== 'function') {
161 chrome.loadTimes = markNative(function loadTimes() {
162 const t = performance.timing;
163 return {
164 requestTime: t.requestStart / 1000,
165 startLoadTime: t.requestStart / 1000,
166 commitLoadTime: t.responseStart / 1000,
167 finishDocumentLoadTime: t.domContentLoadedEventEnd / 1000,
168 finishLoadTime: t.loadEventEnd / 1000,
169 firstPaintTime: t.responseEnd / 1000,
170 firstPaintAfterLoadTime: 0,
171 navigationType: 'Other',
172 wasFetchedViaSpdy: true,
173 wasNpnNegotiated: true,
174 npnNegotiatedProtocol: 'h2',
175 wasAlternateProtocolAvailable: false,
176 connectionInfo: 'h2',
177 };
178 }, 'loadTimes');
179 }
180 } catch (err) {
181 // Non-fatal — page might have a stricter Content Security Policy
182 // that blocks property mutation on window. Leave chrome.* whatever
183 // shape it was; navigator.webdriver mask still applies.
184 }
186 // ──── Notification.permission align with Permissions API ────
187 // The inline addInitScript already overrides permissions.query for
188 // notifications → 'prompt'. Notification.permission must match
189 // ('default' in real Chrome on pages that haven't asked yet).
190 try {
191 if (typeof Notification !== 'undefined') {
192 const notificationPermissionGetter = markNative(function() { return 'default'; }, 'get permission');
193 Object.defineProperty(Notification, 'permission', {
194 get: notificationPermissionGetter,
195 configurable: true,
196 });
197 }
198 } catch {}
200 // ──── Per-install hardware values from GSTACK_* env (T2) ────
201 // gbd's host_profile.go fed real host values via cmdline env. Reporting
202 // those (not hardcoded defaults) avoids the cross-user GBrowser
203 // fingerprint cluster.
204 try {
205 const hwConcurrencyGetter = markNative(function() { return ${hw.hwConcurrency}; }, 'get hardwareConcurrency');
206 Object.defineProperty(navigator, 'hardwareConcurrency', {
207 get: hwConcurrencyGetter,
208 configurable: true,
209 });
210 } catch {}
211 try {
212 const deviceMemoryGetter = markNative(function() { return ${hw.deviceMemory}; }, 'get deviceMemory');
213 Object.defineProperty(navigator, 'deviceMemory', {
214 get: deviceMemoryGetter,
215 configurable: true,
216 });
217 } catch {}
219 // ──── Selenium / Phantom / Nightmare / Playwright global cleanup ────
220 // Static known-name list of Selenium / Playwright / PhantomJS / Nightmare
221 // globals. AUTOMATION_ARTIFACT_CLEANUP_SCRIPT (applied right after this on
222 // every path) covers the cdc_/__webdriver dynamic prefixes; this list is the
223 // fixed-name complement.
224 try {
225 const auto = [
226 '__driver_evaluate', '__webdriver_evaluate', '__selenium_evaluate', '__fxdriver_evaluate',
227 '__driver_unwrapped', '__webdriver_unwrapped', '__selenium_unwrapped', '__fxdriver_unwrapped',
228 '_Selenium_IDE_Recorder', '_selenium', 'calledSelenium',
229 '$chrome_asyncScriptInfo',
230 '__$webdriverAsyncExecutor', '__webdriverFunc',
231 'domAutomation', 'domAutomationController',
232 '__lastWatirAlert', '__lastWatirConfirm', '__lastWatirPrompt',
233 '__webdriver_script_fn', '_WEBDRIVER_ELEM_CACHE',
234 'callPhantom', '_phantom', 'phantom', '__nightmare',
235 '__pwInitScripts', '__playwright__binding__',
236 ];
237 for (const k of auto) {
238 try { delete window[k]; } catch {}
239 }
240 try { delete document.__webdriver_script_fn; } catch {}
241 } catch {}
242})();`;
243}
245/**
246 * Extended-mode init script — six detection-vector patches. Applied
247 * AFTER the default mask, so the property-getter version remains in
248 * place if any of the deletion paths fail.
249 *
250 * Self-contained string so it can be passed to addInitScript({ content })
251 * without bundling concerns.
252 */
253export const EXTENDED_STEALTH_SCRIPT = `
254(() => {
255 try {
256 // 1. Fully delete navigator.webdriver from the prototype so
257 // \`"webdriver" in navigator\` returns false (not just falsy).
258 delete Object.getPrototypeOf(navigator).webdriver;
259 } catch {}
261 try {
262 // 2. WebGL renderer spoof — SwiftShader is the canonical software-GPU
263 // tell. Spoof to a plausible Apple M1 Pro string.
264 const getParameter = WebGLRenderingContext.prototype.getParameter;
265 WebGLRenderingContext.prototype.getParameter = function (parameter) {
266 // UNMASKED_VENDOR_WEBGL (37445) → 'Apple Inc.'
267 if (parameter === 37445) return 'Apple Inc.';
268 // UNMASKED_RENDERER_WEBGL (37446) → realistic Apple silicon string
269 if (parameter === 37446) return 'Apple M1 Pro, OpenGL 4.1';
270 return getParameter.call(this, parameter);
271 };
272 } catch {}
274 try {
275 // 3. navigator.plugins: real PluginArray with MimeType objects.
276 const makePlugin = (name, filename, desc, mimes) => {
277 const p = Object.create(Plugin.prototype);
278 Object.defineProperties(p, {
279 name: { get: () => name },
280 filename: { get: () => filename },
281 description: { get: () => desc },
282 length: { get: () => mimes.length },
283 });
284 mimes.forEach((m, i) => { p[i] = m; });
285 p.item = (i) => mimes[i];
286 p.namedItem = (n) => mimes.find((m) => m.type === n);
287 return p;
288 };
289 const makeMime = (type, suffixes, desc) => {
290 const m = Object.create(MimeType.prototype);
291 Object.defineProperties(m, {
292 type: { get: () => type },
293 suffixes: { get: () => suffixes },
294 description: { get: () => desc },
295 });
296 return m;
297 };
298 const pdfMime = makeMime('application/pdf', 'pdf', '');
299 const cpdfMime = makeMime('application/x-google-chrome-pdf', 'pdf', 'Portable Document Format');
300 const plugins = [
301 makePlugin('PDF Viewer', 'internal-pdf-viewer', '', [pdfMime]),
302 makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
303 makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
304 ];
305 Object.defineProperty(navigator, 'plugins', {
306 get: () => {
307 const arr = Object.create(PluginArray.prototype);
308 Object.defineProperty(arr, 'length', { get: () => plugins.length });
309 plugins.forEach((p, i) => { arr[i] = p; });
310 arr.item = (i) => plugins[i];
311 arr.namedItem = (n) => plugins.find((p) => p.name === n);
312 arr.refresh = () => {};
313 return arr;
314 },
315 });
316 } catch {}
318 try {
319 // 4. window.chrome shape — chrome.app + chrome.runtime + loadTimes/csi.
320 if (!window.chrome) {
321 window.chrome = {};
322 }
323 if (!window.chrome.runtime) {
324 window.chrome.runtime = { OnInstalledReason: {}, OnRestartRequiredReason: {} };
325 }
326 if (!window.chrome.app) {
327 window.chrome.app = {
328 isInstalled: false,
329 InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
330 RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
331 };
332 }
333 if (!window.chrome.loadTimes) {
334 window.chrome.loadTimes = function () {
335 return { commitLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000 };
336 };
337 }
338 if (!window.chrome.csi) {
339 window.chrome.csi = function () {
340 return { startE: Date.now(), onloadT: Date.now(), pageT: 0, tran: 15 };
341 };
342 }
343 } catch {}
345 try {
346 // 5. mediaDevices — some headless builds drop it entirely.
347 if (!navigator.mediaDevices) {
348 Object.defineProperty(navigator, 'mediaDevices', {
349 get: () => ({ enumerateDevices: () => Promise.resolve([]) }),
350 });
351 }
352 } catch {}
354 try {
355 // 6. CDP cdc_* property cleanup. Chromium under CDP sets cdc_*-prefixed
356 // globals (driver injection markers); a bot detector finds them by
357 // iterating window keys. Strip all matching keys.
358 // Note: via applyStealth this is redundant with AUTOMATION_ARTIFACT_
359 // CLEANUP_SCRIPT (which runs first on every path). Kept so this script
360 // is self-sufficient if ever applied standalone.
361 for (const k of Object.keys(window)) {
362 if (k.startsWith('cdc_')) {
363 try { delete window[k]; } catch {}
364 }
365 }
366 } catch {}
367})();
368`;
370function extendedModeEnabled(): boolean {
371 const v = process.env.GSTACK_STEALTH;
372 return v === 'extended' || v === '1' || v === 'true';
373}
375/**
376 * Strip automation runtime artifacts that don't belong in a real browser
377 * and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window
378 * globals (cdc_*, __webdriver*) and the Permissions API quirk where an
379 * automated Chromium reports notifications as 'denied' instead of real
380 * Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's
381 * Notification.permission = 'default' so the two surfaces stay consistent.
382 *
383 * Runs on EVERY launch path via applyStealth (headless launch(),
384 * launchHeaded(), handoff()) so the Notification/Permissions pairing never
385 * diverges by mode. Previously this lived inline in launchHeaded() only,
386 * which left headless and handoff with the Notification value but not the
387 * matching Permissions answer.
388 */
389export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
390 // cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector
391 // finds them by iterating window keys. Strip immediately and again after a
392 // tick in case they are injected late.
393 const cleanup = () => {
394 for (const key of Object.keys(window)) {
395 if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
396 try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; }
397 }
398 }
399 };
400 cleanup();
401 setTimeout(cleanup, 0);
403 // Permissions API: automated Chromium returns 'denied' for notifications,
404 // a known tell. Return 'prompt' to match real Chrome (and Layer C's
405 // Notification.permission = 'default'). Tradeoff: this pins the
406 // notifications state to fresh-Chrome values for the whole session, so a
407 // site that actually grants/denies notifications would see a stale value.
408 // Acceptable for the automation/anti-detection use case (which does not
409 // drive real notification grants); only notifications is overridden.
410 const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
411 if (originalQuery) {
412 window.navigator.permissions.query = (params) => {
413 if (params && params.name === 'notifications') {
414 return Promise.resolve({ state: 'prompt', onchange: null });
415 }
416 return originalQuery.call(window.navigator.permissions, params);
417 };
418 }
419})();`;
421/**
422 * Apply stealth patches to a fresh BrowserContext (or persistent context).
423 * Called by EVERY context-creation path in browser-manager — launch(),
424 * launchHeaded(), handoff(), AND recreateContext() (the useragent /
425 * viewport --scale rebuild) — so a context can never reach a page un-stealthed.
426 *
427 * Injection order (Playwright evaluates init scripts in registration order):
428 * 1. Layer C (buildStealthScript) — the always-on consistency-first default.
429 * 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim),
430 * kept consistent with Layer C's Notification.permission alignment.
431 * 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by
432 * default). Its window.chrome.* patches are `if (!...)`-guarded, so
433 * Layer C's richer shapes win; the extended-only additions (WebGL spoof,
434 * faked navigator.plugins, mediaDevices) apply on top.
435 *
436 * KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT
437 * wrapped by Layer C's Function.prototype.toString proxy, so they stringify
438 * as injected code; its prototype-level navigator.webdriver delete is shadowed
439 * by Layer C's own-property getter (net behavior still matches real Chrome:
440 * webdriver present and false); and its hardcoded Apple-M1 WebGL string can
441 * disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple
442 * hosts. This is acceptable for the documented "actively lies, may break sites"
443 * escape hatch; the consistency-first default (Layer C alone) has none of these.
444 *
445 * Host profile is resolved from process.env at call time so per-install
446 * values bake into the script before Playwright sends it to Chromium via
447 * Page.addScriptToEvaluateOnNewDocument.
448 */
449export async function applyStealth(context: BrowserContext): Promise<void> {
450 const hw = readHostProfile();
451 await context.addInitScript({ content: buildStealthScript(hw) });
452 await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT });
453 if (extendedModeEnabled()) {
454 await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
455 }
456}
458/**
459 * Args added to chromium.launch's `args` to suppress the
460 * AutomationControlled blink feature. This is independent of the init
461 * script — it changes how Chromium identifies itself in the protocol layer.
462 */
463export const STEALTH_LAUNCH_ARGS = [
464 '--disable-blink-features=AutomationControlled',
465];
467/**
468 * Build the `--gstack-*=` cmdline switches that the Pack 1 Chromium
469 * patches read (webgl-vendor-spoof, ua-client-hints-stealth, worker-
470 * navigator-stealth). Values come from the GSTACK_* env vars that
471 * gbd populates from host_profile.go at startup.
472 *
473 * Each switch is only emitted when its env var is non-empty — empty
474 * env values fall through to the patch's "no override" path, which
475 * returns the real Chromium native value. This keeps the helper safe
476 * on builds that DO NOT have the C++ patches applied (gbrowser
477 * pre-Pack-1) and on hosts where gbd hasn't yet populated some
478 * fields (legacy installs).
479 *
480 * TRUSTED-SOURCE ONLY. These GSTACK_* values are populated by gbd from
481 * host_profile.go (system_profiler / sysctl) and become page-visible WebGL /
482 * UA-CH surface data. They are NOT sanitized here (passed verbatim into the
483 * argv array, which is injection-safe because Playwright spawns Chromium with
484 * an argv array, not a shell). NEVER route page content, HTTP headers, or any
485 * remote/untrusted input into these env vars — that would turn this into an
486 * argv-injection sink. readHostProfile() applies the same trust assumption.
487 *
488 * Mapping (gbd env → Chromium cmdline switch → C++ patch consumer):
489 * GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch
490 * GSTACK_GPU_RENDERER → --gstack-gpu-renderer → webgl-vendor-spoof.patch
491 * GSTACK_PLATFORM → --gstack-ua-platform → ua-client-hints-stealth.patch
492 * (maps MacARM/MacIntel → "macOS")
493 * GSTACK_GPU_CHIPSET → --gstack-ua-model → ua-client-hints-stealth.patch
494 * GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency → worker-navigator-stealth.patch
495 * GSTACK_DEVICE_MEMORY → --gstack-device-memory → worker-navigator-stealth.patch
496 */
497export function buildGStackLaunchArgs(): string[] {
498 const env = (globalThis as any).process?.env ?? {};
499 const args: string[] = [];
501 const vendor = env.GSTACK_GPU_VENDOR;
502 if (vendor) args.push(`--gstack-gpu-vendor=${vendor}`);
504 const renderer = env.GSTACK_GPU_RENDERER;
505 if (renderer) args.push(`--gstack-gpu-renderer=${renderer}`);
507 // Map gbd's "MacARM"/"MacIntel" classification to the UA-CH "macOS"
508 // platform string Chromium emits natively. Other future platforms
509 // would map similarly (Win32 → "Windows", Linux → "Linux").
510 const platform = env.GSTACK_PLATFORM;
511 if (platform === 'MacARM' || platform === 'MacIntel') {
512 args.push('--gstack-ua-platform=macOS');
513 } else if (platform === 'Win32') {
514 args.push('--gstack-ua-platform=Windows');
515 } else if (platform && platform.startsWith('Linux')) {
516 args.push('--gstack-ua-platform=Linux');
517 }
519 const chipset = env.GSTACK_GPU_CHIPSET;
520 if (chipset) args.push(`--gstack-ua-model=${chipset}`);
522 const hw = env.GSTACK_HW_CONCURRENCY;
523 if (hw) args.push(`--gstack-hw-concurrency=${hw}`);
525 const memory = env.GSTACK_DEVICE_MEMORY;
526 if (memory) args.push(`--gstack-device-memory=${memory}`);
528 // Pack 2 / B11: suppress user-defined Error.prepareStackTrace during
529 // V8 stack-trace formatting. Closes the Cloudflare Bot Management canary
530 // trick where a page sets prepareStackTrace and watches for it to fire
531 // during CDP serialization.
532 //
533 // OPT-IN (off by default): only emitted when GSTACK_CDP_STEALTH is
534 // on/1/true. This switch is read by a C++ patch that only exists in
535 // gbrowser builds; gbd opts in by exporting GSTACK_CDP_STEALTH=on. Stock
536 // Playwright Chromium leaves it unset, so the flag never reaches a
537 // Chromium that wouldn't understand it. (Previously this was on-by-default
538 // unless GSTACK_CDP_STEALTH=off, which contradicted this very comment.)
539 const cdpStealth = env.GSTACK_CDP_STEALTH;
540 if (cdpStealth === 'on' || cdpStealth === '1' || cdpStealth === 'true') {
541 args.push('--gstack-suppress-prepare-stack-trace');
542 }
544 return args;
545}
547/**
548 * Playwright default args to strip via ignoreDefaultArgs.
549 *
550 * Playwright passes these by default. Each one is a visible automation
551 * tell at some layer:
552 * --enable-automation → infobar + chrome shape
553 * --disable-extensions → blocks our extension
554 * --disable-component-extensions-with-background-pages → blocks component ext
555 * --disable-popup-blocking → automation default
556 * --disable-component-update → automation default
557 * --disable-default-apps → affects plugin enum
558 *
559 * Used by browser-manager via spread into ignoreDefaultArgs to keep
560 * the list in one place across launchHeaded() and handoff().
561 */
562export const STEALTH_IGNORE_DEFAULT_ARGS = [
563 '--enable-automation',
564 '--disable-extensions',
565 '--disable-component-extensions-with-background-pages',
566 '--disable-popup-blocking',
567 '--disable-component-update',
568 '--disable-default-apps',
569];
571/** Test-only helper: report whether extended mode is currently active. */
572export function isExtendedStealthEnabled(): boolean {
573 return extendedModeEnabled();
574}