pi-unsloth-webtools 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,11 +50,19 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
50
50
  - URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
51
51
  port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
52
52
  allowlists).
53
- Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
54
- blocked at the resolved-IP layer (non-canonical numeric encodings like `0x7f.0.0.1` /
55
- `013.0.0.1` / `2130706433` are rejected by URL validation before DNS).
53
+ Canonical public IPv4 literals are accepted like IPv6 literals; private literals are blocked
54
+ at the resolved-IP layer unless `webFetch.allowPrivateAddresses` is enabled (the default;
55
+ non-canonical numeric encodings like `0x7f.0.0.1` / `013.0.0.1` / `2130706433` are rejected
56
+ by URL validation before DNS either way).
57
+ - Local access by default: private/loopback/link-local targets (localhost dev servers, LAN hosts)
58
+ and local files (`file://` URLs, absolute, `~/`, and `./` paths) are fetched directly — local
59
+ PDFs run the same MuPDF extraction, local HTML gets the same Markdown conversion and metadata
60
+ prefix, and the same byte caps and truncation notices apply. Opt out with
61
+ `webFetch.allowPrivateAddresses: false` to restore the resolved-IP SSRF guard, or
62
+ `webFetch.allowLocalFiles: false` to refuse local paths; both are settings, not tool parameters.
56
63
  - DNS resolution with SSRF protection: every resolved address is validated against
57
- private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
64
+ private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges (unless
65
+ `webFetch.allowPrivateAddresses` is enabled, the default), then the validated IP
58
66
  is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
59
67
  validation and fetch; resolution shares the caller's abort signal and the overall deadline,
60
68
  so a stuck resolver cannot outlive the fetch.
@@ -174,10 +182,15 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
174
182
  |---|---|---|
175
183
  | `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
176
184
  | `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
177
- | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` | Default `timeoutMs` (>=1000) |
185
+ | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch and `web_search` url mode fall back to 60000; `web_search` query mode falls back to 300000 |
178
186
  | `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
187
+ | `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
188
+ | `unslothWebTools.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Opt out to restore the resolved-IP SSRF guard: private/loopback/link-local hosts (localhost, LAN IPs) are refused again. Non-canonical numeric IP encodings stay blocked either way |
189
+ | `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` and `web_search` url mode (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
179
190
 
180
- Tool params always win over file defaults.
191
+ Tool params always win over file defaults. Search dedup also strips default ports, so `https://example.com:443/a` and `https://example.com/a` collapse.
192
+
193
+ Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory. Cache entries live 1 hour and stale copies are served only after a network failure.
181
194
 
182
195
  ## Troubleshooting
183
196
 
@@ -190,7 +203,10 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
190
203
  | Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
191
204
  | No results | `No results found.` | Rephrase the query. |
192
205
  | Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`, do not work around it. |
193
- | Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-public IPs, and policy denials. |
206
+ | Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-canonical numeric IP encodings, and policy denials. |
207
+ | Private address blocked | `Blocked: refusing to fetch the non-public address ...` | The SSRF guard is active (`allowPrivateAddresses: false`); remove it or set `true` to reach localhost/LAN, and write the scheme explicitly (`http://localhost:3000`). |
208
+ | Local file blocked | `Blocked: the URL has an invalid hostname or port.` for paths | Local files are disabled: remove `allowLocalFiles: false` to read `file://`, absolute, `~/`, or `./` paths. |
209
+ | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
194
210
  | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
195
211
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
196
212
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
package/cache.ts CHANGED
@@ -98,9 +98,17 @@ async function prune(): Promise<void> {
98
98
  } catch {
99
99
  return;
100
100
  }
101
+ const now = Date.now();
102
+ const tmpFiles = files.filter((f) => f.includes(".tmp."));
103
+ for (const file of tmpFiles) {
104
+ try {
105
+ const full = join(dir, file);
106
+ const s = await stat(full);
107
+ if (now - s.mtimeMs >= CACHE_TTL_MS) await unlinkSafe(full);
108
+ } catch {}
109
+ }
101
110
  const jsonFiles = files.filter((f) => f.endsWith(".json"));
102
111
  if (!jsonFiles.length) return;
103
- const now = Date.now();
104
112
  const entries: { file: string; size: number; timestamp: number }[] = [];
105
113
  let total = 0;
106
114
  for (const file of jsonFiles) {
package/engines.ts CHANGED
@@ -82,6 +82,9 @@ export function canonicalizeHref(href: string): string {
82
82
  }
83
83
  }
84
84
  url.hash = "";
85
+ if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
86
+ url.port = "";
87
+ }
85
88
  return url.toString();
