crawlforge-mcp-server 6.7.0 → 6.8.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
@@ -141,7 +141,7 @@ npm run docker:prod # Run production container
141
141
  - **ActionExecutor**: Browser automation engine (Playwright-based)
142
142
  - **ResearchOrchestrator**: Multi-stage research with query expansion and synthesis
143
143
  - **AgentOrchestrator**: Powers the `agent` tool — NL prompt → autonomous PLAN→GATHER→ACT→DECIDE→SHAPE loop with three orchestrator-enforced hard stops (maxSteps≤10, maxUrls≤20, wall-clock) never delegated to the LLM; degraded no-LLM-key path (D2, v4.6.0)
144
- - **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0
144
+ - **StealthBrowserManager**: Stealth mode scraping with anti-detection; Camoufox (Firefox) engine added in v4.0.0. Engine selection goes through the one resolver `resolveStealthEngine(requested)` → `{engine, fallbackWarning}`; `'auto'` (the default for `scrape.escalate_engine` and `stealth_mode.engine` since the 2026-09 stealth review Phase 2) prefers Camoufox and falls back to Chromium with a caller-visible warning when its binary is absent. `'playwright'` is the tool layer's public name for Chromium. Camoufox personas are pinned to the installed binary's Firefox major so the UA's `rv:` and `Firefox/` tokens agree — see `docs/STEALTH_REVIEW_2026-09.md`
145
145
  - **LocalizationManager**: Multi-language content and localization
146
146
  - **ChangeTracker**: Content change tracking over time
147
147
  - **SnapshotManager**: Website snapshots and version history
@@ -221,8 +221,24 @@ MAX_PAGES_PER_CRAWL=100
221
221
  RESPECT_ROBOTS_TXT=true
222
222
  ROBOTS_CACHE_TTL_MS=3600000 # how long a parsed robots.txt stays good for
223
223
  CRAWLFORGE_BLOCKED_HOSTS= # comma-separated; extends the permanent opt-out blocklist
224
+ CRAWLFORGE_STEALTH_ENGINE= # which engine `auto` resolves to on THIS deployment:
225
+ # chromium|playwright pins it to Chromium; unset or anything
226
+ # else keeps the default preference for Camoufox. A caller
227
+ # naming an engine always wins. There is no correct global
228
+ # value — which engine clears a wall depends on the exit IP
229
+ # (residential favours Camoufox, the hosted datacenter IP
230
+ # favours Chromium, measured twice).
231
+ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the escalation stage,
232
+ # stealth_mode, browser_session, scrape_with_actions and the
233
+ # deep_research fallback when the caller passes none (a proxy
234
+ # passed on the call wins). CrawlForge supplies no proxies, and
235
+ # a datacenter one does not defeat these detectors. Distinct
236
+ # from the PROXY_ROTATION_* family, which belongs to
237
+ # localization. The agent tool does not browse yet (Phase 3).
224
238
  ```
225
239
 
240
+ 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.
241
+
226
242
  Web Bot Auth (optional, off by default — requests go out unsigned when unset):
227
243
 
228
244
  ```bash
@@ -260,7 +276,7 @@ Key mechanisms for security-conscious future sessions:
260
276
  - **endpointGuard** (`src/core/endpointGuard.js`): Hard allow-list of `{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}` for the server's own backend calls; HTTPS required; fail-closed. Localhost only in creator mode (v3.0.18).
261
277
  - **Action allowlist** (`src/core/ActionExecutor.js`): `scrape_with_actions` accepts only 7 action types: `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`. `executeJavaScript` throws unless `ALLOW_JAVASCRIPT_EXECUTION=true` is set at deploy time (off by default).
262
278
  - **Elicitation** (`src/core/ElicitationHelper.js`): User confirmation requested for `deep_research` (>50 URLs), `batch_scrape` (sync, >25 URLs), `crawl_deep` (projected >500 pages), `extract_structured` (schema has >3 required fields, no LLM configured), and credit-low situations. Fail-open if client does not support elicitation.
