crawlforge-mcp-server 6.7.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,19 +11,34 @@
11
11
  import { chromium } from 'playwright';
12
12
  import { z } from 'zod';
13
13
  import crypto from 'crypto';
14
+ import fs from 'fs';
15
+ import os from 'os';
16
+ import path from 'path';
17
+ import { createRequire } from 'module';
14
18
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
15
19
  import { BrowserContextPool } from './BrowserContextPool.js';
16
20
  import { safeGoto } from '../utils/ssrfGuard.js';
17
- import { detectChallengePage } from '../utils/challengeDetection.js';
21
+ import { looksLikeInterstitial } from '../utils/challengeDetection.js';
18
22
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
23
+ import { serverStealthProxies } from '../constants/config.js';
19
24
 
20
25
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
21
26
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
22
27
 
28
+ // The Chrome major claimed when the installed binary cannot be read at all.
29
+ // Only ever a floor: installedChromeVersion() prefers playwright-core's own
30
+ // browsers.json, and a launched browser's real version overrides both.
31
+ const FALLBACK_CHROME_MAJOR = 151;
32
+
23
33
  // The proxy a camoufox browser was launched with, kept on the browser itself so
24
34
  // it survives being parked and restored by an engine switch.
25
35
  const CAMOUFOX_PROXY = Symbol('crawlforge.camoufoxProxy');
26
36
 
37
+ // The locale that camoufox browser was launched with — null when geoip derived
38
+ // one from the proxy's exit IP and we therefore do not know it. Kept on the
39
+ // browser for the same reason as the proxy: camoufox fixes it at launch.
40
+ const CAMOUFOX_LOCALE = Symbol('crawlforge.camoufoxLocale');
41
+
27
42
  const StealthConfigSchema = z.object({
28
43
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
29
44
  randomizeFingerprint: z.boolean().default(true),
@@ -66,8 +81,11 @@ const StealthConfigSchema = z.object({
66
81
  hardwareSpoofing: z.boolean().default(true)
67
82
  }).optional(),
68
83
 
69
- // C2: browser engine selection — 'chromium' (default) or 'camoufox' (Firefox-based)
70
- engine: z.enum(['chromium', 'camoufox']).optional().default('chromium')
84
+ // C2: browser engine selection — 'auto' (default), 'chromium' or 'camoufox'
85
+ // (Firefox-based). 'auto' is resolved to one of the concrete two by
86
+ // resolveStealthEngine before anything reads this field; see it for why the
87
+ // default moved off chromium.
88
+ engine: z.enum(['chromium', 'camoufox', 'auto']).optional().default('auto')
71
89
  });
72
90
 
