pi-unsloth-webtools 0.5.1 → 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 +18 -5
- package/index.ts +11 -5
- package/package.json +1 -1
- package/settings.ts +38 -2
- package/web-fetch.ts +101 -5
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.
|
|
@@ -177,6 +185,8 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
|
|
|
177
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` |
|
|
179
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 |
|
|
180
190
|
|
|
181
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.
|
|
182
192
|
|
|
@@ -193,7 +203,10 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
|
|
|
193
203
|
| Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
|
|
194
204
|
| No results | `No results found.` | Rephrase the query. |
|
|
195
205
|
| Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`, do not work around it. |
|
|
196
|
-
| 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. |
|
|
197
210
|
| HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
|
|
198
211
|
| Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
|
|
199
212
|
| PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
|
package/index.ts
CHANGED
|
@@ -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
|
],
|
|
@@ -127,18 +130,21 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
127
130
|
"main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
|
|
128
131
|
"stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
|
|
129
132
|
"README API, so the README is returned instead of the repo page's UI chrome. " +
|
|
130
|
-
"Private/loopback/link-local targets
|
|
131
|
-
"
|
|
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.",
|
|
132
136
|
promptSnippet: "Fetch a web page and return readable text content",
|
|
133
137
|
parameters: WebFetchParams,
|
|
134
138
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
135
139
|
onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
|
|
136
140
|
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
137
|
-
const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
|
|
141
|
+
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
|
|
138
142
|
const text = await fetchPageText(params.url, {
|
|
139
143
|
timeoutMs,
|
|
140
144
|
signal: signal ?? undefined,
|
|
141
145
|
maxChars,
|
|
146
|
+
allowPrivateAddresses,
|
|
147
|
+
allowLocalFiles,
|
|
142
148
|
});
|
|
143
149
|
return { content: [{ type: "text", text }], details: {} };
|
|
144
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",
|
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/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
|
}
|
|
@@ -813,6 +825,7 @@ export async function fetchUrlRaw(
|
|
|
813
825
|
const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
|
|
814
826
|
const seams = options.seams ?? {};
|
|
815
827
|
const resolveHost = seams.resolve ?? resolveAndValidate;
|
|
828
|
+
const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
|
|
816
829
|
const performRequest = seams.request ?? requestHop;
|
|
817
830
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
818
831
|
const abortController = new AbortController();
|
|
@@ -824,7 +837,7 @@ export async function fetchUrlRaw(
|
|
|
824
837
|
deadlineId = setTimeout(() => resolve({ ok: false, reason: FETCH_TIMEOUT_MESSAGE, ip: "", family: 0 }), waitMs);
|
|
825
838
|
});
|
|
826
839
|
try {
|
|
827
|
-
const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
|
|
840
|
+
const resolved = await Promise.race([resolveHost(hostname, resolveSignal, allowPrivateAddresses), deadlinePromise]);
|
|
828
841
|
if (resolved.ok) return resolved;
|
|
829
842
|
if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
|
|
830
843
|
if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
|
|
@@ -853,7 +866,9 @@ export async function fetchUrlRaw(
|
|
|
853
866
|
const budgetResult = checkBudget();
|
|
854
867
|
if (budgetResult !== null) return budgetResult;
|
|
855
868
|
const parsed = new URL(currentUrl);
|
|
856
|
-
const hostHeader = parsed.hostname
|
|
869
|
+
const hostHeader = parsed.hostname.includes(":")
|
|
870
|
+
? `[${parsed.hostname}]${parsed.port ? `:${parsed.port}` : ""}`
|
|
871
|
+
: parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
857
872
|
const headers: Record<string, string> = {
|
|
858
873
|
"User-Agent": userAgent,
|
|
859
874
|
Host: hostHeader,
|
|
@@ -1239,6 +1254,74 @@ async function fetchWaybackSnapshot(
|
|
|
1239
1254
|
if (!snapResult || snapResult.error !== null) return null;
|
|
1240
1255
|
return { body: snapResult.body, contentType: snapResult.contentType, timestamp };
|
|
1241
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
|
+
|
|
1242
1325
|
export async function fetchPageText(
|
|
1243
1326
|
url: string,
|
|
1244
1327
|
options: FetchPageOptions = {},
|
|
@@ -1250,6 +1333,18 @@ export async function fetchPageText(
|
|
|
1250
1333
|
const policy = options.websitePolicy ?? null;
|
|
1251
1334
|
const maxChars = options.maxChars;
|
|
1252
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
|
+
}
|
|
1253
1348
|
url = normalizeUrlScheme(url);
|
|
1254
1349
|
const [allowed, reason] = checkUrlAccess(url, policy);
|
|
1255
1350
|
if (!allowed) return reason;
|
|
@@ -1257,6 +1352,7 @@ export async function fetchPageText(
|
|
|
1257
1352
|
deadlineMs,
|
|
1258
1353
|
signal,
|
|
1259
1354
|
websitePolicy: policy,
|
|
1355
|
+
allowPrivateAddresses: options.allowPrivateAddresses,
|
|
1260
1356
|
maxBytes: options.maxBytes,
|
|
1261
1357
|
maxPdfBytes: options.maxPdfBytes,
|
|
1262
1358
|
seams: options.seams,
|