crawlforge-mcp-server 6.22.3 → 6.23.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.
Files changed (53) hide show
  1. package/README.md +10 -1
  2. package/package.json +1 -1
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +71 -14
  6. package/src/core/AuthManager.js +7 -1
  7. package/src/core/ChangeTracker.js +52 -4
  8. package/src/core/LLMsTxtAnalyzer.js +21 -9
  9. package/src/core/LocalizationManager.js +20 -4
  10. package/src/core/ResearchOrchestrator.js +8 -1
  11. package/src/core/StealthBrowserManager.js +10 -5
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +286 -0
  14. package/src/core/browser/snapshot.js +22 -0
  15. package/src/core/crawlers/BFSCrawler.js +10 -4
  16. package/src/core/processing/BrowserProcessor.js +5 -3
  17. package/src/core/processing/ContentProcessor.js +2 -1
  18. package/src/core/workers/worker.js +2 -0
  19. package/src/schemas/toolOutputSchemas.js +3 -2
  20. package/src/server/fallbackHints.js +5 -1
  21. package/src/server/inlineThreshold.js +15 -9
  22. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  25. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  26. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  31. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  32. package/src/tools/agent/sourceChecks.js +103 -0
  33. package/src/tools/basic/extractMetadata.js +9 -4
  34. package/src/tools/basic/extractText.js +6 -4
  35. package/src/tools/basic/fetchUrl.js +3 -0
  36. package/src/tools/basic/scrapeStructured.js +66 -21
  37. package/src/tools/crawl/mapSite.js +18 -3
  38. package/src/tools/extract/extractStructured.js +134 -1
  39. package/src/tools/extract/processDocument.js +48 -5
  40. package/src/tools/extract/summarizeContent.js +123 -5
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  42. package/src/tools/result/readResult.js +79 -10
  43. package/src/tools/scrape/_mainContent.js +97 -6
  44. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  45. package/src/tools/search/searchWeb.js +46 -15
  46. package/src/tools/search/serpRank.js +1 -1
  47. package/src/tools/tracking/trackChanges/index.js +1 -0
  48. package/src/tools/tracking/trackChanges/schema.js +2 -1
  49. package/src/utils/htmlToMarkdown.js +77 -1
  50. package/src/utils/localFilePolicy.js +65 -0
  51. package/src/utils/provenance.js +102 -1
  52. package/src/utils/ssrfGuard.js +4 -3
  53. package/src/utils/ssrfProtection.js +3 -0
package/README.md CHANGED
@@ -276,6 +276,12 @@ export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
276
276
 
277
277
  # Optional: turn off the clearance jar (on by default) — see "Stealth engines and proxies"
278
278
  export CRAWLFORGE_CLEARANCE_JAR="off"