263
- - **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox` + `--disable-web-security` (deliberate fingerprint-spoofing trade-off). Camoufox (Firefox, v4.0.0) is the alternative — see `docs/stealth-engines.md`.
279
+ - **Browser sandboxing**: Standard pool retains OS sandbox. Stealth Chromium uses `--no-sandbox` (deliberate fingerprint-spoofing trade-off); `--disable-web-security` was removed from it in Phase 1 of the 2026-09 stealth review — a page reads it in one line — but the non-stealth render browser (`BrowserProcessor`) still carries both flags. Camoufox (Firefox, v4.0.0) is the alternative — see `docs/stealth-engines.md`.
264
280
 
265
281
  See `docs/sandboxing-and-approvals.md` for the full reference.
266
282
 
package/README.md CHANGED
@@ -267,6 +267,12 @@ export CRAWLFORGE_TOOL_GROUPS="basic,search,scrape" # unset = all tools; unkno
267
267
  export RESEARCH_STEALTH_ENGINE="auto" # auto (default) | camoufox | chromium
268
268
  export RESEARCH_STEALTH_FALLBACK="true" # set to "false" to disable entirely
269
269
  export RESEARCH_MAX_STEALTH_RETRIES="8" # cap on stealth retries per research run
270
+
271
+ # Optional: your own proxies for the stealth paths that have no caller to ask —
272
+ # the scrape escalation stage, the deep_research retry, the agent, browser_session.
273
+ # Comma-separated; a proxy passed on a call always wins. CrawlForge supplies none.
274
+ export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
275
+ # Unrelated to the PROXY_ROTATION_* variables, which belong to the localization tool.
270
276
  ```
271
277
 
272
278
  ### MCP Spec Features
@@ -297,6 +303,18 @@ ollama pull llama3.2
297
303
  # extract_with_llm({ url: "https://example.com", prompt: "…", model: "llama3.2" })
298
304
  ```
299
305
 
306
+ ### Stealth engines and proxies
307
+
308
+ Every stealth entry point — `scrape` with `escalate: true`, `stealth_mode`, `browser_session`, `scrape_with_actions` and the `deep_research` retry — now defaults to **`auto`**: use Camoufox (Firefox anti-detect) when its binary is installed, fall back to Chromium when it is not, with the reason in the result's `warnings[]`. Every result names the engine that actually ran. Naming an engine explicitly behaves as it always has — `camoufox` fails rather than falling back, and `chromium` and `playwright` are the same engine (on `scrape`'s `escalate_engine`, spell it `playwright`).
309
+
310
+ On `browser_session` and `scrape_with_actions` the engine applies only with `stealth: true` — their ordinary path is the standard Chromium pool.
311
+
312
+ `auto` is not free. Measured once each on an Apple Silicon Mac on 2026-09-21 (launch plus a context and a blank page): Chromium 148 ms and 253 MB, Camoufox 946 ms and 667 MB — roughly **+0.8 s and +400 MB per stealth call**. Pin `engine: "playwright"` for high-volume work on sites that do not block.
313
+
314
+ **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` and the `deep_research` retry use when the caller passes none; a proxy passed on the call always wins. (The `agent` tool does not browse yet, so it is not in that list — it joins when Phase 3 gives it an escalation path.) With a proxy, Camoufox derives its timezone, locale and geolocation from the exit IP.
315
+
316
+ Full detail, measurements and sources: [docs/stealth-engines.md](docs/stealth-engines.md).
317
+
300
318
  ### Stealth extraction for `deep_research` (Camoufox)
301
319
 
302
320
  `deep_research` automatically retries sources that block the normal fetch path (Reddit, Quora, forums, and Cloudflare/DataDome-protected pages return HTTP 403) through a **real fingerprinted browser**, then re-extracts from the rendered HTML. It's bounded (`RESEARCH_MAX_STEALTH_RETRIES`, default 8, plus a per-page timeout) and lazy — the browser stack only loads when a source is actually blocked.
