crawlforge-mcp-server 6.7.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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:
@@ -9,6 +9,7 @@ import { CacheManager } from './cache/CacheManager.js';
9
9
  import { Logger } from '../utils/Logger.js';
10
10
  import { LLMManager } from './llm/LLMManager.js';
11
11
  import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
12
+ import { serverStealthProxies } from '../constants/config.js';
12
13
  import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
13
14
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
14
15
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
@@ -143,6 +144,12 @@ export class ResearchOrchestrator extends EventEmitter {
143
144
  stealthEngine = process.env.RESEARCH_STEALTH_ENGINE || 'auto',
144
145
  stealthLevel = 'medium',
145
146
  stealthTimeoutMs = 20000,
147
+ // Proxy list for the stealth fallback, in proxyRotation's URL format. The
148
+ // caller's list always wins; with none, the server-level
149
+ // CRAWLFORGE_STEALTH_PROXIES applies — this path launches Camoufox itself
150
+ // rather than through StealthBrowserManager, so it does not inherit that
151
+ // manager's proxy fallback and has to resolve one here.
152
+ stealthProxies = serverStealthProxies(),
146
153
  searchConfig = {},
147
154
  crawlConfig = {},
148
155
  extractConfig = {},
@@ -164,6 +171,7 @@ export class ResearchOrchestrator extends EventEmitter {
164
171
  this.stealthEngine = stealthEngine;
165
172
  this.stealthLevel = stealthLevel;
166
173
  this.stealthTimeoutMs = stealthTimeoutMs;
174
+ this.stealthProxies = Array.isArray(stealthProxies) ? stealthProxies : [];
167
175
  this._stealthManager = null; // Chromium StealthBrowserManager (fallback engine)
168
176
  this._stealthBrowser = null; // Camoufox browser handle (preferred engine)
169
177
  this._stealthEngineActive = null;
@@ -952,6 +960,11 @@ export class ResearchOrchestrator extends EventEmitter {
952
960
  if (!this._stealthInit) {
953
961
  this._stealthInit = (async () => {
954
962
  if (this.stealthEngine === 'camoufox' || this.stealthEngine === 'auto') {
963
+ // Resolved outside the try: a malformed proxy must fail loudly, not be
964
+ // swallowed by the 'auto' fallback into a proxyless Chromium run —
965
+ // "the caller believes their traffic is proxied and it is not" is the
966
+ // failure mode this plumbing exists to prevent.
967
+ const proxy = this._resolveStealthProxy();
955
968
  try {
956
969
  const { createRequire } = await import('module');
957
970
  const require = createRequire(import.meta.url);
@@ -960,15 +973,35 @@ export class ResearchOrchestrator extends EventEmitter {
960
973
  // A page whose own JavaScript throws must not take the process with
961
974
  // it — see src/utils/firefoxPageErrorGuard.js.
962
975
  guardFirefoxPageErrors();
963
- this._stealthBrowser = await camoufox.Camoufox({ headless: true });
976
+ const launchOptions = { headless: true };
977
+ if (proxy) {
978
+ // camoufox takes the URL string and splits the credentials out
979
+ // itself (getProxyUrl → Playwright's {server,username,password}).
980
+ launchOptions.proxy = proxy.url;
981
+ // Same rule as StealthBrowserManager (`geoip: !!proxy`): behind a
982
+ // proxy, geoip derives timezone, locale and geolocation from the
983
+ // exit IP, which is the only thing that makes a proxied browser
984
+ // coherent. Without a proxy it would look up this machine's own
985
+ // public IP — a network call, and an external service learning
986
+ // our address, to confirm a location the browser is already in.
987
+ // No locale is passed either way here, so geoip's derived one
988
+ // has nothing to contradict.
989
+ launchOptions.geoip = true;
990
+ }
991
+ this._stealthBrowser = await camoufox.Camoufox(launchOptions);
964
992
  this._stealthEngineActive = 'camoufox';
965
- this.logger.info('Stealth fallback using Camoufox (Firefox) engine');
993
+ this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
994
+ proxy: proxy ? proxy.redacted : null
995
+ });
966
996
  return;
967
997
  } catch (e) {
968
998
  if (this.stealthEngine === 'camoufox') throw e; // explicit request → surface
969
999
  this.logger.warn('Camoufox unavailable, falling back to Chromium stealth', { error: e.message });
970
1000
  }
971
1001
  }
1002
+ // No proxy wiring on this branch: StealthBrowserManager resolves its own
1003
+ // (resolveProxy), server-level list included, so passing one here would
1004
+ // be a second path to the same setting.
972
1005
  const { StealthBrowserManager } = await import('./StealthBrowserManager.js');
973
1006
  this._stealthManager = new StealthBrowserManager();
974
1007
  await this._stealthManager.launchStealthBrowser({ level: this.stealthLevel });
@@ -978,6 +1011,43 @@ export class ResearchOrchestrator extends EventEmitter {
978
1011
  await this._stealthInit;
979
1012
  }
980
1013
 
1014
+ /**
1015
+ * The proxy this stealth fallback launches behind: the caller's list when they
1016
+ * configured one, otherwise the server-level CRAWLFORGE_STEALTH_PROXIES.
1017
+ * Returns null when neither is set, and `{ url, redacted }` otherwise — only
1018
+ * the redacted form (scheme + host, no credentials) is ever logged.
1019
+ *
1020
+ * Resolved once per browser, taking the first entry: camoufox fixes its
1021
+ * fingerprint — and with geoip its timezone, locale and geolocation — at
1022
+ * launch, so rotating underneath it would leave it reporting the first
1023
+ * proxy's city behind the second proxy's exit IP, which is a worse signal
1024
+ * than not rotating at all (see StealthBrowserManager._doLaunchStealthBrowser).
1025
+ */
1026
+ _resolveStealthProxy() {
1027
+ const raw = (this.stealthProxies[0] || '').trim();
1028
+ if (!raw) return null;
1029
+
1030
+ // `host:port` with no scheme parses as protocol "host:" and an empty host,
1031
+ // so the bare form gets the http:// every proxy list assumes.
1032
+ const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
1033
+ let parsed;
1034
+ try {
1035
+ parsed = new URL(url);
1036
+ } catch {
1037
+ // Never echo the entry: it carries the password, and camoufox's own
1038
+ // `new URL(proxy)` would quote the whole string in its TypeError.
1039
+ throw new Error('Invalid stealth proxy: expected host:port or scheme://user:pass@host:port');
1040
+ }
1041
+ const scheme = parsed.protocol.replace(':', '').toLowerCase();
1042
+ if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) {
1043
+ throw new Error(`Invalid stealth proxy scheme "${scheme}": expected http, https, socks4 or socks5`);
1044
+ }
1045
+ if (!parsed.hostname) {
1046
+ throw new Error('Invalid stealth proxy: no host');
1047
+ }
1048
+ return { url, redacted: `${parsed.protocol}//${parsed.host}` };
1049
+ }
1050
+
981
1051
  /**
982
1052
  * macOS packaging fix for camoufox-js: it expects properties.json in
983
1053
  * Camoufox.app/Contents/MacOS/, but the .app bundle ships it under