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.
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,25 @@ 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's automatic stealth retry
238
+ # (stealth review Phase 3) reads it too.
224
239
  ```
225
240
 
241
+ 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.
242
+
226
243
  Web Bot Auth (optional, off by default — requests go out unsigned when unset):
227
244
 
228
245
  ```bash
@@ -260,7 +277,7 @@ Key mechanisms for security-conscious future sessions:
260
277
  - **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
278
  - **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
279
  - **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`.
280
+ - **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
281
 
265
282
  See `docs/sandboxing-and-approvals.md` for the full reference.
266
283
 
package/README.md CHANGED
@@ -206,7 +206,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
206
206
  | `scrape_with_actions` | 5 | Browser automation chains |
207
207
  | `generate_llms_txt` | 5 | Generate AI interaction guidelines |
208
208
  | `stealth_mode` | 5 | Anti-detection browser management |
209
- | `agent` | 8 | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM) |
209
+ | `agent` | 8 (+5 per stealth retry that gets the page, max 2) | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM). A page that walls the plain fetch is retried in the stealth browser automatically — URLs you name first — and that evidence is marked `via: "stealth"`; each retry that runs adds 5 |
210
210
  | `deep_research` | 10 | Multi-stage research with source verification |
211
211
 
212
212
  Ten tools (`scrape`, `fetch_url`, `extract_content`, `crawl_deep`, `batch_scrape`, `stealth_mode`, `scrape_with_actions`, `process_document`, `deep_research`, `extract_embedded_state`) accept `max_inline_chars` (default 40,000; env `CRAWLFORGE_MAX_INLINE_CHARS`): a result over it comes back as a `preview` plus a `result_handle` for `read_result`, with the full result kept for 1 hour under `~/.crawlforge/results/` on your own machine — nothing is uploaded.
@@ -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`, 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
+
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.9.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
  },
@@ -155,6 +157,6 @@
155
157
  "@hono/node-server": "2.0.12",
156
158
  "hono": "^4.12.4",
157
159
  "dompurify": "^3.4.0",
158
- "adm-zip": "^0.6.0"
160
+ "adm-zip": "^0.6.1"
159
161
  }
160
162
  }
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.9.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",
@@ -243,23 +243,35 @@ const deepResearchTool = new DeepResearchTool();
243
243
  const trackChangesTool = new TrackChangesTool();
244
244
  const generateLLMsTxtTool = new GenerateLLMsTxtTool();
245
245
  const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
246
+ // Phase 3: the escalation stage for `scrape`'s escalate:true and the agent's
247
+ // automatic stealth retry (stealth review Phase 3) — one function, so both
248
+ // go through the same gate, engine resolver and server-level proxy list.
249
+ // Injected so the tool modules never import StealthBrowserManager (that would pull a
250
+ // browser dependency into every unit test that loads `scrape`). Same gate
251
+ // and same browser the stealth_mode tool drives — no new evasion, and the
252
+ // engine name is resolved here, beside its sibling, by the one resolver
253
+ // every stealth entry point shares.
254
+ const stealthEscalation = async ({ url, engine, respectRobots }) => {
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);
262
+ const scraped = await stealthBrowserManager.scrapeWithStealth({
263
+ url,
264
+ engine: resolved.engine
265
+ });
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 };
269
+ };
246
270
  const unifiedScrapeTool = new UnifiedScrapeTool({
247
271
  actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
248
- // Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
249
- // the tool module never imports StealthBrowserManager (that would pull a
250
- // browser dependency into every unit test that loads `scrape`). Same gate
251
- // and same browser the stealth_mode tool drives — no new evasion, and the
252
- // "playwright" → chromium engine mapping stays here with its sibling.
253
- escalateScrape: async ({ url, engine, respectRobots }) => {
254
- const warnings = await stealthComplianceGate(url, respectRobots);
255
- const scraped = await stealthBrowserManager.scrapeWithStealth({
256
- url,
257
- engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
258
- });
259
- return { ...scraped, engine, warnings };
260
- }
272
+ escalateScrape: stealthEscalation
261
273
  });
262
- const agentTool = new AgentTool(); // D4 D2
274
+ const agentTool = new AgentTool({ escalateFetch: stealthEscalation }); // D4 D2 + stealth review Phase 3
263
275
  const stealthBrowserManager = new StealthBrowserManager();
264
276
  const localizationManager = new LocalizationManager();
265
277
 
