crawlforge-mcp-server 6.9.0 → 6.10.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.
package/CLAUDE.md CHANGED
@@ -236,6 +236,10 @@ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the es
236
236
  # from the PROXY_ROTATION_* family, which belongs to
237
237
  # localization. The agent's automatic stealth retry
238
238
  # (stealth review Phase 3) reads it too.
239
+ CRAWLFORGE_CLEARANCE_JAR= # `off` disables the clearance jar (stealth review Phase 4):
240
+ # cf_clearance/__cf_bm/datadome cookies a stealth render earned
241
+ # are replayed to the next stealth context with the same engine,
242
+ # UA and proxy. Persisted at ~/.crawlforge/stealth-clearance.json.
239
243
  ```
240
244
 
241
245
  Camoufox is an **optional** dependency and its transitive `language-tags@2.1.0` requires Node ≥22. npm silently drops an optional subtree that fails an engine check, so on Node 20 it is simply absent — `'auto'` then resolves to Chromium and says so in `fallbackWarning`. `engines.node` stays at `>=20.16.0` deliberately; bumping it is breaking and is a release decision.
package/README.md CHANGED
@@ -273,6 +273,9 @@ export RESEARCH_MAX_STEALTH_RETRIES="8" # cap on stealth retries per research
273
273
  # Comma-separated; a proxy passed on a call always wins. CrawlForge supplies none.
274
274
  export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
275
275
  # Unrelated to the PROXY_ROTATION_* variables, which belong to the localization tool.
276
+
277
+ # Optional: turn off the clearance jar (on by default) — see "Stealth engines and proxies"
278
+ export CRAWLFORGE_CLEARANCE_JAR="off"
276
279
  ```
277
280
 
278
281
  ### MCP Spec Features
@@ -313,6 +316,8 @@ On `browser_session` and `scrape_with_actions` the engine applies only with `ste
313
316
 
314
317
  **CrawlForge supplies no proxies**, and a datacenter proxy does not fix a block: Cloudflare scores the IP's ASN and the TLS/HTTP2 handshake before any JavaScript runs, so no browser-side patch compensates for a datacenter address. Bring your own residential exit with `CRAWLFORGE_STEALTH_PROXIES` (comma-separated URLs), which the escalation stage, `stealth_mode`, `browser_session`, `scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic stealth retry use when the caller passes none; a proxy passed on the call always wins. With a proxy, Camoufox derives its timezone, locale and geolocation from the exit IP.
315
318
 
319
+ **A challenge solved once is not solved again.** When a stealth render gets past a Cloudflare or DataDome wall, the vendor's clearance cookies (`cf_clearance`, `__cf_bm`, `datadome` — nothing else) are kept in `~/.crawlforge/stealth-clearance.json` (mode 0600) and replayed to the next stealth context with the same engine, User-Agent and proxy, for at most the cookie's own lifetime and never more than 24 hours. A render that still meets the wall drops them. Set `CRAWLFORGE_CLEARANCE_JAR=off` to disable.
320
+
316
321
  Full detail, measurements and sources: [docs/stealth-engines.md](docs/stealth-engines.md).
317
322
 
318
323
  ### Stealth extraction for `deep_research` (Camoufox)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.9.0",
3
+ "version": "6.10.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
108
108
  // Create the server
