crawlforge-mcp-server 6.22.2 → 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 (54) 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 +133 -23
  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 +29 -3
  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/research/claimFilters.js +20 -1
  19. package/src/core/workers/worker.js +2 -0
  20. package/src/schemas/toolOutputSchemas.js +3 -2
  21. package/src/server/fallbackHints.js +5 -1
  22. package/src/server/inlineThreshold.js +19 -11
  23. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  26. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  31. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  32. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  33. package/src/tools/agent/sourceChecks.js +103 -0
  34. package/src/tools/basic/extractMetadata.js +9 -4
  35. package/src/tools/basic/extractText.js +6 -4
  36. package/src/tools/basic/fetchUrl.js +3 -0
  37. package/src/tools/basic/scrapeStructured.js +66 -21
  38. package/src/tools/crawl/mapSite.js +18 -3
  39. package/src/tools/extract/extractStructured.js +134 -1
  40. package/src/tools/extract/processDocument.js +48 -5
  41. package/src/tools/extract/summarizeContent.js +123 -5
  42. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  43. package/src/tools/result/readResult.js +79 -10
  44. package/src/tools/scrape/_mainContent.js +97 -6
  45. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  46. package/src/tools/search/searchWeb.js +46 -15
  47. package/src/tools/search/serpRank.js +1 -1
  48. package/src/tools/tracking/trackChanges/index.js +1 -0
  49. package/src/tools/tracking/trackChanges/schema.js +2 -1
  50. package/src/utils/htmlToMarkdown.js +77 -1
  51. package/src/utils/localFilePolicy.js +65 -0
  52. package/src/utils/provenance.js +102 -1
  53. package/src/utils/ssrfGuard.js +4 -3
  54. 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.2",
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.2",
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;
@@ -161,13 +162,15 @@ export function unverifiedUrls(answer, citable, sourceText = '') {
161
162
  });
162
163
  }
163
164
 
164
- /** Absolute http(s) hrefs of the links on a parsed page. */
165
+ /** The http(s) links on a parsed page: absolute href and anchor text. */
165
166
  function pageLinks($, baseUrl) {
166
167
  const links = [];
167
168
  $('a[href]').each((_, el) => {
168
169
  try {
169
170
  const url = new URL($(el).attr('href'), baseUrl);
170
- if (url.protocol === 'http:' || url.protocol === 'https:') links.push(url.href);
171
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
172
+ links.push({ url: url.href, text: $(el).text().replace(/\s+/g, ' ').trim() });
173
+ }
171
174
  } catch {
172
175
  // An unparsable href cites nothing.
173
176
  }
@@ -175,6 +178,38 @@ function pageLinks($, baseUrl) {
175
178
  return links;
176
179
  }
177
180
 
181
+ /** Most links one source lists under its text in the synthesis prompt. */
182
+ const SOURCE_LINKS_MAX = 8;
183
+
184
+ /**
185
+ * The links a source lists under its text in the synthesis prompt. Page text
186
+ * carries no link targets: the model could name the current HN top story but
187
+ * not link it, and cited an item URL from memory (R25 re-check, 2026-10-19).
188
+ * Listed: links whose anchor text is printed in the excerpt the model reads
189
+ * and is long enough to name something (three words: a title, not "97
190
+ * comments" or "next"), links to the page itself left out. Those whose anchor
191
+ * text carries one of the task's terms come first, then page order.
192
+ *
193
+ * @param {{url: string, text: string}[]} links
194
+ * @param {string} excerpt the source text the prompt carries
195
+ * @param {string} pageUrl
196
+ * @param {string[]} terms the task's terms, lowercase
197
+ * @returns {{url: string, text: string}[]}
198
+ */
199
+ export function linksForSynthesis(links, excerpt, pageUrl, terms) {
200
+ const self = citationKey(pageUrl);
201
+ const seen = new Set();
202
+ const listed = (links || []).filter(({ url, text }) => {
203
+ const key = citationKey(url);
204
+ if (!key || key === self || seen.has(key)) return false;
205
+ if (text.split(' ').length < 3 || !excerpt.includes(text)) return false;
206
+ seen.add(key);
207
+ return true;
208
+ });
209
+ const onTask = link => terms.some(t => link.text.toLowerCase().includes(t));
210
+ return [...listed.filter(onTask), ...listed.filter(l => !onTask(l))].slice(0, SOURCE_LINKS_MAX);
211
+ }
212
+
178
213
  /**
179
214
  * Current-state task gate: prompts about the live "now" ("right now",
180
215
  * "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
@@ -246,13 +281,6 @@ export function isCurrentStateTask(prompt) {
246
281
  return CURRENT_STATE_RE.test(prompt || '');
247
282
  }
248
283
 
249
- /** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
250
- function siteLabel(hostname) {
251
- const labels = hostname.toLowerCase().split('.');
252
- const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
253
- return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
254
- }
255
-
256
284
  /**
257
285
  * True when a search result is the entity's own site: its domain is the
258
286
  * entity's name (python.org for "Python"), or it is a site root whose title
@@ -546,15 +574,22 @@ export class AgentOrchestrator {
546
574
  // live front page, which rarely states the fact asked for: "what does
547
575
  // Southwest charge for a first checked bag" fetched southwest.com's home,
548
576
  // booking and careers pages and answered that the fee is not stated
549
- // (R20, 2026-09-07). Keep the entity query first — the domain vote and
550
- // live-root promotion depend on it — and add the task's own words as a
551
- // 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());
552
586
  if (currentState && searchQueries.length === 1) {
553
587
  try {
554
588
  const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
555
589
  const factQuery = clampSearchQuery(compactSearchTopic(prompt));
556
- if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
557
- searchQueries.push(factQuery);
590
+ if (factQuery && factQuery.toLowerCase() !== entityQuery.toLowerCase()) {
591
+ if (oneWordEntity) searchQueries.unshift(factQuery);
592
+ else searchQueries.push(factQuery);
558
593
  }
559
594
  } catch { /* the entity query alone is the pre-R20 behaviour */ }
560
595
  }