86
89
  } catch {
87
90
  return href;
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { webSearch as defaultWebSearch } from "./web-search.ts";
3
+ import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
4
4
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
5
- import { loadDefaultFetchSettings } from "./settings.ts";
5
+ import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs } from "./settings.ts";
6
6
 
7
7
  function positiveNumber(value: unknown): number | undefined {
8
8
  if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
@@ -12,11 +12,12 @@ function positiveNumber(value: unknown): number | undefined {
12
12
  async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
13
13
  const timeoutParam = positiveNumber(params.timeoutMs);
14
14
  const maxCharsParam = positiveNumber(params.maxChars);
15
- if (timeoutParam !== undefined && maxCharsParam !== undefined) return { timeoutMs: timeoutParam, maxChars: maxCharsParam };
16
15
  const defaults = await loadDefaultFetchSettings(cwd);
17
16
  return {
18
17
  timeoutMs: timeoutParam ?? defaults.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
19
18
  maxChars: maxCharsParam ?? defaults.maxChars,
19
+ allowPrivateAddresses: defaults.allowPrivateAddresses,
20
+ allowLocalFiles: defaults.allowLocalFiles,
20
21
  };
21
22
  }
22
23
 
@@ -91,7 +92,7 @@ export function createWebTools(deps: WebToolsDeps = {}) {
91
92
  const url = params.url.trim();
92
93
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
93
94
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
94
- const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
95
+ const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
95
96
  return {
96
97
  content: [
97
98
  {
@@ -100,6 +101,8 @@ export function createWebTools(deps: WebToolsDeps = {}) {
100
101
  timeoutMs,
101
102
  signal: signal ?? undefined,
102
103
  maxChars,
104
+ allowPrivateAddresses,
105
+ allowLocalFiles,
103
106
  }),
104
107
  },
105
108
  ],
@@ -107,11 +110,14 @@ export function createWebTools(deps: WebToolsDeps = {}) {
107
110
  };
108
111
  }
109
112
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
113
+ const timeoutParam = positiveNumber(params.timeoutMs);
114
+ const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
115
+ const searchTimeoutMs = timeoutParam ?? (await loadDefaultFetchTimeoutMs(searchCwd)) ?? SEARCH_TIMEOUT_MS;
110
116
  const text = await webSearch(params.query, {
111
117
  signal: signal ?? undefined,
112
- timeoutMs: positiveNumber(params.timeoutMs),
118
+ timeoutMs: searchTimeoutMs,
113
119
  maxResults: positiveNumber(params.maxResults),
114
- cwd: (_ctx as ExtensionContext | undefined)?.cwd,
120
+ cwd: searchCwd,
115
121
  });
116
122
  return { content: [{ type: "text", text }], details: {} };
117
123
  },
