@demigodmode/pi-web-agent 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -0
  3. package/dist/backends/config.d.ts +13 -0
  4. package/dist/backends/config.js +44 -1
  5. package/dist/backends/doctor.js +27 -29
  6. package/dist/backends/factory.d.ts +18 -7
  7. package/dist/backends/factory.js +126 -99
  8. package/dist/backends/failure.d.ts +11 -0
  9. package/dist/backends/failure.js +34 -0
  10. package/dist/backends/fallback-policy.d.ts +31 -0
  11. package/dist/backends/fallback-policy.js +239 -0
  12. package/dist/backends/provider-failure.d.ts +21 -0
  13. package/dist/backends/provider-failure.js +110 -0
  14. package/dist/backends/provider-health.d.ts +29 -0
  15. package/dist/backends/provider-health.js +49 -0
  16. package/dist/commands/web-agent-config.d.ts +14 -1
  17. package/dist/commands/web-agent-config.js +75 -3
  18. package/dist/extension.js +47 -3
  19. package/dist/extract/bot-check.d.ts +1 -0
  20. package/dist/extract/bot-check.js +10 -0
  21. package/dist/extract/readability.d.ts +4 -0
  22. package/dist/extract/readability.js +67 -3
  23. package/dist/extract/section-selector.d.ts +14 -0
  24. package/dist/extract/section-selector.js +233 -0
  25. package/dist/fetch/destination-policy.d.ts +32 -0
  26. package/dist/fetch/destination-policy.js +24 -0
  27. package/dist/fetch/firecrawl-fetch.d.ts +1 -1
  28. package/dist/fetch/firecrawl-fetch.js +74 -46
  29. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  30. package/dist/fetch/guard-proxy-fetch.js +82 -0
  31. package/dist/fetch/guard-proxy.d.ts +58 -0
  32. package/dist/fetch/guard-proxy.js +420 -0
  33. package/dist/fetch/guarded-fetch.d.ts +7 -0
  34. package/dist/fetch/guarded-fetch.js +75 -0
  35. package/dist/fetch/headless-fetch.d.ts +11 -2
  36. package/dist/fetch/headless-fetch.js +190 -13
  37. package/dist/fetch/http-fetch.d.ts +1 -1
  38. package/dist/fetch/http-fetch.js +29 -8
  39. package/dist/fetch/network-guard.d.ts +82 -0
  40. package/dist/fetch/network-guard.js +275 -0
  41. package/dist/orchestration/answer-synthesizer.js +2 -0
  42. package/dist/orchestration/evidence-quality.d.ts +3 -2
  43. package/dist/orchestration/evidence-quality.js +2 -1
  44. package/dist/orchestration/index.d.ts +26 -7
  45. package/dist/orchestration/index.js +9 -2
  46. package/dist/orchestration/research-orchestrator.d.ts +23 -7
  47. package/dist/orchestration/research-orchestrator.js +60 -26
  48. package/dist/orchestration/research-types.d.ts +13 -1
  49. package/dist/orchestration/research-worker.d.ts +2 -4
  50. package/dist/orchestration/research-worker.js +51 -15
  51. package/dist/orchestration/stop-decider.js +3 -1
  52. package/dist/presentation/config-store.js +6 -0
  53. package/dist/presentation/explore-presentation.js +3 -1
  54. package/dist/presentation/fetch-presentation.js +16 -9
  55. package/dist/presentation/search-presentation.d.ts +2 -1
  56. package/dist/presentation/search-presentation.js +13 -1
  57. package/dist/readers/resolver.d.ts +3 -7
  58. package/dist/search/brave.d.ts +1 -2
  59. package/dist/search/brave.js +23 -80
  60. package/dist/search/duckduckgo.d.ts +7 -3
  61. package/dist/search/duckduckgo.js +17 -18
  62. package/dist/search/exa.d.ts +1 -2
  63. package/dist/search/exa.js +15 -76
  64. package/dist/search/fanout.d.ts +12 -0
  65. package/dist/search/fanout.js +86 -47
  66. package/dist/search/json-provider.d.ts +32 -0
  67. package/dist/search/json-provider.js +76 -0
  68. package/dist/search/searxng.d.ts +1 -2
  69. package/dist/search/searxng.js +15 -57
  70. package/dist/search/tavily.d.ts +1 -2
  71. package/dist/search/tavily.js +17 -74
  72. package/dist/search/youcom.d.ts +4 -2
  73. package/dist/search/youcom.js +49 -75
  74. package/dist/tools/web-explore.d.ts +9 -0
  75. package/dist/tools/web-explore.js +16 -2
  76. package/dist/tools/web-fetch-headless.d.ts +3 -5
  77. package/dist/tools/web-fetch-headless.js +3 -3
  78. package/dist/tools/web-fetch.d.ts +3 -5
  79. package/dist/tools/web-fetch.js +3 -3
  80. package/dist/tools/web-search.js +41 -103
  81. package/dist/types.d.ts +48 -0
  82. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -18,6 +18,35 @@ The format is intentionally simple and release-oriented.