279
+
280
+ # Optional: also refuse private networks (RFC1918, ULA, CGNAT, ...) — see "Security & Approvals"
281
+ export SSRF_STRICT="true"
282
+
283
+ # Optional: the only directories process_document may read local files from (":"-separated, ";" on Windows)
284
+ export CRAWLFORGE_ALLOWED_DIRS="$HOME/Documents:$HOME/Downloads"
279
285
  ```
280
286
 
281
287
  ### MCP Spec Features
@@ -380,7 +386,10 @@ Once configured, use these tools in your AI assistant:
380
386
 
381
387
  ### Security & Approvals
382
388
 
383
- - **SSRF enforcement**: Every scraped URL is validated before the request is sent — http/https only; blocks loopback, RFC1918, IPv6 private/link-local ranges, cloud metadata endpoints (GCP, Azure), and dangerous ports (SSH, SMTP, DNS, MySQL, Postgres, Redis, MongoDB, etc.). Redirects are re-validated each hop, capped at 5.
389
+ - **SSRF enforcement**: Every scraped URL is validated before the request is sent, and every connection (each redirect hop included, capped at 5) is checked against the address it actually connects to — http/https only, cloud metadata hostnames (GCP, Azure) refused. Two levels:
390
+ - **Default (Stage 1)** blocks loopback (`127.0.0.0/8`, `::1`), the unspecified addresses (`0.0.0.0/8`, `::`), link-local and cloud metadata (`169.254.0.0/16` incl. `169.254.169.254`, `fe80::/10`). It **allows private networks** (RFC1918 `10/8`, `172.16/12`, `192.168/16`, IPv6 ULA): run on your own machine, the server acts for you, and reading a router page, a NAS or an intranet docs site on your LAN is a legitimate use that the stricter level would break.
391
+ - **`SSRF_STRICT=true`** adds every private and reserved range: RFC1918, ULA (`fc00::/7`), CGNAT (`100.64/10`), multicast, reserved, IPv4-compatible (`::/96`) and NAT64 (`64:ff9b::/96`) IPv6. Set it if an agent that reads untrusted pages (and could be prompt-injected into fetching a LAN address) should not reach your local network, or whenever the server is shared.
392
+ - **Local files** (`process_document` with `sourceType` `file` or `pdf_file`; refused outright when the server is served to a network): a path is judged by its realpath, so a symlink counts as its target. Only `.pdf`, `.docx`, `.txt`, `.md`, `.html` and `.htm` files are read, and nothing inside a dot-directory (`~/.ssh`, `~/.aws`, `~/.config`, `~/.crawlforge`, ...). `CRAWLFORGE_ALLOWED_DIRS` (directories separated by `:`, `;` on Windows) narrows reads to those directories instead; inside them the dot-directory rule is lifted, the extension rule is not.
384
393
  - **Backend endpoint guard** (v3.0.18): The server's own calls to CrawlForge.dev use a separate fail-closed allow-list (`{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}`, HTTPS required). Setting `CRAWLFORGE_API_URL` to an arbitrary host is blocked at parse time.
385
394
  - **Action allowlist**: `scrape_with_actions` accepts only 11 action types (`snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`). No download, file-write, or arbitrary cross-page navigation primitives exist — `navigate` goes through the same SSRF and robots.txt gate as the initial URL.
386
395
  - **JavaScript gate**: The `executeJavaScript` action throws by default. Set `ALLOW_JAVASCRIPT_EXECUTION=true` at deploy time to enable (not recommended in production).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.22.3",
3
+ "version": "6.23.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
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
113
113
  // Create the server
114
114
  const server = new McpServer({
115
115
  name: "crawlforge",
116
- version: "6.22.3",
116
+ version: "6.23.0",
117
117
  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.",
118
118
  homepage: "https://www.crawlforge.dev",
119
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -427,7 +427,7 @@ const refusedByGate = (result) => result?.success === false && preflightRefusal(
427
427
 
428
428
  // Tool: fetch_url
429
429
  registerToolIfEnabled("fetch_url", {
430
- description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
430
+ description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout; the result reports redirected and final_url. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
431
431
  annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
432
432
  inputSchema: {
433
433
  url: z.string().url().describe("The URL to fetch content from"),
@@ -504,7 +504,10 @@ registerToolIfEnabled("scrape_structured", {
504
504
  annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
505
505
  inputSchema: {
506
506
  url: z.string().url().describe("The URL to scrape"),
507
- selectors: z.record(z.string()).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\")"),
507
+ selectors: z.record(z.union([
508
+ z.string(),
509
+ z.object({ selector: z.string(), multiple: z.boolean().optional() })
510
+ ])).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\"); @href and @src values are resolved to absolute URLs. A field may also be {selector, multiple: true} to return every match as an array - inside a row_selector row a field otherwise keeps only the first match"),
508
511
  row_selector: z.string().optional().describe("CSS selector for the repeating row/container element. When set, each field in selectors is matched inside each row and data is an array of row-aligned records ({field: value|null}) instead of parallel arrays"),
509
512
  max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements, or the maximum number of rows when row_selector is set"),
510
513
  ...COMPLIANCE_PARAMS
@@ -754,7 +757,7 @@ registerToolIfEnabled("extract_content", {
754
757
 
755
758
  // Tool: process_document
756
759
  registerToolIfEnabled("process_document", {
757
- description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
760
+ description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. A local file (sourceType file/pdf_file) must be .pdf, .docx, .txt, .md or .html and outside dot-directories; CRAWLFORGE_ALLOWED_DIRS limits reads to the listed directories. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
758
761
  annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
759
762
  inputSchema: {
760
763
  source: z.string().describe("Document source - URL or file path"),
@@ -1060,7 +1063,7 @@ registerToolIfEnabled("scrape_with_actions", {
1060
1063
  includeMetadata: z.boolean().default(true),
1061
1064
  includeLinks: z.boolean().default(true),
1062
1065
  includeImages: z.boolean().default(true)
1063
- }).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response."),
1066
+ }).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response. Append @attr to a selector to read an attribute instead of the text (\"a.next@href\"); @value on an input, textarea or select reads what it holds after the actions (typed or selected), and any other @attr reads the attribute as written in the page's HTML."),
1064
1067
  continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
1065
1068
  maxRetries: z.number().min(0).max(3).default(0).describe("Whole-chain retries on failure (0-3). A retry re-navigates to the starting URL and replays every action; each attempt is reported under attempts[]."),
1066
1069
  screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
@@ -1294,7 +1297,7 @@ registerToolIfEnabled("agent", {
1294
1297
 
1295
1298
  // Tool: track_changes
1296
1299
  registerToolIfEnabled("track_changes", {
1297
- description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1300
+ description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). A compare always uses the baseline's granularity/customSelectors/excludeSelectors; different values are reported in ignoredOptions. Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1298
1301
  annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1299
1302
  // The tool module owns the schema (G5); this is the same shape it validates with.
1300
1303
  inputSchema: {
@@ -1523,7 +1526,11 @@ registerToolIfEnabled("stealth_mode", {
1523
1526
  };
1524
1527
  if (verdict.blocked) result.blocked = verdict.blocked;
1525
1528
  if (verdict.error) result.error = verdict.error;
1526
- if (scraped.gracedMs > 0) result.waited_for_render_ms = scraped.gracedMs;
1529
+ // The requested wait_for and the render grace the browser added on top
1530
+ // are separate numbers; reporting only the grace made wait_for look
1531
+ // unapplied (wait_for:5000 showed as 4 ms, live sweep 2026-10-20).
1532
+ if (scraped.waitedMs > 0) result.waited_ms = scraped.waitedMs;
1533
+ if (scraped.gracedMs > 0) result.render_grace_ms = scraped.gracedMs;
1527
1534
 
1528
1535
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1529
1536
  // scrape and scrape_with_actions, so the base64 never bloats the result.
@@ -9,7 +9,7 @@ import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
11
11
  import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode, paceBrowserAction } from '../utils/robotsGate.js';
12
- import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
12
+ import { isRef, resolveRef, reresolveRef, captureSnapshot } from './browser/snapshot.js';
13
13
  import { settlePage } from './browser/settle.js';
14
14
  import { handleConsent } from './browser/consent.js';
15
15
  import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
@@ -55,6 +55,27 @@ const withoutHash = (url) => url.split('#')[0];
55
55
  * A failed action's message, naming its selector. Playwright names it only in
56
56
  * the "Call log:", which is stripped before the model reads the error (R25 3.2).
57
57
  */
58
+ /**
59
+ * The live `.value` of every input, textarea and select, in document order.
60
+ *
61
+ * page.content() serializes attributes, and typing changes the property, not
62
+ * the attribute: `input[type=number]@value` came back null after typing "42"
63
+ * (live sweep 2026-10-20, 3.4). The serialized HTML's controls are matched to
64
+ * these by position (ScrapeWithActionsTool.extractWithSelectors).
65
+ * @param {Page} page - Playwright page
66
+ * @returns {Promise<Array<{tag: string, value: string}>>} [] when the page cannot answer
67
+ */
68
+ async function readFormValues(page) {
69
+ try {
70
+ return await page.evaluate(() => Array.from(
71
+ document.querySelectorAll('input, textarea, select'),
72
+ (el) => ({ tag: el.localName, value: el.value })
73
+ ));
74
+ } catch (_) {
75
+ return [];
76
+ }
77
+ }
78
+
58
79
  function actionErrorMessage(action, error) {
59
80
  const firstLine = String(error.message).split('\n')[0];
60
81
  return action.selector && !firstLine.includes(action.selector)
@@ -363,6 +384,7 @@ export class ActionExecutor extends EventEmitter {
363
384
  if (finalState) {
364
385
  executionContext.finalHtml = finalState.html;
365
386
  executionContext.finalUrl = finalState.url;
387
+ executionContext.finalFormValues = finalState.formValues;
366
388
  }
367
389
 
368
390
  this.stats.successfulChains++;
@@ -433,6 +455,7 @@ export class ActionExecutor extends EventEmitter {
433
455
  ...executionContext,
434
456
  page: undefined, // Don't store page in history
435
457
  finalHtml: undefined,
458
+ finalFormValues: undefined, // what was typed, passwords included
436
459
  screenshots: undefined,
437
460
  screenshotCount: executionContext.screenshots.length,
438
461
  capturedStates: undefined,
@@ -459,6 +482,7 @@ export class ActionExecutor extends EventEmitter {
459
482
  url,
460
483
  finalUrl: executionContext.finalUrl || url,
461
484
  finalHtml: executionContext.finalHtml,
485
+ finalFormValues: executionContext.finalFormValues || [],
462
486
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
463
487
  navigations: executionContext.navigations || [],
464
488
  consent: executionContext.consent,
@@ -579,6 +603,7 @@ export class ActionExecutor extends EventEmitter {
579
603
  afterActionId: actionResult.id,
580
604
  url: captured.url,
581
605
  html: captured.html,
606
+ formValues: captured.formValues,
582
607
  timestamp: Date.now()
583
608
  });
584
609
  }
@@ -780,18 +805,19 @@ export class ActionExecutor extends EventEmitter {
780
805
  }
781
806
 
782
807
  /**
783
- * The page's URL and HTML, read natively (no in-page JS execution, so it
784
- * works regardless of ALLOW_JAVASCRIPT_EXECUTION). A page that cannot be
785
- * read yields null and a warning; a page the gate refuses throws.
808
+ * The page's URL and HTML, read natively (no caller-supplied JS, so it
809
+ * works regardless of ALLOW_JAVASCRIPT_EXECUTION), and the live value of
810
+ * each form control. A page that cannot be read yields null and a warning;
811
+ * a page the gate refuses throws.
786
812
  * @param {Page} page - Playwright page
787
813
  * @param {Object} [browserOptions] - Browser options (`respectRobots` override)
788
814
  * @param {string} what - names the capture in the warning
789
- * @returns {Promise<{ url: string, html: string }|null>}
815
+ * @returns {Promise<{ url: string, html: string, formValues: Array<{tag: string, value: string}> }|null>}
790
816
  */
791
817
  async captureState(page, browserOptions, what) {
792
818
  return await this.readGated(page, browserOptions, async () => {
793
819
  try {
794
- return { html: await page.content(), url: page.url() };
820
+ return { html: await page.content(), url: page.url(), formValues: await readFormValues(page) };
795
821
  } catch (captureErr) {
796
822
  this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
797
823
  return null;
@@ -879,7 +905,7 @@ export class ActionExecutor extends EventEmitter {
879
905
  // "Action timeout".
880
906
  const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
881
907
  let backstopTimer;
882
- const executionPromise = this.executeActionByType(page, action, executionContext);
908
+ const executionPromise = this.executeActionWithRefRetry(page, action, executionContext);
883
909
  const timeoutPromise = new Promise((_, reject) => {
884
910
  backstopTimer = setTimeout(
885
911
  () => reject(new Error(
@@ -1058,6 +1084,42 @@ export class ActionExecutor extends EventEmitter {
1058
1084
  return await settlePage(page, { timeout: remaining });
1059
1085
  }
1060
1086
 
1087
+ /**
1088
+ * Run the action; if its ref names an element the page has since replaced,
1089
+ * re-find the element by the ref's role and name once and run it again.
1090
+ *
1091
+ * Checked up front as well as on the error, because a detached ref does not
1092
+ * always fail fast: Playwright reports "Element is not attached to the DOM"
1093
+ * when the swap lands mid-action, but waits out the whole action timeout for
1094
+ * a ref that was already detached when the action began.
1095
+ * @param {Page} page - Playwright page
1096
+ * @param {Object} action - Action configuration
1097
+ * @param {Object} [executionContext] - Execution context
1098
+ * @returns {Promise<any>} Action result
1099
+ */
1100
+ async executeActionWithRefRetry(page, action, executionContext) {
1101
+ const ref = [action.selector, action.toElement].find(isRef);
1102
+ let reresolved = false;
1103
+ if (ref) {
1104
+ let matches = 1;
1105
+ try {
1106
+ matches = await this.elementLocator(page, ref).count();
1107
+ } catch (_) {
1108
+ // A stale or unknown ref: the action itself reports it.
1109
+ }
1110
+ if (matches === 0) reresolved = await reresolveRef(page, ref);
1111
+ }
1112
+ try {
1113
+ return await this.executeActionByType(page, action, executionContext);
1114
+ } catch (error) {
1115
+ if (!ref || reresolved || !/not attached to the DOM/i.test(error?.message) ||
1116
+ !(await reresolveRef(page, ref))) {
1117
+ throw error;
1118
+ }
1119
+ return await this.executeActionByType(page, action, executionContext);
1120
+ }
1121
+ }
1122
+
1061
1123
  /**
1062
1124
  * Execute action based on its type
1063
1125
  * @param {Page} page - Playwright page
@@ -1442,14 +1504,22 @@ export class ActionExecutor extends EventEmitter {
1442
1504
  const startedAt = Date.now();
1443
1505
  const timeout = this.actionTimeout(action);
1444
1506
 
1507
+ // Both checks run before the page is touched, so a target refused here
1508
+ // leaves the page where it was.
1445
1509
  await assertUrlAllowed(action.url, { resolveDns: true });
1446
1510
  const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1447
1511
 
1448
- await this.navigateToUrl(page, action.url, {
1449
- waitUntil: action.waitUntil,
1450
- timeout,
1451
- browserOptions: executionContext?.browserOptions
1452
- });
1512
+ const previousUrl = page.url();
1513
+ try {
1514
+ await this.navigateToUrl(page, action.url, {
1515
+ waitUntil: action.waitUntil,
1516
+ timeout,
1517
+ browserOptions: executionContext?.browserOptions
1518
+ });
1519
+ } catch (error) {
1520
+ await this.restoreAfterRefusal(page, previousUrl, error, executionContext?.browserOptions);
1521
+ throw error;
1522
+ }
1453
1523
  page.__crawlforgeGateWarnings = gateWarnings;
1454
1524
 
1455
1525
  // Consent handling gets at most what is left of the action's deadline, so
@@ -1477,6 +1547,41 @@ export class ActionExecutor extends EventEmitter {
1477
1547
  };
1478
1548
  }
1479
1549
 
1550
+ /**
1551
+ * Put the page back after a navigation refused partway.
1552
+ *
1553
+ * A target refused up front never leaves the page. A redirect hop to a
1554
+ * blocked address is refused only once the browser asks for it — by the
1555
+ * guarded proxy — and by then the page is gone: blanked by
1556
+ * assertNavigationAllowed, or on the browser's error page when the proxy
1557
+ * refused an https tunnel. The browser cannot undo a committed navigation, so
1558
+ * the page that was left is loaded again (through the same gate). That keeps
1559
+ * a session on its page and its cookies, but not on state that lived only in
1560
+ * the page, and the error says which of the two happened (live sweep
1561
+ * 2026-10-20, 3.3).
1562
+ * @param {Page} page - Playwright page
1563
+ * @param {string} previousUrl - where the page was before the navigation
1564
+ * @param {Error} error - the navigation's failure; its message is extended
1565
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` for the reload's gate)
1566
+ * @returns {Promise<void>}
1567
+ */
1568
+ async restoreAfterRefusal(page, previousUrl, error, browserOptions) {
1569
+ const refused = gateRefusalCode(error) || /ERR_TUNNEL_CONNECTION_FAILED/.test(error?.message);
1570
+ if (!refused || !/^https?:\/\//i.test(previousUrl)) return;
1571
+ // A refused tunnel's error page commits after goto() has already rejected;
1572
+ // let it land first, or it interrupts the reload.
1573
+ if (page.url() === previousUrl) {
1574
+ await page.waitForURL((url) => url.href !== previousUrl, { timeout: 2000 }).catch(() => {});
1575
+ }
1576
+ try {
1577
+ await this.navigateToUrl(page, previousUrl, { browserOptions });
1578
+ error.message += ` The page was returned to ${previousUrl} by loading it again; ` +
1579
+ 'state held only in the page (unsaved form input, script state) was lost.';
1580
+ } catch (_) {
1581
+ error.message += ` The page could not be returned to ${previousUrl} and is now ${page.url()}.`;
1582
+ }
1583
+ }
1584
+
1480
1585
  /**
1481
1586
  * What the navigation that just finished reached: the page, or a wall. The
1482
1587
  * same verdict the chain's final document gets, run on this document with
@@ -27,6 +27,7 @@ import { fenceUntrusted } from '../utils/untrustedContent.js';
27
27
  import { stealthDocumentVerdict as documentVerdict } from '../utils/stealthVerdict.js';
28
28
  import { aBrowserMightPass } from '../tools/scrape/escalation.js';
29
29
  import { AGENT_MAX_ESCALATIONS } from '../tools/agent/escalation.js';
30
+ import { siteLabel, corroboratedOrigins, updateLabelledDates } from '../tools/agent/sourceChecks.js';
30
31
 
31
32
  const DEFAULT_WALL_CLOCK_MS = 120_000;
32
33
  const DEFAULT_MAX_STEPS = 5;
@@ -280,13 +281,6 @@ export function isCurrentStateTask(prompt) {
280
281
  return CURRENT_STATE_RE.test(prompt || '');
281
282
  }
282
283
 
283
- /** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
284
- function siteLabel(hostname) {
285
- const labels = hostname.toLowerCase().split('.');
286
- const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
287
- return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
288
- }
289
-
290
284
  /**
291
285
  * True when a search result is the entity's own site: its domain is the
292
286
  * entity's name (python.org for "Python"), or it is a site root whose title
@@ -580,15 +574,22 @@ export class AgentOrchestrator {
580
574
  // live front page, which rarely states the fact asked for: "what does
581
575
  // Southwest charge for a first checked bag" fetched southwest.com's home,
582
576
  // booking and careers pages and answered that the fee is not stated
583
- // (R20, 2026-09-07). Keep the entity query first — the domain vote and
584
- // live-root promotion depend on it — and add the task's own words as a
585
- // second query so the page that states the fact is in the queue too.
577
+ // (R20, 2026-09-07). Add the task's own words as a second query so the
578
+ // page that states the fact is in the queue too. A one-word name is also
579
+ // ambiguous: "Latest stable Rust version and its release date" searched
580
+ // "Rust" first and got the game and the film (live sweep, 2026-10-20).
581
+ // Then the task's words, its noun phrase intact, are searched first. The
582
+ // entity query is searched either way: the domain vote and live-root
583
+ // promotion below read its results.
584
+ const entityQuery = searchQueries[0];
585
+ const oneWordEntity = !/\s/.test(entityQuery.trim());
586
586
  if (currentState && searchQueries.length === 1) {
587
587
  try {
588
588
  const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
589
589
  const factQuery = clampSearchQuery(compactSearchTopic(prompt));
590
- if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
591
- searchQueries.push(factQuery);
590
+ if (factQuery && factQuery.toLowerCase() !== entityQuery.toLowerCase()) {
591
+ if (oneWordEntity) searchQueries.unshift(factQuery);
592
+ else searchQueries.push(factQuery);
592
593
  }
593
594
  } catch { /* the entity query alone is the pre-R20 behaviour */ }