@@ -124,18 +130,21 @@ export function createWebTools(deps: WebToolsDeps = {}) {
124
130
  "main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
125
131
  "stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
126
132
  "README API, so the README is returned instead of the repo page's UI chrome. " +
127
- "Private/loopback/link-local targets are blocked (SSRF protection), and the download size " +
128
- "is capped.",
133
+ "Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
134
+ "PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
135
+ "webFetch.allowLocalFiles: false in settings. The download size is capped.",
129
136
  promptSnippet: "Fetch a web page and return readable text content",
130
137
  parameters: WebFetchParams,
131
138
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
132
139
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
133
140
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
134
- const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
141
+ const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
135
142
  const text = await fetchPageText(params.url, {
136
143
  timeoutMs,
137
144
  signal: signal ?? undefined,
138
145
  maxChars,
146
+ allowPrivateAddresses,
147
+ allowLocalFiles,
139
148
  });
140
149
  return { content: [{ type: "text", text }], details: {} };
141
150
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -55,7 +55,7 @@
55
55
  "test:smoke": "vitest run test/smoke.test.ts",
56
56
  "typecheck": "tsc --noEmit",
57
57
  "check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
58
- "prepublishOnly": "npm run typecheck && npm run test:unit && npm run check:package && npm run check:publint",
58
+ "prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
59
59
  "lint": "oxlint .",
60
60
  "check:publint": "publint",
61
61
  "check:attw": "attw --pack"
package/pdf.ts CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { inflateSync } from "node:zlib";
3
3
 
4
4
  export const MAX_WEB_PDF_PAGES = 50;
5
+ const MAX_PDF_INFLATE_BYTES = 64 * 1024 * 1024;
5
6
 
6
7
  export class PdfParseError extends Error {
7
8
  constructor() {
@@ -786,7 +787,7 @@ async function extractPdfTextFallback(data: Buffer): Promise<string> {
786
787
  let bytes = raw;
787
788
  if (isFlate) {
788
789
  try {
789
- bytes = inflateSync(raw);
790
+ bytes = inflateSync(raw, { maxOutputLength: MAX_PDF_INFLATE_BYTES });
790
791
  } catch {
791
792
  continue;
792
793
  }
package/settings.ts CHANGED
@@ -34,6 +34,21 @@ function pickNumber(data: Record<string, unknown>, paths: string[][]): number |
34
34
  return undefined;
35
35
  }
36
36
 
37
+ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean | undefined {
38
+ for (const path of paths) {
39
+ let cur: unknown = data;
40
+ for (const key of path) {
41
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
42
+ else {
43
+ cur = undefined;
44
+ break;
45
+ }
46
+ }
47
+ if (typeof cur === "boolean") return cur;
48
+ }
49
+ return undefined;
50
+ }
51
+
37
52
  const MAX_RESULTS = 5;
38
53
 
39
54
  const MAX_RESULTS_PATHS: string[][] = [
@@ -54,6 +69,16 @@ const FETCH_TIMEOUT_PATHS: string[][] = [
54
69
  ["smartFetchDefaultTimeoutMs"],
55
70
  ];
56
71
 
72
+ const ALLOW_PRIVATE_ADDRESSES_PATHS: string[][] = [
73
+ ["unslothWebTools", "allowPrivateAddresses"],
74
+ ["webFetch", "allowPrivateAddresses"],
75
+ ];
76
+
77
+ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
78
+ ["unslothWebTools", "allowLocalFiles"],
79
+ ["webFetch", "allowLocalFiles"],
80
+ ];
81
+
57
82
  function clampMaxResults(value: number): number {
58
83
  return Math.min(20, Math.max(1, value));
59
84
  }
@@ -101,14 +126,25 @@ export async function loadDefaultFetchTimeoutMs(cwd?: string): Promise<number |
101
126
  return loadFetchSetting(cwd, FETCH_TIMEOUT_PATHS, (n) => n >= 1000);
102
127
  }
103
128
 
104
- export async function loadDefaultFetchSettings(cwd?: string): Promise<{ maxChars?: number; timeoutMs?: number }> {
129
+ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
130
+ maxChars?: number;
131
+ timeoutMs?: number;
132
+ allowPrivateAddresses: boolean;
133
+ allowLocalFiles: boolean;
134
+ }> {
105
135
  let maxChars: number | undefined;
106
136
  let timeoutMs: number | undefined;
137
+ let allowPrivateAddresses = true;
138
+ let allowLocalFiles = true;
107
139
  for (const data of await settingsEntries(cwd)) {
108
140
  const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
109
141
  if (c !== undefined && c > 0) maxChars = c;
110
142
  const t = pickNumber(data, FETCH_TIMEOUT_PATHS);
111
143
  if (t !== undefined && t >= 1000) timeoutMs = t;
144
+ const p = pickBoolean(data, ALLOW_PRIVATE_ADDRESSES_PATHS);
145
+ if (p !== undefined) allowPrivateAddresses = p;
146
+ const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
147
+ if (l !== undefined) allowLocalFiles = l;
112
148
  }
113
- return { maxChars, timeoutMs };
149
+ return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
114
150
  }
package/user-agents.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  const USER_AGENTS = [
2
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
3
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
4
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
5
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
6
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
7
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
2
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
3
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
4
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
5
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
6
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0",
7
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
8
8
  ];
9
-
10
9
  export function randomUserAgent(): string {
11
10
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
12
11
  }
package/web-fetch.ts CHANGED
@@ -2,6 +2,11 @@ import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import type { LookupAllOptions } from "node:dns";
3
3
  import http from "node:http";
4
4
  import https from "node:https";
5
+ import { open } from "node:fs/promises";
6
+ import type { FileHandle } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
5
10
 
6
11
  import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
7
12
  import type { IncomingMessage } from "node:http";
@@ -171,6 +176,8 @@ export interface FetchPageOptions {
171
176
  maxChars?: number;
172
177
  maxBytes?: number;
173
178
  maxPdfBytes?: number;
179
+ allowPrivateAddresses?: boolean;
180
+ allowLocalFiles?: boolean;
174
181
  seams?: FetchSeams;
175
182
  rawFetch?: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>;
176
183
  }
@@ -191,7 +198,7 @@ export interface ResolvedHost {
191
198
  }
192
199
 
193
200
  export interface FetchSeams {
194
- resolve?: (hostname: string, signal?: AbortSignal) => Promise<ResolvedHost>;
201
+ resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
195
202
  request?: (opts: HopOptions) => Promise<HopResponse>;
196
203
  }
197
204
 
@@ -217,6 +224,7 @@ export interface RawFetchOptions {
217
224
  websitePolicy?: WebsitePolicy | null;
218
225
  maxBytes?: number;
219
226
  maxPdfBytes?: number;
227
+ allowPrivateAddresses?: boolean;
220
228
  seams?: FetchSeams;
221
229
  }
222
230
 
@@ -549,7 +557,11 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
549
557
  });
550
558
  }
