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