@@ -622,7 +657,6 @@ export class AgentOrchestrator {
622
657
  const official = new Set();
623
658
  // Only the bare entity query votes: the fact query added above returns
624
659
  // guides and news sites, which must not outvote the official domain.
625
- const entityQuery = searchQueries[0];
626
660
  for (const s of searchResults) {
627
661
  if (s.query !== entityQuery) continue;
628
662
  try {
@@ -631,6 +665,22 @@ export class AgentOrchestrator {
631
665
  if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
632
666
  } catch { /* unparsable result url */ }
633
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
+ }
634
684
  // The entity's own site wins over any vote count; only when no result
635
685
  // is official does the vote decide, and a tied vote decides nothing —
636
686
  // with one result per origin it would just promote the top-ranked one.
@@ -638,6 +688,7 @@ export class AgentOrchestrator {
638
688
  let bestCount = 0;
639
689
  let tied = false;
640
690
  for (const [origin, count] of originCounts) {
691
+ if (!onTopic.has(origin)) continue;
641
692
  if (official.size > 0 && !official.has(origin)) continue;
642
693
  // Strict > keeps the earliest-seen (top-ranked) origin on ties.
643
694
  if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
@@ -665,6 +716,8 @@ export class AgentOrchestrator {
665
716
  // URLs an answer may cite: every evidence page's URL (requested and
666
717
  // final) and the links on it.
667
718
  const citable = [];
719
+ /** evidence url -> the links on that page, for the synthesis prompt. */
720
+ const linksOf = new Map();
668
721
  let urlsFetched = 0;
669
722
  let step = 0;
670
723
  const warnings = [];
@@ -700,7 +753,9 @@ export class AgentOrchestrator {
700
753
  text: truncate(plain.textContent),
701
754
  step
702
755
  });
703
- citable.push(url, plain.finalUrl, ...pageLinks(plain.$, plain.finalUrl));
756
+ const links = pageLinks(plain.$, plain.finalUrl);
757
+ linksOf.set(plain.finalUrl, links);
758
+ citable.push(url, plain.finalUrl, ...links.map(l => l.url));
704
759
  continue;
705
760
  }
706
761
  // A real page that is simply off-topic: nothing to retry.
@@ -745,7 +800,9 @@ export class AgentOrchestrator {
745
800
  via: 'stealth',
746
801
  step
747
802
  });
748
- citable.push(url, stealth.url || url, ...pageLinks(load(stealth.html || ''), stealth.url || url));
803
+ const links = pageLinks(load(stealth.html || ''), stealth.url || url);
804
+ linksOf.set(stealth.url || url, links);
805
+ citable.push(url, stealth.url || url, ...links.map(l => l.url));
749
806
  continue;
750
807
  }
751
808
  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})` : ''}`}`);
@@ -782,7 +839,12 @@ export class AgentOrchestrator {
782
839
  const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
783
840
  .filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
784
841
  const combinedText = orderedEvidence
785
- .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
842
+ .map(e => {
843
+ const excerpt = sliceForSynthesis(e.text, perSourceCap, sliceTerms);
844
+ const links = linksForSynthesis(linksOf.get(e.url), excerpt, e.url, sliceTerms);
845
+ return `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${excerpt}` +
846
+ (links.length > 0 ? `\nLinks on this page:\n${links.map(l => `- ${l.text}: ${l.url}`).join('\n')}` : '');
847
+ })
786
848
  .join('\n\n');
787
849
 
788
850
  if (!combinedText.trim()) {
@@ -851,6 +913,7 @@ export class AgentOrchestrator {
851
913
  let unverified = [];
852
914
  let unverifiedLinks = [];
853
915
  let forumOnlyVersions = [];
916
+ let updateDates = [];
854
917
  // Distinct from `degraded`: a run can be degraded because its version
855
918
  // claims are unsupported and still have had its provenance checked.
856
919
  let synthesisFailed = false;
@@ -880,7 +943,8 @@ export class AgentOrchestrator {
880
943
  : '') +
881
944
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
882
945
  `- Read the sources carefully before concluding anything is missing from them.\n` +
883
- `- Cite the exact source URL(s) you used.\n` +
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` +
884
948
  `- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
885
949
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
886
950
  `Provide a clear, concise answer.`;
@@ -888,6 +952,29 @@ export class AgentOrchestrator {
888
952
  const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
889
953
  answer = text;
890
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
+
891
978
  // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
892
979
  // published January 18, 2024" — none of it on any fetched page (R17,
893
980
  // 2026-09-04). Versions, dates and counts in the answer must appear in
@@ -967,18 +1054,40 @@ export class AgentOrchestrator {
967
1054
  }
968
1055
  }
969
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
+
970
1071
  // A promoted live page is only worth its +1000 if the answer came from
971
1072
  // it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
972
1073
  // Main Page, and the HN question from thehackernews.com, both with
973
1074
  // degraded:false. The answer counts as the live page's when it cites
974
- // that host or states a value (version, date, number) the page shows.
1075
+ // that host, states a value (version, date, number) the page shows, or
1076
+ // names an item the page links (anchor text of three words or more:
1077
+ // the HN title "DeepSeek just open-sourced its inference engine",
1078
+ // 2026-10-20). An invented URL on that host is no citation of it: it is
1079
+ // taken out before the host is looked for.
975
1080
  if (currentState && promotedOrigin && !degraded) {
976
1081
  const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
977
1082
  const liveHost = host(promotedOrigin);
978
- const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
1083
+ const livePages = evidence.filter(e => host(e.url) === liveHost);
1084
+ const liveText = livePages.map(e => e.text || '').join('\n');
979
1085
  // unverifiedValues against an empty text lists every value the answer states.
980
1086
  const stated = unverifiedValues(answer, '');
981
- const fromLivePage = answer.toLowerCase().includes(liveHost) ||
1087
+ const cited = unverifiedLinks.reduce((text, url) => text.split(url).join(' '), answer).toLowerCase();
1088
+ const namesLiveItem = livePages.some(e => (linksOf.get(e.url) || [])
1089
+ .some(l => l.text.split(' ').length >= 3 && cited.includes(l.text.toLowerCase())));
1090
+ const fromLivePage = cited.includes(liveHost) || namesLiveItem ||
982
1091
  (liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
983
1092
  if (!fromLivePage) {
984
1093
  degraded = true;
@@ -1016,7 +1125,8 @@ export class AgentOrchestrator {
1016
1125
  checked: !synthesisFailed,
1017
1126
  unverified,
1018
1127
  ...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
1019
- ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
1128
+ ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {}),
1129
+ ...(updateDates.length > 0 ? { update_dates: updateDates } : {})
1020
1130
  }
1021
1131
  };
1022
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