@zeldrisho/pi-web-fetch 0.8.2 → 0.9.1
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/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/package.json +8 -10
- package/src/abort.ts +3 -0
- package/src/cache.ts +50 -0
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +108 -11
- package/src/focus.ts +17 -0
- package/src/index.ts +10 -1
- package/src/inflight.ts +10 -0
- package/src/network-policy.ts +17 -0
- package/src/network-redirects.ts +35 -0
- package/src/network-transport.ts +46 -0
- package/src/network.ts +1 -0
- package/src/outline.ts +38 -0
- package/src/redact.ts +7 -0
- package/src/render.ts +3 -0
- package/src/service.ts +156 -17
package/src/fetch.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
FETCH_MAX_BYTES,
|
|
12
12
|
readResponseBytes,
|
|
13
13
|
responseHeader,
|
|
14
|
+
responseHeaderValues,
|
|
14
15
|
} from "./network-transport";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -27,31 +28,43 @@ import {
|
|
|
27
28
|
export function normalizeGitHubRawUrl(rawUrl: string): string {
|
|
28
29
|
try {
|
|
29
30
|
const url = new URL(rawUrl);
|
|
31
|
+
|
|
30
32
|
if (url.protocol !== "https:") return rawUrl;
|
|
33
|
+
|
|
31
34
|
if (url.hostname === "github.com") {
|
|
32
35
|
if (url.pathname.includes("/blob/")) {
|
|
33
36
|
return `https://raw.githubusercontent.com${url.pathname.replace("/blob/", "/")}${url.search}`;
|
|
34
37
|
}
|
|
38
|
+
|
|
35
39
|
// Parse pathname segments: /owner/repo/tree/ref/path
|
|
36
40
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
41
|
+
|
|
37
42
|
if (segments.length >= 4 && segments[2] === "tree") {
|
|
38
43
|
const lastSegment = segments[segments.length - 1];
|
|
44
|
+
|
|
39
45
|
if (lastSegment.includes(".")) {
|
|
40
46
|
// Reconstruct path: /owner/repo/ref/path (remove "tree" segment)
|
|
41
47
|
const newPath = `/${segments[0]}/${segments[1]}/${segments.slice(3).join("/")}`;
|
|
48
|
+
|
|
42
49
|
return `https://raw.githubusercontent.com${newPath}${url.search}`;
|
|
43
50
|
}
|
|
51
|
+
|
|
44
52
|
return rawUrl;
|
|
45
53
|
}
|
|
54
|
+
|
|
46
55
|
return rawUrl;
|
|
47
56
|
}
|
|
57
|
+
|
|
48
58
|
if (url.hostname === "gist.github.com") {
|
|
49
59
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
60
|
+
|
|
50
61
|
// Only rewrite bare gist pages. Subpages such as `/revisions`, `/forks`, or an
|
|
51
62
|
// explicit `/raw` path already point at a usable resource and stay untouched.
|
|
52
63
|
if (segments.length !== 2) return rawUrl;
|
|
64
|
+
|
|
53
65
|
return `https://gist.github.com/${segments[0]}/${segments[1]}/raw${url.search}`;
|
|
54
66
|
}
|
|
67
|
+
|
|
55
68
|
return rawUrl;
|
|
56
69
|
} catch {
|
|
57
70
|
return rawUrl;
|
|
@@ -90,10 +103,14 @@ export function parseLinkHeaderForAgentHints(
|
|
|
90
103
|
let current = "";
|
|
91
104
|
let inQuotes = false;
|
|
92
105
|
let inAngleBrackets = false;
|
|
106
|
+
|
|
93
107
|
for (const character of header) {
|
|
94
108
|
if (character === '"') inQuotes = !inQuotes;
|
|
109
|
+
|
|
95
110
|
if (character === "<") inAngleBrackets = true;
|
|
111
|
+
|
|
96
112
|
if (character === ">" && !inQuotes) inAngleBrackets = false;
|
|
113
|
+
|
|
97
114
|
if (character === "," && !inQuotes && !inAngleBrackets) {
|
|
98
115
|
directives.push(current);
|
|
99
116
|
current = "";
|
|
@@ -101,39 +118,52 @@ export function parseLinkHeaderForAgentHints(
|
|
|
101
118
|
current += character;
|
|
102
119
|
}
|
|
103
120
|
}
|
|
121
|
+
|
|
104
122
|
directives.push(current);
|
|
105
123
|
|
|
106
124
|
let describedBy: string | undefined;
|
|
107
125
|
let markdownAlternate: string | undefined;
|
|
126
|
+
|
|
108
127
|
for (const directive of directives) {
|
|
109
128
|
const match = /^\s*<([^>]*)>\s*(.*)$/.exec(directive);
|
|
129
|
+
|
|
110
130
|
if (!match) continue;
|
|
111
131
|
const [, rawTarget, rawParameters] = match;
|
|
112
132
|
const parameters = new Map<string, string>();
|
|
133
|
+
|
|
113
134
|
for (const parameter of rawParameters.split(";")) {
|
|
114
135
|
const equals = parameter.indexOf("=");
|
|
136
|
+
|
|
115
137
|
if (equals === -1) continue;
|
|
116
138
|
const key = parameter.slice(0, equals).trim().toLowerCase();
|
|
139
|
+
|
|
117
140
|
const value = parameter
|
|
118
141
|
.slice(equals + 1)
|
|
119
142
|
.trim()
|
|
120
143
|
.replace(/^"|"$/g, "");
|
|
144
|
+
|
|
121
145
|
parameters.set(key, value);
|
|
122
146
|
}
|
|
147
|
+
|
|
123
148
|
const relations = (parameters.get("rel") ?? "").toLowerCase().split(/\s+/);
|
|
124
149
|
const type = (parameters.get("type") ?? "").toLowerCase();
|
|
125
150
|
let resolved: string | undefined;
|
|
151
|
+
|
|
126
152
|
try {
|
|
127
153
|
resolved = new URL(rawTarget.trim(), baseUrl).href;
|
|
128
154
|
} catch {
|
|
129
155
|
continue;
|
|
130
156
|
}
|
|
157
|
+
|
|
131
158
|
if (!resolved) continue;
|
|
159
|
+
|
|
132
160
|
if (!describedBy && relations.includes("describedby")) describedBy = resolved;
|
|
161
|
+
|
|
133
162
|
if (!markdownAlternate && relations.includes("alternate") && type === "text/markdown") {
|
|
134
163
|
markdownAlternate = resolved;
|
|
135
164
|
}
|
|
136
165
|
}
|
|
166
|
+
|
|
137
167
|
return { describedBy, markdownAlternate };
|
|
138
168
|
}
|
|
139
169
|
|
|
@@ -142,6 +172,43 @@ export interface FetchRemoteDependencies extends RedirectDependencies {
|
|
|
142
172
|
timeoutMs?: number;
|
|
143
173
|
}
|
|
144
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Returns a usable media type from a response header.
|
|
177
|
+
*
|
|
178
|
+
* Some servers incorrectly emit an equivalent media type more than once (for example,
|
|
179
|
+
* `text/html,text/html; charset=utf-8`). Treat that as the declared type, but reject
|
|
180
|
+
* conflicting media types rather than guessing which representation to parse.
|
|
181
|
+
*/
|
|
182
|
+
function normalizeContentType(header: string): string | undefined {
|
|
183
|
+
const mediaTypes: string[] = [];
|
|
184
|
+
let current = "";
|
|
185
|
+
let quoted = false;
|
|
186
|
+
|
|
187
|
+
for (const character of header) {
|
|
188
|
+
if (character === '"') quoted = !quoted;
|
|
189
|
+
|
|
190
|
+
if (character === "," && !quoted) {
|
|
191
|
+
mediaTypes.push(current);
|
|
192
|
+
current = "";
|
|
193
|
+
} else current += character;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
mediaTypes.push(current);
|
|
197
|
+
const normalized: string[] = [];
|
|
198
|
+
|
|
199
|
+
for (const value of mediaTypes) {
|
|
200
|
+
const mediaType = value.split(";", 1)[0].trim().toLowerCase();
|
|
201
|
+
|
|
202
|
+
if (mediaType) normalized.push(mediaType);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (normalized.length === 0 || normalized.some((value) => value !== normalized[0])) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return normalized[0];
|
|
210
|
+
}
|
|
211
|
+
|
|
145
212
|
/**
|
|
146
213
|
* Converts a successful HTTP response into a complete document.
|
|
147
214
|
*
|
|
@@ -160,47 +227,62 @@ async function documentFromResponse(
|
|
|
160
227
|
extractHtml: typeof extractHtmlToMarkdown,
|
|
161
228
|
): Promise<CompleteDocument> {
|
|
162
229
|
const status = response.statusCode ?? 0;
|
|
230
|
+
|
|
163
231
|
if (status < 200 || status >= 300) {
|
|
164
232
|
response.resume();
|
|
233
|
+
|
|
165
234
|
const authenticationHint = [401, 403, 404].includes(status)
|
|
166
235
|
? " The page may be missing, private, or require authentication."
|
|
167
236
|
: "";
|
|
237
|
+
|
|
168
238
|
throw new Error(`web_fetch returned HTTP ${status}.${authenticationHint}`);
|
|
169
239
|
}
|
|
170
240
|
|
|
171
|
-
const contentTypeHeader =
|
|
172
|
-
|
|
241
|
+
const contentTypeHeader =
|
|
242
|
+
responseHeaderValues(response, "content-type").join(", ") || "text/plain";
|
|
243
|
+
|
|
244
|
+
const contentType = normalizeContentType(contentTypeHeader);
|
|
245
|
+
|
|
173
246
|
const linkHints = parseLinkHeaderForAgentHints(
|
|
174
247
|
responseHeader(response, "link") ?? "",
|
|
175
248
|
target.url,
|
|
176
249
|
);
|
|
250
|
+
|
|
177
251
|
const allowed =
|
|
178
|
-
contentType
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
252
|
+
contentType !== undefined &&
|
|
253
|
+
(contentType.startsWith("text/") ||
|
|
254
|
+
[
|
|
255
|
+
"application/json",
|
|
256
|
+
"application/markdown",
|
|
257
|
+
"application/x-markdown",
|
|
258
|
+
"application/xml",
|
|
259
|
+
"application/xhtml+xml",
|
|
260
|
+
].includes(contentType));
|
|
261
|
+
|
|
186
262
|
if (!allowed) {
|
|
187
263
|
response.destroy();
|
|
188
|
-
throw new Error(
|
|
264
|
+
throw new Error(
|
|
265
|
+
`web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
|
|
266
|
+
);
|
|
189
267
|
}
|
|
190
268
|
|
|
191
269
|
const raw = decodeResponse(
|
|
192
270
|
await readResponseBytes(response, FETCH_MAX_BYTES, signal),
|
|
193
271
|
contentTypeHeader,
|
|
194
272
|
);
|
|
273
|
+
|
|
195
274
|
let markdown: string;
|
|
196
275
|
let title: string | undefined;
|
|
276
|
+
let fragmentOffsets: Record<string, number> | undefined;
|
|
197
277
|
let extractor: CompleteDocument["extractor"] = "raw";
|
|
198
278
|
let describedBy: string | undefined = linkHints.describedBy;
|
|
199
279
|
let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
|
|
280
|
+
|
|
200
281
|
if (contentType === "text/html" || contentType === "application/xhtml+xml") {
|
|
201
282
|
const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
|
|
202
283
|
markdown = extracted.markdown;
|
|
203
284
|
title = extracted.title;
|
|
285
|
+
fragmentOffsets = extracted.fragmentOffsets;
|
|
204
286
|
extractor = extracted.extractor;
|
|
205
287
|
describedBy = describedBy ?? extracted.describedByLink;
|
|
206
288
|
markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
|
|
@@ -214,9 +296,11 @@ async function documentFromResponse(
|
|
|
214
296
|
|
|
215
297
|
const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
216
298
|
const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
|
|
299
|
+
|
|
217
300
|
const shellSuspected = extractionDiagnostics
|
|
218
301
|
? hasExtractionWarning(extractionDiagnostics)
|
|
219
302
|
: false;
|
|
303
|
+
|
|
220
304
|
const etag = responseHeader(response, "etag");
|
|
221
305
|
const lastModified = responseHeader(response, "last-modified");
|
|
222
306
|
|
|
@@ -224,6 +308,7 @@ async function documentFromResponse(
|
|
|
224
308
|
url: target.url.toString(),
|
|
225
309
|
contentType,
|
|
226
310
|
markdown: markdown.replace(/<\/untrusted_web_content>/gi, "</untrusted_web_content>"),
|
|
311
|
+
fragmentOffsets,
|
|
227
312
|
title,
|
|
228
313
|
extractor,
|
|
229
314
|
shellSuspected,
|
|
@@ -245,14 +330,17 @@ async function documentFromResponse(
|
|
|
245
330
|
*/
|
|
246
331
|
function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
247
332
|
let url: URL;
|
|
333
|
+
|
|
248
334
|
try {
|
|
249
335
|
url = new URL(value);
|
|
250
336
|
} catch {
|
|
251
337
|
throw new Error("web_fetch received an invalid URL.");
|
|
252
338
|
}
|
|
339
|
+
|
|
253
340
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
254
341
|
throw new Error(`web_fetch only supports http(s) URLs: ${redactUrlForDisplay(url)}`);
|
|
255
342
|
}
|
|
343
|
+
|
|
256
344
|
return url;
|
|
257
345
|
}
|
|
258
346
|
|
|
@@ -276,14 +364,18 @@ async function fetchDocument(
|
|
|
276
364
|
const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
277
365
|
const extractHtml = dependencies.extractHtml ?? extractHtmlToMarkdown;
|
|
278
366
|
let timedOut = false;
|
|
367
|
+
|
|
279
368
|
const timeout = setTimeout(() => {
|
|
280
369
|
timedOut = true;
|
|
281
370
|
controller.abort();
|
|
282
371
|
}, timeoutMs);
|
|
372
|
+
|
|
283
373
|
const cancel = () => controller.abort();
|
|
284
374
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
285
375
|
const conditionalHeaders: Record<string, string> = {};
|
|
376
|
+
|
|
286
377
|
if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
|
|
378
|
+
|
|
287
379
|
if (cached?.validators?.lastModified)
|
|
288
380
|
conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
|
|
289
381
|
|
|
@@ -294,11 +386,14 @@ async function fetchDocument(
|
|
|
294
386
|
dependencies,
|
|
295
387
|
conditionalHeaders,
|
|
296
388
|
);
|
|
389
|
+
|
|
297
390
|
if (response.statusCode === 304 && cached) {
|
|
298
391
|
response.resume();
|
|
299
392
|
const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
|
|
393
|
+
|
|
300
394
|
const lastModified =
|
|
301
395
|
responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
|
|
396
|
+
|
|
302
397
|
return {
|
|
303
398
|
document: {
|
|
304
399
|
...cached,
|
|
@@ -308,12 +403,14 @@ async function fetchDocument(
|
|
|
308
403
|
revalidated: true,
|
|
309
404
|
};
|
|
310
405
|
}
|
|
406
|
+
|
|
311
407
|
return {
|
|
312
408
|
document: await documentFromResponse(target, response, controller.signal, extractHtml),
|
|
313
409
|
revalidated: false,
|
|
314
410
|
};
|
|
315
411
|
} catch (error) {
|
|
316
412
|
if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
|
|
413
|
+
|
|
317
414
|
if (signal?.aborted) throw new Error("web_fetch was cancelled.");
|
|
318
415
|
throw error;
|
|
319
416
|
} finally {
|
package/src/focus.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const WORD_PATTERN = /[\p{L}\p{N}_+#]+/gu;
|
|
2
|
+
|
|
2
3
|
const HEADING_PATTERN = /^#{1,6}\s+\S/;
|
|
3
4
|
|
|
4
5
|
/** Metadata describing deterministic query-focused extraction. */
|
|
@@ -35,24 +36,31 @@ function splitSections(markdown: string): Section[] {
|
|
|
35
36
|
.split(/\n{2,}/)
|
|
36
37
|
.map((block) => block.trim())
|
|
37
38
|
.filter(Boolean);
|
|
39
|
+
|
|
38
40
|
if (blocks.length === 0) return [];
|
|
39
41
|
|
|
40
42
|
const lines = markdown.split(/\r?\n/);
|
|
41
43
|
const hasHeadings = lines.some((line) => HEADING_PATTERN.test(line));
|
|
44
|
+
|
|
42
45
|
if (!hasHeadings) return blocks.map((block) => ({ markdown: block, tokens: tokens(block) }));
|
|
43
46
|
|
|
44
47
|
const sections: string[] = [];
|
|
45
48
|
let current: string[] = [];
|
|
49
|
+
|
|
46
50
|
const flush = () => {
|
|
47
51
|
const section = current.join("\n").trim();
|
|
52
|
+
|
|
48
53
|
if (section) sections.push(section);
|
|
49
54
|
current = [];
|
|
50
55
|
};
|
|
56
|
+
|
|
51
57
|
for (const line of lines) {
|
|
52
58
|
if (HEADING_PATTERN.test(line)) flush();
|
|
53
59
|
current.push(line);
|
|
54
60
|
}
|
|
61
|
+
|
|
55
62
|
flush();
|
|
63
|
+
|
|
56
64
|
return sections.map((section) => ({ markdown: section, tokens: tokens(section) }));
|
|
57
65
|
}
|
|
58
66
|
|
|
@@ -83,7 +91,9 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
83
91
|
|
|
84
92
|
const averageLength =
|
|
85
93
|
sections.reduce((sum, section) => sum + section.tokens.length, 0) / sections.length || 1;
|
|
94
|
+
|
|
86
95
|
const documentFrequency = new Map<string, number>();
|
|
96
|
+
|
|
87
97
|
for (const term of queryTerms) {
|
|
88
98
|
documentFrequency.set(
|
|
89
99
|
term,
|
|
@@ -93,24 +103,31 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
93
103
|
|
|
94
104
|
const selected = sections.filter((section) => {
|
|
95
105
|
const frequencies = new Map<string, number>();
|
|
106
|
+
|
|
96
107
|
for (const token of section.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
|
97
108
|
const lengthNormalization = 1 - 0.75 + 0.75 * (section.tokens.length / averageLength);
|
|
98
109
|
let score = 0;
|
|
110
|
+
|
|
99
111
|
for (const term of queryTerms) {
|
|
100
112
|
const frequency = frequencies.get(term) ?? 0;
|
|
113
|
+
|
|
101
114
|
if (frequency === 0) continue;
|
|
102
115
|
const frequencyInDocuments = documentFrequency.get(term) ?? 0;
|
|
116
|
+
|
|
103
117
|
const inverseDocumentFrequency = Math.log(
|
|
104
118
|
1 + (sections.length - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5),
|
|
105
119
|
);
|
|
120
|
+
|
|
106
121
|
score +=
|
|
107
122
|
inverseDocumentFrequency *
|
|
108
123
|
((frequency * (1.2 + 1)) / (frequency + 1.2 * lengthNormalization));
|
|
109
124
|
}
|
|
125
|
+
|
|
110
126
|
return score > 0;
|
|
111
127
|
});
|
|
112
128
|
|
|
113
129
|
const focused = selected.map((section) => section.markdown).join("\n\n");
|
|
130
|
+
|
|
114
131
|
return {
|
|
115
132
|
markdown: focused,
|
|
116
133
|
details: {
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,9 @@ import { formatCollapsibleOutput } from "./render";
|
|
|
15
15
|
import { executeWebFetch } from "./service";
|
|
16
16
|
|
|
17
17
|
export { ExpiringLruCache } from "./cache";
|
|
18
|
+
|
|
18
19
|
export type { FetchResult } from "./content";
|
|
20
|
+
|
|
19
21
|
export {
|
|
20
22
|
diagnoseExtraction,
|
|
21
23
|
extractDocumentLinks,
|
|
@@ -23,10 +25,15 @@ export {
|
|
|
23
25
|
type ExtractedLinks,
|
|
24
26
|
type ExtractionDiagnostics,
|
|
25
27
|
} from "./evidence";
|
|
28
|
+
|
|
26
29
|
export { fetchRemoteContent, type FetchRemoteDependencies } from "./fetch";
|
|
30
|
+
|
|
27
31
|
export { focusMarkdown, type FocusDetails, type FocusResult } from "./focus";
|
|
32
|
+
|
|
28
33
|
export { createDocumentOutline, type DocumentOutline, type OutlineHeading } from "./outline";
|
|
34
|
+
|
|
29
35
|
export { redactUrlForDisplay } from "./redact";
|
|
36
|
+
|
|
30
37
|
export {
|
|
31
38
|
executeWebFetch,
|
|
32
39
|
type CacheStatus,
|
|
@@ -34,6 +41,7 @@ export {
|
|
|
34
41
|
type WebFetchParameters,
|
|
35
42
|
type WebFetchTruncationDetails,
|
|
36
43
|
} from "./service";
|
|
44
|
+
|
|
37
45
|
export {
|
|
38
46
|
FETCH_MAX_BYTES,
|
|
39
47
|
isPrivateAddress,
|
|
@@ -85,7 +93,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
85
93
|
name: "web_fetch",
|
|
86
94
|
label: "Web Fetch",
|
|
87
95
|
description:
|
|
88
|
-
"Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata.",
|
|
96
|
+
"Fetch a public HTTP(S) page and return a bounded Markdown content chunk with continuation metadata. URL fragments start at the matching heading or anchor; explicit offsets take precedence.",
|
|
89
97
|
promptSnippet: "Read a public web page as bounded Markdown",
|
|
90
98
|
promptGuidelines: [
|
|
91
99
|
"Use web_fetch for a user-provided URL or to inspect relevant sources found with web_search.",
|
|
@@ -110,6 +118,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
110
118
|
if (isPartial) return new Text(theme.fg("warning", "Fetching…"), 0, 0);
|
|
111
119
|
|
|
112
120
|
const content = result.content.find((item) => item.type === "text");
|
|
121
|
+
|
|
113
122
|
return new Text(
|
|
114
123
|
content?.type === "text"
|
|
115
124
|
? formatCollapsibleOutput(content.text, expanded, theme)
|
package/src/inflight.ts
CHANGED
|
@@ -19,12 +19,16 @@ function waitForCaller<T>(
|
|
|
19
19
|
cancelledMessage: string,
|
|
20
20
|
): Promise<T> {
|
|
21
21
|
if (!signal) return operation;
|
|
22
|
+
|
|
22
23
|
return new Promise((resolve, reject) => {
|
|
23
24
|
const abort = (): void => reject(new Error(cancelledMessage));
|
|
25
|
+
|
|
24
26
|
if (signal.aborted) {
|
|
25
27
|
abort();
|
|
28
|
+
|
|
26
29
|
return;
|
|
27
30
|
}
|
|
31
|
+
|
|
28
32
|
signal.addEventListener("abort", abort, { once: true });
|
|
29
33
|
operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
30
34
|
});
|
|
@@ -60,7 +64,10 @@ export class InflightCoalescer<K, V> {
|
|
|
60
64
|
signal: AbortSignal | undefined,
|
|
61
65
|
cancelledMessage: string,
|
|
62
66
|
): Promise<V> {
|
|
67
|
+
if (signal?.aborted) throw new Error(cancelledMessage);
|
|
68
|
+
|
|
63
69
|
let entry = this.#entries.get(key);
|
|
70
|
+
|
|
64
71
|
if (!entry) {
|
|
65
72
|
if (this.#entries.size >= this.maxEntries) return operation(signal);
|
|
66
73
|
const controller = new AbortController();
|
|
@@ -79,16 +86,19 @@ export class InflightCoalescer<K, V> {
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
entry.waiters += 1;
|
|
89
|
+
|
|
82
90
|
try {
|
|
83
91
|
return await waitForCaller(entry.promise, signal, cancelledMessage);
|
|
84
92
|
} finally {
|
|
85
93
|
entry.waiters -= 1;
|
|
94
|
+
|
|
86
95
|
if (entry.waiters === 0 && !entry.settled) entry.controller.abort();
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
#settle(key: K, entry: InflightEntry<V>): void {
|
|
91
100
|
entry.settled = true;
|
|
101
|
+
|
|
92
102
|
if (this.#entries.get(key) === entry) this.#entries.delete(key);
|
|
93
103
|
}
|
|
94
104
|
}
|
package/src/network-policy.ts
CHANGED
|
@@ -64,15 +64,19 @@ const GLOBALLY_REACHABLE_IPV6_EXCEPTIONS = [
|
|
|
64
64
|
] as const;
|
|
65
65
|
|
|
66
66
|
const blockedIPv4Addresses = new BlockList();
|
|
67
|
+
|
|
67
68
|
const blockedIPv6Addresses = new BlockList();
|
|
69
|
+
|
|
68
70
|
const allowedIPv6Addresses = new BlockList();
|
|
69
71
|
|
|
70
72
|
for (const [network, prefix] of BLOCKED_IPV4_RANGES) {
|
|
71
73
|
blockedIPv4Addresses.addSubnet(network, prefix, "ipv4");
|
|
72
74
|
}
|
|
75
|
+
|
|
73
76
|
for (const [network, prefix] of BLOCKED_IPV6_RANGES) {
|
|
74
77
|
blockedIPv6Addresses.addSubnet(network, prefix, "ipv6");
|
|
75
78
|
}
|
|
79
|
+
|
|
76
80
|
for (const [network, prefix] of GLOBALLY_REACHABLE_IPV6_EXCEPTIONS) {
|
|
77
81
|
allowedIPv6Addresses.addSubnet(network, prefix, "ipv6");
|
|
78
82
|
}
|
|
@@ -106,11 +110,15 @@ export type ResolveAddresses = (hostname: string) => Promise<string[]>;
|
|
|
106
110
|
*/
|
|
107
111
|
export function isPrivateAddress(address: string): boolean {
|
|
108
112
|
const family = isIP(address);
|
|
113
|
+
|
|
109
114
|
if (family === 4) return blockedIPv4Addresses.check(address, "ipv4");
|
|
115
|
+
|
|
110
116
|
if (family === 6) {
|
|
111
117
|
if (allowedIPv6Addresses.check(address, "ipv6")) return false;
|
|
118
|
+
|
|
112
119
|
return blockedIPv6Addresses.check(address, "ipv6");
|
|
113
120
|
}
|
|
121
|
+
|
|
114
122
|
return true;
|
|
115
123
|
}
|
|
116
124
|
|
|
@@ -136,8 +144,10 @@ export async function validateRemoteUrl(
|
|
|
136
144
|
resolveHostname?: ResolveAddresses,
|
|
137
145
|
): Promise<ValidatedTarget> {
|
|
138
146
|
const url = value instanceof URL ? value : new URL(value);
|
|
147
|
+
|
|
139
148
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
140
149
|
throw new Error("web_fetch only supports HTTP and HTTPS URLs.");
|
|
150
|
+
|
|
141
151
|
if (url.username || url.password)
|
|
142
152
|
throw new Error("web_fetch blocks URLs containing credentials.");
|
|
143
153
|
|
|
@@ -145,24 +155,31 @@ export async function validateRemoteUrl(
|
|
|
145
155
|
.toLowerCase()
|
|
146
156
|
.replace(/^\[|\]$/g, "")
|
|
147
157
|
.replace(/\.$/, "");
|
|
158
|
+
|
|
148
159
|
if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
149
160
|
throw new Error("web_fetch blocks local hostnames.");
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
let addresses: string[];
|
|
164
|
+
|
|
153
165
|
if (isIP(hostname)) addresses = [hostname];
|
|
154
166
|
else if (resolveHostname) addresses = await resolveHostname(hostname);
|
|
155
167
|
else {
|
|
156
168
|
const records = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
157
169
|
addresses = [];
|
|
170
|
+
|
|
158
171
|
for (const record of records) addresses.push(record.address);
|
|
159
172
|
}
|
|
173
|
+
|
|
160
174
|
if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
|
|
161
175
|
throw new Error(`web_fetch blocks private or reserved network targets (${hostname}).`);
|
|
162
176
|
}
|
|
177
|
+
|
|
163
178
|
const ordered = preferIpv4First(addresses);
|
|
164
179
|
const address = ordered[0];
|
|
165
180
|
const family = isIP(address);
|
|
181
|
+
|
|
166
182
|
if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${hostname}.`);
|
|
183
|
+
|
|
167
184
|
return { url, address, family, addresses: ordered };
|
|
168
185
|
}
|