@@ -307,7 +325,7 @@ Engine selection (`RESEARCH_STEALTH_ENGINE`):
307
325
  - **`camoufox`** — force Camoufox.
308
326
  - **`chromium`** — force the Chromium stealth engine.
309
327
 
310
- Headless Chromium **cannot** clear modern challenges (Cloudflare Turnstile, DataDome) — **Camoufox can**. In testing it recovered Quora and Trustpilot pages that were otherwise fully blocked. To enable it, install the optional dependency and run its one-time binary fetch:
328
+ Camoufox gets through walls that stop headless Chromium: in a benchmark run on 2026-09-21 from a residential IP it was the only engine to clear Cloudflare Turnstile (indeed.com) and Akamai (harrods.com), both of which blocked Chromium stealth. Neither engine cleared DataDome or an interactive Turnstile, so this is a better fetch path, not a bypass. To enable it, install the optional dependency and run its one-time binary fetch:
311
329
 
312
330
  ```bash
313
331
  # Camoufox is declared as an optional dependency, so a normal install already pulls it.
@@ -320,7 +338,7 @@ npx camoufox fetch
320
338
 
321
339
  Without the Camoufox binary, `deep_research` silently falls back to Chromium stealth and then to plain fetch — no errors, just lower recovery on heavily-protected sites. Disable the whole fallback with `RESEARCH_STEALTH_FALLBACK=false`.
322
340
 
323
- > **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide. See [docs/stealth-engines.md](docs/stealth-engines.md) for details.
341
+ > **Note:** Hard IP-reputation blocks (e.g. Reddit's edge `403`) resist headless stealth from any IP and require residential/mobile proxies, which CrawlForge does not provide — point the server at your own with `CRAWLFORGE_STEALTH_PROXIES`. See [docs/stealth-engines.md](docs/stealth-engines.md) for details.
324
342
 
325
343
  ### Manual Configuration
326
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.7.0",
3
+ "version": "6.8.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",
@@ -28,6 +28,8 @@
28
28
  "docker:build": "docker build -t crawlforge .",
29
29
  "docker:dev": "docker-compose up crawlforge-dev",
30
30
  "docker:prod": "docker-compose up crawlforge-prod",
31
+ "bench:stealth": "node scripts/stealth-bench.mjs",
32
+ "bench:stealth:ci": "node scripts/stealth-bench.mjs --ci",
31
33
  "eval:selection": "node scripts/tool-selection-eval.mjs",
32
34
  "usage:report": "node scripts/tool-usage-report.mjs"
33
35
  },
package/server.js CHANGED
@@ -29,7 +29,7 @@ import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
29
29
  import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
30
30
  import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
31
31
  import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
32
- import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
32
+ import { StealthBrowserManager, resolveStealthEngine } from "./src/core/StealthBrowserManager.js";
33
33
  import { LocalizationManager } from "./src/core/LocalizationManager.js";
34
34
  // Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
35
35
  import * as cheerio from "cheerio";
@@ -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.7.0",
111
+ version: "6.8.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",
@@ -249,14 +249,23 @@ const unifiedScrapeTool = new UnifiedScrapeTool({
249
249
  // the tool module never imports StealthBrowserManager (that would pull a
250
250
  // browser dependency into every unit test that loads `scrape`). Same gate
251
251
  // and same browser the stealth_mode tool drives — no new evasion, and the
252
- // "playwright" → chromium engine mapping stays here with its sibling.
252
+ // engine name is resolved here, beside its sibling, by the one resolver
253
+ // every stealth entry point shares.
253
254
  escalateScrape: async ({ url, engine, respectRobots }) => {
254
255
  const warnings = await stealthComplianceGate(url, respectRobots);
256
+ // "auto" prefers camoufox and falls back to chromium when its binary is
257
+ // missing. Mapping here instead ("camoufox" or else chromium) collapsed
258
+ // auto to chromium and hid the fallback; the resolver says which engine it
259
+ // landed on and why, and the caller is told.
260
+ const resolved = await resolveStealthEngine(engine);
261
+ if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
255
262
  const scraped = await stealthBrowserManager.scrapeWithStealth({
256
263
  url,
257
- engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
264
+ engine: resolved.engine
258
265
  });
259
- return { ...scraped, engine, warnings };
266
+ // The RESOLVED engine: `scrape` reports it as stealth.engine, where the
267
+ // requested "auto" would tell the caller nothing about what ran.
268
+ return { ...scraped, engine: resolved.engine, warnings };
260
269
  }
261
270
  });
262
271
  const agentTool = new AgentTool(); // D4 D2
@@ -913,7 +922,7 @@ registerToolIfEnabled("read_result", {
913
922
 
914
923
  // Tool: scrape_with_actions
915
924
  registerToolIfEnabled("scrape_with_actions", {
916
- description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser. robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
925
+ description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
917
926
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
918
927
  inputSchema: {
919
928
  url: z.string().url().describe("The URL to scrape"),
@@ -985,7 +994,8 @@ registerToolIfEnabled("scrape_with_actions", {
985
994
  viewportWidth: z.number().min(800).max(1920).default(1280),
986
995
  viewportHeight: z.number().min(600).max(1080).default(720),
987
996
  timeout: z.number().min(10000).max(120000).default(30000),
988
- stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges.")
997
+ stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
998
+ engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium.")
989
999
  }).optional().describe("Browser configuration options"),
990
1000
  extractionOptions: z.object({
991
1001
  selectors: z.record(z.string()).optional(),
@@ -1028,13 +1038,14 @@ registerToolIfEnabled("scrape_with_actions", {
1028
1038
 
1029
1039
  // Tool: browser_session
1030
1040
  registerToolIfEnabled("browser_session", {
1031
- description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1041
+ description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1032
1042
  annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1033
1043
  inputSchema: {
1034
1044
  operation: z.enum(["open", "snapshot", "act", "read", "screenshot", "close", "list"]).describe("open a session, observe it, act on it, read it, or close it"),
1035
1045
  session_id: z.string().optional().describe("The id returned by operation:\"open\". Required by every operation except open and list"),
1036
1046
  url: z.string().url().optional().describe("open: the URL to load the session on"),
1037
1047
  stealth: z.boolean().default(false).describe("open: run the session in the stealth browser"),
1048
+ engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("open: stealth engine for the session, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; every operation echoes the `engine` that actually ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
1038
1049
  ttl: z.number().min(30).max(3600).optional().describe("open: seconds the session may live at most (default 600)"),
1039
1050
  activity_ttl: z.number().min(10).max(3600).optional().describe("open: seconds the session may sit idle (default 300)"),
1040
1051
  viewport: z.object({
@@ -1337,7 +1348,7 @@ function stealthScrapeFormats(formats, scraped) {
1337
1348
 
1338
1349
  // Tool: stealth_mode
1339
1350
  registerToolIfEnabled("stealth_mode", {
1340
- description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a Playwright browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context -> create_page -> cleanup operations remain for multi-step work. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, enable, disable, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
1351
+ description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. The create_context -> create_page -> cleanup operations remain for multi-step work. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, enable, disable, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
1341
1352
  annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1342
1353
  inputSchema: {
1343
1354
  operation: z.enum(['scrape', 'configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
@@ -1379,7 +1390,7 @@ registerToolIfEnabled("stealth_mode", {
1379
1390
  hardwareSpoofing: z.boolean().default(true)
1380
1391
  }).optional()
1381
1392
  }).optional().describe("Stealth browser configuration with anti-detection settings"),
1382
- engine: z.enum(["playwright", "camoufox"]).optional().default("playwright").describe("Browser engine: \"playwright\" (Chromium, default) or \"camoufox\" (Firefox-based, higher anti-detect score — install with npm install camoufox)"),
1393
+ engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).optional().default("auto").describe("Browser engine: \"auto\" (default — camoufox when it is installed, Chromium otherwise, and the result says which), \"camoufox\" (Firefox-based, higher anti-detect score; fails if not installed), or \"chromium\" (\"playwright\" is the same engine under its old name)"),
1383
1394
  contextId: z.string().optional().describe("Browser context ID for page operations"),
1384
1395
  urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
1385
1396
  url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
@@ -1399,12 +1410,16 @@ registerToolIfEnabled("stealth_mode", {
1399
1410
  // Gate first: a disallowed URL must never launch a browser.
1400
1411
  const warnings = await stealthComplianceGate(url, respect_robots);
1401
1412
 
1413
+ // "auto" prefers camoufox and falls back to chromium when its binary
1414
+ // is missing; "playwright" is this tool's old public name for chromium.
1415
+ // The resolver owns both, and reports a fallback the caller can see.
1416
+ const resolvedEngine = await resolveStealthEngine(engine);
1417
+ if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
1418
+
1402
1419
  const wantsScreenshot = formats.includes('screenshot');
1403
1420
  const scraped = await stealthBrowserManager.scrapeWithStealth({
1404
1421
  url,
1405
- // "playwright" is this tool's public name for the chromium engine
1406
- // (the manager and the CLI both call it chromium).
1407
- engine: engine === 'camoufox' ? 'camoufox' : 'chromium',
1422
+ engine: resolvedEngine.engine,
1408
1423
  wait_for: wait_for || 0,
1409
1424
  screenshot: wantsScreenshot,
1410
1425
  stealthConfig
@@ -1421,6 +1436,7 @@ registerToolIfEnabled("stealth_mode", {
1421
1436
  url: scraped.url,
1422
1437
  title: scraped.title,
1423
1438
  status: verdict.status,
1439
+ engine: resolvedEngine.engine,
1424
1440
  content: stealthScrapeFormats(formats, scraped)
1425
1441
  };
1426
1442
  if (verdict.blocked) result.blocked = verdict.blocked;
@@ -1457,10 +1473,11 @@ registerToolIfEnabled("stealth_mode", {
1457
1473
  case 'create_context': {
1458
1474
  // Forward the tool-level engine the same way operation:"scrape" does —
1459
1475
  // without it, create_context always ran on chromium whatever the caller
1460
- // asked for ("playwright" is this tool's public name for chromium).
1476
+ // asked for. Resolved, so "auto" reaches the manager as a real engine.
1477
+ const contextEngine = await resolveStealthEngine(engine);
1461
1478
  const contextData = await stealthBrowserManager.createStealthContext({
1462
1479
  ...(stealthConfig || {}),
1463
- engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
1480
+ engine: contextEngine.engine
1464
1481
  });
1465
1482
  // The full fingerprint is ~4 KB of canvas noise arrays and WebGL
1466
1483
  // extension lists no caller acts on. Summarise by default; verbose:true
@@ -1468,6 +1485,10 @@ registerToolIfEnabled("stealth_mode", {
1468
1485
  result = {
1469
1486
  contextId: contextData.contextId,
1470
1487
  created: true,
1488
+ // Which engine the context is on, and why, when "auto" had to fall
1489
+ // back: every page made from it inherits that engine.
1490
+ engine: contextEngine.engine,
1491
+ ...(contextEngine.fallbackWarning ? { warnings: [contextEngine.fallbackWarning] } : {}),
1471
1492
  fingerprint: verbose
1472
1493
  ? contextData.fingerprint
1473
1494
  : stealthBrowserManager.summarizeFingerprint(contextData.fingerprint)
@@ -454,6 +454,28 @@ export function isStealthConfigured() {
454
454
  );
455
455
  }
456
456
 
457
+ // The server operator's own exit IPs for every stealth path, from
458
+ // CRAWLFORGE_STEALTH_PROXIES — a comma-separated list in proxyRotation's URL
459
+ // format (http/https/socks4/socks5, credentials percent-encoded in the URL).
460
+ //
461
+ // Deliberately a function reading process.env rather than a field on `config`
462
+ // above: a value frozen at import time cannot be changed for a single run, and
463
+ // every consumer (the escalation stage, the deep_research fallback, the agent,
464
+ // browser_session) resolves its proxy mid-process.
465
+ //
466
+ // Separate from the PROXY_ROTATION_* family under localization.proxy on
467
+ // purpose. Those answer "which country should this page be fetched from", which
468
+ // is the localization tool's whole job; this one answers "which address does
469
+ // this server go out from", which is about not being the same datacentre IP a
470
+ // wall has already seen. Overloading one on the other would make either
471
+ // setting silently change the other's behaviour.
472
+ export function serverStealthProxies() {
473
+ return (process.env.CRAWLFORGE_STEALTH_PROXIES || '')
474
+ .split(',')
475
+ .map((entry) => entry.trim())
476
+ .filter(Boolean);
477
+ }
478
+
457
479
  // Get localization configuration
458
480
  export function getLocalizationConfig() {
459
481
  return config.localization;
@@ -9,6 +9,7 @@ import { CacheManager } from './cache/CacheManager.js';
9
9
  import { Logger } from '../utils/Logger.js';
10
10
  import { LLMManager } from './llm/LLMManager.js';
11
11
  import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
12
+ import { serverStealthProxies } from '../constants/config.js';
12
13
  import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
13
14
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
14
15
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
@@ -143,6 +144,12 @@ export class ResearchOrchestrator extends EventEmitter {
143
144
  stealthEngine = process.env.RESEARCH_STEALTH_ENGINE || 'auto',
144
145
  stealthLevel = 'medium',
145
146
  stealthTimeoutMs = 20000,
147
+ // Proxy list for the stealth fallback, in proxyRotation's URL format. The
148
+ // caller's list always wins; with none, the server-level
149
+ // CRAWLFORGE_STEALTH_PROXIES applies — this path launches Camoufox itself
150
+ // rather than through StealthBrowserManager, so it does not inherit that
151
+ // manager's proxy fallback and has to resolve one here.
152
+ stealthProxies = serverStealthProxies(),
146
153
  searchConfig = {},
147
154
  crawlConfig = {},
148
155
  extractConfig = {},
@@ -164,6 +171,7 @@ export class ResearchOrchestrator extends EventEmitter {
164
171
  this.stealthEngine = stealthEngine;
165
172
  this.stealthLevel = stealthLevel;
166
173
  this.stealthTimeoutMs = stealthTimeoutMs;
174
+ this.stealthProxies = Array.isArray(stealthProxies) ? stealthProxies : [];
167
175
  this._stealthManager = null; // Chromium StealthBrowserManager (fallback engine)
168
176
  this._stealthBrowser = null; // Camoufox browser handle (preferred engine)
169
177
  this._stealthEngineActive = null;
@@ -952,6 +960,11 @@ export class ResearchOrchestrator extends EventEmitter {
952
960
  if (!this._stealthInit) {
953
961
  this._stealthInit = (async () => {
954
962
  if (this.stealthEngine === 'camoufox' || this.stealthEngine === 'auto') {
963
+ // Resolved outside the try: a malformed proxy must fail loudly, not be
964
+ // swallowed by the 'auto' fallback into a proxyless Chromium run —
965
+ // "the caller believes their traffic is proxied and it is not" is the
966
+ // failure mode this plumbing exists to prevent.
967
+ const proxy = this._resolveStealthProxy();
955
968
  try {
956
969
  const { createRequire } = await import('module');
957
970
  const require = createRequire(import.meta.url);
@@ -960,15 +973,35 @@ export class ResearchOrchestrator extends EventEmitter {
960
973
  // A page whose own JavaScript throws must not take the process with
961
974
  // it — see src/utils/firefoxPageErrorGuard.js.
962
975
  guardFirefoxPageErrors();
963
- this._stealthBrowser = await camoufox.Camoufox({ headless: true });
976
+ const launchOptions = { headless: true };
977
+ if (proxy) {
978
+ // camoufox takes the URL string and splits the credentials out
979
+ // itself (getProxyUrl → Playwright's {server,username,password}).
980
+ launchOptions.proxy = proxy.url;
981
+ // Same rule as StealthBrowserManager (`geoip: !!proxy`): behind a
982
+ // proxy, geoip derives timezone, locale and geolocation from the
983
+ // exit IP, which is the only thing that makes a proxied browser
984
+ // coherent. Without a proxy it would look up this machine's own
985
+ // public IP — a network call, and an external service learning
986
+ // our address, to confirm a location the browser is already in.
987
+ // No locale is passed either way here, so geoip's derived one
988
+ // has nothing to contradict.
989
+ launchOptions.geoip = true;
990
+ }
991
+ this._stealthBrowser = await camoufox.Camoufox(launchOptions);
964
992
  this._stealthEngineActive = 'camoufox';
965
- this.logger.info('Stealth fallback using Camoufox (Firefox) engine');
993
+ this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
994
+ proxy: proxy ? proxy.redacted : null
995
+ });
966
996
  return;
967
997
  } catch (e) {
968
998
  if (this.stealthEngine === 'camoufox') throw e; // explicit request → surface
969
999
  this.logger.warn('Camoufox unavailable, falling back to Chromium stealth', { error: e.message });
970
1000
  }
971
1001
  }
1002
+ // No proxy wiring on this branch: StealthBrowserManager resolves its own
1003
+ // (resolveProxy), server-level list included, so passing one here would
1004
+ // be a second path to the same setting.
972
1005
  const { StealthBrowserManager } = await import('./StealthBrowserManager.js');
973
1006
  this._stealthManager = new StealthBrowserManager();
974
1007
  await this._stealthManager.launchStealthBrowser({ level: this.stealthLevel });
@@ -978,6 +1011,43 @@ export class ResearchOrchestrator extends EventEmitter {
978
1011
  await this._stealthInit;
979
1012
  }
980
1013
 
1014
+ /**
1015
+ * The proxy this stealth fallback launches behind: the caller's list when they
1016
+ * configured one, otherwise the server-level CRAWLFORGE_STEALTH_PROXIES.
1017
+ * Returns null when neither is set, and `{ url, redacted }` otherwise — only
1018
+ * the redacted form (scheme + host, no credentials) is ever logged.
1019
+ *
1020
+ * Resolved once per browser, taking the first entry: camoufox fixes its
1021
+ * fingerprint — and with geoip its timezone, locale and geolocation — at
1022
+ * launch, so rotating underneath it would leave it reporting the first
1023
+ * proxy's city behind the second proxy's exit IP, which is a worse signal
1024
+ * than not rotating at all (see StealthBrowserManager._doLaunchStealthBrowser).
1025
+ */
1026
+ _resolveStealthProxy() {
1027
+ const raw = (this.stealthProxies[0] || '').trim();
1028
+ if (!raw) return null;
1029
+
1030
+ // `host:port` with no scheme parses as protocol "host:" and an empty host,
1031
+ // so the bare form gets the http:// every proxy list assumes.
1032
+ const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
1033
+ let parsed;
1034
+ try {
1035
+ parsed = new URL(url);
1036
+ } catch {
1037
+ // Never echo the entry: it carries the password, and camoufox's own
1038
+ // `new URL(proxy)` would quote the whole string in its TypeError.
1039
+ throw new Error('Invalid stealth proxy: expected host:port or scheme://user:pass@host:port');
1040
+ }
1041
+ const scheme = parsed.protocol.replace(':', '').toLowerCase();
1042
+ if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) {
1043
+ throw new Error(`Invalid stealth proxy scheme "${scheme}": expected http, https, socks4 or socks5`);
1044
+ }
1045
+ if (!parsed.hostname) {
1046
+ throw new Error('Invalid stealth proxy: no host');
1047
+ }
1048
+ return { url, redacted: `${parsed.protocol}//${parsed.host}` };
1049
+ }
1050
+
981
1051
  /**
982
1052
  * macOS packaging fix for camoufox-js: it expects properties.json in
983
1053
  * Camoufox.app/Contents/MacOS/, but the .app bundle ships it under