@@ -913,7 +925,7 @@ registerToolIfEnabled("read_result", {
913
925
 
914
926
  // Tool: scrape_with_actions
915
927
  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\"}]})",
928
+ 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
929
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
918
930
  inputSchema: {
919
931
  url: z.string().url().describe("The URL to scrape"),
@@ -985,7 +997,8 @@ registerToolIfEnabled("scrape_with_actions", {
985
997
  viewportWidth: z.number().min(800).max(1920).default(1280),
986
998
  viewportHeight: z.number().min(600).max(1080).default(720),
987
999
  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.")
1000
+ 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."),
1001
+ 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
1002
  }).optional().describe("Browser configuration options"),
990
1003
  extractionOptions: z.object({
991
1004
  selectors: z.record(z.string()).optional(),
@@ -1028,13 +1041,14 @@ registerToolIfEnabled("scrape_with_actions", {
1028
1041
 
1029
1042
  // Tool: browser_session
1030
1043
  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:\"...\"})",
1044
+ 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
1045
  annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1033
1046
  inputSchema: {
1034
1047
  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
1048
  session_id: z.string().optional().describe("The id returned by operation:\"open\". Required by every operation except open and list"),
1036
1049
  url: z.string().url().optional().describe("open: the URL to load the session on"),
1037
1050
  stealth: z.boolean().default(false).describe("open: run the session in the stealth browser"),
1051
+ 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
1052
  ttl: z.number().min(30).max(3600).optional().describe("open: seconds the session may live at most (default 600)"),
1039
1053
  activity_ttl: z.number().min(10).max(3600).optional().describe("open: seconds the session may sit idle (default 300)"),
1040
1054
  viewport: z.object({
@@ -1201,7 +1215,7 @@ registerToolIfEnabled("scrape", {
1201
1215
 
1202
1216
  // Tool: agent (D4 D2 — autonomous NL prompt → search/navigate/extract)
1203
1217
  registerToolIfEnabled("agent", {
1204
- description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Cost: 8 credits, scales with maxUrls. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1218
+ description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Pages that block a plain fetch are retried in the stealth browser automatically (at most 2 a run, URLs you name first; evidence marked via:\"stealth\"). Cost: 18 credits at most - 8, plus 5 per stealth retry that gets the page; a retry that is blocked again is free. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
1205
1219
  annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1206
1220
  inputSchema: {
1207
1221
  prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
@@ -1337,7 +1351,7 @@ function stealthScrapeFormats(formats, scraped) {
1337
1351
 
1338
1352
  // Tool: stealth_mode
1339
1353
  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\"]})",
1354
+ 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
1355
  annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1342
1356
  inputSchema: {
1343
1357
  operation: z.enum(['scrape', 'configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
@@ -1379,7 +1393,7 @@ registerToolIfEnabled("stealth_mode", {
1379
1393
  hardwareSpoofing: z.boolean().default(true)
1380
1394
  }).optional()
1381
1395
  }).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)"),
1396
+ 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
1397
  contextId: z.string().optional().describe("Browser context ID for page operations"),
1384
1398
  urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page"),
1385
1399
  url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
@@ -1399,12 +1413,16 @@ registerToolIfEnabled("stealth_mode", {
1399
1413
  // Gate first: a disallowed URL must never launch a browser.
1400
1414
  const warnings = await stealthComplianceGate(url, respect_robots);
1401
1415
 
1416
+ // "auto" prefers camoufox and falls back to chromium when its binary
1417
+ // is missing; "playwright" is this tool's old public name for chromium.
1418
+ // The resolver owns both, and reports a fallback the caller can see.
1419
+ const resolvedEngine = await resolveStealthEngine(engine);
1420
+ if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
1421
+
1402
1422
  const wantsScreenshot = formats.includes('screenshot');
1403
1423
  const scraped = await stealthBrowserManager.scrapeWithStealth({
1404
1424
  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',
1425
+ engine: resolvedEngine.engine,
1408
1426
  wait_for: wait_for || 0,
1409
1427
  screenshot: wantsScreenshot,
1410
1428
  stealthConfig
@@ -1421,6 +1439,7 @@ registerToolIfEnabled("stealth_mode", {
1421
1439
  url: scraped.url,
1422
1440
  title: scraped.title,
1423
1441
  status: verdict.status,
1442
+ engine: resolvedEngine.engine,
1424
1443
  content: stealthScrapeFormats(formats, scraped)
1425
1444
  };
1426
1445
  if (verdict.blocked) result.blocked = verdict.blocked;
@@ -1457,10 +1476,11 @@ registerToolIfEnabled("stealth_mode", {
1457
1476
  case 'create_context': {
1458
1477
  // Forward the tool-level engine the same way operation:"scrape" does —
1459
1478
  // without it, create_context always ran on chromium whatever the caller
1460
- // asked for ("playwright" is this tool's public name for chromium).
1479
+ // asked for. Resolved, so "auto" reaches the manager as a real engine.
1480
+ const contextEngine = await resolveStealthEngine(engine);
1461
1481
  const contextData = await stealthBrowserManager.createStealthContext({
1462
1482
  ...(stealthConfig || {}),
1463
- engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
1483
+ engine: contextEngine.engine
1464
1484
  });
1465
1485
  // The full fingerprint is ~4 KB of canvas noise arrays and WebGL
1466
1486
  // extension lists no caller acts on. Summarise by default; verbose:true
@@ -1468,6 +1488,10 @@ registerToolIfEnabled("stealth_mode", {
1468
1488
  result = {
1469
1489
  contextId: contextData.contextId,
1470
1490
  created: true,
1491
+ // Which engine the context is on, and why, when "auto" had to fall
1492
+ // back: every page made from it inherits that engine.
1493
+ engine: contextEngine.engine,
1494
+ ...(contextEngine.fallbackWarning ? { warnings: [contextEngine.fallbackWarning] } : {}),
1471
1495
  fingerprint: verbose
1472
1496
  ? contextData.fingerprint
1473
1497
  : 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;
@@ -4,7 +4,8 @@
4
4
  * Design: hardcoded 3-action state machine.
5
5
  * PLAN — one SamplingClient call to decompose prompt into search queries
6
6
  * GATHER — search_web (≤maxUrls results total)
7
- * ACT — fetchAndParse + relevance gate per URL
7
+ * ACT — fetchAndParse + relevance gate per URL; a walled page is retried
8
+ * once in the stealth browser (capped, see tools/agent/escalation.js)
8
9
  * DECIDE — loop or answer (step/URL/time hard stops; never LLM-trusted)
9
10
  * SHAPE — schema→ExtractWithLlm prose→synthesis via SamplingClient
10
11
  *
@@ -20,10 +21,21 @@
20
21
  import { fetchAndParse } from '../tools/extract/_fetchAndParse.js';
21
22
  import { SamplingClient } from './SamplingClient.js';
22
23
  import { fenceUntrusted } from '../utils/untrustedContent.js';
24
+ import { stealthDocumentVerdict as documentVerdict } from '../utils/stealthVerdict.js';
25
+ import { aBrowserMightPass } from '../tools/scrape/escalation.js';
26
+ import { AGENT_MAX_ESCALATIONS } from '../tools/agent/escalation.js';
23
27
 
24
28
  const DEFAULT_WALL_CLOCK_MS = 120_000;
25
29
  const DEFAULT_MAX_STEPS = 5;
26
30
  const DEFAULT_MAX_URLS = 10;
31
+ // A stealth retry launches a browser and waits out a challenge; one started
32
+ // with less than this left on the wall clock would overrun the hard stop.
33
+ const ESCALATION_MIN_REMAINING_MS = 20_000;
34
+
35
+ /** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
36
+ function isRefusal(err) {
37
+ return err?.code === 'ROBOTS_DISALLOWED' || err?.code === 'HOST_BLOCKED';
38
+ }
27
39
 
28
40
  // ── Helpers ───────────────────────────────────────────────────────────────────
29
41
 
@@ -168,6 +180,11 @@ export class AgentOrchestrator {
168
180
  * @param {object|null} options.mcpServer - McpServer instance (for SamplingClient)
169
181
  * @param {object} options.searchConfig - passed to SearchWebTool constructor
170
182
  * @param {object} options.llmConfig - passed to ExtractWithLlm constructor
183
+ * @param {Function} [options.escalateFetch] - the stealth stage, injected from
184
+ * server.js (same function `scrape`'s escalate:true uses):
185
+ * ({ url, engine, respectRobots }) => { html, url, title, text, status, engine, warnings? }
186
+ * Absent in unit tests and builds without a browser: ACT then keeps the
187
+ * plain-fetch-or-snippet behaviour.
171
188
  */
172
189
  constructor(options = {}) {
173
190
  this._mcpServer = options.mcpServer || null;
@@ -177,6 +194,7 @@ export class AgentOrchestrator {
177
194
  this._searchTool = null;
178
195
  this._extractWithLlm = null;
179
196
  this._researchOrchestrator = null;
197
+ this._escalateFetch = options.escalateFetch || null;
180
198
  }
181
199
 
182
200
  /** Set MCP server (called by agent.js after construction). */
@@ -237,6 +255,9 @@ export class AgentOrchestrator {
237
255
  * @param {number} [params.maxSteps] - Max ACT iterations (≤10)
238
256
  * @param {number} [params.maxUrls] - Max URLs to fetch (≤20)
239
257
  * @param {number} [params.wallClockMs] - Wall-clock budget in ms
258
+ * @param {object} [params.usage] - out-param: `usage.escalations` counts
259
+ * stealth retries that ran (the cap), `usage.charged` those that got the
260
+ * page (the bill), so the tool can bill them even if run() throws
240
261
  * @returns {Promise<object>}
241
262
  */
242
263
  async run(params) {
@@ -247,8 +268,11 @@ export class AgentOrchestrator {
247
268
  model = 'default',
248
269
  maxSteps = DEFAULT_MAX_STEPS,
249
270
  maxUrls = DEFAULT_MAX_URLS,
250
- wallClockMs = DEFAULT_WALL_CLOCK_MS
271
+ wallClockMs = DEFAULT_WALL_CLOCK_MS,
272
+ usage = {}
251
273
  } = params;
274
+ usage.escalations = 0;
275
+ usage.charged = 0;
252
276
 
253
277
  const startTime = Date.now();
254
278
  const deadline = () => (Date.now() - startTime) >= wallClockMs;
@@ -365,6 +389,10 @@ export class AgentOrchestrator {
365
389
  if (!urlQueue.includes(url)) urlQueue.push(url);
366
390
  if (!priorityUrls.includes(url)) priorityUrls.push(url);
367
391
  }
392
+ // The URLs the caller named — seeds and sites written into the prompt.
393
+ // Snapshotted before GATHER adds a voted live root to priorityUrls: that
394
+ // root is the agent's own discovery, not the caller's.
395
+ const namedUrls = new Set(priorityUrls);
368
396
  const searchResults = [];
369
397
  /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
370
398
  const excerpts = new Map();
@@ -441,32 +469,93 @@ export class AgentOrchestrator {
441
469
  const evidence = [];
442
470
  let urlsFetched = 0;
443
471
  let step = 0;
472
+ const warnings = [];
444
473
 
445
474
  for (const url of urlQueue) {
446
475
  if (urlsFetched >= capUrls || step >= capSteps || deadline()) break;
447
476
  urlsFetched++;
448
477
 
478
+ // The plain fetch reads an error document instead of throwing on it, so
479
+ // a wall served as a 403 can be told apart from a 404 (the same
480
+ // arrangement `scrape` has).
481
+ let plain = null;
482
+ let plainError = null;
483
+ let verdict = null;
449
484
  try {
450
- const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
451
- if (!isRelevant(textContent, prompt)) continue;
452
- step++;
453
- const sr = searchResults.find(s => s.url === url);
454
- evidence.push({
455
- url: finalUrl,
456
- title: sr ? sr.title : '',
457
- text: truncate(textContent),
458
- step
459
- });
460
- } catch {
461
- // The page is out of reach (challenge page, 403, timeout) but the
462
- // search engine's excerpt of it is not: npmjs.com's "Latest version:
463
- // 15.0.0" sat in the snippet while every fetch of the package page was
464
- // challenged, and the run ended with no evidence at all (R17,
465
- // 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
466
- const excerpt = excerpts.get(url);
467
- if (excerpt && isRelevant(excerpt.text, prompt)) {
468
- evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
485
+ plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
486
+ verdict = documentVerdict(
487
+ { url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
488
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
489
+ );
490
+ } catch (err) {
491
+ plainError = err;
492
+ }
493
+
494
+ const ok2xx = plain && plain.status >= 200 && plain.status < 300;
495
+ if (ok2xx && !verdict.blocked) {
496
+ if (isRelevant(plain.textContent, prompt)) {
497
+ step++;
498
+ const sr = searchResults.find(s => s.url === url);
499
+ evidence.push({
500
+ url: plain.finalUrl,
501
+ title: sr ? sr.title : '',
502
+ text: truncate(plain.textContent),
503
+ step
504
+ });
505
+ continue;
469
506
  }
507
+ // A real page that is simply off-topic: nothing to retry.
508
+ if (verdict.success) continue;
509
+ }
510
+
511
+ // The page is out of reach: a wall, a 403/429, an empty shell, a
512
+ // timeout. A search excerpt may still carry the fact (npmjs.com's
513
+ // "Latest version: 15.0.0" sat in the snippet while every fetch of the
514
+ // package page was challenged, R17 2026-09-04).
515
+ const excerpt = excerpts.get(url);
516
+ const relevantExcerpt = excerpt && isRelevant(excerpt.text, prompt) ? excerpt : null;
517
+
518
+ // Stealth retry (review Phase 3). A URL the caller named always gets it
519
+ // first; a discovered one only when there is no relevant excerpt to fall
520
+ // back on. Never for a refusal, a 404 or a 5xx; bounded by its own cap
521
+ // and by the time left before the wall-clock stop.
522
+ const mightPass = plainError ? !isRefusal(plainError) : aBrowserMightPass(verdict, plain.status);
523
+ const canEscalate = this._escalateFetch &&
524
+ usage.escalations < Math.min(AGENT_MAX_ESCALATIONS, capUrls) &&
525
+ (wallClockMs - (Date.now() - startTime)) >= ESCALATION_MIN_REMAINING_MS;
526
+ if (mightPass && canEscalate && (namedUrls.has(url) || !relevantExcerpt)) {
527
+ usage.escalations++;
528
+ try {
529
+ const stealth = await this._escalateFetch({ url: plain?.finalUrl || url, engine: 'auto' });
530
+ if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
531
+ const text = (stealth.text || '').trim();
532
+ const rendered = documentVerdict(
533
+ { url: stealth.url || url, title: stealth.title || '', text, html: stealth.html || '', status: stealth.status ?? null },
534
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
535
+ );
536
+ // Billed only when the browser got past the wall: a retry that met
537
+ // the wall again, or threw, costs nothing (owner decision).
538
+ if (rendered.success) usage.charged++;
539
+ if (rendered.success && isRelevant(text, prompt)) {
540
+ step++;
541
+ const sr = searchResults.find(s => s.url === url);
542
+ evidence.push({
543
+ url: stealth.url || url,
544
+ title: stealth.title || (sr ? sr.title : ''),
545
+ text: truncate(text),
546
+ via: 'stealth',
547
+ step
548
+ });
549
+ continue;
550
+ }
551
+ warnings.push(`stealth retry of ${url} ${rendered.success ? 'returned a page unrelated to the task' : `did not get the page either${rendered.blocked?.vendor ? ` (${rendered.blocked.vendor})` : ''}`}`);
552
+ } catch (err) {
553
+ warnings.push(`stealth retry of ${url} failed: ${err.message}`);
554
+ }
555
+ }
556
+
557
+ if (relevantExcerpt) {
558
+ evidence.push({ url, title: relevantExcerpt.title, text: relevantExcerpt.text, snippet: true, step });
470
559
  }
471
560
  }
472
561
 
@@ -501,7 +590,10 @@ export class AgentOrchestrator {
501
590
  evidence: [],
502
591
  answer: null,
503
592
  steps: step,
504
- urls_fetched: urlsFetched
593
+ urls_fetched: urlsFetched,
594
+ stealth_retries: usage.escalations,
595
+ stealth_retries_charged: usage.charged,
596
+ ...(warnings.length ? { warnings } : {})
505
597
  };
506
598
  }
507
599
 
@@ -520,11 +612,14 @@ export class AgentOrchestrator {
520
612
  answer: result.success ? result.data : null,
521
613
  structured: true,
522
614
  search_results: searchResults,
523
- evidence: evidence.map(e => ({ url: e.url })),
615
+ evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
524
616
  degraded: !result.success,
525
617
  reason: result.success ? undefined : result.error,
526
618
  steps: step,
527
- urls_fetched: urlsFetched
619
+ urls_fetched: urlsFetched,
620
+ stealth_retries: usage.escalations,
621
+ stealth_retries_charged: usage.charged,
622
+ ...(warnings.length ? { warnings } : {})
528
623
  };
529
624
  } catch (err) {
530
625
  // Fall through to prose synthesis
@@ -648,11 +743,14 @@ export class AgentOrchestrator {
648
743
  success: true,
649
744
  answer,
650
745
  search_results: searchResults,
651
- evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
746
+ evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : e.via ? { url: e.url, via: e.via } : { url: e.url })),
652
747
  degraded,
653
748
  reason: degradedReason,
654
749
  steps: step,
655
750
  urls_fetched: urlsFetched,
751
+ stealth_retries: usage.escalations,
752
+ stealth_retries_charged: usage.charged,
753
+ ...(warnings.length ? { warnings } : {}),
656
754
  provenance: {
657
755
  checked: !synthesisFailed,
658
756
  unverified,