73
91
  /**
@@ -89,9 +107,73 @@ function isChromium(page) {
89
107
  }
90
108
  }
91
109
 
110
+ /**
111
+ * Resolve a requested engine to the one that will actually launch.
112
+ *
113
+ * Camoufox is the engine that gets past the walls a stealth call is made for —
114
+ * it spoofs at the C++/Juggler level, where a page cannot see the seam — so
115
+ * 'auto', now the default everywhere, asks for it and drops to Chromium only
116
+ * when the package is not there. That is the semantic ResearchOrchestrator has
117
+ * run with since v4.6.6 (RESEARCH_STEALTH_ENGINE='auto'); it is lifted here so
118
+ * every stealth path shares one definition of it.
119
+ *
120
+ * A caller who NAMED an engine gets that engine and nothing else: 'camoufox'
121
+ * is never downgraded, so a missing install still fails at launch with the
122
+ * install instructions rather than quietly running a browser the caller
123
+ * rejected — the failure mode this whole review exists to remove.
124
+ *
125
+ * @param {'auto'|'chromium'|'camoufox'|'playwright'|null|undefined} requested
126
+ * 'playwright' is the tool layer's public name for chromium; null/undefined
127
+ * mean 'auto'.
128
+ * @returns {Promise<{engine: 'chromium'|'camoufox', fallbackWarning: string|null}>}
129
+ * fallbackWarning is set only when 'auto' wanted camoufox and could not have
130
+ * it, and is the one thing that stops that downgrade from being silent.
131
+ */
132
+ export async function resolveStealthEngine(requested) {
133
+ if (requested === 'camoufox') return { engine: 'camoufox', fallbackWarning: null };
134
+ if (requested && requested !== 'auto') return { engine: 'chromium', fallbackWarning: null };
135
+
136
+ // Deployment-level answer for 'auto', because there is no correct global one.
137
+ // Which engine clears a wall depends on the exit IP: the 2026-09-21
138
+ // residential run had Camoufox clearing indeed.com where Chromium failed, and
139
+ // two hosted runs from a datacenter address found the exact reverse, with
140
+ // Camoufox winning no wall Chromium lost. An operator who knows which side of
141
+ // that their egress sits on can say so here; a caller naming an engine still
142
+ // overrides it. Anything unrecognised is ignored rather than fatal — a typo in
143
+ // an env var should not take stealth down.
144
+ const configured = String(process.env.CRAWLFORGE_STEALTH_ENGINE || '').trim().toLowerCase();
145
+ if (configured === 'chromium' || configured === 'playwright') {
146
+ return { engine: 'chromium', fallbackWarning: null };
147
+ }
148
+
149
+ let reason = 'camoufox is not installed (npm install camoufox)';
150
+ try {
151
+ if (await new CamoufoxAdapter().isAvailable()) {
152
+ return { engine: 'camoufox', fallbackWarning: null };
153
+ }
154
+ } catch (error) {
155
+ // Installed-but-broken arrives as a throw, and it is a different problem
156
+ // from not installed — a fetched binary that will not load is worth
157
+ // naming. Either way it is a reason to run Chromium, not to fail a scrape
158
+ // the caller never asked to pin to one engine.
159
+ reason = error.message;
160
+ }
161
+ return {
162
+ engine: 'chromium',
163
+ fallbackWarning:
164
+ `Stealth engine fell back to chromium: ${reason}. ` +
165
+ 'Camoufox passes bot walls this Chromium does not; install it to use it.'
166
+ };
167
+ }
168
+
92
169
  export class StealthBrowserManager {
93
170
  constructor(options = {}) {
94
171
  this.browser = null;
172
+ // Why the running browser is not the engine 'auto' asked for, or null when
173
+ // nothing was downgraded. Kept on the instance beside _launchedEngine so a
174
+ // tool layer can tell the caller which browser actually ran — a drop to
175
+ // Chromium that nobody reports reads as a camoufox result that failed.
176
+ this._engineFallbackWarning = null;
95
177
  this._maxContexts = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10);
96
178
  this.contexts = this._createContextPool();
97
179
  // D2.2: fingerprints Map is capped at _maxContexts to prevent unbounded growth.
@@ -154,25 +236,19 @@ export class StealthBrowserManager {
154
236
  // UA thirty majors behind the engine — the pool sat at 119–121 while
155
237
  // Chrome 152 shipped — is a staleness tell for anything that reads
156
238
  // sec-ch-ua (R15, 2026-09-04).
239
+ //
240
+ // The Chrome major is now read from the installed binary rather than
241
+ // listed. A pool of four majors on one binary meant three of four personas
242
+ // mis-stated the version navigator.userAgentData reports — the UA claimed
243
+ // Chrome 149 on a 151 Chromium (2026-09-21 benchmark, ua-version-vs-binary).
244
+ // One string per OS is not lost entropy: Chrome's UA has been frozen since
245
+ // the UA-reduction rollout, so minor/build/patch are always 0.0.0 and the OS
246
+ // token is fixed, and that one string is the only one a real Chrome of that
247
+ // major sends. Variation lives in the display, fonts, hardware and persona.
248
+ this.chromeVersion = StealthBrowserManager.installedChromeVersion() || `${FALLBACK_CHROME_MAJOR}.0.0.0`;
249
+ this.chromeMajor = StealthBrowserManager.majorVersion(this.chromeVersion) || FALLBACK_CHROME_MAJOR;
157
250
  this.userAgentPools = {
158
- chrome: {
159
- windows: [
160
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
161
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
162
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
163
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
164
- ],
165
- macos: [
166
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
167
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
168
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
169
- ],
170
- linux: [
171
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
172
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
173
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
174
- ]
175
- },
251
+ chrome: this.buildChromeUserAgents(this.chromeMajor),
176
252
  firefox: {
177
253
  windows: [
178
254
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
@@ -193,13 +269,9 @@ export class StealthBrowserManager {
193
269
  }
194
270
  };
195
271
 
196
- // Operating system distributions for realistic user agent selection
197
- this.osDistribution = {
198
- windows: 0.75,
199
- macos: 0.15,
200
- linux: 0.10
201
- };
202
-
272
+ // No OS distribution any more: the persona's OS is the host's (see
273
+ // selectOS), not a draw from market share.
274
+
203
275
  // Browser market share for realistic selection
204
276
  this.browserDistribution = {
205
277
  chrome: 0.65,
@@ -292,6 +364,12 @@ export class StealthBrowserManager {
292
364
  */
293
365
  async launchStealthBrowser(config = {}) {
294
366
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
367
+ // 'auto' has to become a concrete engine before the comparison below: an
368
+ // unresolved 'auto' never equals _launchedEngine, so every call would park
369
+ // a perfectly good browser and launch a second one beside it. This is the
370
+ // call that decides the engine — every other path reaches a browser
371
+ // through it — so it is the one that records the outcome.
372
+ await this._resolveConfigEngine(validatedConfig, true);
295
373
 
296
374
  // A Chromium that was OOM-killed or crashed doesn't error on reuse — its
297
375
  // protocol calls hang. Detect the corpse and relaunch instead.
@@ -333,11 +411,49 @@ export class StealthBrowserManager {
333
411
  }
334
412
  }
335
413
 
414
+ /**
415
+ * Rewrite config.engine in place to the engine that will actually launch,
416
+ * recording any downgrade on the instance.
417
+ *
418
+ * In place, because `engine` is read again further down — the park/reuse
419
+ * comparison, the fingerprint's browser pool, the camoufox context surgery —
420
+ * and every one of them has to see the same answer. Idempotent and cheap (a
421
+ * concrete engine resolves to itself; isAvailable()'s require is cached by
422
+ * the module loader), so the context path and the launch path can each call
423
+ * it without arranging who goes first.
424
+ *
425
+ * @param {{engine: string}} validatedConfig — mutated
426
+ * @param {boolean} [ownsTheDecision] — true for the call this launch's engine
427
+ * is decided by (launchStealthBrowser). _doLaunchStealthBrowser re-checks
428
+ * the very config that call already resolved, and recording "nothing was
429
+ * downgraded" there would erase the downgrade the first pass had just
430
+ * found — which is how the fallback would go silent again.
431
+ * @returns {Promise<string|null>} this call's fallback warning, if any
432
+ */
433
+ async _resolveConfigEngine(validatedConfig, ownsTheDecision = false) {
434
+ // A concrete engine is already the answer and cannot have fallen back.
435
+ if (validatedConfig.engine && validatedConfig.engine !== 'auto') {
436
+ if (ownsTheDecision) this._engineFallbackWarning = null;
437
+ return null;
438
+ }
439
+
440
+ const { engine, fallbackWarning } = await resolveStealthEngine(validatedConfig.engine);
441
+ validatedConfig.engine = engine;
442
+ this._engineFallbackWarning = fallbackWarning;
443
+ return fallbackWarning;
444
+ }
445
+
336
446
  /**
337
447
  * Actual browser launch, guarded by launchStealthBrowser's in-flight
338
448
  * promise so only one launch can be in progress at a time.
339
449
  */
340
450
  async _doLaunchStealthBrowser(validatedConfig) {
451
+ // Resolved again here rather than trusted: this method is reachable on its
452
+ // own, and an 'auto' that got this far unresolved would fall through to the
453
+ // chromium branch below — the exact silent downgrade 'auto' is meant to
454
+ // report.
455
+ await this._resolveConfigEngine(validatedConfig);
456
+
341
457
  // C2: delegate to CamoufoxAdapter when engine === 'camoufox'
342
458
  if (validatedConfig.engine === 'camoufox') {
343
459
  const adapter = new CamoufoxAdapter();
@@ -355,9 +471,49 @@ export class StealthBrowserManager {
355
471
  // proxy's exit IP, which is a worse signal than not rotating at all.
356
472
  // A rotation takes effect on the next launch, after cleanup().
357
473
  const proxy = this.resolveProxy(validatedConfig);
474
+ // The caller's locale goes to the launcher, not to the context. camoufox
475
+ // sets language, Accept-Language and Intl together below the JS layer,
476
+ // where a Worker reads the same answer as the document; Playwright's
477
+ // Firefox context override reaches the document only, so asking for
478
+ // de-DE there produced ["de-DE"] in the page beside camoufox's own list
479
+ // in its worker (2026-09-21 benchmark, worker-languages). Fixed at
480
+ // launch, like the proxy: a later context asking for a different locale
481
+ // inherits the launched one until cleanup(). With a proxy, geoip derives
482
+ // the locale from the exit IP and must win — a persona from this call
483
+ // would name a country the address contradicts.
484
+ const locale = proxy ? null : validatedConfig.locale;
358
485
  const browser = await adapter.launch({
359
- headless: true,
486
+ // 'virtual' runs a real, windowed Firefox inside Xvfb instead of the
487
+ // headless build. Headless Firefox is its own tell — no window manager,
488
+ // no compositor, and a set of media/GL answers that differ from the
489
+ // browser everyone else runs — and camoufox ships this mode precisely
490
+ // to avoid it. Only on Linux: that is where a hosted image has no
491
+ // display and where the Dockerfile provides Xvfb; a Mac or Windows
492
+ // developer machine has neither, so plain headless stays there.
493
+ headless: process.platform === 'linux' ? 'virtual' : true,
360
494
  proxy,
495
+ locale,
496
+ // camoufox draws its persona from ["windows","macos","linux"] when it
497
+ // is told nothing, so it claimed Windows on a Mac while still reporting
498
+ // an Apple M1 GPU and a `-apple-system: Mac` CSS platform hint
499
+ // (2026-09-21 benchmark, persona-os-vs-host). The host's OS is the only
500
+ // one the GPU strings, the font list and the TCP/IP fingerprint agree
501
+ // with, so it is the one camoufox is given.
502
+ //
503
+ // KNOWN NOT TO TAKE EFFECT on camoufox npm 0.1.19, measured 2026-09-21:
504
+ // ten launches asking for "macos" on a Mac produced a Mac persona about
505
+ // one time in three, which is the market-share draw, not the answer to a
506
+ // constraint. The client forwards `{ screen, os }` to
507
+ // fingerprint-generator (chunk-QNWJYPXY.js `generateFingerprint`), whose
508
+ // option for this is `operatingSystems: string[]` — `os` is not a key it
509
+ // knows, so it is dropped and the OS is drawn at random. The call below
510
+ // is the documented API and is kept: it is inert (camoufox only
511
+ // validates the value), it is correct the moment upstream honours it,
512
+ // and the alternative — generating a BrowserForge fingerprint ourselves
513
+ // and passing it through `fingerprint` — needs a dependency this package
514
+ // does not declare. Phase 2 of the stealth review owns the camoufox
515
+ // client; until then persona-os-vs-host stays baselined for camoufox.
516
+ os: this.hostOS(),
361
517
  // Only ask for geoip when there is a proxy to derive it from. Without
362
518
  // one it would look up this machine's own public IP — a network call,
363
519
  // and an external service learning our address, to confirm a location
@@ -368,6 +524,7 @@ export class StealthBrowserManager {
368
524
  launchOptions: {}
369
525
  });
370
526
  browser[CAMOUFOX_PROXY] = proxy;
527
+ browser[CAMOUFOX_LOCALE] = locale;
371
528
  this.browser = browser;
372
529
  this._launchedEngine = 'camoufox';
373
530
  return this.browser;
@@ -379,9 +536,7 @@ export class StealthBrowserManager {
379
536
  '--no-sandbox',
380
537
  '--disable-dev-shm-usage',
381
538
  '--disable-blink-features=AutomationControlled',
382
- '--disable-web-security',
383
539
  '--disable-features=VizDisplayCompositor',
384
- '--disable-extensions',
385
540
  '--disable-plugins',
386
541
  '--disable-background-timer-throttling',
387
542
  '--disable-backgrounding-occluded-windows',
@@ -397,12 +552,18 @@ export class StealthBrowserManager {
397
552
  '--safebrowsing-disable-auto-update',
398
553
  '--password-store=basic',
399
554
  '--use-mock-keychain',
400
-
555
+
556
+ // A headless Chromium has no pointing device, so it reports
557
+ // primaryPointerType=coarse/none and hover:none — the media-query answer
558
+ // of a touchscreen kiosk under a desktop UA. These four blink settings
559
+ // (2 = HOVER_HOVER_TYPE, 4 = POINTER_FINE_TYPE) give it the mouse a
560
+ // desktop persona is supposed to have. Verified present in Chromium 151
561
+ // with `strings -a` on the binary.
562
+ '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
563
+
401
564
  // Additional stealth arguments
402
- '--disable-default-apps',
403
565
  '--disable-component-extensions-with-background-pages',
404
566
  '--disable-background-networking',
405
- '--disable-component-update',
406
567
  '--disable-client-side-phishing-detection',
407
568
  '--disable-domain-reliability',
408
569
  '--disable-ipc-flooding-protection',
@@ -415,9 +576,7 @@ export class StealthBrowserManager {
415
576
  if (validatedConfig.level === 'advanced') {
416
577
  stealthArgs.push(
417
578
  '--disable-gpu-sandbox',
418
- '--disable-popup-blocking',
419
579
  '--disable-setuid-sandbox',
420
- '--disable-site-isolation-trials',
421
580
  '--disable-threaded-animation',
422
581
  '--disable-threaded-scrolling',
423
582
  '--disable-in-process-stack-traces',
@@ -436,15 +595,15 @@ export class StealthBrowserManager {
436
595
  );
437
596
  }
438
597
 
439
- // WebRTC blocking
598
+ // WebRTC blocking. The five flags that used to stand here disabled
599
+ // hardware codecs and extra routes — none of them stops an ICE candidate
600
+ // carrying the real address, which is what CreepJS read the host's IPv6
601
+ // out of (2026-09-21 benchmark, finding 9). This one is the switch that
602
+ // governs which local addresses WebRTC may use: with it, only the address
603
+ // the proxy already exposes is offered. Verified present in Chromium 151
604
+ // with `strings -a` on the binary.
440
605
  if (validatedConfig.blockWebRTC) {
441
- stealthArgs.push(
442
- '--disable-webrtc-hw-decoding',
443
- '--disable-webrtc-hw-encoding',
444
- '--disable-webrtc-multiple-routes',
445
- '--disable-webrtc-hw-vp8-encoding',
446
- '--enforce-webrtc-ip-permission-check'
447
- );
606
+ stealthArgs.push('--webrtc-ip-handling-policy=disable_non_proxied_udp');
448
607
  }
449
608
 
450
609
  // No proxy argument here. Chromium's --proxy-server= has no field for the
@@ -459,12 +618,27 @@ export class StealthBrowserManager {
459
618
  // never reads it) — see Dockerfile.
460
619
  executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
461
620
  args: stealthArgs,
621
+ // patchright's "marks you as a stealth driver" list. Playwright passes
622
+ // all four by default, so dropping them from stealthArgs above is only
623
+ // half the job — they have to be taken off the default set too, or the
624
+ // command line still carries them. --disable-web-security went with them:
625
+ // it is readable in one line from any page (a cross-origin fetch that
626
+ // should throw and does not), and it contradicts this file's own decision
627
+ // to leave bypassCSP unset (R15, 2026-09-04).
462
628
  ignoreDefaultArgs: [
463
629
  '--enable-blink-features=IdleDetection',
464
- '--enable-automation'
630
+ '--enable-automation',
631
+ '--disable-component-update',
632
+ '--disable-default-apps',
633
+ '--disable-extensions',
634
+ '--disable-popup-blocking'
465
635
  ]
466
636
  });
467
637
 
638
+ // A hosted image's system Chromium may not be the version browsers.json
639
+ // named, and the UA has to state the version that is actually running.
640
+ this._alignUserAgentsWithBinary(browser);
641
+
468
642
  // If this Chromium dies (OOM kill, crash), drop the handle so the next
469
643
  // call relaunches instead of reusing a corpse. The identity guard keeps a
470
644
  // late event from an old instance from nulling a newer one.
@@ -483,12 +657,18 @@ export class StealthBrowserManager {
483
657
  */
484
658
  async createStealthContext(config = {}) {
485
659
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
486
-
660
+
487
661
  // Always go through launchStealthBrowser: it returns the running browser
488
662
  // when the engine matches, and closes + relaunches on an engine mismatch.
489
663
  // Guarding on `!this.browser` here skipped that mismatch check, so a
490
664
  // camoufox request silently reused an already-running chromium browser.
491
665
  await this.launchStealthBrowser(validatedConfig);
666
+ // The browser that came back is the authority on which engine this context
667
+ // is created over, and everything below reads it: the fingerprint's browser
668
+ // pool branches on it, and an 'auto' left unresolved here would draw a
669
+ // Chrome persona for a Firefox browser.
670
+ validatedConfig.engine = this._launchedEngine ?? validatedConfig.engine;
671
+ const engineFallbackWarning = this._engineFallbackWarning;
492
672
 
493
673
  // Generate fingerprint for this context
494
674
  const fingerprint = this.generateAdvancedFingerprint(validatedConfig);
@@ -568,16 +748,23 @@ export class StealthBrowserManager {
568
748
  // sent sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform — client hints
569
749
  // Firefox has never implemented and never sends. That is a decision a
570
750
  // detector can make from the request headers alone, before a line of
571
- // script runs. Playwright still derives a correctly shaped Accept-Language
572
- // from `locale`, so dropping these loses nothing real.
751
+ // script runs.
573
752
  delete contextOptions.userAgent;
574
753
  delete contextOptions.extraHTTPHeaders;
575
754
 
576
- // Behind a proxy, camoufox has already derived locale, timezone and
577
- // geolocation from the exit IP. Those three agree with the address the
578
- // site sees; a persona drawn here does not, so it must not override them.
755
+ // The locale goes the same way, proxy or no proxy — but it is not
756
+ // dropped: _doLaunchStealthBrowser hands the caller's locale to camoufox
757
+ // itself, which sets language, Accept-Language and Intl together below
758
+ // the JS layer. Playwright's Firefox context override reaches the
759
+ // document only, so setting it here reported ["de-DE"] in the page beside
760
+ // camoufox's own list in its worker (2026-09-21 benchmark,
761
+ // worker-languages).
762
+ delete contextOptions.locale;
763
+
764
+ // Behind a proxy, camoufox has already derived timezone and geolocation
765
+ // from the exit IP. Those agree with the address the site sees; a persona
766
+ // drawn here does not, so it must not override them.
579
767
  if (proxy) {
580
- delete contextOptions.locale;
581
768
  delete contextOptions.timezoneId;
582
769
  delete contextOptions.geolocation;
583
770
  }
@@ -589,8 +776,11 @@ export class StealthBrowserManager {
589
776
  fingerprint.headers = {};
590
777
  fingerprint.viewport = null;
591
778
  fingerprint.hardware = { ...fingerprint.hardware, platform: null };
779
+ // The locale camoufox was actually launched with, which is this call's
780
+ // only when this call is the one that launched it — null behind a proxy,
781
+ // where geoip derived a locale we never see.
782
+ fingerprint.locale = this.browser[CAMOUFOX_LOCALE] ?? null;
592
783
  if (proxy) {
593
- fingerprint.locale = null;
594
784
  fingerprint.timezone = null;
595
785
  }
596
786
  }
@@ -605,7 +795,11 @@ export class StealthBrowserManager {
605
795
  // D2.2: enforce LRU cap on fingerprints Map
606
796
  this._setFingerprint(contextId, fingerprint);
607
797
 
608
- return { context, contextId, fingerprint };
798
+ // The engine and its warning ride back with the context, not only on the
799
+ // instance: the instance fields follow the browser, which the next call
800
+ // may switch, and the caller of THIS context still has to be able to say
801
+ // which browser it got.
802
+ return { context, contextId, fingerprint, engine: validatedConfig.engine, engineFallbackWarning };
609
803
  }
610
804
 
611
805
  /**
@@ -702,17 +896,33 @@ export class StealthBrowserManager {
702
896
 
703
897
  /**
704
898
  * Choose a single OS ('windows' | 'macos' | 'linux') for a fingerprint.
705
- * A custom UA pins the OS to whatever that UA reports; a non-random UA pins
706
- * to windows (the default pool below); otherwise weighted-random.
899
+ * A custom UA pins the OS to whatever that UA reports; every other path takes
900
+ * the host's OS.
901
+ *
902
+ * A persona drawn at random claimed Windows, then Linux, on a macOS host
903
+ * (2026-09-21 benchmark, persona-os-vs-host). Nothing about the machine
904
+ * follows the draw: the TCP/IP fingerprint is the host kernel's, the GPU
905
+ * strings and the CSS platform hints are the host's, and a WebGL renderer
906
+ * reading "Apple M1" under a Windows UA is a cleaner signal than no spoofing
907
+ * at all. So the OS is the one thing in this fingerprint that is not
908
+ * randomised — it is observed.
707
909
  */
708
910
  selectOS(config = {}) {
709
911
  if (config.customUserAgent) {
710
912
  return this.inferOSFromUserAgent(config.customUserAgent);
711
913
  }
712
- if (!config.useRandomUserAgent) {
713
- return 'windows';
714
- }
715
- return this.weightedRandom(this.osDistribution);
914
+ return this.hostOS();
915
+ }
916
+
917
+ /**
918
+ * The host's own OS in the vocabulary the pools and generators use. Anything
919
+ * that is neither macOS nor Windows is treated as linux: it is the only other
920
+ * desktop persona modelled, and the closest to a BSD host's TCP fingerprint.
921
+ */
922
+ hostOS() {
923
+ if (process.platform === 'darwin') return 'macos';
924
+ if (process.platform === 'win32') return 'windows';
925
+ return 'linux';
716
926
  }
717
927
 
718
928
  /**
@@ -724,6 +934,64 @@ export class StealthBrowserManager {
724
934
  return 'windows';
725
935
  }
726
936
 
937
+ /**
938
+ * The version of the Chromium that is actually installed, as
939
+ * `151.0.7922.34`. playwright-core's browsers.json names the build it
940
+ * downloads — the same file Scrapling reads — and is the only source
941
+ * available before a browser is launched; once one is running,
942
+ * _alignUserAgentsWithBinary() corrects for a hosted image that pointed
943
+ * PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH at a Chromium of its own.
944
+ */
945
+ static installedChromeVersion() {
946
+ try {
947
+ const require = createRequire(import.meta.url);
948
+ const file = path.join(path.dirname(require.resolve('playwright-core')), 'browsers.json');
949
+ const entry = JSON.parse(fs.readFileSync(file, 'utf8')).browsers
950
+ .find((browser) => browser.name === 'chromium');
951
+ return entry?.browserVersion || null;
952
+ } catch {
953
+ return null;
954
+ }
955
+ }
956
+
957
+ /** The leading integer of a version string: `151.0.7922.34` -> 151. */
958
+ static majorVersion(version) {
959
+ const match = /^(\d+)/.exec(String(version ?? '').trim());
960
+ return match ? Number(match[1]) : null;
961
+ }
962
+
963
+ /**
964
+ * One Chrome user agent per OS at `major`. Chrome froze every other token of
965
+ * the string years ago, so these are literal, not templates with room.
966
+ */
967
+ buildChromeUserAgents(major) {
968
+ const chrome = `AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
969
+ return {
970
+ windows: [`Mozilla/5.0 (Windows NT 10.0; Win64; x64) ${chrome}`],
971
+ macos: [`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ${chrome}`],
972
+ linux: [`Mozilla/5.0 (X11; Linux x86_64) ${chrome}`]
973
+ };
974
+ }
975
+
976
+ /**
977
+ * Re-derive the Chrome pool from the browser that actually launched. Only
978
+ * does anything when the running binary is not the one browsers.json named —
979
+ * a hosted image's system Chromium — which is exactly the case where the
980
+ * pool would otherwise mis-state the version userAgentData reports.
981
+ */
982
+ _alignUserAgentsWithBinary(browser) {
983
+ let version = null;
984
+ try {
985
+ version = browser.version();
986
+ } catch { /* a browser that cannot be asked keeps the installed version */ }
987
+ if (!version || version === this.chromeVersion) return;
988
+ this.chromeVersion = version;
989
+ const major = StealthBrowserManager.majorVersion(version);
990
+ if (!major || major === this.chromeMajor) return;
991
+ this.chromeMajor = major;
992
+ this.userAgentPools.chrome = this.buildChromeUserAgents(major);
993
+ }
994
+
727
995
  /**
728
996
  * Select realistic user agent based on market distribution
729
997
  */
@@ -733,12 +1001,14 @@ export class StealthBrowserManager {
733
1001
  }
734
1002
 
735
1003
  if (!config.useRandomUserAgent) {
736
- return this.userAgentPools.chrome.windows[0];
1004
+ // Not "the Windows default" any more: a fixed UA still has to be the
1005
+ // host's, or it contradicts everything the machine itself reports.
1006
+ return this.userAgentPools.chrome[this.hostOS()][0];
737
1007
  }
738
1008
 
739
- // Use the OS chosen once for this fingerprint (falls back to a fresh draw
740
- // if called without one, preserving the original standalone behavior).
741
- selectedOS = selectedOS || this.weightedRandom(this.osDistribution);
1009
+ // Use the OS chosen once for this fingerprint (falls back to the host's own
1010
+ // if called without one, which is what selectOS would have returned).
1011
+ selectedOS = selectedOS || this.hostOS();
742
1012
 
743
1013
  // Select browser based on distribution and OS compatibility
744
1014
  let availableBrowsers = { ...this.browserDistribution };
@@ -872,24 +1142,41 @@ export class StealthBrowserManager {
872
1142
  }
873
1143
 
874
1144
  /**
875
- * Generate sec-ch-ua header.
876
- * C2: brand versions are derived from the UA's Chrome major version so
877
- * sec-ch-ua and the User-Agent header stay consistent.
1145
+ * The brand list Chrome presents, in both places it is read: the sec-ch-ua
1146
+ * request header and navigator.userAgentData.brands.
1147
+ *
1148
+ * One source for both because the two used to disagree. The header was built
1149
+ * here and said "Google Chrome"; userAgentData was left to Chromium, which
1150
+ * filled it in from the binary and answered "HeadlessChrome 151, Chromium
1151
+ * 151" with no Google Chrome brand at all — rebrowser flagged the page as
1152
+ * Chrome for Testing (2026-09-21 benchmark, useragentdata-brands and
1153
+ * headless-markers). _applyEmulatedIdentity now hands this list to the
1154
+ * renderer, so the header and the JS API are the same list.
1155
+ *
878
1156
  * @param {string} [userAgent] — the selected user agent string
1157
+ * @returns {Array<{brand:string, version:string}>}
879
1158
  */
880
- generateSecChUaHeader(userAgent = '') {
1159
+ generateUserAgentBrands(userAgent = '') {
881
1160
  // Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
882
- // Fall back to the bundled Chromium major if the UA is not a Chrome UA.
883
- const match = userAgent.match(/Chrome\/(\d+)/i);
884
- const version = match ? match[1] : '151';
1161
+ // Fall back to the installed Chromium's major if the UA is not a Chrome UA.
1162
+ const match = String(userAgent).match(/Chrome\/(\d+)/i);
1163
+ const version = match ? match[1] : String(this.chromeMajor);
885
1164
 
886
- const brands = [
1165
+ return [
887
1166
  { brand: 'Not_A Brand', version: '8' },
888
1167
  { brand: 'Chromium', version },
889
1168
  { brand: 'Google Chrome', version }
890
1169
  ];
1170
+ }
891
1171
 
892
- return brands
1172
+ /**
1173
+ * Generate sec-ch-ua header.
1174
+ * C2: brand versions are derived from the UA's Chrome major version so
1175
+ * sec-ch-ua and the User-Agent header stay consistent.
1176
+ * @param {string} [userAgent] — the selected user agent string
1177
+ */
1178
+ generateSecChUaHeader(userAgent = '') {
1179
+ return this.generateUserAgentBrands(userAgent)
893
1180
  .map(b => `"${b.brand}";v="${b.version}"`)
894
1181
  .join(', ');
895
1182
  }
@@ -904,7 +1191,7 @@ export class StealthBrowserManager {
904
1191
  linux: '"Linux"'
905
1192
  };
906
1193
 
907
- selectedOS = selectedOS || this.weightedRandom(this.osDistribution);
1194
+ selectedOS = selectedOS || this.hostOS();
908
1195
  return platforms[selectedOS] || '"Windows"';
909
1196
  }
910
1197
 
@@ -1169,7 +1456,7 @@ export class StealthBrowserManager {
1169
1456
  * Generate realistic hardware fingerprint
1170
1457
  */
1171
1458
  generateHardwareFingerprint(selectedOS) {
1172
- selectedOS = selectedOS || this.weightedRandom(this.osDistribution);
1459
+ selectedOS = selectedOS || this.hostOS();
1173
1460
 
1174
1461
  const processors = [
1175
1462
  { cores: 4, threads: 8, name: 'Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz' },
@@ -1180,20 +1467,60 @@ export class StealthBrowserManager {
1180
1467
  { cores: 8, threads: 16, name: 'AMD Ryzen 7 3700X 8-Core Processor' }
1181
1468
  ];
1182
1469
 
1183
- const selectedProcessor = processors[Math.floor(Math.random() * processors.length)];
1470
+ // hardwareConcurrency and deviceMemory are observed, not drawn — the same
1471
+ // call taken for the persona OS, and for the same reason.
1472
+ //
1473
+ // Both are readable from a Worker. An init script does not run in one, and
1474
+ // Emulation.setHardwareConcurrencyOverride does not reach one either: a
1475
+ // Worker is a separate target and the Emulation domain is not available on
1476
+ // it. So a drawn value only ever landed in the document, beside the host's
1477
+ // real one in the worker — 16 cores against 32 (2026-09-21 benchmark,
1478
+ // worker-hardware-concurrency). A machine that reports its own cores is not
1479
+ // a signal. A machine that disagrees with itself is.
1480
+ const hardwareConcurrency = this.hostHardwareConcurrency();
1481
+ const matching = processors.filter((p) => p.threads === hardwareConcurrency);
1482
+ const selectedProcessor = matching.length
1483
+ ? matching[Math.floor(Math.random() * matching.length)]
1484
+ // No modelled CPU has this many threads. Name one that could — the
1485
+ // processor string is reported in the fingerprint, never to a page.
1486
+ : {
1487
+ cores: Math.max(1, Math.round(hardwareConcurrency / 2)),
1488
+ threads: hardwareConcurrency,
1489
+ name: `AMD Ryzen ${Math.max(1, Math.round(hardwareConcurrency / 2))}-Core Processor`
1490
+ };
1184
1491
 
1185
1492
  return {
1186
- hardwareConcurrency: selectedProcessor.threads,
1493
+ hardwareConcurrency,
1187
1494
  processor: selectedProcessor.name,
1188
1495
  architecture: 'x86_64',
1189
1496
  memory: Math.floor(Math.random() * 24) + 8, // 8-32 GB
1190
- // navigator.deviceMemory is capped at 8 in every Chromium build, so 16
1191
- // or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
1192
- deviceMemory: Math.random() < 0.7 ? 8 : 4,
1497
+ deviceMemory: this.hostDeviceMemory(),
1193
1498
  platform: this.selectRealisticPlatform(selectedOS)
1194
1499
  };
1195
1500
  }
1196
1501
 
1502
+ /**
1503
+ * The core count navigator.hardwareConcurrency will report: the host's own
1504
+ * logical CPU count, which is what Chromium answers with
1505
+ * (base::SysInfo::NumberOfProcessors) in the document and in every worker.
1506
+ */
1507
+ hostHardwareConcurrency() {
1508
+ const count = os.cpus()?.length || 0;
1509
+ return count > 0 ? count : 4;
1510
+ }
1511
+
1512
+ /**
1513
+ * navigator.deviceMemory as Chromium computes it: physical RAM rounded to the
1514
+ * nearest power of two, in GiB, then clamped to [0.25, 8]. The clamp is why
1515
+ * 16 or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL),
1516
+ * and why almost every desktop answers 8.
1517
+ */
1518
+ hostDeviceMemory() {
1519
+ const gib = os.totalmem() / (1024 ** 3);
1520
+ if (!(gib > 0)) return 8;
1521
+ return Math.min(8, Math.max(0.25, 2 ** Math.round(Math.log2(gib))));
1522
+ }
1523
+
1197
1524
  /**
1198
1525
  * Map the chosen OS to its navigator.platform value so it stays consistent
1199
1526
  * with the user-agent and sec-ch-ua-platform header.
@@ -1327,7 +1654,7 @@ export class StealthBrowserManager {
1327
1654
  async applyAdvancedStealthConfigurations(context, config, fingerprint) {
1328
1655
  // Nothing is injected into camoufox.
1329
1656
  //
1330
- // Every script below is Chromium-shaped — it deletes navigator.webdriver,
1657
+ // Every script below is Chromium-shaped — it patches navigator.webdriver,
1331
1658
  // installs a window.chrome, and patches getContext, AudioContext and font
1332
1659
  // metrics from the main world. camoufox does all of that in its own
1333
1660
  // C++/Juggler layer, where there is no JS seam to find, and a page that
@@ -1344,16 +1671,48 @@ export class StealthBrowserManager {
1344
1671
  }
1345
1672
 
1346
1673
  // Enhanced initialization script with comprehensive stealth measures
1347
- await context.addInitScript((locale) => {
1348
- // Remove webdriver property completely
1349
- Object.defineProperty(navigator, 'webdriver', {
1350
- get: () => undefined,
1351
- configurable: true
1352
- });
1674
+ await context.addInitScript(() => {
1675
+ // Everything this script changes about navigator goes on
1676
+ // Navigator.prototype, never on the navigator instance.
1677
+ //
1678
+ // A real Chrome answers Object.getOwnPropertyNames(navigator) with an
1679
+ // empty array: every property it has is inherited. So one
1680
+ // defineProperty(navigator, …) puts the spoof's own name in a list any
1681
+ // page can print in a line — rebrowser's bot detector prints exactly that
1682
+ // list, and it read ["connection","plugins","mimeTypes","getBattery"]
1683
+ // (2026-09-21 bench). It is the same class of tell as the deleted
1684
+ // webdriver property, one level up.
1685
+ //
1686
+ // A property that is not on the prototype is left alone rather than
1687
+ // invented: a shape that no Chromium has is worse than the real value.
1688
+ const defineOnPrototype = (key, descriptor) => {
1689
+ if (!(key in Navigator.prototype)) return false;
1690
+ try {
1691
+ // WebIDL attributes and operations are both enumerable and
1692
+ // configurable on the prototype; matching that keeps the descriptor
1693
+ // indistinguishable from the one it replaces.
1694
+ Object.defineProperty(Navigator.prototype, key, Object.assign(
1695
+ { configurable: true, enumerable: true }, descriptor
1696
+ ));
1697
+ return true;
1698
+ } catch (e) {
1699
+ return false;
1700
+ }
1701
+ };
1702
+
1703
+ // navigator.webdriver reports false; the property stays. Deleting it was
1704
+ // its own tell — rebrowser's bot detector marks a missing webdriver red,
1705
+ // because every real Chrome has the property and answers false
1706
+ // (2026-09-21 benchmark, navigator-webdriver). The launch flags do this
1707
+ // already (--disable-blink-features=AutomationControlled, and
1708
+ // --enable-automation taken off the default args), so on a normal launch
1709
+ // this is a no-op and the native prototype getter is left untouched; the
1710
+ // redefinition is only for a binary where the flag did not take.
1711
+ if (navigator.webdriver !== false) {
1712
+ defineOnPrototype('webdriver', { get: () => false });
1713
+ }
1353
1714
 
1354
1715
  // Hide automation indicators
1355
- delete window.navigator.__proto__.webdriver;
1356
- delete window.navigator.webdriver;
1357
1716
  delete window.webdriver;
1358
1717
  delete window._phantom;
1359
1718
  delete window.__nightmare;
@@ -1378,18 +1737,15 @@ export class StealthBrowserManager {
1378
1737
  originalQuery(parameters)
1379
1738
  );
1380
1739
 
1381
- // Hide headless indicators. configurable: true because the hardware
1382
- // spoofing script below redefines this with the fingerprint's own core
1383
- // count — without it that redefinition throws "Cannot redefine property"
1384
- // and takes navigator.platform and deviceMemory down with it, so the
1385
- // page saw a Win32 platform on every persona.
1386
- Object.defineProperty(navigator, 'hardwareConcurrency', {
1387
- get: () => 4,
1388
- configurable: true
1389
- });
1740
+ // No hardwareConcurrency here, and none anywhere else either: the
1741
+ // fingerprint now carries the host's own core count
1742
+ // (generateHardwareFingerprint), so there is nothing to override. A
1743
+ // defineProperty here only ever reached the document, and the worker
1744
+ // answering with the real count beside it was the mismatch
1745
+ // bot.incolumitas.com scores.
1390
1746
 
1391
1747
  // Spoof connection
1392
- Object.defineProperty(navigator, 'connection', {
1748
+ defineOnPrototype('connection', {
1393
1749
  get: () => ({
1394
1750
  effectiveType: '4g',
1395
1751
  rtt: 50 + Math.random() * 50,
@@ -1454,7 +1810,7 @@ export class StealthBrowserManager {
1454
1810
  Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
1455
1811
  Object.defineProperty(pluginArray, plugin.name, { value: plugin });
1456
1812
  });
1457
- Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
1813
+ defineOnPrototype('plugins', { get: () => pluginArray });
1458
1814
 
1459
1815
  // Real Chrome pairs those plugins with two navigator.mimeTypes entries
1460
1816
  // (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
@@ -1473,31 +1829,29 @@ export class StealthBrowserManager {
1473
1829
  Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
1474
1830
  Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
1475
1831
  });
1476
- Object.defineProperty(navigator, 'mimeTypes', { get: () => mimeTypeArray });
1832
+ defineOnPrototype('mimeTypes', { get: () => mimeTypeArray });
1477
1833
  }
1478
1834
  }
1479
1835
 
1480
- // Override languages with the fingerprint's own locale — a hardcoded
1481
- // en-US here contradicts navigator.language and Accept-Language whenever
1482
- // the persona is not American.
1483
- Object.defineProperty(navigator, 'languages', {
1484
- get: function() {
1485
- const primary = locale.split('-')[0];
1486
- return primary === locale ? [locale] : [locale, primary];
1487
- }
1488
- });
1489
-
1490
- // Mock battery API with realistic values
1491
- Object.defineProperty(navigator, 'getBattery', {
1492
- get: function() {
1493
- return function() {
1494
- return Promise.resolve({
1495
- charging: true,
1496
- chargingTime: 0,
1497
- dischargingTime: Infinity,
1498
- level: 0.8 + Math.random() * 0.19 // 80-99%
1499
- });
1500
- };
1836
+ // No languages here either. The persona's language list is handed to
1837
+ // Chromium as the accept-language override in _applyEmulatedIdentity, so
1838
+ // the document and every worker parse the same list; this defineProperty
1839
+ // reached only the document, and the worker's ["en-US"] against the
1840
+ // document's ["en-US", "en"] was the difference the benchmark measured
1841
+ // (2026-09-21, worker-languages).
1842
+
1843
+ // Mock battery API with realistic values. A data property, not an
1844
+ // accessor: getBattery is a WebIDL operation, so on a real Navigator
1845
+ // prototype it is a writable function value and not a getter.
1846
+ defineOnPrototype('getBattery', {
1847
+ writable: true,
1848
+ value: function getBattery() {
1849
+ return Promise.resolve({
1850
+ charging: true,
1851
+ chargingTime: 0,
1852
+ dischargingTime: Infinity,
1853
+ level: 0.8 + Math.random() * 0.19 // 80-99%
1854
+ });
1501
1855
  }
1502
1856
  });
1503
1857
 
@@ -1529,7 +1883,7 @@ export class StealthBrowserManager {
1529
1883
  return originalPrepareStackTrace.call(this, error, filteredStack);
1530
1884
  };
1531
1885
  }
1532
- }, fingerprint.locale || config.locale || 'en-US');
1886
+ });
1533
1887
 
1534
1888
  // WebRTC leak prevention with advanced spoofing
1535
1889
  if (config.blockWebRTC) {
@@ -1703,73 +2057,56 @@ export class StealthBrowserManager {
1703
2057
  }, fingerprint.mediaDevices);
1704
2058
  }
1705
2059
 
1706
- // Hardware spoofing
1707
- if (config.fingerprinting?.hardwareSpoofing) {
2060
+ // Hardware. Nothing is defined onto the document's navigator any more.
2061
+ // hardwareConcurrency and deviceMemory are the host's own values
2062
+ // (generateHardwareFingerprint), so there is nothing left to override — a
2063
+ // defineProperty that sets a property to the value it already has is a JS
2064
+ // seam bought for nothing. platform is set through CDP
2065
+ // (_applyEmulatedIdentity), and with the persona OS taken from the host it
2066
+ // is usually the truth as well.
2067
+ //
2068
+ // Init scripts never run inside a dedicated Worker, so a detector that
2069
+ // compares navigator in a worker with the main thread saw the real
2070
+ // platform, deviceMemory and hardwareConcurrency beside the spoofed
2071
+ // ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
2072
+ // R17 2026-09-04). At the advanced level, classic workers start from a
2073
+ // blob that patches WorkerNavigator and then importScripts() the real
2074
+ // script; relative importScripts/fetch inside such a worker resolve
2075
+ // against the original script URL. Module workers, data:/blob: worker
2076
+ // URLs and a CSP that refuses blob workers fall through to the native
2077
+ // constructor untouched. (camoufox never reaches here — this whole method
2078
+ // returns early for it.)
2079
+ //
2080
+ // MEASURED COVERAGE, 2026-09-16, chromium, comparing a worker's navigator
2081
+ // with the main thread's:
2082
+ //
2083
+ // level new Worker('/w.js') new Worker(blob:)
2084
+ // medium leaks all four leaks all four
2085
+ // advanced matches leaks all four
2086
+ //
2087
+ // The four are platform, hardwareConcurrency, deviceMemory and languages.
2088
+ // So a detector that builds its worker from a Blob — which needs no second
2089
+ // request and is the usual shape — read straight past this at every level.
2090
+ //
2091
+ // THE CALL, taken 2026-09-21: stop drawing what a worker can contradict.
2092
+ // hardwareConcurrency and deviceMemory are the host's own values now, and
2093
+ // languages comes from the context locale, which Playwright applies to the
2094
+ // worker as well as the document — all three agree without a wrapper.
2095
+ // That leaves platform, and only for a caller who pins a customUserAgent
2096
+ // from another OS: the persona's OS is otherwise the host's, so the spoofed
2097
+ // platform IS the real one and the wrapper would rewrite every worker
2098
+ // source on the page to change nothing. Hence the guard — the surface is
2099
+ // paid for only when it buys something. WebGL's unmasked vendor/renderer is
2100
+ // still window-only and still leaks in a worker.
2101
+ const hostPlatform = this.selectRealisticPlatform(this.hostOS());
2102
+ if (config.fingerprinting?.hardwareSpoofing
2103
+ && config.level === 'advanced'
2104
+ && fingerprint.hardware.platform !== hostPlatform) {
1708
2105
  await context.addInitScript((hardware) => {
1709
- Object.defineProperty(navigator, 'hardwareConcurrency', {
1710
- get: () => hardware.hardwareConcurrency
1711
- });
1712
-
1713
- Object.defineProperty(navigator, 'platform', {
1714
- get: () => hardware.platform
1715
- });
1716
-
1717
- if (navigator.deviceMemory !== undefined) {
1718
- Object.defineProperty(navigator, 'deviceMemory', {
1719
- get: () => hardware.deviceMemory
1720
- });
1721
- }
1722
- }, fingerprint.hardware);
1723
-
1724
- // Init scripts never run inside a dedicated Worker, so a detector that
1725
- // compares navigator in a worker with the main thread saw the real
1726
- // platform, deviceMemory and hardwareConcurrency beside the spoofed
1727
- // ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
1728
- // R17 2026-09-04). At the advanced level, classic workers start from a
1729
- // blob that patches WorkerNavigator and then importScripts() the real
1730
- // script; relative importScripts/fetch inside such a worker resolve
1731
- // against the original script URL. Module workers, data:/blob: worker
1732
- // URLs and a CSP that refuses blob workers fall through to the native
1733
- // constructor untouched. (camoufox never reaches here — this whole method
1734
- // returns early for it.)
1735
- //
1736
- // MEASURED COVERAGE, 2026-09-16, chromium, comparing a worker's navigator
1737
- // with the main thread's:
1738
- //
1739
- // level new Worker('/w.js') new Worker(blob:)
1740
- // medium leaks all four leaks all four
1741
- // advanced matches leaks all four
1742
- //
1743
- // The four are platform, hardwareConcurrency, deviceMemory and languages;
1744
- // WebGL's unmasked vendor/renderer leaks the same way, because the
1745
- // prototype patch above lives in the window, not in a worker scope. So a
1746
- // detector that builds its worker from a Blob — which needs no second
1747
- // request and is the usual shape — reads straight past this at every
1748
- // level, and deviceandbrowserinfo.com's hasInconsistentWorkerValues
1749
- // stays set. Every one of those mismatches is ours: a worker reports the
1750
- // truth, and only the main thread was spoofed.
1751
- //
1752
- // Closing it properly means one of two things, and both are trades:
1753
- // rewrite blob/data worker sources so the patch reaches them (more
1754
- // surface, and it still misses module workers and SharedWorker), or stop
1755
- // spoofing in the window what cannot be spoofed in a worker (consistent,
1756
- // but then the real values show — 32 cores and a SwiftShader GPU, which
1757
- // is its own datacenter tell). Left as it is pending that call; do not
1758
- // read the `advanced` gate as coverage.
1759
- if (config.level === 'advanced') {
1760
- await context.addInitScript(({ hardware, locale }) => {
1761
- const NativeWorker = window.Worker;
1762
- if (typeof NativeWorker !== 'function') return;
1763
- const primary = locale.split('-')[0];
1764
- const languages = primary === locale ? [locale] : [locale, primary];
1765
- const spoof = JSON.stringify({
1766
- hardwareConcurrency: hardware.hardwareConcurrency,
1767
- platform: hardware.platform,
1768
- deviceMemory: hardware.deviceMemory,
1769
- languages,
1770
- language: locale
1771
- });
1772
- const prelude = (scriptUrl) =>
2106
+ const NativeWorker = window.Worker;
2107
+ if (typeof NativeWorker !== 'function') return;
2108
+ const spoof = JSON.stringify({ platform: hardware.platform });
2109
+ const prelude = (scriptUrl) =>
1773
2110
  `(() => {
1774
2111
  const spoof = ${spoof};
1775
2112
  const proto = self.WorkerNavigator && self.WorkerNavigator.prototype;
@@ -1777,11 +2114,7 @@ export class StealthBrowserManager {
1777
2114
  try { Object.defineProperty(proto, key, { get: () => value, configurable: true }); } catch (e) {}
1778
2115
  };
1779
2116
  if (proto) {
1780
- define('hardwareConcurrency', spoof.hardwareConcurrency);
1781
2117
  define('platform', spoof.platform);
1782
- if ('deviceMemory' in self.navigator) define('deviceMemory', spoof.deviceMemory);
1783
- define('languages', spoof.languages);
1784
- define('language', spoof.language);
1785
2118
  }
1786
2119
  const base = ${JSON.stringify(scriptUrl)};
1787
2120
  const resolve = (u) => { try { return new URL(String(u), base).href; } catch (e) { return u; } };
@@ -1799,25 +2132,24 @@ export class StealthBrowserManager {
1799
2132
  }
1800
2133
  })();
1801
2134
  importScripts(${JSON.stringify(scriptUrl)});`;
1802
- const Wrapped = function Worker(scriptURL, options) {
1803
- try {
1804
- const isModule = options && options.type === 'module';
1805
- const absolute = new URL(String(scriptURL), location.href).href;
1806
- if (!isModule && /^https?:/.test(absolute)) {
1807
- const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
1808
- return new NativeWorker(URL.createObjectURL(blob), options);
1809
- }
1810
- } catch (e) {
1811
- // CSP refused the blob URL or the URL failed to parse: native worker.
2135
+ const Wrapped = function Worker(scriptURL, options) {
2136
+ try {
2137
+ const isModule = options && options.type === 'module';
2138
+ const absolute = new URL(String(scriptURL), location.href).href;
2139
+ if (!isModule && /^https?:/.test(absolute)) {
2140
+ const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
2141
+ return new NativeWorker(URL.createObjectURL(blob), options);
1812
2142
  }
1813
- return new NativeWorker(scriptURL, options);
1814
- };
1815
- Wrapped.prototype = NativeWorker.prototype;
1816
- Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
1817
- Wrapped.toString = () => 'function Worker() { [native code] }';
1818
- window.Worker = Wrapped;
1819
- }, { hardware: fingerprint.hardware, locale: config.locale });
1820
- }
2143
+ } catch (e) {
2144
+ // CSP refused the blob URL or the URL failed to parse: native worker.
2145
+ }
2146
+ return new NativeWorker(scriptURL, options);
2147
+ };
2148
+ Wrapped.prototype = NativeWorker.prototype;
2149
+ Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
2150
+ Wrapped.toString = () => 'function Worker() { [native code] }';
2151
+ window.Worker = Wrapped;
2152
+ }, fingerprint.hardware);
1821
2153
  }
1822
2154
 
1823
2155
  // Font spoofing
@@ -1899,11 +2231,22 @@ export class StealthBrowserManager {
1899
2231
  // Battery API spoofing
1900
2232
  if (config.antiDetection?.spoofBatteryAPI) {
1901
2233
  await context.addInitScript((battery) => {
1902
- if (navigator.getBattery) {
1903
- navigator.getBattery = function() {
1904
- return Promise.resolve(battery);
1905
- };
1906
- }
2234
+ // On the prototype, where getBattery natively lives. A plain
2235
+ // `navigator.getBattery = …` assignment makes it an OWN property of the
2236
+ // navigator instance, which is the list rebrowser prints (2026-09-21
2237
+ // bench) — a real Chrome's Object.getOwnPropertyNames(navigator) is
2238
+ // empty.
2239
+ if (!('getBattery' in Navigator.prototype)) return;
2240
+ try {
2241
+ Object.defineProperty(Navigator.prototype, 'getBattery', {
2242
+ configurable: true,
2243
+ enumerable: true,
2244
+ writable: true,
2245
+ value: function getBattery() {
2246
+ return Promise.resolve(battery);
2247
+ }
2248
+ });
2249
+ } catch (e) { /* a build that refuses the redefinition keeps the real one */ }
1907
2250
  }, fingerprint.battery);
1908
2251
  }
1909
2252
  }
@@ -2142,10 +2485,21 @@ export class StealthBrowserManager {
2142
2485
  * proxy in a list was never reached.
2143
2486
  */
2144
2487
  resolveProxy(config) {
2145
- const proxies = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
2488
+ // The caller's list always wins: a per-call proxyRotation is a deliberate
2489
+ // choice of exit address, and the server-level CRAWLFORGE_STEALTH_PROXIES
2490
+ // is only what this server goes out from when nobody chose one — the
2491
+ // escalation stage, the agent and browser_session have no caller to ask.
2492
+ // Read through the helper on every call, so an operator's list can be set
2493
+ // for a run without restarting the process.
2494
+ const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
2495
+ const proxies = requested.length ? requested : serverStealthProxies();
2146
2496
  if (!proxies.length) {
2147
2497
  return null;
2148
2498
  }
2499
+ // The server-level list arrives as a bare list with no rotation block of
2500
+ // its own, so it gets the schema's own default interval rather than
2501
+ // comparing against undefined — which is never greater, i.e. never rotates.
2502
+ const rotationInterval = config.proxyRotation?.rotationInterval ?? 300000;
2149
2503
 
2150
2504
  const now = Date.now();
2151
2505
  if (this.proxyManager.currentProxy === null) {
@@ -2154,7 +2508,7 @@ export class StealthBrowserManager {
2154
2508
  // longer list silently started at the second entry.
2155
2509
  this.proxyManager.proxyIndex = 0;
2156
2510
  this.proxyManager.lastRotation = now;
2157
- } else if (now - this.proxyManager.lastRotation > config.proxyRotation.rotationInterval) {
2511
+ } else if (now - this.proxyManager.lastRotation > rotationInterval) {
2158
2512
  this.proxyManager.proxyIndex = (this.proxyManager.proxyIndex + 1) % proxies.length;
2159
2513
  this.proxyManager.lastRotation = now;
2160
2514
  }
@@ -2201,18 +2555,27 @@ export class StealthBrowserManager {
2201
2555
  *
2202
2556
  * @param {Object} params
2203
2557
  * @param {string} params.url — URL to scrape
2204
- * @param {string} [params.engine] — browser engine (forwarded to config; playwright by default)
2558
+ * @param {string} [params.engine] — browser engine (forwarded to config; 'auto' by default, i.e. camoufox when installed)
2205
2559
  * @param {number} [params.wait_for] — extra wait after load, in ms
2206
2560
  * @param {boolean} [params.screenshot] — capture a base64 PNG screenshot
2207
2561
  * @param {Object} [params.stealthConfig] — stealth configuration overrides
2208
- * @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string}>}
2562
+ * @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string, engine:?string, warnings:string[]}>}
2563
+ * `engine` is the one that actually ran and `warnings` carries the
2564
+ * 'auto'→chromium downgrade, so the caller can report it.
2209
2565
  */
2210
2566
  /**
2211
- * If the document is a self-solving bot-wall interstitial (its title is a
2212
- * known challenge title), wait up to timeoutMs for the title to change —
2213
- * the challenge script navigates to the real page when it passes — and for
2214
- * that navigation to reach domcontentloaded. A challenge that never passes
2215
- * is left to challengeDetection to report as blocked.
2567
+ * If the document is a self-solving bot-wall interstitial, wait up to
2568
+ * timeoutMs for the title to change — the challenge script navigates to the
2569
+ * real page when it passes — and for that navigation to reach
2570
+ * domcontentloaded. A challenge that never passes is left to
2571
+ * challengeDetection to report as blocked.
2572
+ *
2573
+ * The trigger is looksLikeInterstitial, not a known title: nowsecure.nl's
2574
+ * interstitial is titled "nowsecure.nl", so a title-only test skipped the
2575
+ * wait entirely and the block verdict fired on a challenge that had not been
2576
+ * given its chance to solve itself (2026-09-21 benchmark, section 2.2). That
2577
+ * costs one page.content() per call, which is what reading the challenge
2578
+ * bootstrap out of the document takes.
2216
2579
  */
2217
2580
  /**
2218
2581
  * A document with no title and no text right after domcontentloaded is
@@ -2324,7 +2687,15 @@ export class StealthBrowserManager {
2324
2687
  } catch {
2325
2688
  return;
2326
2689
  }
2327
- if (!detectChallengePage({ title })) return;
2690
+ // A page that is navigating — which a challenge that has just solved itself
2691
+ // is — can answer title() and still refuse content(). Falling back to the
2692
+ // title-only decision keeps a known interstitial waited on; only the marker
2693
+ // branch, and with it the custom-titled wall, is lost for that call.
2694
+ let html = '';
2695
+ try {
2696
+ html = await page.content();
2697
+ } catch { /* title-only decision */ }
2698
+ if (!looksLikeInterstitial({ title, html })) return;
2328
2699
  await page
2329
2700
  .waitForFunction((t) => document.title !== t, title, { timeout: timeoutMs })
2330
2701
  .catch(() => {});
@@ -2334,7 +2705,12 @@ export class StealthBrowserManager {
2334
2705
  async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
2335
2706
  if (!url) throw new Error('scrapeWithStealth requires a url');
2336
2707
 
2337
- const { contextId } = await this.createStealthContext({ ...stealthConfig, engine });
2708
+ const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
2709
+ // What ran, not what was asked for: 'auto' becomes chromium when camoufox
2710
+ // is absent, and a result that says the stealth browser did not get the
2711
+ // page has to name the browser that tried.
2712
+ const engineUsed = this._launchedEngine ?? null;
2713
+ const warnings = engineFallbackWarning ? [engineFallbackWarning] : [];
2338
2714
  try {
2339
2715
  const page = await this.createStealthPage(contextId);
2340
2716
  let crashed = false;
@@ -2394,44 +2770,132 @@ export class StealthBrowserManager {
2394
2770
  ? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
2395
2771
  : null;
2396
2772
 
2397
- return { success: true, url, title, text, html, screenshot: shot, status, gracedMs };
2773
+ return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
2398
2774
  } finally {
2399
2775
  await this.closeContext(contextId).catch(() => {});
2400
2776
  }
2401
2777
  }
2402
2778
 
2403
2779
  /**
2404
- * Whether a request is dropped before it leaves the browser. Only the
2405
- * advanced level sheds assets, never by URL and never the document itself.
2780
+ * Whether a request is dropped before it leaves the browser. Nothing is, at
2781
+ * any level, which is why applyPageStealthMeasures routes no page at all —
2782
+ * this is the one place that answers the question, and the answer is the
2783
+ * reason the interception is gone.
2406
2784
  * @param {string} resourceType - Playwright's request.resourceType()
2407
2785
  * @param {string} level - stealth level
2408
2786
  * @returns {boolean}
2409
2787
  */
2410
- static shouldAbortRequest(resourceType, level) {
2411
- if (level !== 'advanced') return false;
2412
- if (!['image', 'font', 'stylesheet'].includes(resourceType)) return false;
2413
- // Allow some images/fonts to maintain realism
2414
- return Math.random() >= 0.3;
2788
+ static shouldAbortRequest(_resourceType, _level) {
2789
+ return false;
2790
+ }
2791
+
2792
+ /**
2793
+ * The full user-agent client-hint metadata for a fingerprint: what Playwright
2794
+ * already derives from the user agent, plus the brand lists it leaves to the
2795
+ * binary. Omitting a field means "fill this in with what you would normally
2796
+ * use", and what a headless Chromium normally uses says HeadlessChrome — in
2797
+ * brands, in fullVersionList, and so in every high-entropy hint a detector
2798
+ * asks for.
2799
+ *
2800
+ * The five derived fields are what playwright-core's own
2801
+ * calculateUserAgentMetadata produces for the three desktop personas this
2802
+ * manager draws — so for every user agent it can hand out, this changes the
2803
+ * brands and nothing else. A mobile persona is out of scope here for the same
2804
+ * reason isMobile is pinned false in generateAdvancedFingerprint: the pool has
2805
+ * no mobile user agent to be coherent with.
2806
+ *
2807
+ * @param {Object} fingerprint
2808
+ * @returns {Object} a CDP Emulation.UserAgentMetadata
2809
+ */
2810
+ generateUserAgentMetadata(fingerprint) {
2811
+ const userAgent = String(fingerprint.userAgent || '');
2812
+ const brands = this.generateUserAgentBrands(userAgent);
2813
+ const brandVersion = brands[brands.length - 1].version;
2814
+ // The four-part build number, but only when it is the binary the brand
2815
+ // version names; a custom UA on another major gets the reduced form rather
2816
+ // than this machine's build under someone else's version.
2817
+ const fullVersion = brandVersion === String(this.chromeMajor)
2818
+ ? this.chromeVersion
2819
+ : `${brandVersion}.0.0.0`;
2820
+ const platformVersions = { windows: '10.0', macos: '10_15_7', linux: '' };
2821
+ const platforms = { windows: 'Windows', macos: 'macOS', linux: 'Linux' };
2822
+ const os = this.inferOSFromUserAgent(userAgent);
2823
+
2824
+ return {
2825
+ brands,
2826
+ fullVersionList: brands.map((brand) => ({ brand: brand.brand, version: fullVersion })),
2827
+ fullVersion,
2828
+ platform: platforms[os],
2829
+ platformVersion: platformVersions[os],
2830
+ architecture: 'x86',
2831
+ model: '',
2832
+ mobile: false
2833
+ };
2834
+ }
2835
+
2836
+ /**
2837
+ * Tell the renderer who it is, instead of patching it from script.
2838
+ *
2839
+ * navigator.userAgent, .platform and .userAgentData were being defined on the
2840
+ * document's navigator by init scripts. An init script does not run in a
2841
+ * Worker, so every one of those values was spoofed in the document and
2842
+ * truthful in the worker beside it — a MacIntel under a Windows persona,
2843
+ * which is what bot.incolumitas.com and CreepJS score (2026-09-21 benchmark,
2844
+ * worker-*). Emulation.setUserAgentOverride is applied by the renderer, and
2845
+ * the brands it carries are the ones the binary would otherwise fill in as
2846
+ * HeadlessChrome.
2847
+ *
2848
+ * `acceptLanguage` is the plain locale tag, the same single value Playwright
2849
+ * gives the context. It is deliberately NOT the two-entry "en-US,en" list:
2850
+ * this override reaches the document only, while a worker's
2851
+ * navigator.languages comes from the context locale, so a second entry here
2852
+ * showed up in the document and nowhere else — the very mismatch the init
2853
+ * script used to create (2026-09-21 benchmark, worker-languages). The
2854
+ * Accept-Language *header* still carries the `en;q=0.9` fallback, from
2855
+ * generateAdvancedHeaders, which is what a real Chrome sends for a
2856
+ * single-locale preference. The field cannot simply be omitted: that clears
2857
+ * the override Playwright installed and a de-DE persona would fall back to
2858
+ * the binary's default.
2859
+ *
2860
+ * Chromium only: CDP does not exist on camoufox's Firefox, which spoofs all
2861
+ * of this in its own engine anyway. A failure here is not fatal — Playwright
2862
+ * has already set the user agent through the context, so the persona survives
2863
+ * without the brands.
2864
+ */
2865
+ async _applyEmulatedIdentity(page, config, fingerprint) {
2866
+ if (!isChromium(page) || !fingerprint.userAgent) return;
2867
+ try {
2868
+ const client = await page.context().newCDPSession(page);
2869
+ await client.send('Emulation.setUserAgentOverride', {
2870
+ userAgent: fingerprint.userAgent,
2871
+ acceptLanguage: fingerprint.locale || config.locale || 'en-US',
2872
+ platform: fingerprint.hardware.platform,
2873
+ userAgentMetadata: this.generateUserAgentMetadata(fingerprint)
2874
+ });
2875
+ } catch (error) {
2876
+ console.warn(`Identity emulation skipped: ${error.message}`);
2877
+ }
2415
2878
  }
2416
2879
 
2417
2880
  /**
2418
2881
  * Apply page-level stealth measures
2419
2882
  */
2420
2883
  async applyPageStealthMeasures(page, config, fingerprint) {
2421
- // Resource routing. Nothing is aborted by URL any more: the old list
2422
- // blocked challenges.cloudflare.com and every URL containing "selenium",
2423
- // "webdriver" or "puppeteer" — the first meant a Cloudflare challenge
2424
- // could never complete (the interstitial named the blocked host), the
2425
- // second killed the top-level navigation to www.selenium.dev on every
2426
- // stealth path (R15, 2026-09-04). A vendor script that never loads is a
2427
- // louder tell than one that runs.
2428
- await page.route('**/*', route => {
2429
- if (StealthBrowserManager.shouldAbortRequest(route.request().resourceType(), config.level)) {
2430
- route.abort();
2431
- } else {
2432
- route.continue();
2433
- }
2434
- });
2884
+ // No request routing at all. R15 had already stopped aborting by URL (the
2885
+ // old list blocked challenges.cloudflare.com, so a Cloudflare challenge
2886
+ // could never complete, and killed the navigation to www.selenium.dev);
2887
+ // what was left was the advanced level dropping about a third of images,
2888
+ // fonts and stylesheets at random. Both halves of that are timeable: a page
2889
+ // that renders without the fonts and images it asked for does not look like
2890
+ // a browser reading it, and route('**/*') itself puts every request through
2891
+ // a node round trip, which shows up as latency no network explains
2892
+ // (2026-09-21 review, finding 10). With nothing left to abort there is
2893
+ // nothing for the interception to decide, so the handler is gone rather
2894
+ // than made conditional.
2895
+
2896
+ // Every identity the renderer can be told about directly, rather than
2897
+ // patched into from script.
2898
+ await this._applyEmulatedIdentity(page, config, fingerprint);
2435
2899
 
2436
2900
  // Add request headers — Chromium only. These carry sec-ch-ua,
2437
2901
  // sec-ch-ua-mobile and sec-ch-ua-platform, client hints Gecko does not
@@ -2641,6 +3105,7 @@ export class StealthBrowserManager {
2641
3105
  this._parkedBrowsers.clear();
2642
3106
  }
2643
3107
  this._launchedEngine = null;
3108
+ this._engineFallbackWarning = null;
2644
3109
  for (const browser of browsers) {
2645
3110
  const closed = await withDeadline(browser.close(), 5000);
2646
3111
  if (!closed) {
@@ -2813,6 +3278,115 @@ export class CamoufoxAdapter extends BrowserEngine {
2813
3278
  }
2814
3279
  }
2815
3280
 
3281
+ /**
3282
+ * A browserforge fingerprint pinned to the installed binary's Firefox major.
3283
+ *
3284
+ * camoufox@0.1.19 rewrites the persona's version tokens with
3285
+ *
3286
+ * data.replace(/(?<!\d)(1[0-9]{2})(\.0)(?!\d)/, `${ffVersion}$2`)
3287
+ *
3288
+ * and there is no /g, so only the FIRST match in each string changes. In a
3289
+ * Firefox UA that match is `rv:`, and `Firefox/` keeps whatever version
3290
+ * browserforge drew — the two then agree only by coincidence. Measured on
3291
+ * the installed 135 binary: 4 of 8 launches produced a self-contradicting UA
3292
+ * (`rv:135.0 ... Firefox/150.0`), which is a one-line detection. Generating
3293
+ * the persona ourselves at the binary's own version makes that replace a
3294
+ * no-op and both tokens agree (8/8 measured, end to end through
3295
+ * launchOptions).
3296
+ *
3297
+ * Nothing else is constrained here because camoufox constrains nothing else:
3298
+ * its getScreenCons() returns null on both branches, and its `os` option is
3299
+ * dropped before it reaches the generator (the client sends `os`, the
3300
+ * generator's key is `operatingSystems`). Pinning the OS too would fix that
3301
+ * second bug, but it is Phase 1's documented `persona-os-vs-host` finding and
3302
+ * changing it here would move a baseline this change has no business moving.
3303
+ *
3304
+ * @returns {object|null} null when the version cannot be read, or when
3305
+ * browserforge has no data for it — 152 is outside its set, and silently
3306
+ * drawing some other version would put us back where we started. The
3307
+ * caller then lets camoufox generate as it did before.
3308
+ */
3309
+ /** process.platform in the vocabulary fingerprint-generator uses, or null. */
3310
+ static _hostOperatingSystem() {
3311
+ if (process.platform === 'darwin') return 'macos';
3312
+ if (process.platform === 'win32') return 'windows';
3313
+ if (process.platform === 'linux') return 'linux';
3314
+ return null;
3315
+ }
3316
+
3317
+ /** The OS a Firefox UA claims, in the same vocabulary, or null. */
3318
+ static _osFromUserAgent(ua) {
3319
+ if (/Macintosh|Mac OS X/.test(ua)) return 'macos';
3320
+ if (/Windows/.test(ua)) return 'windows';
3321
+ if (/Android/.test(ua)) return null; // Linux-derived, but not the host OS we mean
3322
+ if (/X11|Linux/.test(ua)) return 'linux';
3323
+ return null;
3324
+ }
3325
+
3326
+ _pinnedFingerprint(camoufox) {
3327
+ try {
3328
+ const raw = fs.readFileSync(path.join(camoufox.INSTALL_DIR, 'version.json'), 'utf8');
3329
+ const major = parseInt(String(JSON.parse(raw).version).split('.')[0], 10);
3330
+ if (!Number.isInteger(major)) return null;
3331
+
3332
+ // Resolved through camoufox's own module path first: fingerprint-generator
3333
+ // is its dependency, not ours, and this guarantees the same copy it feeds
3334
+ // its data to rather than a second one hoisted elsewhere.
3335
+ const here = createRequire(import.meta.url);
3336
+ let load;
3337
+ try {
3338
+ load = createRequire(here.resolve('camoufox'));
3339
+ } catch {
3340
+ load = here;
3341
+ }
3342
+ const { FingerprintGenerator } = load('fingerprint-generator');
3343
+
3344
+ // Verify rather than assume: a generator with no data for a constraint can
3345
+ // still hand back a persona that ignores it, and an unchecked fingerprint
3346
+ // would reintroduce exactly the mismatch this exists to close.
3347
+ const attempt = (operatingSystems) => {
3348
+ const { fingerprint } = new FingerprintGenerator({
3349
+ browsers: [{ name: 'firefox', minVersion: major, maxVersion: major }],
3350
+ ...(operatingSystems ? { operatingSystems } : {})
3351
+ }).getFingerprint();
3352
+ const ua = fingerprint?.navigator?.userAgent ?? '';
3353
+ if (ua.match(/rv:(\d+)/)?.[1] !== String(major)) return null;
3354
+ if (ua.match(/Firefox\/(\d+)/)?.[1] !== String(major)) return null;
3355
+ if (operatingSystems && CamoufoxAdapter._osFromUserAgent(ua) !== operatingSystems[0]) return null;
3356
+ return fingerprint;
3357
+ };
3358
+
3359
+ // `operatingSystems` is the key the client gets wrong — it sends `os`, so
3360
+ // the OS is drawn from market share and a Linux host ships a macOS
3361
+ // persona. Generating here means we can use the right key. Measured on the
3362
+ // hosted box 2026-09-22: persona-os-vs-host failed with a coherent UA
3363
+ // version, which is a contradiction a detector scores on its own.
3364
+ const hostOs = CamoufoxAdapter._hostOperatingSystem();
3365
+ if (hostOs) {
3366
+ try {
3367
+ const matched = attempt([hostOs]);
3368
+ if (matched) return matched;
3369
+ } catch {
3370
+ // An impossible constraint THROWS here rather than returning a
3371
+ // mismatched persona ("No headers based on this input can be
3372
+ // generated"), and browserforge has no Firefox 135 on Linux at all —
3373
+ // measured, and it is the combination the hosted box runs. Letting
3374
+ // that escape would lose the version pin too and put the UA back
3375
+ // where it started, so it falls through to the version-only attempt.
3376
+ }
3377
+ }
3378
+
3379
+ // Fall back to a version-only pin. browserforge may have no data for this
3380
+ // Firefox version on this OS, and a persona that is coherent about its
3381
+ // version still beats one that is coherent about neither.
3382
+ return attempt(null);
3383
+ } catch {
3384
+ // Best effort: a missing version.json or an absent generator is a reason
3385
+ // to fall back to camoufox's own persona, not to fail a launch.
3386
+ return null;
3387
+ }
3388
+ }
3389
+
2816
3390
  async launch(config = {}) {
2817
3391
  let camoufox;
2818
3392
  try {
@@ -2842,11 +3416,24 @@ export class CamoufoxAdapter extends BrowserEngine {
2842
3416
  // level, where a page cannot see the seam, and every one of these was
2843
3417
  // simply not being passed — camoufox ran with its own features off.
2844
3418
  const options = {
2845
- headless: config.headless !== false,
3419
+ // A string mode ('virtual' — Xvfb) has to reach camoufox as that string.
3420
+ // `config.headless !== false` collapsed it to `true`, which is plain
3421
+ // headless: the one mode 'virtual' exists to avoid. Booleans keep their
3422
+ // old meaning, so an unset headless is still headless.
3423
+ headless: typeof config.headless === 'string' ? config.headless : config.headless !== false,
2846
3424
  ...config.launchOptions
2847
3425
  };
2848
3426
  // A bare `{ server }` is fine here; camoufox normalises both shapes.
2849
3427
  if (config.proxy) options.proxy = config.proxy;
3428
+ // "windows" | "macos" | "linux", as a plain string — a one-element ARRAY is
3429
+ // ignored and falls back to the random draw. Left unset, camoufox picks one
3430
+ // of the three at random, which is how a Mac ended up claiming Windows.
3431
+ if (config.os) options.os = config.os;
3432
+ // The first listed locale is the one used for the Intl API. camoufox
3433
+ // applies it in its own engine, so navigator.language, Accept-Language and
3434
+ // Intl agree in the document and in every worker. Left unset with geoip on,
3435
+ // camoufox derives it from the proxy's exit IP instead.
3436
+ if (config.locale) options.locale = config.locale;
2850
3437
  // geoip derives longitude, latitude, timezone, country and locale from the
2851
3438
  // proxy's exit IP, which is the one thing that makes a proxied browser
2852
3439
  // coherent. It costs a request through the proxy, and the first ever call
@@ -2859,6 +3446,21 @@ export class CamoufoxAdapter extends BrowserEngine {
2859
3446
  // Native cursor humanization: camoufox moves the pointer along a plausible
2860
3447
  // path rather than teleporting it.
2861
3448
  if (config.humanize) options.humanize = true;
3449
+ // Pin the persona to the installed binary's Firefox major so the UA's two
3450
+ // version tokens agree. Left unpinned, camoufox's own version rewrite
3451
+ // reaches only `rv:` and about half of all launches announce a Firefox
3452
+ // that is not the one running — see _pinnedFingerprint.
3453
+ const fingerprint = this._pinnedFingerprint(camoufox);
3454
+ if (fingerprint) {
3455
+ options.fingerprint = fingerprint;
3456
+ // Switches off exactly two things: camoufox's "you passed your own
3457
+ // fingerprint" advisory, and its non-Firefox fingerprint check, which is
3458
+ // satisfied by construction — the generator above is Firefox-only. The
3459
+ // other options this flag gates (ff_version, block_images, disable_coop,
3460
+ // block_webgl) are ones this adapter never sets, so nothing else is
3461
+ // silenced.
3462
+ options.i_know_what_im_doing = true;
3463
+ }
2862
3464
 
2863
3465
  try {
2864
3466
  return await camoufox.Camoufox(options);