18
18
  ### Breaking
19
19
  - None.
20
20
 
21
+ ## [1.13.0] - 2026-09-24
22
+ ### Added
23
+ - `web_explore` now uses the research question to pick relevant sections from long HTML pages instead of stopping at the first 4,000 characters. It can reach an answer buried later in a page, whether the page came through HTTP, headless browsing, or Firecrawl. Direct `web_fetch` output is unchanged. (#36)
24
+
25
+ ### Changed
26
+ - You.com search and `/web-agent doctor` now use the documented `/v1/search` endpoint. Web and news results appear in the usual search list, with web results first. Result descriptions provide the snippets when present. (#60)
27
+
28
+ ### Fixed
29
+ - Research still excludes recognized bot-check pages when their verification message sits outside the section chosen for the question. That page cannot be used as evidence just because another section looks relevant. (#36)
30
+ - `/web-agent doctor` no longer repeats the default DuckDuckGo and HTTP backends after listing them in the config summary. It still shows checks and warnings for backends that need them. (#64)
31
+ - On pages with several `<article>` sections and no `<main>`, query-based reads now search across the articles. A match in the first article no longer hides an answer in a later one. (#71)
32
+
33
+ ### Breaking
34
+ - None.
35
+
36
+ ## [1.12.0] - 2026-09-17
37
+ ### Added
38
+ - A network allow list, under Settings → Backends → Network allow list. web_explore now refuses private and local addresses when the link came from the model or a page it read, so if you genuinely want it to read something on your own network (an internal docs site, a service on localhost), add that range here as a CIDR like `10.0.0.0/24`. Entries are checked when you save, and ones that would allow everything (`0.0.0.0/0`, `::/0`) are rejected, since that would quietly turn the protection off. It is also the fix if every fetch suddenly fails: some proxy apps run in fake-IP mode and make every site look like it lives in `198.18.0.0/15`, and adding that range gets you going again. (#53)
39
+ - A "Trust the upstream proxy to enforce private-address restrictions" setting (`backends.network.trustProxyDns`). With a proxy configured, pi-web-agent normally looks each address up itself and asks the proxy to connect to that exact IP, so the proxy can't send a request somewhere else. Some proxies only accept hostnames, and some networks only resolve names inside the proxy. Turning this on hands the hostname to the proxy instead. It is off by default because it moves the address decision to your proxy, and even when it is on, localhost, private IPs typed straight into a link, and anything your own machine resolves to a private address are still refused. `/web-agent doctor` shows the allow list and this setting. (#53)
40
+
41
+ ### Changed
42
+ - Search and fetch fallback now act on why a backend failed. Rate limits pause that backend for the time the provider asks (capped at 15 minutes), exhausted quotas and bad API keys skip it until settings change, and timeouts or server errors get one retry. An empty result is treated as a real answer instead of a failure, a private-address refusal or an invalid shared proxy setting never falls back to another backend, and answers say when some search backends were unavailable. DuckDuckGo no longer retries on its own. (#55)
43
+
44
+ ### Fixed
45
+ - web_explore no longer fetches private, loopback, or link-local addresses when the link came from the model or a page it read. The check happens where connections are opened, through a local guard proxy, so it also covers redirects, DNS changes, and everything a headless page loads. A web page could previously steer it at cloud metadata endpoints or services on your network. Addresses you configured yourself (search backends, SearXNG, Firecrawl, the proxy) are unaffected. If you do need to reach a private range, add it under Settings → Backends → Network allow list. With an upstream proxy that only accepts hostnames, turn on "Trust the upstream proxy to enforce private-address restrictions". (#53)
46
+
47
+ ### Breaking
48
+ - None.
49
+
21
50
  ## [1.11.0] - 2026-09-16
22
51
  ### Added
23
52
  - Optional proxy support. Point web_explore at an HTTP/HTTPS proxy and everything outbound goes through it: search, fetch, Firecrawl, the GitHub/PDF/YouTube readers, doctor health checks, and the headless browser. Off unless you set it, and nothing changes if you don't. Set it in Settings → Backends, and keep credentials in `PI_WEB_AGENT_PROXY_USERNAME` / `PI_WEB_AGENT_PROXY_PASSWORD` rather than in the URL. Thanks to @lo-tp for building this. (#50)
package/README.md CHANGED
@@ -34,6 +34,8 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
34
34
  - **Six search backends.** DuckDuckGo (keyless default), SearXNG, Brave, You.com, Exa, Tavily.
35
35
  - **Optional search fanout.** Query several backends at once, dedupe, and rank pages that more than one provider agreed on to the top. Off by default; flip it to `on` or `auto`.
36
36
  - **Honest by default.** Weak, narrow, blocked, or cautionary evidence gets flagged instead of dressed up as confidence.
37
+ - **Safe with untrusted pages.** Links the model picks, or finds on a page it read, can't reach localhost, your LAN, or cloud metadata endpoints. That includes redirects and everything a headless page loads. Addresses you configure yourself aren't affected, and an allow list covers the private ranges you do want.
38
+ - **Fallback that knows why.** Rate-limited or misconfigured backends get skipped for a while, flaky ones get one retry, and answers say when some search backends were unavailable.
37
39
  - **Bounded output.** `compact` / `preview` / `verbose` transcript modes.
38
40
  - **Zero-config to start.** Runs keyless out of the box (DuckDuckGo search, local browser, the built-in readers). Opt into hosted backends, fallback, search fanout, and per-tool output modes through config when you want more control.
39
41
 
@@ -34,17 +34,25 @@ export type FetchBackendConfig = {
34
34
  export type HeadlessBackendConfig = {
35
35
  provider: 'local-browser';
36
36
  };
37
+ export type NetworkConfig = {
38
+ /** CIDR ranges exempted from the private-address guard (#53). */
39
+ allowRanges?: string[];
40
+ /** Trust the upstream proxy to enforce private-address restrictions. */
41
+ trustProxyDns?: boolean;
42
+ };
37
43
  export type BackendConfig = {
38
44
  search: SearchBackendConfig;
39
45
  fetch: FetchBackendConfig;
40
46
  headless: HeadlessBackendConfig;
41
47
  proxy?: ProxyConfig;
48
+ network?: NetworkConfig;
42
49
  };
43
50
  export type BackendConfigOverride = {
44
51
  search?: Partial<SearchBackendConfig>;
45
52
  fetch?: Partial<FetchBackendConfig>;
46
53
  headless?: Partial<HeadlessBackendConfig>;
47
54
  proxy?: ProxyConfig;
55
+ network?: NetworkConfig;
48
56
  };
49
57
  export type BackendConfigFile = {
50
58
  backends?: {
@@ -70,6 +78,10 @@ export type BackendConfigFile = {
70
78
  username?: unknown;
71
79
  password?: unknown;
72
80
  };
81
+ network?: {
82
+ allowRanges?: unknown;
83
+ trustProxyDns?: unknown;
84
+ };
73
85
  };
74
86
  };
75
87
  export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
@@ -88,6 +100,7 @@ export declare function stripProxyCredentials(url: string): string;
88
100
  */
89
101
  export declare function isValidProxyUrl(url: string): boolean;
90
102
  export declare function extractProxyConfig(value: unknown): ProxyConfig | undefined;
103
+ export declare function extractNetworkConfig(value: unknown): NetworkConfig | undefined;
91
104
  export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
92
105
  export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
93
106
  export declare function validateBackendConfig(config: BackendConfig): string[];
@@ -1,3 +1,4 @@
1
+ import { parseCidr } from '../fetch/network-guard.js';
1
2
  export const DEFAULT_BACKEND_CONFIG = {
2
3
  search: { provider: 'duckduckgo' },
3
4
  fetch: { provider: 'http' },
@@ -79,6 +80,19 @@ export function extractProxyConfig(value) {
79
80
  config.password = raw.password;
80
81
  return config;
81
82
  }
83
+ export function extractNetworkConfig(value) {
84
+ if (!value || typeof value !== 'object')
85
+ return undefined;
86
+ const raw = value;
87
+ const config = {};
88
+ if (Array.isArray(raw.allowRanges)) {
89
+ // Coerce non-string entries so validation flags them instead of dropping the list.
90
+ config.allowRanges = raw.allowRanges.map((entry) => (typeof entry === 'string' ? entry : String(entry)));
91
+ }
92
+ if (typeof raw.trustProxyDns === 'boolean')
93
+ config.trustProxyDns = raw.trustProxyDns;
94
+ return Object.keys(config).length > 0 ? config : undefined;
95
+ }
82
96
  function extractSearxngOptions(value) {
83
97
  if (!value || typeof value !== 'object')
84
98
  return undefined;
@@ -187,6 +201,10 @@ export function extractBackendConfigOverride(file) {
187
201
  if (proxy) {
188
202
  override.proxy = proxy;
189
203
  }
204
+ const network = extractNetworkConfig(backends?.network);
205
+ if (network) {
206
+ override.network = network;
207
+ }
190
208
  return override;
191
209
  }
192
210
  export function validateBackendConfig(config) {
@@ -214,6 +232,18 @@ export function validateBackendConfig(config) {
214
232
  if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
215
233
  issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
216
234
  }
235
+ for (const range of config.network?.allowRanges ?? []) {
236
+ const cidr = parseCidr(range);
237
+ if (!cidr) {
238
+ issues.push(`backends.network.allowRanges entry "${range}" is not a valid CIDR range`);
239
+ }
240
+ else if (cidr.prefix === 0) {
241
+ issues.push(`backends.network.allowRanges entry "${range}" allows every address, which turns the guard off; list specific ranges instead`);
242
+ }
243
+ }
244
+ if (config.network?.trustProxyDns && !config.proxy?.url?.trim()) {
245
+ issues.push('backends.network.trustProxyDns has no effect without backends.proxy');
246
+ }
217
247
  if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily') {
218
248
  issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, or tavily');
219
249
  }
@@ -260,6 +290,17 @@ function mergeFetchConfig(current, override) {
260
290
  }
261
291
  return { ...current, ...override };
262
292
  }
293
+ function mergeNetworkConfig(base, layer) {
294
+ if (!layer)
295
+ return base;
296
+ const next = { ...base };
297
+ // A layer's allow list replaces the lower one outright; trust is set independently.
298
+ if (layer.allowRanges)
299
+ next.allowRanges = [...layer.allowRanges];
300
+ if (layer.trustProxyDns !== undefined)
301
+ next.trustProxyDns = layer.trustProxyDns;
302
+ return next;
303
+ }
263
304
  export function mergeBackendConfigLayers(...layers) {
264
305
  return layers.reduce((merged, layer) => ({
265
306
  search: mergeSearchConfig(merged.search, layer?.search),
@@ -269,6 +310,8 @@ export function mergeBackendConfigLayers(...layers) {
269
310
  ? layer.proxy.url === ''
270
311
  ? undefined // explicit disable overrides any proxy from lower layers
271
312
  : { ...merged.proxy, ...layer.proxy }
272
- : merged.proxy
313
+ : merged.proxy,
314
+ // Replace, don't union: a project list is the whole list for that project.
315
+ network: mergeNetworkConfig(merged.network, layer?.network)
273
316
  }), DEFAULT_BACKEND_CONFIG);
274
317
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeYouComResults, YOUCOM_SEARCH_URL } from '../search/youcom.js';
1
2
  function withTimeout(timeoutMs) {
2
3
  const controller = new AbortController();
3
4
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -13,7 +14,7 @@ function braveDoctorUrl() {
13
14
  return url.toString();
14
15
  }
15
16
  function youcomDoctorBody() {
16
- return JSON.stringify({ query: 'pi-web-agent-doctor', max_results: 1 });
17
+ return JSON.stringify({ query: 'pi-web-agent-doctor', count: 1 });
17
18
  }
18
19
  function exaDoctorBody() {
19
20
  return JSON.stringify({ query: 'pi-web-agent-doctor', numResults: 1 });
@@ -42,10 +43,7 @@ function firecrawlDoctorBody(options = {}) {
42
43
  }
43
44
  export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
44
45
  const lines = [];
45
- if (config.search.provider === 'duckduckgo') {
46
- lines.push('search backend: duckduckgo');
47
- }
48
- else if (config.search.provider === 'brave') {
46
+ if (config.search.provider === 'brave') {
49
47
  const apiKey = process.env.PI_WEB_AGENT_BRAVE_API_KEY;
50
48
  if (!apiKey?.trim()) {
51
49
  lines.push('search backend: brave warning (missing PI_WEB_AGENT_BRAVE_API_KEY)');
@@ -86,7 +84,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
86
84
  else {
87
85
  const timeout = withTimeout(timeoutMs);
88
86
  try {
89
- const response = await fetchImpl('https://api.you.com/v1/agents/search', {
87
+ const response = await fetchImpl(YOUCOM_SEARCH_URL, {
90
88
  method: 'POST',
91
89
  headers: {
92
90
  Accept: 'application/json',
@@ -100,8 +98,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
100
98
  lines.push(`search backend: youcom warning (HTTP ${response.status})`);
101
99
  }
102
100
  else {
103
- const json = (await response.json());
104
- lines.push(Array.isArray(json.results)
101
+ const normalized = normalizeYouComResults(await response.json());
102
+ lines.push(normalized && (normalized.rawCount === 0 || normalized.results.length > 0)
105
103
  ? 'search backend: youcom ok'
106
104
  : 'search backend: youcom warning (unexpected response)');
107
105
  }
@@ -186,23 +184,26 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
186
184
  }
187
185
  }
188
186
  }
189
- else if (!config.search.baseUrl) {
190
- lines.push('search backend: searxng warning (missing baseUrl)');
191
- }
192
- else {
193
- const timeout = withTimeout(timeoutMs);
194
- try {
195
- const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl, config.search.options), { signal: timeout.signal });
196
- const json = (await response.json());
197
- lines.push(response.ok && Array.isArray(json.results)
198
- ? 'search backend: searxng ok'
199
- : 'search backend: searxng warning (unexpected response)');
200
- }
201
- catch (error) {
202
- lines.push(`search backend: searxng warning (${message(error)})`);
187
+ else if (config.search.provider === 'searxng') {
188
+ const baseUrl = config.search.baseUrl;
189
+ if (!baseUrl) {
190
+ lines.push('search backend: searxng warning (missing baseUrl)');
203
191
  }
204
- finally {
205
- timeout.done();
192
+ else {
193
+ const timeout = withTimeout(timeoutMs);
194
+ try {
195
+ const response = await fetchImpl(searxngDoctorUrl(baseUrl, config.search.options), { signal: timeout.signal });
196
+ const json = (await response.json());
197
+ lines.push(response.ok && Array.isArray(json.results)
198
+ ? 'search backend: searxng ok'
199
+ : 'search backend: searxng warning (unexpected response)');
200
+ }
201
+ catch (error) {
202
+ lines.push(`search backend: searxng warning (${message(error)})`);
203
+ }
204
+ finally {
205
+ timeout.done();
206
+ }
206
207
  }
207
208
  }
208
209
  if (config.search.fallback) {
@@ -241,13 +242,10 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
241
242
  }
242
243
  }
243
244
  }
244
- if (config.fetch.provider === 'http') {
245
- lines.push('fetch backend: http');
246
- }
247
- else if (!config.fetch.baseUrl) {
245
+ if (config.fetch.provider !== 'http' && !config.fetch.baseUrl) {
248
246
  lines.push('fetch backend: firecrawl warning (missing baseUrl)');
249
247
  }
250
- else {
248
+ else if (config.fetch.provider !== 'http') {
251
249
  const timeout = withTimeout(timeoutMs);
252
250
  try {
253
251
  const headers = { 'content-type': 'application/json' };
@@ -1,24 +1,26 @@
1
1
  import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
2
+ import { type GuardProxy, type GuardProxyOptions } from '../fetch/guard-proxy.js';
3
+ import { type NetworkGuard } from '../fetch/network-guard.js';
2
4
  import { createBraveSearchTool } from '../search/brave.js';
3
5
  import { createYouComSearchTool } from '../search/youcom.js';
4
6
  import { createExaSearchTool } from '../search/exa.js';
5
7
  import { createTavilySearchTool } from '../search/tavily.js';
6
8
  import { createSearxngSearchTool } from '../search/searxng.js';
9
+ import { type PolicyDeps } from './fallback-policy.js';
10
+ import { type ProviderHealth } from './provider-health.js';
7
11
  import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
8
12
  import { createWebFetchTool } from '../tools/web-fetch.js';
9
13
  import { createWebSearchTool } from '../tools/web-search.js';
10
- import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
14
+ import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
11
15
  import { type BackendConfig, type ProxyConfig } from './config.js';
12
16
  export type BackendSet = {
13
17
  search: (input: {
14
18
  query: string;
15
19
  }) => Promise<WebSearchResponse>;
16
- fetchPage: (input: {
17
- url: string;
18
- }) => Promise<WebFetchResponse>;
19
- headlessFetch: (input: {
20
- url: string;
21
- }) => Promise<WebFetchHeadlessResponse>;
20
+ fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
21
+ headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
22
+ /** Releases the guard proxy and its agents. Idempotent; never starts the proxy. */
23
+ close: () => Promise<void>;
22
24
  };
23
25
  export type BackendFactoryDeps = {
24
26
  createDuckDuckGoSearch?: typeof createWebSearchTool;
@@ -31,5 +33,14 @@ export type BackendFactoryDeps = {
31
33
  createFirecrawlFetch?: typeof createFirecrawlFetcher;
32
34
  createHeadlessFetch?: typeof createWebFetchHeadlessTool;
33
35
  createProxyFetch?: (proxy: ProxyConfig) => typeof fetch;
36
+ networkGuard?: NetworkGuard;
37
+ /** Test seam: the fetch used for model-chosen URLs, before redirect handling. */
38
+ createModelFetch?: (guard: NetworkGuard) => typeof fetch;
39
+ createGuardProxy?: (options: GuardProxyOptions) => Promise<GuardProxy>;
40
+ providerHealth?: ProviderHealth;
41
+ /** Test seam for the retry sleep, jitter, and clock. */
42
+ policy?: Omit<PolicyDeps, 'health'>;
43
+ /** Test seam for the per-call fanout provider timeout. */
44
+ fanoutTimeoutMs?: number;
34
45
  };
35
46
  export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
@@ -2,13 +2,19 @@ import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
2
2
  import { createHttpFetcher } from '../fetch/http-fetch.js';
3
3
  import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
4
4
  import { headlessFetch } from '../fetch/headless-fetch.js';
5
+ import { createGuardedFetch } from '../fetch/guarded-fetch.js';
6
+ import { startGuardProxy } from '../fetch/guard-proxy.js';
7
+ import { createGuardProxyFetch } from '../fetch/guard-proxy-fetch.js';
8
+ import { createNetworkGuard, findGuardError } from '../fetch/network-guard.js';
5
9
  import { createBraveSearchTool } from '../search/brave.js';
6
10
  import { createYouComSearchTool } from '../search/youcom.js';
7
11
  import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
8
12
  import { createExaSearchTool } from '../search/exa.js';
9
13
  import { createTavilySearchTool } from '../search/tavily.js';
10
14
  import { createSearxngSearchTool } from '../search/searxng.js';
11
- import { createFanoutSearch } from '../search/fanout.js';
15
+ import { createFanoutSearch, FANOUT_PROVIDER_TIMEOUT_MS, withCallTimeout } from '../search/fanout.js';
16
+ import { chainSearch, withFetchPolicy, withSearchPolicy } from './fallback-policy.js';
17
+ import { createProviderHealth } from './provider-health.js';
12
18
  import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
13
19
  import { buildSearchPresentation } from '../presentation/search-presentation.js';
14
20
  import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
@@ -27,7 +33,8 @@ function invalidSearxngSearch() {
27
33
  metadata: { backend: 'searxng', cacheHit: false },
28
34
  error: {
29
35
  code: 'BACKEND_CONFIG_INVALID',
30
- message: 'SearXNG search requires backends.search.baseUrl.'
36
+ message: 'SearXNG search requires backends.search.baseUrl.',
37
+ failure: { kind: 'not_configured' }
31
38
  }
32
39
  };
33
40
  return { ...result, presentation: buildSearchPresentation(result) };
@@ -41,47 +48,36 @@ function invalidFirecrawlFetch() {
41
48
  metadata: { method: 'firecrawl', cacheHit: false },
42
49
  error: {
43
50
  code: 'BACKEND_CONFIG_INVALID',
44
- message: 'Firecrawl fetch requires backends.fetch.baseUrl.'
51
+ message: 'Firecrawl fetch requires backends.fetch.baseUrl.',
52
+ failure: { kind: 'not_configured' }
45
53
  }
46
54
  };
47
55
  return { ...result, presentation: buildFetchPresentation(result) };
48
56
  };
49
57
  }
50
- function withSearchFallback(primary, fallback, fallbackFrom) {
58
+ /**
59
+ * Checks the target URL before dispatching to the http fetcher, a content
60
+ * reader, or Firecrawl. Doing it here means a private URL is refused once, in
61
+ * one place, and never reaches Firecrawl or its http fallback (#53).
62
+ */
63
+ function withTargetGuard(fetchPage, guard, method) {
51
64
  return async (input) => {
52
- const first = await primary(input);
53
- if (first.status !== 'error')
54
- return first;
55
- const second = await fallback(input);
56
- const result = {
57
- ...second,
58
- metadata: {
59
- ...second.metadata,
60
- fallbackFrom,
61
- fallbackReason: first.error?.message ?? `${fallbackFrom} search failed.`,
62
- // Keep the primary's fanout provenance (which providers were tried/skipped) even though
63
- // the answer came from the fallback backend.
64
- ...(first.metadata.fanout ? { fanout: first.metadata.fanout } : {})
65
- }
66
- };
67
- return { ...result, presentation: buildSearchPresentation(result) };
68
- };
69
- }
70
- function withFetchFallback(primary, fallback) {
71
- return async (input) => {
72
- const first = await primary(input);
73
- if (first.status !== 'error' && first.status !== 'needs_headless')
74
- return first;
75
- const second = await fallback(input);
76
- const result = {
77
- ...second,
78
- metadata: {
79
- ...second.metadata,
80
- fallbackFrom: 'firecrawl',
81
- fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
82
- }
83
- };
84
- return { ...result, presentation: buildFetchPresentation(result) };
65
+ try {
66
+ await guard.assertUrlAllowed(input.url);
67
+ }
68
+ catch (error) {
69
+ const blocked = findGuardError(error);
70
+ if (!blocked)
71
+ throw error;
72
+ const result = {
73
+ status: 'error',
74
+ url: input.url,
75
+ metadata: { method, cacheHit: false },
76
+ error: { code: blocked.code, message: blocked.message, failure: { kind: 'guard_refused' } }
77
+ };
78
+ return { ...result, presentation: buildFetchPresentation(result) };
79
+ }
80
+ return fetchPage(input);
85
81
  };
86
82
  }
87
83
  export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
@@ -109,7 +105,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
109
105
  status: 'error',
110
106
  results: [],
111
107
  metadata: { backend: config.search.provider, cacheHit: false },
112
- error: { code: 'BACKEND_CONFIG_INVALID', message }
108
+ error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
113
109
  };
114
110
  return { ...result, presentation: buildSearchPresentation(result) };
115
111
  },
@@ -118,7 +114,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
118
114
  status: 'error',
119
115
  url,
120
116
  metadata: { method: 'http', cacheHit: false },
121
- error: { code: 'BACKEND_CONFIG_INVALID', message }
117
+ error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
122
118
  };
123
119
  return { ...result, presentation: buildFetchPresentation(result) };
124
120
  },
@@ -127,24 +123,71 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
127
123
  status: 'error',
128
124
  url,
129
125
  metadata: { method: 'headless', cacheHit: false },
130
- error: { code: 'BACKEND_CONFIG_INVALID', message }
126
+ error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
131
127
  };
132
128
  return { ...result, presentation: buildFetchPresentation(result) };
133
- }
129
+ },
130
+ close: async () => undefined
134
131
  };
135
132
  }
136
- // When a proxy is configured, every outbound HTTP request (search, fetch,
137
- // readers, and doctor-style checks) goes through it; headless browser
138
- // traffic gets the same proxy via Playwright launch options.
133
+ // When a proxy is configured, every outbound HTTP request goes through it.
134
+ // User-configured endpoints use fetchImpl directly; model-chosen fetches and
135
+ // the headless browser reach it through the guard proxy below.
139
136
  const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
140
137
  const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
141
- const proxyBrowserOptions = proxy
142
- ? {
143
- server: stripProxyCredentials(proxy.url),
144
- ...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
145
- ...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
138
+ // Model-chosen URLs only. Search APIs and the configured SearXNG/Firecrawl
139
+ // endpoints keep using fetchImpl: the user typed those (#53).
140
+ const networkGuard = deps.networkGuard ?? createNetworkGuard({ allowRanges: config.network?.allowRanges ?? [] });
141
+ // One guard proxy per backend set, started on first use. It is the single
142
+ // place the address policy is enforced, for Node fetches and the browser,
143
+ // and it chains to the user's upstream proxy itself.
144
+ let guardProxy;
145
+ let isClosed = false;
146
+ const getGuardProxy = () => {
147
+ if (isClosed)
148
+ return Promise.reject(new Error('Backend set is closed.'));
149
+ if (guardProxy)
150
+ return guardProxy;
151
+ const started = (deps.createGuardProxy ?? startGuardProxy)({
152
+ guard: networkGuard,
153
+ ...(proxy
154
+ ? {
155
+ upstream: {
156
+ url: stripProxyCredentials(proxy.url),
157
+ ...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
158
+ ...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
159
+ }
160
+ }
161
+ : {}),
162
+ trustProxyDns: config.network?.trustProxyDns === true
163
+ });
164
+ guardProxy = started;
165
+ // A failed start should not stick around for the rest of the backend
166
+ // set's life: clear it so the next call tries again, unless a newer
167
+ // attempt has already replaced it.
168
+ started.catch(() => {
169
+ if (guardProxy === started)
170
+ guardProxy = undefined;
171
+ });
172
+ return started;
173
+ };
174
+ const modelFetch = deps.createModelFetch
175
+ ? deps.createModelFetch(networkGuard)
176
+ : createGuardProxyFetch(getGuardProxy);
177
+ const targetFetch = createGuardedFetch(modelFetch, networkGuard);
178
+ let closing;
179
+ const close = () => (closing ??= (async () => {
180
+ isClosed = true;
181
+ if ('close' in modelFetch)
182
+ await modelFetch.close();
183
+ if (guardProxy) {
184
+ const started = await guardProxy.catch(() => undefined);
185
+ await started?.close().catch(() => undefined);
146
186
  }
147
- : undefined;
187
+ })());
188
+ // One health state per backend set: a rebuilt set (config change) starts fresh.
189
+ const policyDeps = { health: deps.providerHealth ?? createProviderHealth(deps.policy?.now ? { now: deps.policy.now } : {}), ...deps.policy };
190
+ const guarded = (name, search, healthKey) => withSearchPolicy(name, search, policyDeps, healthKey);
148
191
  const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
149
192
  function buildProviderSearch(name) {
150
193
  switch (name) {
@@ -165,34 +208,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
165
208
  return createDuckDuckGo();
166
209
  }
167
210
  }
168
- let search = config.search.provider === 'searxng'
169
- ? config.search.baseUrl
170
- ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
171
- : invalidSearxngSearch()
172
- : config.search.provider === 'brave'
173
- ? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl })
174
- : config.search.provider === 'youcom'
175
- ? createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl })
176
- : config.search.provider === 'exa'
177
- ? createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl })
178
- : config.search.provider === 'tavily'
179
- ? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl })
180
- : createDuckDuckGo();
181
- if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
182
- search = withSearchFallback(search, createDuckDuckGo(), 'searxng');
183
- }
184
- if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
185
- search = withSearchFallback(search, createDuckDuckGo(), 'brave');
186
- }
187
- if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
188
- search = withSearchFallback(search, createDuckDuckGo(), 'youcom');
189
- }
190
- if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
191
- search = withSearchFallback(search, createDuckDuckGo(), 'exa');
192
- }
193
- if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
194
- search = withSearchFallback(search, createDuckDuckGo(), 'tavily');
211
+ const primarySearch = guarded(config.search.provider, buildProviderSearch(config.search.provider));
212
+ const chain = [primarySearch];
213
+ if (config.search.provider !== 'duckduckgo' && config.search.fallback === 'duckduckgo') {
214
+ chain.push(guarded('duckduckgo', createDuckDuckGo()));
195
215
  }
216
+ let search = chainSearch(chain, policyDeps);
196
217
  const fanoutConfig = config.search.fanout;
197
218
  if (fanoutConfig && fanoutConfig.mode !== 'off') {
198
219
  const baseNames = fanoutConfig.providers && fanoutConfig.providers.length > 0
@@ -203,9 +224,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
203
224
  ? [...baseNames, 'duckduckgo']
204
225
  : baseNames;
205
226
  const ordered = [config.search.provider, ...providerNames.filter((n) => n !== config.search.provider)].filter((n, i, arr) => arr.indexOf(n) === i);
227
+ const timeoutMs = deps.fanoutTimeoutMs ?? FANOUT_PROVIDER_TIMEOUT_MS;
206
228
  search = createFanoutSearch({
207
- providers: ordered.map((name) => ({ name, search: buildProviderSearch(name) })),
208
- mode: fanoutConfig.mode
229
+ // The timeout sits inside the policy so a stalled provider gets its one transient retry.
230
+ providers: ordered.map((name) => ({ name, search: guarded(name, withCallTimeout(buildProviderSearch(name), timeoutMs, name)) })),
231
+ mode: fanoutConfig.mode,
232
+ timeoutMs
209
233
  });
210
234
  }
211
235
  // Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
@@ -214,32 +238,35 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
214
238
  const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
215
239
  const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
216
240
  if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
217
- search = withSearchFallback(search, createTavilySearch({ keyless: true, fetchImpl }), 'duckduckgo');
241
+ // chainSearch only falls back on non-terminal failures, so a terminal result or bad_request never reaches keyless Tavily.
242
+ search = chainSearch([search, guarded('tavily', createTavilySearch({ keyless: true, fetchImpl }), 'tavily-keyless')], policyDeps);
218
243
  }
219
- const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl }) });
220
- let fetchPage = config.fetch.provider === 'firecrawl'
221
- ? config.fetch.baseUrl
222
- ? createHttpFetch({
223
- fetchPage: createFirecrawlFetch({
224
- baseUrl: config.fetch.baseUrl,
225
- apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
226
- options: config.fetch.options,
227
- fetchImpl
228
- })
229
- })
230
- : createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
244
+ const httpFetcher = createHttpFetcher({ fetchImpl: targetFetch });
245
+ const httpFetch = createHttpFetch({ fetchPage: ({ url, query }) => httpFetcher(url, query) });
246
+ const firecrawlFetcher = config.fetch.baseUrl
247
+ ? createFirecrawlFetch({
248
+ baseUrl: config.fetch.baseUrl,
249
+ apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
250
+ options: config.fetch.options,
251
+ fetchImpl
252
+ })
253
+ : invalidFirecrawlFetch();
254
+ const fetchPage = config.fetch.provider === 'firecrawl'
255
+ ? withFetchPolicy(createHttpFetch({ fetchPage: ({ url, query }) => firecrawlFetcher(url, query) }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
231
256
  : httpFetch;
232
- if (config.fetch.provider === 'firecrawl' && config.fetch.fallback === 'http') {
233
- fetchPage = withFetchFallback(fetchPage, httpFetch);
234
- }
235
257
  const fetchPageWithReaders = createSpecialContentResolver({
236
- readers: [createGithubReader({ fetchImpl }), createPdfReader({ fetchImpl }), createYoutubeReader({ fetchImpl })],
258
+ readers: [
259
+ createGithubReader({ fetchImpl: targetFetch }),
260
+ createPdfReader({ fetchImpl: targetFetch }),
261
+ createYoutubeReader({ fetchImpl: targetFetch })
262
+ ],
237
263
  fallback: fetchPage
238
264
  });
239
- const headlessPage = (url) => proxyBrowserOptions ? headlessFetch(url, { proxy: proxyBrowserOptions }) : headlessFetch(url);
265
+ const headlessPage = ({ url, query }) => headlessFetch(url, { query, guard: networkGuard, guardProxy: getGuardProxy });
240
266
  return {
241
267
  search,
242
- fetchPage: fetchPageWithReaders,
243
- headlessFetch: createHeadlessFetch({ fetchPage: headlessPage })
268
+ fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
269
+ headlessFetch: createHeadlessFetch({ fetchPage: headlessPage }),
270
+ close
244
271
  };
245
272
  }