@lincoln504/pi-research 1.3.9 → 1.3.10

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/dist/cli.mjs CHANGED
@@ -2662,6 +2662,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2662
2662
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
2663
2663
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
2664
2664
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
2665
+ PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
2665
2666
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
2666
2667
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
2667
2668
  ...config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {},
@@ -2826,6 +2827,7 @@ function createConfig(env, processEnv) {
2826
2827
  LLM_THINKING_LEVEL: parseEnvEnum(e, "PI_RESEARCH_LLM_THINKING_LEVEL", ["off", "minimal", "low", "medium", "high"], DEFAULTS.LLM_THINKING_LEVEL),
2827
2828
  PLANNING_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_PLANNING_MAX_TOKENS", DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
2828
2829
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_SYNTHESIS_MAX_TOKENS", DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
2830
+ RESEARCHER_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_RESEARCHER_MAX_TOKENS", DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
2829
2831
  MIGRATION_STRATEGY: parseEnvEnum(e, "PI_RESEARCH_MIGRATION_STRATEGY", ["drop", "re-embed", "backup"], DEFAULTS.MIGRATION_STRATEGY),
2830
2832
  CONSOLE_LOG: parseEnvBool(e, "PI_RESEARCH_CONSOLE_LOG", DEFAULTS.CONSOLE_LOG),
2831
2833
  RESEARCH_MODEL: parseEnvString(e, "PI_RESEARCH_MODEL", DEFAULTS.RESEARCH_MODEL),
@@ -3013,6 +3015,15 @@ var init_config = __esm({
3013
3015
  * the model's real maxTokens at call time.
3014
3016
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
3015
3017
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
3018
+ /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
3019
+ * Without a cap the session inherits the model's full catalog ceiling as the
3020
+ * per-request max_tokens — ~230k for large-context models — which providers that
3021
+ * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
3022
+ * reject outright on low balances, even though the actual response would cost
3023
+ * cents. Default 16384: researcher turns are tool calls plus a cited report, far
3024
+ * below this. Clamped to the model's real ceiling at session build time.
3025
+ * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
3026
+ RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
3016
3027
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
3017
3028
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
3018
3029
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -3090,6 +3101,7 @@ var init_config = __esm({
3090
3101
  "PI_RESEARCH_LLM_THINKING_LEVEL",
3091
3102
  "PI_RESEARCH_PLANNING_MAX_TOKENS",
3092
3103
  "PI_RESEARCH_SYNTHESIS_MAX_TOKENS",
3104
+ "PI_RESEARCH_RESEARCHER_MAX_TOKENS",
3093
3105
  "PI_RESEARCH_MIGRATION_STRATEGY",
3094
3106
  "PI_RESEARCH_CONSOLE_LOG",
3095
3107
  "PI_RESEARCH_MODEL",
@@ -3132,8 +3144,7 @@ function getMaxScrapeBatches(config) {
3132
3144
  }
3133
3145
  }
3134
3146
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3135
- const base = callerExclusions && callerExclusions.length > 0 ? callerExclusions : DEFAULT_EXCLUDED_TOOLS;
3136
- return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
3147
+ return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3137
3148
  }
3138
3149
  var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
3139
3150
  var init_constants = __esm({
@@ -17024,17 +17035,30 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
17024
17035
  return callback(null, safe[0].address, safe[0].family);
17025
17036
  });
17026
17037
  }
17027
- var cachedSsrfDispatcher = void 0;
17028
- async function getSsrfSafeDispatcher() {
17029
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
17038
+ function isSsrfBlockError(error) {
17039
+ let e = error;
17040
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
17041
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
17042
+ return true;
17043
+ }
17044
+ e = e.cause;
17045
+ }
17046
+ return false;
17047
+ }
17048
+ var cachedSsrfFetcher = void 0;
17049
+ async function getSsrfSafeFetcher() {
17050
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
17030
17051
  try {
17031
- const { Agent: Agent2 } = await import("undici");
17032
- cachedSsrfDispatcher = new Agent2({ connect: { lookup: ssrfSafeLookup } });
17052
+ const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
17053
+ cachedSsrfFetcher = {
17054
+ fetch: undiciFetch,
17055
+ dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
17056
+ };
17033
17057
  } catch (e) {
17034
17058
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
17035
- cachedSsrfDispatcher = null;
17059
+ cachedSsrfFetcher = null;
17036
17060
  }
17037
- return cachedSsrfDispatcher;
17061
+ return cachedSsrfFetcher;
17038
17062
  }
17039
17063
  function formatErrorWithCause(err) {
17040
17064
  try {
@@ -17284,7 +17308,7 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
17284
17308
  }
17285
17309
  }
17286
17310
  function trackFetchLayerFailure(url, error) {
17287
- const isSsrf = error instanceof Error && error.message.includes("not allowed");
17311
+ const isSsrf = isSsrfBlockError(error);
17288
17312
  errorTracker.trackError(error instanceof Error ? error : String(error), {
17289
17313
  component: "scrapers",
17290
17314
  operation: "fetch",
@@ -17296,7 +17320,8 @@ function trackFetchLayerFailure(url, error) {
17296
17320
  }
17297
17321
  async function scrapeWithFetch(url, signal) {
17298
17322
  await validateUrlForSSRF(url);
17299
- const dispatcher = await getSsrfSafeDispatcher();
17323
+ const fetcher = await getSsrfSafeFetcher();
17324
+ const doFetch = fetcher?.fetch ?? ((u, init) => fetch(u, init));
17300
17325
  const controller = new AbortController();
17301
17326
  const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
17302
17327
  safeUnref(timeoutId);
@@ -17315,15 +17340,15 @@ async function scrapeWithFetch(url, signal) {
17315
17340
  let response;
17316
17341
  const userAgent = getRandomUserAgent();
17317
17342
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
17318
- response = await fetch(currentUrl, {
17343
+ response = await doFetch(currentUrl, {
17319
17344
  signal: controller.signal,
17320
17345
  redirect: "manual",
17321
17346
  headers: {
17322
17347
  "User-Agent": userAgent,
17323
17348
  "Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8"
17324
17349
  },
17325
- // RequestInit's standard type omits undici's `dispatcher`; cast to attach it.
17326
- ...dispatcher ? { dispatcher } : {}
17350
+ // Only ever attached alongside the fetch that honours it (see above).
17351
+ ...fetcher ? { dispatcher: fetcher.dispatcher } : {}
17327
17352
  });
17328
17353
  if (response.status >= 300 && response.status < 400) {
17329
17354
  const location = response.headers.get("location");
@@ -17424,7 +17449,7 @@ async function scrapeWithFetch(url, signal) {
17424
17449
  metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "html", status: "success" });
17425
17450
  return { source: "fetch", layer: "fetch", markdown };
17426
17451
  } catch (error) {
17427
- if (error instanceof Error && error.message.includes("not allowed")) {
17452
+ if (isSsrfBlockError(error)) {
17428
17453
  metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
17429
17454
  metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
17430
17455
  throw error;
@@ -21524,6 +21549,8 @@ async function createResearcherSession(options) {
21524
21549
  cwd
21525
21550
  });
21526
21551
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
21552
+ const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
21553
+ const sessionModel = modelToUse.maxTokens > outputCap ? { ...modelToUse, maxTokens: outputCap } : modelToUse;
21527
21554
  try {
21528
21555
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
21529
21556
  const globalLinks = updateGlobalLinks || (() => {
@@ -21564,7 +21591,7 @@ async function createResearcherSession(options) {
21564
21591
  sessionManager: SessionManager.inMemory(),
21565
21592
  // Each researcher gets its own isolated session
21566
21593
  settingsManager: researcherSettings,
21567
- model: modelToUse,
21594
+ model: sessionModel,
21568
21595
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
21569
21596
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
21570
21597
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -21595,7 +21622,7 @@ async function createResearcherSession(options) {
21595
21622
  throw new Error("Session creation returned invalid result");
21596
21623
  }
21597
21624
  sessionRef.session = result.session;
21598
- return { session: result.session, resolvedModel: modelToUse };
21625
+ return { session: result.session, resolvedModel: sessionModel };
21599
21626
  } catch (tailError) {
21600
21627
  const session = result?.session;
21601
21628
  if (session && typeof session.abort === "function") {
@@ -21618,7 +21645,7 @@ async function createResearcherSession(options) {
21618
21645
 
21619
21646
  // src/orchestration/researcher-executor.ts
21620
21647
  init_text_utils();
21621
- init_llm();
21648
+ init_llm_usage();
21622
21649
  init_logger();
21623
21650
  init_metrics();
21624
21651
  init_service_interfaces();
@@ -22054,12 +22081,13 @@ ${prompt}`);
22054
22081
  if (msg2?.["role"] !== "assistant") return;
22055
22082
  const rawUsage = msg2["usage"];
22056
22083
  if (rawUsage) {
22057
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
22084
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
22085
+ component: "researcher",
22086
+ complexity,
22087
+ observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) }
22088
+ });
22058
22089
  if (tokens > 0 || cost > 0) {
22059
- metrics.increment("llm_tokens_total", tokens, { component: "researcher", complexity: String(complexity) });
22060
- metrics.increment("llm_cost_total", cost, { component: "researcher", complexity: String(complexity) });
22061
22090
  safeObserve(() => observer?.onResearcherProgress?.(id, void 0, tokens, cost));
22062
- safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
22063
22091
  }
22064
22092
  }
22065
22093
  } else if (event.type === "tool_execution_start") {
@@ -22186,7 +22214,7 @@ ${responseText}`);
22186
22214
 
22187
22215
  // src/orchestration/quick-research-orchestrator.ts
22188
22216
  init_prompts();
22189
- init_llm();
22217
+ init_llm_usage();
22190
22218
  init_logger();
22191
22219
  init_config();
22192
22220
  init_text_utils();
@@ -22467,12 +22495,13 @@ ${prompt}`);
22467
22495
  if (msg2?.["role"] !== "assistant") return;
22468
22496
  const rawUsage = msg2["usage"];
22469
22497
  if (rawUsage) {
22470
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
22498
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
22499
+ component: "quick_researcher",
22500
+ complexity: 0,
22501
+ observer
22502
+ });
22471
22503
  if (tokens > 0 || cost > 0) {
22472
- metrics.increment("llm_tokens_total", tokens, { component: "quick_researcher", complexity: "0" });
22473
- metrics.increment("llm_cost_total", cost, { component: "quick_researcher", complexity: "0" });
22474
22504
  observer?.onResearcherProgress?.("quick", void 0, tokens, cost);
22475
- observer?.onTokensConsumed?.(tokens, cost);
22476
22505
  }
22477
22506
  }
22478
22507
  } else if (event.type === "tool_execution_start") {
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
1512
1512
  extractDomain: () => extractDomain,
1513
1513
  formatErrorWithCause: () => formatErrorWithCause,
1514
1514
  getRandomUserAgent: () => getRandomUserAgent,
1515
- getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1515
+ getSsrfSafeFetcher: () => getSsrfSafeFetcher,
1516
1516
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1517
+ isSsrfBlockError: () => isSsrfBlockError,
1517
1518
  ssrfSafeLookup: () => ssrfSafeLookup,
1518
1519
  stripImageLinks: () => stripImageLinks,
1519
1520
  validateContent: () => validateContent,
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
1633
1634
  return callback(null, safe[0].address, safe[0].family);
1634
1635
  });
1635
1636
  }
1636
- async function getSsrfSafeDispatcher() {
1637
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
1637
+ function isSsrfBlockError(error) {
1638
+ let e = error;
1639
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
1640
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
1641
+ return true;
1642
+ }
1643
+ e = e.cause;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ async function getSsrfSafeFetcher() {
1648
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1638
1649
  try {
1639
- const { Agent } = await import("undici");
1640
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
1650
+ const { Agent, fetch: undiciFetch } = await import("undici");
1651
+ cachedSsrfFetcher = {
1652
+ fetch: undiciFetch,
1653
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
+ };
1641
1655
  } catch (e) {
1642
1656
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1643
- cachedSsrfDispatcher = null;
1657
+ cachedSsrfFetcher = null;
1644
1658
  }
1645
- return cachedSsrfDispatcher;
1659
+ return cachedSsrfFetcher;
1646
1660
  }
1647
1661
  function formatErrorWithCause(err) {
1648
1662
  try {
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
1776
1790
  throw error;
1777
1791
  }
1778
1792
  }
1779
- var cachedSsrfDispatcher;
1793
+ var cachedSsrfFetcher;
1780
1794
  var init_scraper_utils = __esm({
1781
1795
  "src/web-research/scraper-utils.ts"() {
1782
1796
  "use strict";
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
1785
1799
  init_logger();
1786
1800
  init_scraper_types();
1787
1801
  init_browser_error_utils();
1788
- cachedSsrfDispatcher = void 0;
1802
+ cachedSsrfFetcher = void 0;
1789
1803
  }
1790
1804
  });
1791
1805
 
@@ -2530,6 +2544,15 @@ var ConfigSchema = Type.Object({
2530
2544
  * the model's real maxTokens at call time.
2531
2545
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2532
2546
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
+ /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
2548
+ * Without a cap the session inherits the model's full catalog ceiling as the
2549
+ * per-request max_tokens — ~230k for large-context models — which providers that
2550
+ * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
2551
+ * reject outright on low balances, even though the actual response would cost
2552
+ * cents. Default 16384: researcher turns are tool calls plus a cited report, far
2553
+ * below this. Clamped to the model's real ceiling at session build time.
2554
+ * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
2555
+ RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2533
2556
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2534
2557
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2535
2558
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -115,12 +115,14 @@ agent to invoke:
115
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
116
116
  out URLs directly.
117
117
 
118
- `grep` is excluded by default at **every** depth and on every front-end (CLI, SDK, agent
119
- skill, pi extension): this is web research, and a capable model otherwise spends turns
120
- searching the local filesystem. Passing an explicit `excludeTools` list — `--exclude-tools`
121
- on the CLI, the `excludeTools` tool parameter in the extension — REPLACES that default, so
122
- naming other tools re-enables `grep`. `PI_RESEARCH_DISABLED_TOOLS` is separate and strictly
123
- additive: it can only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)).
118
+ `grep` is excluded at **every** depth and on every front-end (CLI, SDK, agent skill,
119
+ pi extension): this is web research, and a capable model otherwise spends turns
120
+ searching the local filesystem. Both exclusion surfaces — the `excludeTools` list
121
+ (`--exclude-tools` on the CLI, the `excludeTools` tool parameter in the extension) and
122
+ `PI_RESEARCH_DISABLED_TOOLS` — are strictly additive on top of that default: they can
123
+ only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)). Before 1.3.10
124
+ a non-empty `excludeTools` list replaced the default, so naming any other tool silently
125
+ re-enabled `grep`.
124
126
 
125
127
  Researchers cannot write files, run shell commands, or reach the network outside these
126
128
  tools.
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.10] - 2026-08-14
9
+
10
+ The post-release verification round for 1.3.9 kept digging and found that the
11
+ fetch scrape path — the fast primary transport in front of the stealth browser —
12
+ had been dead in production on every supported Node version, hidden completely by
13
+ the browser fallback. Three more researcher-session defects surfaced from the same
14
+ investigation, two of them demonstrated live against a real provider. Each fix
15
+ carries a regression test verified to fail against the pre-fix code (the
16
+ accounting fix pins the deep-researcher site; the quick site shares the same
17
+ helper call), and the fetch fix carries the first test of that layer that uses a
18
+ real socket instead of a mocked `fetch`. An independent fresh-eyes review of the
19
+ whole delta then caught two follow-on defects, fixed below.
20
+
21
+ ### Fixed
22
+
23
+ - **Every fetch scrape failed instantly; the whole path silently ran on the browser fallback.** `getSsrfSafeDispatcher()` built an `Agent` from the undici 8.x this package depends on and handed it to Node's **global** `fetch()` — which is backed by the undici compiled *into* the runtime (6.x on Node 22, 7.x on Node 25). Cross-major dispatchers are rejected outright (`UND_ERR_INVALID_ARG: invalid onRequestStart method`), so every fetch attempt threw before a single packet left the machine, burned one retry, and fell back to the stealth browser: ~3.4 s per page against the ~0.9 s the fetch path delivers, the browser pool saturated with work it never needed — and the connect-time SSRF DNS pin, the entire reason the dispatcher exists, never engaged once (request-time validation still applied throughout, so this was defence-in-depth loss, not an open hole). Nothing caught it because every test of the layer stubs the global `fetch`, and the fallback kept scrapes succeeding. The replacement, `getSsrfSafeFetcher()`, returns the Agent *paired with undici's own `fetch`*, making the invalid combination unrepresentable, and a new test drives the real fetch path against a real loopback socket with the browser layer disabled — it fails against the pre-fix code with the exact production error. Measured on live runs: 0 fetch / 5 browser successes before, 4 fetch / 0 browser after.
24
+ - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered.
25
+ - **`--exclude-tools` silently granted researchers local-filesystem `grep`.** A non-empty exclusion list *replaced* the default exclusions instead of adding to them, so `--exclude-tools stackexchange` enabled a capability as a side effect of a flag documented as "disable internal tools". The same defect was fixed for `PI_RESEARCH_DISABLED_TOOLS` in 1.3.0 with the rationale "a subtract-only setting must never grant a capability"; the caller-facing surface kept the replacement semantics until now. Both surfaces are strictly additive over the default exclusions. (Behavioral change: there is no longer any way to hand researchers `grep`, which ARCHITECTURE.md previously documented as a quirk of the replacement rule.)
26
+ - **A misconfigured $0 price table was silent on depth-0 runs.** Both researcher `message_end` accounting sites hand-copied the extract→increment→emit block instead of calling `recordLlmUsage`, so `warnIfUnpriced` — added in 1.1.2 precisely because a hand-written `models.json` entry silently discards pi's pricing — never ran for researcher usage. Depth-1+ runs still warned via the coordinator; depth-0 runs have no coordinator and reported `$0.0000` with no diagnostic at all. Metric labels and observer payloads are unchanged (`onTokensConsumed` now fires before `onResearcherProgress` rather than after — no consumer is order-sensitive), and the deep path keeps its `safeObserve` isolation.
27
+ - **A connect-time SSRF block was filed as a generic fetch error.** undici never surfaces the connector's `ESSRFBLOCKED` error directly — `fetch` throws `TypeError: fetch failed` with the real error down the `cause` chain — so the message-based classification at both tracking sites missed it and a DNS-rebinding block would have been invisible in the SSRF metrics. Unreachable before this release (the dispatcher path had never executed); now that it is live, `isSsrfBlockError()` walks the bounded cause chain. Diagnostics-only: the block itself always held.
28
+ - **One unit test silently made live network requests.** The fetch-stub migration for the undici change covered two of the three test files that drive the scrape layer; the missed one (`scraper-pdf-native-module-failure`) kept stubbing the *global* fetch, so every run of that "hermetic" test performed real DNS and an outbound TLS connection to a third-party host — and passed only because that host's certificate mismatch happened to reproduce the stubbed failure. Found by the fresh-eyes review; it now shims `getSsrfSafeFetcher` like its siblings.
29
+
30
+ ### Verified
31
+
32
+ - Full suites green after the changes: 191 unit files / 2457 tests, integration 53+8 skipped and 103, lint, both type-checks, dependency rules.
33
+ - Live production runs on OpenRouter at depths 0, 1, and 2: reports with citations, fetch-path scrapes at ~0.6–1.5 s/page with zero browser fallbacks and zero undici errors, non-zero cost accounting, knowledge store round-trip (same-day scrapes retrieved and grounded).
34
+ - The stderr FD-2 capture deliberately still skips pipes/sockets: when a parent process is reading stderr (pi host, MCP, CI), stealing FD 2 would hide fatal diagnostics to suppress cosmetic Dawn noise. Reviewed and kept as designed.
35
+
8
36
  ## [1.3.9] - 2026-08-14
9
37
 
10
38
  A whole-tree audit sweep. Six defects, each found by looking at what the code
@@ -114,6 +114,7 @@ These are advanced, env-only knobs (not in the TUI).
114
114
  | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
115
  | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
116
116
  | `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
117
+ | `PI_RESEARCH_RESEARCHER_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens per researcher LLM call (deep and quick). Without it the model's full catalog ceiling is sent as `max_tokens`, which OpenRouter budget-checks against remaining credits and rejects on low balances. |
117
118
 
118
119
  Knowledge store
119
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.9", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.10", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/config.ts CHANGED
@@ -122,6 +122,15 @@ export const ConfigSchema = Type.Object({
122
122
  * the model's real maxTokens at call time.
123
123
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
124
124
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
125
+ /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
126
+ * Without a cap the session inherits the model's full catalog ceiling as the
127
+ * per-request max_tokens — ~230k for large-context models — which providers that
128
+ * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
129
+ * reject outright on low balances, even though the actual response would cost
130
+ * cents. Default 16384: researcher turns are tool calls plus a cited report, far
131
+ * below this. Clamped to the model's real ceiling at session build time.
132
+ * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
133
+ RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
125
134
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
126
135
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
127
136
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -233,6 +242,7 @@ const USER_MIGRATION_KEYS = [
233
242
  'PI_RESEARCH_LLM_THINKING_LEVEL',
234
243
  'PI_RESEARCH_PLANNING_MAX_TOKENS',
235
244
  'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
245
+ 'PI_RESEARCH_RESEARCHER_MAX_TOKENS',
236
246
  'PI_RESEARCH_MIGRATION_STRATEGY',
237
247
  'PI_RESEARCH_CONSOLE_LOG',
238
248
  'PI_RESEARCH_MODEL',
@@ -865,6 +875,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
865
875
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
866
876
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
867
877
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
878
+ PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
868
879
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
869
880
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
870
881
  ...(config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {}),
@@ -1095,6 +1106,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
1095
1106
  LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
1096
1107
  PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
1097
1108
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
1109
+ RESEARCHER_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_RESEARCHER_MAX_TOKENS', DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
1098
1110
  MIGRATION_STRATEGY: parseEnvEnum(e, 'PI_RESEARCH_MIGRATION_STRATEGY', ['drop', 're-embed', 'backup'] as const, DEFAULTS.MIGRATION_STRATEGY),
1099
1111
  CONSOLE_LOG: parseEnvBool(e, 'PI_RESEARCH_CONSOLE_LOG', DEFAULTS.CONSOLE_LOG),
1100
1112
  RESEARCH_MODEL: parseEnvString(e, 'PI_RESEARCH_MODEL', DEFAULTS.RESEARCH_MODEL),
package/src/constants.ts CHANGED
@@ -185,22 +185,17 @@ export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
185
185
  /**
186
186
  * Resolve the effective researcher exclusion list.
187
187
  *
188
- * An EMPTY array is "no preference", not "exclude nothing". The two front-ends
189
- * express no-preference differently — the pi extension always builds an array
190
- * (`[...new Set([...])]`, often empty) while the CLI omits the key entirely — so a
191
- * plain truthiness test made the same user input mean opposite things: grep was
192
- * available in the extension and excluded on the CLI, regardless of depth.
193
- *
194
- * `configDisabled` (PI_RESEARCH_DISABLED_TOOLS) is strictly ADDITIVE. Folding it in
195
- * as a replacement meant disabling any unrelated tool silently ENABLED grep, so a
196
- * subtract-only setting could grant a capability.
188
+ * EVERY input is strictly ADDITIVE on top of DEFAULT_EXCLUDED_TOOLS. Both
189
+ * subtract-only surfaces — `--exclude-tools` / `excludeTools` and
190
+ * PI_RESEARCH_DISABLED_TOOLS (`configDisabled`) — are documented as "disable
191
+ * tools", so neither may GRANT a capability as a side effect. Each has held the
192
+ * replacement semantics at some point, with the same consequence both times:
193
+ * excluding any unrelated tool silently enabled local-filesystem grep for web
194
+ * researchers.
197
195
  */
198
196
  export function resolveExcludedTools(
199
197
  callerExclusions: readonly string[] | undefined,
200
198
  configDisabled: readonly string[] = [],
201
199
  ): string[] {
202
- const base = callerExclusions && callerExclusions.length > 0
203
- ? callerExclusions
204
- : DEFAULT_EXCLUDED_TOOLS;
205
- return [...new Set([...base, ...configDisabled])];
200
+ return [...new Set([...DEFAULT_EXCLUDED_TOOLS, ...(callerExclusions ?? []), ...configDisabled])];
206
201
  }
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
1512
1512
  extractDomain: () => extractDomain,
1513
1513
  formatErrorWithCause: () => formatErrorWithCause,
1514
1514
  getRandomUserAgent: () => getRandomUserAgent,
1515
- getSsrfSafeDispatcher: () => getSsrfSafeDispatcher,
1515
+ getSsrfSafeFetcher: () => getSsrfSafeFetcher,
1516
1516
  isBenignScrapeFailure: () => isBenignScrapeFailure,
1517
+ isSsrfBlockError: () => isSsrfBlockError,
1517
1518
  ssrfSafeLookup: () => ssrfSafeLookup,
1518
1519
  stripImageLinks: () => stripImageLinks,
1519
1520
  validateContent: () => validateContent,
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
1633
1634
  return callback(null, safe[0].address, safe[0].family);
1634
1635
  });
1635
1636
  }
1636
- async function getSsrfSafeDispatcher() {
1637
- if (cachedSsrfDispatcher !== void 0) return cachedSsrfDispatcher;
1637
+ function isSsrfBlockError(error) {
1638
+ let e = error;
1639
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
1640
+ if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
1641
+ return true;
1642
+ }
1643
+ e = e.cause;
1644
+ }
1645
+ return false;
1646
+ }
1647
+ async function getSsrfSafeFetcher() {
1648
+ if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1638
1649
  try {
1639
- const { Agent } = await import("undici");
1640
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
1650
+ const { Agent, fetch: undiciFetch } = await import("undici");
1651
+ cachedSsrfFetcher = {
1652
+ fetch: undiciFetch,
1653
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
+ };
1641
1655
  } catch (e) {
1642
1656
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1643
- cachedSsrfDispatcher = null;
1657
+ cachedSsrfFetcher = null;
1644
1658
  }
1645
- return cachedSsrfDispatcher;
1659
+ return cachedSsrfFetcher;
1646
1660
  }
1647
1661
  function formatErrorWithCause(err) {
1648
1662
  try {
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
1776
1790
  throw error;
1777
1791
  }
1778
1792
  }
1779
- var cachedSsrfDispatcher;
1793
+ var cachedSsrfFetcher;
1780
1794
  var init_scraper_utils = __esm({
1781
1795
  "src/web-research/scraper-utils.ts"() {
1782
1796
  "use strict";
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
1785
1799
  init_logger();
1786
1800
  init_scraper_types();
1787
1801
  init_browser_error_utils();
1788
- cachedSsrfDispatcher = void 0;
1802
+ cachedSsrfFetcher = void 0;
1789
1803
  }
1790
1804
  });
1791
1805
 
@@ -2530,6 +2544,15 @@ var ConfigSchema = Type.Object({
2530
2544
  * the model's real maxTokens at call time.
2531
2545
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2532
2546
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
+ /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
2548
+ * Without a cap the session inherits the model's full catalog ceiling as the
2549
+ * per-request max_tokens — ~230k for large-context models — which providers that
2550
+ * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
2551
+ * reject outright on low balances, even though the actual response would cost
2552
+ * cents. Default 16384: researcher turns are tool calls plus a cited report, far
2553
+ * below this. Clamped to the model's real ceiling at session build time.
2554
+ * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
2555
+ RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2533
2556
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2534
2557
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2535
2558
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -13,7 +13,7 @@ import {
13
13
  import { type Model } from '@earendil-works/pi-ai';
14
14
  import { injectCurrentDate } from '../core/llm/inject-date.ts';
15
15
  import { loadPrompt } from '../core/llm/prompts.ts';
16
- import { extractUsage } from '../types/llm.ts';
16
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
17
17
  import { logger } from '../logger.ts';
18
18
  import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
@@ -238,13 +238,13 @@ export class QuickResearchOrchestrator {
238
238
 
239
239
  const rawUsage = msg['usage'] as any;
240
240
  if (rawUsage) {
241
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
242
-
241
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
242
+ component: 'quick_researcher',
243
+ complexity: 0,
244
+ observer,
245
+ });
243
246
  if (tokens > 0 || cost > 0) {
244
- metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
245
- metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
246
247
  observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
247
- observer?.onTokensConsumed?.(tokens, cost);
248
248
  }
249
249
  }
250
250
  } else if (event.type === 'tool_execution_start') {
@@ -9,7 +9,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
9
9
  import { createResearcherSession } from './researcher.ts';
10
10
  import { registerScrapedLinks } from '../utils/shared-links.ts';
11
11
  import { ensureAssistantResponse } from '../utils/text-utils.ts';
12
- import { extractUsage } from '../types/llm.ts';
12
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
13
13
  import { logger } from '../logger.ts';
14
14
  import { metrics } from '../utils/metrics.ts';
15
15
  import { ServiceNames,
@@ -347,13 +347,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
347
347
 
348
348
  const rawUsage = msg['usage'] as any;
349
349
  if (rawUsage) {
350
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
351
-
350
+ // Observer exceptions must not kill the researcher, so hand recordLlmUsage
351
+ // a safeObserve-wrapped sink rather than the raw observer.
352
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
353
+ component: 'researcher',
354
+ complexity,
355
+ observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) },
356
+ });
352
357
  if (tokens > 0 || cost > 0) {
353
- metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
354
- metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
355
358
  safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
356
- safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
357
359
  }
358
360
  }
359
361
  } else if (event.type === 'tool_execution_start') {
@@ -85,6 +85,17 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
85
85
 
86
86
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
87
87
 
88
+ // The session sends model.maxTokens as the per-request max_tokens. Large-context
89
+ // catalog entries carry ~230k ceilings, and providers that budget-check max_tokens
90
+ // up front (OpenRouter 402s it against remaining credits) then reject every
91
+ // researcher call on a low balance even though the real response costs cents.
92
+ // Clamp the session's copy of the model to RESEARCHER_MAX_TOKENS; Model is plain
93
+ // catalog data, so a spread clone is safe and the shared registry entry is untouched.
94
+ const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
95
+ const sessionModel = modelToUse.maxTokens > outputCap
96
+ ? { ...modelToUse, maxTokens: outputCap }
97
+ : modelToUse;
98
+
88
99
  try {
89
100
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
90
101
 
@@ -138,7 +149,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
138
149
  tools, // Explicit allowlist (BUG-1 fix)
139
150
  sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
140
151
  settingsManager: researcherSettings,
141
- model: modelToUse,
152
+ model: sessionModel,
142
153
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
143
154
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
144
155
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -185,7 +196,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
185
196
 
186
197
  sessionRef.session = result.session;
187
198
 
188
- return { session: result.session, resolvedModel: modelToUse };
199
+ return { session: result.session, resolvedModel: sessionModel };
189
200
  } catch (tailError) {
190
201
  const session = result?.session;
191
202
  if (session && typeof session.abort === 'function') {
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
250
250
  });
251
251
  }
252
252
 
253
- // undici Agent used as the per-request dispatcher for scrape fetches. Cached
254
- // after first construction. `undefined` = not yet attempted, `null` = undici
255
- // unavailable (fall back to plain fetch with request-time validation only).
256
- let cachedSsrfDispatcher: unknown | null | undefined = undefined;
253
+ /**
254
+ * True when the error is one of our SSRF blocks — request-time validation
255
+ * (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
256
+ *
257
+ * The connect-time error never surfaces directly: undici wraps it in
258
+ * `TypeError: fetch failed` with the real error down the `cause` chain, so a
259
+ * message check on the outer error alone files a connect-time block as a generic
260
+ * fetch error. Walk the chain (bounded — causes can be cyclic) instead.
261
+ */
262
+ export function isSsrfBlockError(error: unknown): boolean {
263
+ let e: unknown = error;
264
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
265
+ if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
266
+ return true;
267
+ }
268
+ e = (e as { cause?: unknown }).cause;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ /** A fetch-shaped callable plus the dispatcher it must be invoked with. */
274
+ export interface SsrfSafeFetcher {
275
+ /** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
276
+ fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
277
+ dispatcher: unknown;
278
+ }
279
+
280
+ // Cached after first construction. `undefined` = not yet attempted, `null` =
281
+ // undici unavailable (fall back to the global fetch with request-time
282
+ // validation only).
283
+ let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
257
284
 
258
285
  /**
259
286
  * Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
260
287
  * every scrape fetch — including each manually-followed redirect hop — connects
261
- * only to a validated public address. Returns null if undici can't be loaded;
262
- * callers then fall back to plain fetch (request-time validation still applies).
288
+ * only to a validated public address, PAIRED with the fetch implementation that
289
+ * can actually accept it.
290
+ *
291
+ * The pairing is the whole point. A dispatcher is only honoured by the undici
292
+ * that created it: Node's global `fetch` is backed by the undici BUILT INTO the
293
+ * runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
294
+ * undici 8.x. Handing a v8 `Agent` to the global fetch throws
295
+ * `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
296
+ * every supported Node — which is exactly what it did: the entire fetch scrape
297
+ * path failed instantly, every URL was retried once and then fell back to the
298
+ * stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
299
+ * this dispatcher exists for never engaged even once. The browser fallback made
300
+ * it invisible: scrapes still succeeded, just 15-20x slower.
301
+ *
302
+ * Returning the two together makes the invalid combination unrepresentable —
303
+ * a caller cannot end up holding a dispatcher without the fetch that honours it.
304
+ * Returns null if undici can't be loaded; callers then use the global fetch with
305
+ * no dispatcher (request-time validation still applies).
263
306
  */
264
- export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
265
- if (cachedSsrfDispatcher !== undefined) return cachedSsrfDispatcher as unknown | null;
307
+ export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
308
+ if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
266
309
  try {
267
- const { Agent } = await import('undici');
268
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
310
+ const { Agent, fetch: undiciFetch } = await import('undici');
311
+ cachedSsrfFetcher = {
312
+ fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
313
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
314
+ };
269
315
  } catch (e) {
270
316
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
271
- cachedSsrfDispatcher = null;
317
+ cachedSsrfFetcher = null;
272
318
  }
273
- return cachedSsrfDispatcher as unknown | null;
319
+ return cachedSsrfFetcher;
274
320
  }
275
321
 
276
322
  /**
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  FETCH_LAYER_TIMEOUT,
25
25
  } from './types.ts';
26
- import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
26
+ import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
27
27
  import { isTransientError, abortableDelay } from './retry-utils.ts';
28
28
  import { safeUnref } from '../utils/safe-unref.ts';
29
29
  import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
208
208
  * counts are precisely what production incidents are diagnosed from.
209
209
  */
210
210
  function trackFetchLayerFailure(url: string, error: unknown): void {
211
- const isSsrf = error instanceof Error && error.message.includes('not allowed');
211
+ const isSsrf = isSsrfBlockError(error);
212
212
  errorTracker.trackError(error instanceof Error ? error : String(error), {
213
213
  component: 'scrapers',
214
214
  operation: 'fetch',
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
226
226
  // closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
227
227
  // time) and fetch's own re-resolution (connect time). Null when undici is
228
228
  // unavailable — request-time validation above still applies.
229
- const dispatcher = await getSsrfSafeDispatcher();
229
+ //
230
+ // The fetch implementation comes WITH the dispatcher and must be used with it:
231
+ // a dispatcher is only honoured by the undici that built it, and the global
232
+ // fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
233
+ const fetcher = await getSsrfSafeFetcher();
234
+ const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
230
235
 
231
236
  const controller = new AbortController();
232
237
  const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
@@ -257,16 +262,16 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
257
262
  const userAgent = getRandomUserAgent();
258
263
 
259
264
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
260
- response = await fetch(currentUrl, {
265
+ response = await doFetch(currentUrl, {
261
266
  signal: controller.signal,
262
267
  redirect: 'manual',
263
268
  headers: {
264
269
  'User-Agent': userAgent,
265
270
  'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
266
271
  },
267
- // RequestInit's standard type omits undici's `dispatcher`; cast to attach it.
268
- ...(dispatcher ? { dispatcher } : {}),
269
- } as RequestInit);
272
+ // Only ever attached alongside the fetch that honours it (see above).
273
+ ...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
274
+ });
270
275
 
271
276
  if (response.status >= 300 && response.status < 400) {
272
277
  const location = response.headers.get('location');
@@ -380,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
380
385
  // call in a retry, and tracking per attempt would record one failure twice —
381
386
  // inflating exactly the error counts these numbers are read to diagnose.
382
387
  // scrapeSingle calls trackFetchLayerFailure once with the final error.
383
- if (error instanceof Error && error.message.includes('not allowed')) {
388
+ if (isSsrfBlockError(error)) {
384
389
  metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
385
390
  metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
386
391
  throw error;