551
559
 
552
- async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
560
+ async function resolveAndValidate(
561
+ hostname: string,
562
+ signal?: AbortSignal,
563
+ allowPrivateAddresses = false,
564
+ ): Promise<ResolvedHost> {
553
565
  let lastError: unknown = null;
554
566
  for (let attempt = 0; attempt < MAX_DNS_ATTEMPTS; attempt++) {
555
567
  if (signal?.aborted) break;
@@ -568,7 +580,7 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
568
580
  return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
569
581
  }
570
582
  for (const entry of addresses) {
571
- if (!isPublicIp(entry.address)) {
583
+ if (!allowPrivateAddresses && !isPublicIp(entry.address)) {
572
584
  return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
573
585
  }
574
586
  }
@@ -631,8 +643,14 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
631
643
  headers: opts.headers,
632
644
  timeout: opts.inactivityMs,
633
645
  servername: url.protocol === "https:" ? url.hostname : undefined,
634
- lookup: (_host, _opts, callback) =>
635
- callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
646
+ lookup: ((_hostname: string, _options: unknown, _callback: unknown) => {
647
+ const done = (typeof _options === "function" ? _options : _callback) as (err: unknown, address: unknown, family?: unknown) => void;
648
+ if ((_options as { all?: boolean } | undefined)?.all) {
649
+ done(null, [{ address: opts.pinnedIp, family: opts.family }]);
650
+ } else {
651
+ done(null, opts.pinnedIp, opts.family);
652
+ }
653
+ }) as typeof options.lookup,
636
654
  };
637
655
  let settled = false;
638
656
  let resRef: IncomingMessage | null = null;
@@ -798,7 +816,7 @@ export async function fetchUrlRaw(
798
816
  url: string,
799
817
  options: RawFetchOptions = {},
800
818
  ): Promise<RawFetchResult> {
801
- const timeoutMs = options.timeoutMs ?? 30_000;
819
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
802
820
  const now = options.nowMs ?? Date.now;
803
821
  const deadline = options.deadlineMs ?? now() + timeoutMs;
804
822
  const signal = options.signal;
@@ -807,6 +825,7 @@ export async function fetchUrlRaw(
807
825
  const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
808
826
  const seams = options.seams ?? {};
809
827
  const resolveHost = seams.resolve ?? resolveAndValidate;
828
+ const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
810
829
  const performRequest = seams.request ?? requestHop;
811
830
  const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
812
831
  const abortController = new AbortController();
@@ -818,7 +837,7 @@ export async function fetchUrlRaw(
818
837
  deadlineId = setTimeout(() => resolve({ ok: false, reason: FETCH_TIMEOUT_MESSAGE, ip: "", family: 0 }), waitMs);
819
838
  });
820
839
  try {
821
- const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
840
+ const resolved = await Promise.race([resolveHost(hostname, resolveSignal, allowPrivateAddresses), deadlinePromise]);
822
841
  if (resolved.ok) return resolved;
823
842
  if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
824
843
  if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
@@ -847,7 +866,9 @@ export async function fetchUrlRaw(
847
866
  const budgetResult = checkBudget();
848
867
  if (budgetResult !== null) return budgetResult;
849
868
  const parsed = new URL(currentUrl);
850
- const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
869
+ const hostHeader = parsed.hostname.includes(":")
870
+ ? `[${parsed.hostname}]${parsed.port ? `:${parsed.port}` : ""}`
871
+ : parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
851
872
  const headers: Record<string, string> = {
852
873
  "User-Agent": userAgent,
853
874
  Host: hostHeader,
@@ -1233,6 +1254,74 @@ async function fetchWaybackSnapshot(
1233
1254
  if (!snapResult || snapResult.error !== null) return null;
1234
1255
  return { body: snapResult.body, contentType: snapResult.contentType, timestamp };
1235
1256
  }
1257
+ const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
1258
+
1259
+ function parseLocalPath(url: string): string | null {
1260
+ const trimmed = url.trim();
1261
+ if (/^file:/i.test(trimmed)) {
1262
+ try {
1263
+ return fileURLToPath(trimmed);
1264
+ } catch {
1265
+ return "";
1266
+ }
1267
+ }
1268
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
1269
+ if (WINDOWS_PATH_RE.test(trimmed) || trimmed.startsWith("/") || /^\.{1,2}\//.test(trimmed)) return trimmed;
1270
+ return null;
1271
+ }
1272
+
1273
+ function localFileFailure(err: unknown): string {
1274
+ return `Failed to read file: ${err instanceof Error ? err.message : String(err)}`;
1275
+ }
1276
+
1277
+ async function readLocalFile(
1278
+ filePath: string,
1279
+ options: { signal?: AbortSignal; maxChars?: number; maxBytes?: number; maxPdfBytes?: number },
1280
+ ): Promise<string> {
1281
+ if (options.signal?.aborted) return "Failed to read file: cancelled.";
1282
+ let handle: FileHandle;
1283
+ try {
1284
+ handle = await open(filePath, "r");
1285
+ } catch (err) {
1286
+ return localFileFailure(err);
1287
+ }
1288
+ try {
1289
+ const stats = await handle.stat();
1290
+ if (stats.isDirectory()) return `Failed to read file: ${filePath} is a directory.`;
1291
+ const maxBytes = options.maxBytes ?? MAX_FETCH_BYTES;
1292
+ const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
1293
+ const length = Math.min(stats.size, Math.max(maxBytes, maxPdfBytes));
1294
+ const buffer = Buffer.alloc(Number(length));
1295
+ let read = 0;
1296
+ while (read < length) {
1297
+ const { bytesRead } = await handle.read(buffer, read, length - read, read);
1298
+ if (!bytesRead) break;
1299
+ read += bytesRead;
1300
+ }
1301
+ if (options.signal?.aborted) return "Failed to read file: cancelled.";
1302
+ const head = read < length ? buffer.subarray(0, read) : buffer;
1303
+ const isPdf = hasPdfMagic(head);
1304
+ const limit = isPdf ? maxPdfBytes : maxBytes;
1305
+ const truncated = stats.size > limit;
1306
+ const body = head.length > limit ? head.subarray(0, limit) : head;
1307
+ if (isPdf) {
1308
+ let pdfText: string;
1309
+ try {
1310
+ pdfText = await extractPdfText(body);
1311
+ } catch {
1312
+ return withTruncation("(PDF content could not be read as text)", truncated);
1313
+ }
1314
+ if (!pdfText) pdfText = "(PDF contains no extractable text)";
1315
+ return truncatePageText(withTruncation(pdfText, truncated), options.maxChars);
1316
+ }
1317
+ const text = decodeWithCodec(body, bomCodecFor(body) ?? "utf-8");
1318
+ if (looksBinary(text)) return `(binary content, ${body.length} bytes; not readable as text)`;
1319
+ return truncatePageText(withTruncation(renderBody(text, ""), truncated), options.maxChars);
1320
+ } finally {
1321
+ await handle.close().catch(() => {});
1322
+ }
1323
+ }
1324
+
1236
1325
  export async function fetchPageText(
1237
1326
  url: string,
1238
1327
  options: FetchPageOptions = {},
@@ -1244,6 +1333,18 @@ export async function fetchPageText(
1244
1333
  const policy = options.websitePolicy ?? null;
1245
1334
  const maxChars = options.maxChars;
1246
1335
  const rawFetch = options.rawFetch ?? fetchUrlRaw;
1336
+ if (options.allowLocalFiles !== false) {
1337
+ const localPath = parseLocalPath(url);
1338
+ if (localPath !== null) {
1339
+ if (!localPath) return "Failed to read file: invalid file URL.";
1340
+ return readLocalFile(localPath, {
1341
+ signal,
1342
+ maxChars,
1343
+ maxBytes: options.maxBytes,
1344
+ maxPdfBytes: options.maxPdfBytes,
1345
+ });
1346
+ }
1347
+ }
1247
1348
  url = normalizeUrlScheme(url);
1248
1349
  const [allowed, reason] = checkUrlAccess(url, policy);
1249
1350
  if (!allowed) return reason;
@@ -1251,6 +1352,7 @@ export async function fetchPageText(
1251
1352
  deadlineMs,
1252
1353
  signal,
1253
1354
  websitePolicy: policy,
1355
+ allowPrivateAddresses: options.allowPrivateAddresses,
1254
1356
  maxBytes: options.maxBytes,
1255
1357
  maxPdfBytes: options.maxPdfBytes,
1256
1358
  seams: options.seams,
package/web-search.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  export { EmptySweepError, SearchCancelled, SearchTimeoutError } from "./engines.ts";
13
13
 
14
- const SEARCH_TIMEOUT_MS = 300_000;
14
+ export const SEARCH_TIMEOUT_MS = 300_000;
15
15
  const MAX_RESULTS = 5;
16
16
 
17
17
  export const EMPTY_SEARCH_RESULTS = [
@@ -56,7 +56,8 @@ export async function webSearch(
56
56
  const saneMax = Number.isFinite(rawMaxResults) ? Math.floor(rawMaxResults as number) : MAX_RESULTS;
57
57
  const maxResults = Math.min(20, Math.max(1, saneMax));
58
58
  const rawTimeout = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
59
- const timeoutMs = Number.isFinite(rawTimeout) ? (rawTimeout as number) : SEARCH_TIMEOUT_MS;
59
+ const flooredTimeout = Number.isFinite(rawTimeout) ? Math.floor(rawTimeout as number) : SEARCH_TIMEOUT_MS;
60
+ const timeoutMs = flooredTimeout >= 1 ? flooredTimeout : SEARCH_TIMEOUT_MS;
60
61
  const signal = options.signal;
61
62
  const policy = options.websitePolicy ?? null;
62
63
  const client = options.client ?? ddgSearch;