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 +23 -7
- package/cache.ts +9 -1
- package/engines.ts +3 -0
- package/index.ts +18 -9
- package/package.json +2 -2
- package/pdf.ts +2 -1
- package/settings.ts +38 -2
- package/user-agents.ts +6 -7
- package/web-fetch.ts +110 -8
- package/web-search.ts +3 -2
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
|
|
54
|
-
|
|
55
|
-
`013.0.0.1` / `2130706433` are rejected
|
|
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
|
|
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-
|
|
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:
|
|
118
|
+
timeoutMs: searchTimeoutMs,
|
|
113
119
|
maxResults: positiveNumber(params.maxResults),
|
|
114
|
-
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
|
|
128
|
-
"
|
|
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.
|
|
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<{
|
|
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/
|
|
3
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
4
|
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
5
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
6
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
7
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/
|
|
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(
|
|
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: (
|
|
635
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
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;
|