109
109
  const server = new McpServer({
110
110
  name: "crawlforge",
111
- version: "6.9.0",
111
+ version: "6.10.0",
112
112
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
113
113
  homepage: "https://www.crawlforge.dev",
114
114
  icon: "https://www.crawlforge.dev/icon.png",
@@ -0,0 +1,229 @@
1
+ /**
2
+ * ClearanceJar — a solved bot-wall challenge is not solved again for its lifetime.
3
+ *
4
+ * Stealth review Phase 4 (docs/STEALTH_REVIEW_2026-09.md). Every stealth
5
+ * context used to start cold, so a Cloudflare or DataDome challenge the browser
6
+ * had just passed was served again on the next call to the same site. The
7
+ * vendors hand out a clearance cookie when a challenge passes; this keeps those
8
+ * cookies and gives them back to the next stealth context that would present
9
+ * the same identity.
10
+ *
11
+ * Three rules keep it honest:
12
+ *
13
+ * - **Only clearance cookies.** The allow-list below is the vendors' own
14
+ * clearance and bot-management cookies. Nothing a site sets for a login, a
15
+ * cart or a preference is ever kept, so a browser_session one caller logged
16
+ * into cannot leak its session into another caller's context — on the hosted
17
+ * instance those are different customers.
18
+ * - **Keyed on who solved it.** cf_clearance is bound to the IP and the User
19
+ * Agent that earned it, and a TLS stack that is not the one that earned it is
20
+ * scored too. The key is the engine, the exact User-Agent and the proxy
21
+ * identity (server + username, never the password), so a clearance earned
22
+ * through one exit is never presented from another. A rotating proxy behind
23
+ * one fixed username can still change exit underneath a key; the vendor then
24
+ * challenges again, the verdict reports a block, and the host is discarded.
25
+ * - **Bounded.** Expiry is the cookie's own, capped at MAX_TTL_MS; the jar
26
+ * holds at most MAX_KEYS identities and MAX_COOKIES_PER_KEY cookies each, and
27
+ * the file is rewritten only when its contents change.
28
+ *
29
+ * Persisted at ~/.crawlforge/stealth-clearance.json (mode 0600) so an MCP
30
+ * server that a client restarts per session keeps its clearances.
31
+ * CRAWLFORGE_CLEARANCE_JAR=off disables the jar entirely.
32
+ */
33
+
34
+ import crypto from 'crypto';
35
+ import fs from 'fs';
36
+ import os from 'os';
37
+ import path from 'path';
38
+
39
+ /**
40
+ * Cloudflare's clearance (cf_clearance) and bot-management (__cf_bm) cookies,
41
+ * and DataDome's (datadome). Exact names: a prefix match would sweep in
42
+ * whatever else a site happens to name alike.
43
+ */
44
+ export const CLEARANCE_COOKIE_NAMES = new Set(['cf_clearance', '__cf_bm', 'datadome']);
45
+
46
+ /** No clearance outlives this, whatever expiry the vendor set. */
47
+ export const MAX_TTL_MS = 24 * 60 * 60 * 1000;
48
+ /** A session cookie (no expiry) is kept this long. */
49
+ export const SESSION_TTL_MS = 30 * 60 * 1000;
50
+ export const MAX_KEYS = 32;
51
+ export const MAX_COOKIES_PER_KEY = 200;
52
+
53
+ const DEFAULT_FILE = path.join(os.homedir(), '.crawlforge', 'stealth-clearance.json');
54
+
55
+ /** Whether a cookie domain (".example.com" or "example.com") covers `host`. */
56
+ function domainCovers(domain, host) {
57
+ const d = String(domain || '').replace(/^\./, '').toLowerCase();
58
+ const h = String(host || '').toLowerCase();
59
+ return Boolean(d) && (h === d || h.endsWith(`.${d}`));
60
+ }
61
+
62
+ export class ClearanceJar {
63
+ /**
64
+ * @param {{ file?: string|null, now?: () => number }} [options]
65
+ * file: where the jar persists; null keeps it in memory only.
66
+ */
67
+ constructor({ file = DEFAULT_FILE, now = Date.now } = {}) {
68
+ this.file = file;
69
+ this.now = now;
70
+ /** @type {Map<string, Map<string, object>>} key → (domain|path|name → cookie) */
71
+ this.entries = null;
72
+ }
73
+
74
+ /**
75
+ * The identity a clearance is bound to, or null when it cannot be named — an
76
+ * unknown User-Agent cannot be matched, so nothing is stored or reused for it.
77
+ *
78
+ * @param {{ engine?: string|null, userAgent?: string|null, proxy?: {server?: string, username?: string}|null }} identity
79
+ * @returns {string|null}
80
+ */
81
+ static keyFor({ engine, userAgent, proxy } = {}) {
82
+ if (!engine || !userAgent) return null;
83
+ const exit = proxy?.server ? `${proxy.server}|${proxy.username || ''}` : 'direct';
84
+ // Hashed so the file names no proxy host, account or persona in clear.
85
+ return crypto.createHash('sha256').update(`${engine}\n${userAgent}\n${exit}`).digest('hex');
86
+ }
87
+
88
+ /** Clearance cookies stored for `key` that have not expired, Playwright-shaped. */
89
+ cookiesFor(key) {
90
+ if (!key) return [];
91
+ const bucket = this._load().get(key);
92
+ if (!bucket) return [];
93
+ const nowS = this.now() / 1000;
94
+ const live = [...bucket.values()].filter((c) => c.expires > nowS);
95
+ if (live.length !== bucket.size) {
96
+ this._replace(key, live);
97
+ this._save();
98
+ }
99
+ return live;
100
+ }
101
+
102
+ /**
103
+ * Keep the clearance cookies from `cookies` (the output of
104
+ * BrowserContext.cookies()) under `key`, replacing older copies.
105
+ * @returns {number} how many clearance cookies were kept
106
+ */
107
+ store(key, cookies = []) {
108
+ if (!key) return 0;
109
+ const nowMs = this.now();
110
+ const kept = [];
111
+ for (const c of cookies) {
112
+ if (!c || !CLEARANCE_COOKIE_NAMES.has(c.name) || !c.value || !c.domain) continue;
113
+ const vendorExpiryMs = c.expires > 0 ? c.expires * 1000 : nowMs + SESSION_TTL_MS;
114
+ const expiresMs = Math.min(vendorExpiryMs, nowMs + MAX_TTL_MS);
115
+ if (expiresMs <= nowMs) continue;
116
+ kept.push({
117
+ name: c.name,
118
+ value: c.value,
119
+ domain: c.domain,
120
+ path: c.path || '/',
121
+ expires: Math.floor(expiresMs / 1000),
122
+ httpOnly: Boolean(c.httpOnly),
123
+ secure: Boolean(c.secure),
124
+ sameSite: c.sameSite || 'Lax'
125
+ });
126
+ }
127
+ if (!kept.length) return 0;
128
+
129
+ const entries = this._load();
130
+ const bucket = entries.get(key) || new Map();
131
+ let changed = false;
132
+ for (const c of kept) {
133
+ const id = `${c.domain}|${c.path}|${c.name}`;
134
+ const old = bucket.get(id);
135
+ if (old && old.value === c.value && old.expires === c.expires) continue;
136
+ bucket.delete(id); // re-insert at the end: insertion order is recency
137
+ bucket.set(id, c);
138
+ changed = true;
139
+ }
140
+ if (!changed) return kept.length;
141
+ while (bucket.size > MAX_COOKIES_PER_KEY) bucket.delete(bucket.keys().next().value);
142
+ entries.delete(key);
143
+ entries.set(key, bucket);
144
+ while (entries.size > MAX_KEYS) entries.delete(entries.keys().next().value);
145
+ this._save();
146
+ return kept.length;
147
+ }
148
+
149
+ /**
150
+ * Drop every clearance for `host` under `key` — called when a stealth render
151
+ * still met the wall, so a clearance that no longer works is not replayed.
152
+ * @returns {number} how many cookies were dropped
153
+ */
154
+ discard(key, host) {
155
+ if (!key || !host) return 0;
156
+ const bucket = this._load().get(key);
157
+ if (!bucket) return 0;
158
+ let dropped = 0;
159
+ for (const [id, c] of bucket) {
160
+ if (domainCovers(c.domain, host)) {
161
+ bucket.delete(id);
162
+ dropped++;
163
+ }
164
+ }
165
+ if (dropped) {
166
+ if (!bucket.size) this.entries.delete(key);
167
+ this._save();
168
+ }
169
+ return dropped;
170
+ }
171
+
172
+ _replace(key, cookies) {
173
+ const entries = this._load();
174
+ if (!cookies.length) {
175
+ entries.delete(key);
176
+ return;
177
+ }
178
+ entries.set(key, new Map(cookies.map((c) => [`${c.domain}|${c.path}|${c.name}`, c])));
179
+ }
180
+
181
+ _load() {
182
+ if (this.entries) return this.entries;
183
+ this.entries = new Map();
184
+ if (!this.file) return this.entries;
185
+ let raw;
186
+ try {
187
+ raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
188
+ } catch {
189
+ return this.entries; // absent or unreadable: start empty
190
+ }
191
+ const nowS = this.now() / 1000;
192
+ for (const [key, cookies] of Object.entries(raw?.entries || {})) {
193
+ if (!Array.isArray(cookies)) continue;
194
+ const live = cookies.filter((c) => c && CLEARANCE_COOKIE_NAMES.has(c.name) && c.expires > nowS);
195
+ if (live.length) this._replace(key, live.slice(-MAX_COOKIES_PER_KEY));
196
+ }
197
+ while (this.entries.size > MAX_KEYS) this.entries.delete(this.entries.keys().next().value);
198
+ return this.entries;
199
+ }
200
+
201
+ _save() {
202
+ if (!this.file) return;
203
+ const out = { version: 1, entries: {} };
204
+ for (const [key, bucket] of this.entries) out.entries[key] = [...bucket.values()];
205
+ try {
206
+ fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
207
+ const tmp = `${this.file}.${process.pid}.tmp`;
208
+ fs.writeFileSync(tmp, JSON.stringify(out), { mode: 0o600 });
209
+ fs.renameSync(tmp, this.file);
210
+ } catch {
211
+ // Persistence is an optimisation: an unwritable home directory costs the
212
+ // next process a challenge, not this call its result.
213
+ }
214
+ }
215
+ }
216
+
217
+ let shared;
218
+
219
+ /**
220
+ * The process-wide jar, shared by every StealthBrowserManager (server.js,
221
+ * BrowserProcessor and ResearchOrchestrator each construct their own), or null
222
+ * when CRAWLFORGE_CLEARANCE_JAR=off.
223
+ * @returns {ClearanceJar|null}
224
+ */
225
+ export function sharedClearanceJar() {
226
+ if (String(process.env.CRAWLFORGE_CLEARANCE_JAR || '').toLowerCase() === 'off') return null;
227
+ shared ??= new ClearanceJar();
228
+ return shared;
229
+ }
@@ -18,9 +18,10 @@ import { createRequire } from 'module';
18
18
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
19
19
  import { BrowserContextPool } from './BrowserContextPool.js';
20
20
  import { safeGoto } from '../utils/ssrfGuard.js';
21
- import { looksLikeInterstitial } from '../utils/challengeDetection.js';
21
+ import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
22
22
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
23
23
  import { serverStealthProxies } from '../constants/config.js';
24
+ import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
24
25
 
25
26
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
26
27
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
@@ -39,6 +40,15 @@ const CAMOUFOX_PROXY = Symbol('crawlforge.camoufoxProxy');
39
40
  // browser for the same reason as the proxy: camoufox fixes it at launch.
40
41
  const CAMOUFOX_LOCALE = Symbol('crawlforge.camoufoxLocale');
41
42
 
43
+ // The User-Agent that camoufox browser presents — the persona pinned at launch,
44
+ // null when camoufox drew its own and we therefore do not know it. The
45
+ // clearance jar keys on it (see ClearanceJar), and camoufox fixes it at launch.
46
+ const CAMOUFOX_USER_AGENT = Symbol('crawlforge.camoufoxUserAgent');
47
+
48
+ // How long closeContext waits to read a context's cookies before giving up on
49
+ // keeping its clearances: a wedged browser hangs rather than errors.
50
+ const CLEARANCE_HARVEST_TIMEOUT_MS = 2000;
51
+
42
52
  const StealthConfigSchema = z.object({
43
53
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
44
54
  randomizeFingerprint: z.boolean().default(true),
@@ -175,6 +185,12 @@ export class StealthBrowserManager {
175
185
  // Chromium that nobody reports reads as a camoufox result that failed.
176
186
  this._engineFallbackWarning = null;
177
187
  this._maxContexts = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10);
188
+ // Stealth review Phase 4: clearance cookies a solved challenge earned are
189
+ // replayed to the next context with the same identity. One process-wide jar
190
+ // by default (every manager instance shares it); null turns it off — the
191
+ // benchmark does, since a replayed clearance would measure the jar instead
192
+ // of the engine.
193
+ this.clearanceJar = options.clearanceJar !== undefined ? options.clearanceJar : sharedClearanceJar();
178
194
  this.contexts = this._createContextPool();
179
195
  // D2.2: fingerprints Map is capped at _maxContexts to prevent unbounded growth.
180
196
  // Oldest entries are evicted when the cap is exceeded (insertion order via Map).
@@ -525,6 +541,7 @@ export class StealthBrowserManager {
525
541
  });
526
542
  browser[CAMOUFOX_PROXY] = proxy;
527
543
  browser[CAMOUFOX_LOCALE] = locale;
544
+ browser[CAMOUFOX_USER_AGENT] = adapter.lastUserAgent ?? null;
528
545
  this.browser = browser;
529
546
  this._launchedEngine = 'camoufox';
530
547
  return this.browser;
@@ -727,6 +744,19 @@ export class StealthBrowserManager {
727
744
  contextOptions.proxy = proxy;
728
745
  }
729
746
 
747
+ // The identity a clearance is bound to: engine, the User-Agent this context
748
+ // presents, and the exit it goes out through. Read before the camoufox
749
+ // block below takes the User-Agent off the context options.
750
+ const jarKey = this.clearanceJar
751
+ ? ClearanceJar.keyFor({
752
+ engine: this._launchedEngine,
753
+ userAgent: this._launchedEngine === 'camoufox'
754
+ ? this.browser[CAMOUFOX_USER_AGENT]
755
+ : contextOptions.userAgent,
756
+ proxy
757
+ })
758
+ : null;
759
+
730
760
  if (this._launchedEngine === 'camoufox') {
731
761
  // camoufox's Firefox build predates the Browser.setDefaultViewport fields
732
762
  // playwright-core 1.62 sends (screenSize, isMobile, ...) and rejects
@@ -787,11 +817,19 @@ export class StealthBrowserManager {
787
817
 
788
818
  const context = await this.browser.newContext(contextOptions);
789
819
  const contextId = this.generateContextId();
790
-
820
+
821
+ // Replay clearances this identity already earned. addCookies scopes each
822
+ // to its own domain, so a context bound for another site sends none of
823
+ // them. Best effort: a context without them just meets the challenge.
824
+ const clearances = jarKey ? this.clearanceJar.cookiesFor(jarKey) : [];
825
+ if (clearances.length) {
826
+ await context.addCookies(clearances).catch(() => {});
827
+ }
828
+
791
829
  // Apply stealth scripts and configurations
792
830
  await this.applyAdvancedStealthConfigurations(context, validatedConfig, fingerprint);
793
831
 
794
- await this.contexts.set(contextId, { context, fingerprint, config: validatedConfig });
832
+ await this.contexts.set(contextId, { context, fingerprint, config: validatedConfig, jarKey });
795
833
  // D2.2: enforce LRU cap on fingerprints Map
796
834
  this._setFingerprint(contextId, fingerprint);
797
835
 
@@ -2770,6 +2808,13 @@ export class StealthBrowserManager {
2770
2808
  ? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
2771
2809
  : null;
2772
2810
 
2811
+ // Still a wall after everything above: whatever clearance this context
2812
+ // carried for the site did not work, so closeContext drops it instead of
2813
+ // keeping it for the next call.
2814
+ if (detectChallengePage({ title, html, text })) {
2815
+ this._markBlocked(contextId, [url, page.url()]);
2816
+ }
2817
+
2773
2818
  return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
2774
2819
  } finally {
2775
2820
  await this.closeContext(contextId).catch(() => {});
@@ -3050,11 +3095,54 @@ export class StealthBrowserManager {
3050
3095
  */
3051
3096
  async closeContext(contextId) {
3052
3097
  if (this.contexts.has(contextId)) {
3098
+ await this._harvestClearances(contextId);
3053
3099
  await this.contexts.dispose(contextId);
3054
3100
  this.fingerprints.delete(contextId);
3055
3101
  }
3056
3102
  }
3057
3103
 
3104
+ /**
3105
+ * Record that a context's render still met a bot wall on these URLs' hosts.
3106
+ * @param {string} contextId
3107
+ * @param {string[]} urls
3108
+ */
3109
+ _markBlocked(contextId, urls) {
3110
+ const contextData = this.contexts.get(contextId);
3111
+ if (!contextData) return;
3112
+ contextData.blockedHosts ??= new Set();
3113
+ for (const url of urls) {
3114
+ try {
3115
+ contextData.blockedHosts.add(new URL(url).hostname);
3116
+ } catch { /* about:blank or a malformed URL names no host */ }
3117
+ }
3118
+ }
3119
+
3120
+ /**
3121
+ * Keep the clearance cookies a context earned, and drop the ones for any host
3122
+ * it was still blocked on. Runs before the context is closed, and never
3123
+ * throws or hangs the close it precedes.
3124
+ * @param {string} contextId
3125
+ */
3126
+ async _harvestClearances(contextId) {
3127
+ const contextData = this.contexts.get(contextId);
3128
+ if (!this.clearanceJar || !contextData?.jarKey) return;
3129
+ let timer;
3130
+ try {
3131
+ const cookies = await Promise.race([
3132
+ contextData.context.cookies(),
3133
+ new Promise((resolve) => { timer = setTimeout(() => resolve([]), CLEARANCE_HARVEST_TIMEOUT_MS); })
3134
+ ]);
3135
+ this.clearanceJar.store(contextData.jarKey, cookies);
3136
+ for (const host of contextData.blockedHosts ?? []) {
3137
+ this.clearanceJar.discard(contextData.jarKey, host);
3138
+ }
3139
+ } catch {
3140
+ // A closed or crashed context has nothing to keep.
3141
+ } finally {
3142
+ clearTimeout(timer);
3143
+ }
3144
+ }
3145
+
3058
3146
  /**
3059
3147
  * D2.2: LRU-capped fingerprint setter.
3060
3148
  * Evicts the oldest entry when the Map exceeds _maxContexts to prevent unbounded growth.
@@ -3451,6 +3539,9 @@ export class CamoufoxAdapter extends BrowserEngine {
3451
3539
  // reaches only `rv:` and about half of all launches announce a Firefox
3452
3540
  // that is not the one running — see _pinnedFingerprint.
3453
3541
  const fingerprint = this._pinnedFingerprint(camoufox);
3542
+ // The UA this launch will present, for the clearance jar's key. Only known
3543
+ // when we pinned the persona; camoufox's own draw is not visible here.
3544
+ this.lastUserAgent = fingerprint?.navigator?.userAgent || null;
3454
3545
  if (fingerprint) {
3455
3546
  options.fingerprint = fingerprint;
3456
3547
  // Switches off exactly two things: camoufox's "you passed your own