594
595
  }
@@ -656,7 +657,6 @@ export class AgentOrchestrator {
656
657
  const official = new Set();
657
658
  // Only the bare entity query votes: the fact query added above returns
658
659
  // guides and news sites, which must not outvote the official domain.
659
- const entityQuery = searchQueries[0];
660
660
  for (const s of searchResults) {
661
661
  if (s.query !== entityQuery) continue;
662
662
  try {
@@ -665,6 +665,22 @@ export class AgentOrchestrator {
665
665
  if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
666
666
  } catch { /* unparsable result url */ }
667
667
  }
668
+ // For a one-word name, a site the task's own search does not surface is
669
+ // a namesake, not the subject: rust.facepunch.com, a root titled "Rust",
670
+ // passed as official for the Rust language and was blamed in the
671
+ // degraded reason (live sweep, 2026-10-20).
672
+ const onTopic = oneWordEntity
673
+ ? corroboratedOrigins(originCounts.keys(), searchResults.filter(s => s.query !== entityQuery).map(s => s.url))
674
+ : new Set(originCounts.keys());
675
+ for (const origin of official) if (!onTopic.has(origin)) official.delete(origin);
676
+ // The namesakes' pages are not fetched either: the game's site is not a
677
+ // source for the language's release.
678
+ for (let i = urlQueue.length - 1; i >= 0; i--) {
679
+ try {
680
+ if (!namedUrls.has(urlQueue[i]) && originCounts.has(new URL(urlQueue[i]).origin) &&
681
+ !onTopic.has(new URL(urlQueue[i]).origin)) urlQueue.splice(i, 1);
682
+ } catch { /* unparsable url stays */ }
683
+ }
668
684
  // The entity's own site wins over any vote count; only when no result
669
685
  // is official does the vote decide, and a tied vote decides nothing —
670
686
  // with one result per origin it would just promote the top-ranked one.
@@ -672,6 +688,7 @@ export class AgentOrchestrator {
672
688
  let bestCount = 0;
673
689
  let tied = false;
674
690
  for (const [origin, count] of originCounts) {
691
+ if (!onTopic.has(origin)) continue;
675
692
  if (official.size > 0 && !official.has(origin)) continue;
676
693
  // Strict > keeps the earliest-seen (top-ranked) origin on ties.
677
694
  if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
@@ -896,6 +913,7 @@ export class AgentOrchestrator {
896
913
  let unverified = [];
897
914
  let unverifiedLinks = [];
898
915
  let forumOnlyVersions = [];
916
+ let updateDates = [];
899
917
  // Distinct from `degraded`: a run can be degraded because its version
900
918
  // claims are unsupported and still have had its provenance checked.
901
919
  let synthesisFailed = false;
@@ -926,6 +944,7 @@ export class AgentOrchestrator {
926
944
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
927
945
  `- Read the sources carefully before concluding anything is missing from them.\n` +
928
946
  `- Cite the exact URL(s) you used: a source URL, or the URL an item has under "Links on this page".\n` +
947
+ `- For a date, use the one the sources give for the event itself (e.g. "released", "release date"), never a page's "last updated" or "modified" date, and quote the sentence or table row it came from.\n` +
929
948
  `- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
930
949
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
931
950
  `Provide a clear, concise answer.`;
@@ -933,6 +952,29 @@ export class AgentOrchestrator {
933
952
  const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
934
953
  answer = text;
935
954
 
955
+ // A page's "Last updated on 16 October 2026" is not the release date
956
+ // its table gives (15 Oct 2026), and the provenance check below passes
957
+ // it: the string is on the page (live sweep, 2026-10-20). A date the
958
+ // sources state only as an update date gets one corrective rewrite
959
+ // before the provenance check; what survives it is flagged below.
960
+ updateDates = updateLabelledDates(answer, evidence, prompt);
961
+ if (updateDates.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
962
+ const dateRetryPrompt =
963
+ `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
964
+ `It gives ${updateDates.map(d => `"${d.date}"`).join(', ')}, which the sources state only as the date a page was last updated or modified. ` +
965
+ `Rewrite the answer using the date the sources give for the event itself (e.g. "released", "release date", the date in the row of the version asked about), ` +
966
+ `and quote the sentence or table row it came from. If the sources give no such date, say so.`;
967
+ try {
968
+ const { text: rewritten } = await this._getSamplingClient().complete(dateRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
969
+ if (rewritten && rewritten.trim()) {
970
+ answer = rewritten;
971
+ updateDates = updateLabelledDates(answer, evidence, prompt);
972
+ }
973
+ } catch {
974
+ // Keep the first answer; it is flagged below.
975
+ }
976
+ }
977
+
936
978
  // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
937
979
  // published January 18, 2024" — none of it on any fetched page (R17,
938
980
  // 2026-09-04). Versions, dates and counts in the answer must appear in
@@ -1012,6 +1054,20 @@ export class AgentOrchestrator {
1012
1054
  }
1013
1055
  }
1014
1056
 
1057
+ // A provenance or version rewrite above may have changed the dates.
1058
+ updateDates = updateLabelledDates(answer, evidence, prompt);
1059
+ if (updateDates.length > 0) {
1060
+ answer +=
1061
+ `\n\nDate warning: ${updateDates.map(d => `"${d.date}"`).join(', ')} ` +
1062
+ `${updateDates.length === 1 ? 'is' : 'are'} the date a source page was last updated, not the date of the event asked about.`;
1063
+ if (!degraded) {
1064
+ degraded = true;
1065
+ degradedReason =
1066
+ `The answer gives a page-update date as the date asked for: ` +
1067
+ updateDates.map(d => `"${d.date}" is labelled as an update date on ${d.url}`).join('; ') + '.';
1068
+ }
1069
+ }
1070
+
1015
1071
  // A promoted live page is only worth its +1000 if the answer came from
1016
1072
  // it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
1017
1073
  // Main Page, and the HN question from thehackernews.com, both with
@@ -1069,7 +1125,8 @@ export class AgentOrchestrator {
1069
1125
  checked: !synthesisFailed,
1070
1126
  unverified,
1071
1127
  ...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
1072
- ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
1128
+ ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {}),
1129
+ ...(updateDates.length > 0 ? { update_dates: updateDates } : {})
1073
1130
  }
1074
1131
  };
1075
1132
  }
@@ -769,7 +769,7 @@ class AuthManager {
769
769
  : 'Browser operation. configure/get_stats/cleanup cost 1 credit each.';
770
770
  break;
771
771
  case 'browser_session':
772
- note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call bills ${projected}.`;
772
+ note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call's list price is ${projected}.`;
773
773
  break;
774
774
  case 'serp_rank':
775
775
  note = projected === 0
@@ -811,6 +811,12 @@ class AuthManager {
811
811
  note += ` redact_pii mode:"model" adds ${REDACT_PII_MODEL_CREDITS} once for the PERSON/LOCATION pass; the charge drops back when no LLM route answered.`;
812
812
  }
813
813
 
814
+ // Creator mode bills nothing, so withAuth reports _cost.projected and
815
+ // actual as 0; the note must not read as a charge next to them (6.10).
816
+ if (this.isCreatorMode()) {
817
+ note += ` This install is not billed (creator mode): the figures above are list prices, and _cost.projected and actual are 0.`;
818
+ }
819
+
814
820
  return { projected, note };
815
821
  }
816
822
 
@@ -28,6 +28,7 @@ const ChangeTrackingSchema = z.object({
28
28
  trackLinks: z.boolean().default(true),
29
29
  ignoreWhitespace: z.boolean().default(true),
30
30
  ignoreCase: z.boolean().default(false),
31
+ includeMarkupChanges: z.boolean().default(false),
31
32
  customSelectors: z.array(z.string()).optional(),
32
33
  excludeSelectors: z.array(z.string()).optional().default([
33
34
  'script', 'style', 'noscript', '.advertisement', '.ad'
@@ -344,10 +345,12 @@ export class ChangeTracker extends EventEmitter {
344
345
  const currentAnalysis = await this.analyzeContent(currentContent, baseline.options);
345
346
 
346
347
  // Perform comprehensive change detection
348
+ // includeMarkupChanges only shapes the response, so unlike the analysis
349
+ // options it may be asked for at compare time.
347
350
  const changeAnalysis = await this.detectChanges(
348
351
  baseline.analysis,
349
352
  currentAnalysis,
350
- baseline.options
353
+ { ...baseline.options, includeMarkupChanges: options.includeMarkupChanges ?? baseline.options.includeMarkupChanges }
351
354
  );
352
355
 
353
356
  // Calculate change significance
@@ -410,7 +413,16 @@ export class ChangeTracker extends EventEmitter {
410
413
  metrics: changeRecord.metrics,
411
414
  recommendations: this.generateChangeRecommendations(changeRecord),
412
415
  ...(alerts.length ? { alerts } : {}),
416
+ // ignoredOptions is the structured form of the warning: each entry
417
+ // names what the caller asked for and what the baseline applied, so
418
+ // a compare at granularity "text" against a "section" baseline says
419
+ // so in a field, not only in prose (6.12).
413
420
  ...(ignoredOptions.length ? {
421
+ ignoredOptions: ignoredOptions.map(option => ({
422
+ option,
423
+ requested: options[option],
424
+ applied: baseline.options[option] ?? null
425
+ })),
414
426
  warnings: [
415
427
  `${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
416
428
  `the baseline's options are applied to both sides of the diff. Recreate the baseline to change them.`
@@ -615,13 +627,23 @@ export class ChangeTracker extends EventEmitter {
615
627
  changes.modifiedElements.push(...elementChanges.modified);
616
628
  }
617
629
 
618
- // Detect text changes
630
+ // Detect text changes. Diffed over the visible text, not the raw HTML,
631
+ // so a class rename or icon markup no longer reads as a text change
632
+ // (live sweep 4.1). The raw markup diff is kept apart in markupChanges
633
+ // and only computed when the caller asks for it.
619
634
  if (options.trackText) {
620
635
  changes.textChanges = await this.detectTextChanges(
621
- baseline.originalContent,
622
- current.originalContent,
636
+ this.visibleText(baseline.originalContent),
637
+ this.visibleText(current.originalContent),
623
638
  options
624
639
  );
640
+ if (options.includeMarkupChanges) {
641
+ changes.markupChanges = await this.detectTextChanges(
642
+ baseline.originalContent,
643
+ current.originalContent,
644
+ options
645
+ );
646
+ }
625
647
  }
626
648
 
627
649
  // Detect monetary value changes. Scored by magnitude rather than by how
@@ -1038,6 +1060,32 @@ export class ChangeTracker extends EventEmitter {
1038
1060
  return changes;
1039
1061
  }
1040
1062
 
1063
+ /**
1064
+ * The text a reader sees: text nodes in document order, one per line, with
1065
+ * script, style, noscript and template content dropped. Input with no
1066
+ * markup (a plain-text content param) is returned unchanged.
1067
+ * @param {string} content - HTML or plain text
1068
+ * @returns {string} - Visible text
1069
+ */
1070
+ visibleText(content = '') {
1071
+ if (!/<[a-z!/]/i.test(content)) return content;
1072
+ const $ = load(content);
1073
+ $('script, style, noscript, template').remove();
1074
+ const parts = [];
1075
+ const walk = nodes => {
1076
+ for (const node of nodes) {
1077
+ if (node.type === 'text') {
1078
+ const text = node.data.trim();
1079
+ if (text) parts.push(text);
1080
+ } else if (node.children) {
1081
+ walk(node.children);
1082
+ }
1083
+ }
1084
+ };
1085
+ walk($.root().contents().toArray());
1086
+ return parts.join('\n');
1087
+ }
1088
+
1041
1089
  async detectTextChanges(baselineContent, currentContent, options = {}) {
1042
1090
  const textChanges = [];
1043
1091