@demigodmode/pi-web-agent 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/backends/config.d.ts +13 -0
  3. package/dist/backends/config.js +44 -1
  4. package/dist/backends/factory.d.ts +15 -0
  5. package/dist/backends/factory.js +118 -91
  6. package/dist/backends/failure.d.ts +11 -0
  7. package/dist/backends/failure.js +34 -0
  8. package/dist/backends/fallback-policy.d.ts +33 -0
  9. package/dist/backends/fallback-policy.js +239 -0
  10. package/dist/backends/provider-failure.d.ts +21 -0
  11. package/dist/backends/provider-failure.js +111 -0
  12. package/dist/backends/provider-health.d.ts +29 -0
  13. package/dist/backends/provider-health.js +49 -0
  14. package/dist/commands/web-agent-config.d.ts +14 -1
  15. package/dist/commands/web-agent-config.js +75 -3
  16. package/dist/extension.js +47 -3
  17. package/dist/fetch/destination-policy.d.ts +32 -0
  18. package/dist/fetch/destination-policy.js +24 -0
  19. package/dist/fetch/firecrawl-fetch.js +64 -45
  20. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  21. package/dist/fetch/guard-proxy-fetch.js +82 -0
  22. package/dist/fetch/guard-proxy.d.ts +58 -0
  23. package/dist/fetch/guard-proxy.js +420 -0
  24. package/dist/fetch/guarded-fetch.d.ts +7 -0
  25. package/dist/fetch/guarded-fetch.js +75 -0
  26. package/dist/fetch/headless-fetch.d.ts +10 -2
  27. package/dist/fetch/headless-fetch.js +181 -9
  28. package/dist/fetch/http-fetch.js +16 -1
  29. package/dist/fetch/network-guard.d.ts +82 -0
  30. package/dist/fetch/network-guard.js +275 -0
  31. package/dist/orchestration/answer-synthesizer.js +2 -0
  32. package/dist/orchestration/evidence-quality.d.ts +3 -2
  33. package/dist/orchestration/evidence-quality.js +2 -1
  34. package/dist/orchestration/index.d.ts +23 -0
  35. package/dist/orchestration/index.js +9 -2
  36. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  37. package/dist/orchestration/research-orchestrator.js +40 -7
  38. package/dist/orchestration/research-types.d.ts +13 -1
  39. package/dist/orchestration/research-worker.js +38 -3
  40. package/dist/orchestration/stop-decider.js +3 -1
  41. package/dist/presentation/config-store.js +6 -0
  42. package/dist/presentation/explore-presentation.js +3 -1
  43. package/dist/presentation/fetch-presentation.js +16 -9
  44. package/dist/presentation/search-presentation.d.ts +2 -1
  45. package/dist/presentation/search-presentation.js +13 -1
  46. package/dist/search/brave.d.ts +1 -2
  47. package/dist/search/brave.js +23 -80
  48. package/dist/search/duckduckgo.d.ts +7 -3
  49. package/dist/search/duckduckgo.js +17 -18
  50. package/dist/search/exa.d.ts +1 -2
  51. package/dist/search/exa.js +15 -76
  52. package/dist/search/fanout.d.ts +12 -0
  53. package/dist/search/fanout.js +86 -47
  54. package/dist/search/json-provider.d.ts +32 -0
  55. package/dist/search/json-provider.js +76 -0
  56. package/dist/search/searxng.d.ts +1 -2
  57. package/dist/search/searxng.js +15 -57
  58. package/dist/search/tavily.d.ts +1 -2
  59. package/dist/search/tavily.js +17 -74
  60. package/dist/search/youcom.d.ts +1 -2
  61. package/dist/search/youcom.js +15 -76
  62. package/dist/tools/web-explore.d.ts +9 -0
  63. package/dist/tools/web-explore.js +16 -2
  64. package/dist/tools/web-search.js +41 -103
  65. package/dist/types.d.ts +40 -0
  66. package/package.json +3 -3
@@ -0,0 +1,420 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createServer, request as httpRequest } from 'node:http';
3
+ import { connect as netConnect, isIP } from 'node:net';
4
+ import { connect as tlsConnect } from 'node:tls';
5
+ import { decideDestination } from './destination-policy.js';
6
+ import { UnverifiedDestinationError, UpstreamProxyRefusedError } from './network-guard.js';
7
+ /**
8
+ * Enforces the private-address policy where connections are opened (#53, spec
9
+ * revision 2). Every model-chosen connection, from Node or from Chromium, goes
10
+ * through here: the destination is resolved once, every answer is checked, and
11
+ * the socket is opened to an approved IP. Nothing downstream resolves the
12
+ * hostname again, so a redirect or a DNS change cannot move the connection.
13
+ *
14
+ * Listens on loopback only and requires per-start credentials, so it is not an
15
+ * open egress proxy for other local processes. It is an owned resource: call
16
+ * close() to stop it. unref() only keeps an idle listener from holding the
17
+ * process open.
18
+ */
19
+ export const BLOCKED_HEADER = 'x-pi-web-agent-blocked';
20
+ const MAX_REFUSALS = 500;
21
+ const DEFAULT_TIMEOUT_MS = 10_000;
22
+ const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$/;
23
+ /**
24
+ * Strict on purpose: in trust mode an unresolvable name is delegated upstream
25
+ * verbatim, so anything that is not a plain IP or DNS name (userinfo, zone ids,
26
+ * percent-encoding, paths) must never get that far.
27
+ */
28
+ function parseAuthority(authority) {
29
+ const match = /^\[([^\]]+)\]:(\d{1,5})$/.exec(authority) ?? /^([^:[\]]+):(\d{1,5})$/.exec(authority);
30
+ if (!match)
31
+ return undefined;
32
+ const port = Number(match[2]);
33
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
34
+ return undefined;
35
+ const host = match[1];
36
+ const bracketed = authority.startsWith('[');
37
+ if (bracketed) {
38
+ if (isIP(host) !== 6)
39
+ return undefined;
40
+ }
41
+ else if (isIP(host) !== 4 && (host.length > 253 || !HOSTNAME.test(host))) {
42
+ return undefined;
43
+ }
44
+ return { host, port };
45
+ }
46
+ const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'te', 'trailer', 'upgrade'];
47
+ /** Removes hop-by-hop headers, including any named in Connection. Returns a copy. */
48
+ function stripHopByHop(headers, keep = []) {
49
+ const copy = { ...headers };
50
+ const named = String(headers.connection ?? '')
51
+ .split(',')
52
+ .map((name) => name.trim().toLowerCase())
53
+ .filter(Boolean);
54
+ for (const name of [...HOP_BY_HOP, ...named]) {
55
+ if (!keep.includes(name))
56
+ delete copy[name];
57
+ }
58
+ return copy;
59
+ }
60
+ function formatAuthority(host, port) {
61
+ return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
62
+ }
63
+ function headerValue(error, seq) {
64
+ // `<code> <seq> <message>`; the seq lets the Node fetch match this exact refusal.
65
+ // Encoded so a hostile hostname cannot inject headers.
66
+ return `${error.code} ${seq} ${encodeURIComponent(error.message)}`;
67
+ }
68
+ function upstreamAuthorization(upstream) {
69
+ if (!upstream.username)
70
+ return undefined;
71
+ return `Basic ${Buffer.from(`${upstream.username}:${upstream.password ?? ''}`).toString('base64')}`;
72
+ }
73
+ export async function startGuardProxy(options) {
74
+ const { guard, upstream, trustProxyDns = false, upstreamTls, connectTimeoutMs = DEFAULT_TIMEOUT_MS, handshakeTimeoutMs = DEFAULT_TIMEOUT_MS } = options;
75
+ const password = randomBytes(24).toString('hex');
76
+ const passwordBuffer = Buffer.from(password);
77
+ const sockets = new Set();
78
+ const refusals = [];
79
+ let seq = 0;
80
+ let clientCounter = 0;
81
+ let closed = false;
82
+ let closing;
83
+ const track = (socket) => {
84
+ sockets.add(socket);
85
+ socket.on('close', () => sockets.delete(socket));
86
+ if (closed)
87
+ socket.destroy();
88
+ };
89
+ const defaultOpenSocket = (host, port, useTls, servername) => useTls
90
+ ? tlsConnect({ host, port, ...(servername ? { servername } : {}), ...upstreamTls })
91
+ : netConnect({ host, port });
92
+ function authenticate(header) {
93
+ if (!header?.startsWith('Basic '))
94
+ return undefined;
95
+ const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
96
+ const separator = decoded.indexOf(':');
97
+ if (separator <= 0)
98
+ return undefined;
99
+ const candidate = Buffer.from(decoded.slice(separator + 1));
100
+ if (candidate.length !== passwordBuffer.length || !timingSafeEqual(candidate, passwordBuffer))
101
+ return undefined;
102
+ return decoded.slice(0, separator);
103
+ }
104
+ /** Records the refusal and returns the header value that names it. */
105
+ function refuse(client, host, error) {
106
+ seq += 1;
107
+ refusals.push({ seq, client, host, error });
108
+ if (refusals.length > MAX_REFUSALS)
109
+ refusals.splice(0, refusals.length - MAX_REFUSALS);
110
+ return headerValue(error, seq);
111
+ }
112
+ async function decide(host) {
113
+ // Resolution is bounded by the guard's own lookup timeout, and an unresolved
114
+ // result still goes through the policy (a trusted upstream may take it).
115
+ try {
116
+ return await decideDestination(host, guard, { upstream: Boolean(upstream), trustProxyDns });
117
+ }
118
+ catch {
119
+ return { action: 'refuse', error: new UnverifiedDestinationError(host) };
120
+ }
121
+ }
122
+ function upstreamEndpoint() {
123
+ const url = new URL(upstream.url);
124
+ return {
125
+ url,
126
+ host: url.hostname.replace(/^\[|\]$/g, ''),
127
+ port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80)
128
+ };
129
+ }
130
+ /** Tracked from creation, so close() also destroys sockets that are still connecting. */
131
+ function openSocket(host, port, useTls, servername) {
132
+ if (closed)
133
+ return Promise.reject(new Error('Guard proxy is closed.'));
134
+ const socket = (options.openSocket ?? defaultOpenSocket)(host, port, useTls, servername);
135
+ track(socket);
136
+ const connectEvent = useTls ? 'secureConnect' : 'connect';
137
+ return new Promise((resolve, reject) => {
138
+ const finish = () => {
139
+ clearTimeout(timer);
140
+ socket.off(connectEvent, onConnect);
141
+ socket.off('error', onError);
142
+ socket.off('close', onClose);
143
+ };
144
+ const onConnect = () => {
145
+ finish();
146
+ resolve(socket);
147
+ };
148
+ const onError = (error) => {
149
+ finish();
150
+ socket.destroy();
151
+ reject(error);
152
+ };
153
+ const onClose = () => {
154
+ finish();
155
+ reject(new Error(`Connection to ${host}:${port} closed before it opened.`));
156
+ };
157
+ const timer = setTimeout(() => {
158
+ finish();
159
+ socket.destroy();
160
+ reject(new Error(`Timed out connecting to ${host}:${port}.`));
161
+ }, connectTimeoutMs);
162
+ socket.once(connectEvent, onConnect);
163
+ socket.once('error', onError);
164
+ socket.once('close', onClose);
165
+ });
166
+ }
167
+ function readConnectResponse(socket) {
168
+ return new Promise((resolve, reject) => {
169
+ let buffered = Buffer.alloc(0);
170
+ const finish = () => {
171
+ clearTimeout(timer);
172
+ socket.off('data', onData);
173
+ socket.off('error', onError);
174
+ socket.off('close', onClose);
175
+ };
176
+ const onData = (chunk) => {
177
+ buffered = Buffer.concat([buffered, chunk]);
178
+ const end = buffered.indexOf('\r\n\r\n');
179
+ if (end === -1) {
180
+ if (buffered.length > 16 * 1024) {
181
+ finish();
182
+ reject(new Error('Upstream proxy sent an oversized CONNECT response.'));
183
+ }
184
+ return;
185
+ }
186
+ finish();
187
+ const statusLine = buffered.subarray(0, buffered.indexOf('\r\n')).toString('latin1');
188
+ resolve({
189
+ status: Number(/^HTTP\/1\.[01] (\d{3})/.exec(statusLine)?.[1] ?? 0),
190
+ rest: buffered.subarray(end + 4)
191
+ });
192
+ };
193
+ const onError = (error) => {
194
+ finish();
195
+ reject(error);
196
+ };
197
+ const onClose = () => {
198
+ finish();
199
+ reject(new Error('Upstream proxy closed the connection before responding.'));
200
+ };
201
+ const timer = setTimeout(() => {
202
+ finish();
203
+ reject(new Error('Upstream proxy did not answer the CONNECT in time.'));
204
+ }, handshakeTimeoutMs);
205
+ socket.on('data', onData);
206
+ socket.once('error', onError);
207
+ socket.once('close', onClose);
208
+ });
209
+ }
210
+ async function openTunnel(destination, port) {
211
+ const target = destination.action === 'connect' ? destination.address : destination.host;
212
+ if (!upstream) {
213
+ // Only 'connect' reaches here: delegation needs an upstream. `target` is an IP, so no lookup happens.
214
+ return openSocket(target, port, false);
215
+ }
216
+ const endpoint = upstreamEndpoint();
217
+ const authority = formatAuthority(target, port);
218
+ let socket;
219
+ try {
220
+ socket = await openSocket(endpoint.host, endpoint.port, endpoint.url.protocol === 'https:', isIP(endpoint.host) ? undefined : endpoint.host);
221
+ }
222
+ catch (error) {
223
+ throw new UpstreamProxyRefusedError(destination.host, authority, error instanceof Error ? error.message : 'error');
224
+ }
225
+ const authorization = upstreamAuthorization(upstream);
226
+ socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n` +
227
+ `${authorization ? `Proxy-Authorization: ${authorization}\r\n` : ''}\r\n`);
228
+ let response;
229
+ try {
230
+ response = await readConnectResponse(socket);
231
+ }
232
+ catch (error) {
233
+ socket.destroy();
234
+ throw new UpstreamProxyRefusedError(destination.host, authority, error instanceof Error ? error.message : 'error');
235
+ }
236
+ if (response.status < 200 || response.status >= 300) {
237
+ socket.destroy();
238
+ // No retry by hostname: that would hand resolution back to the upstream.
239
+ throw new UpstreamProxyRefusedError(destination.host, authority, response.status);
240
+ }
241
+ if (response.rest.length)
242
+ socket.unshift(response.rest);
243
+ return socket;
244
+ }
245
+ const server = createServer({
246
+ headersTimeout: handshakeTimeoutMs,
247
+ // No overall request limit: downloads can legitimately be slow.
248
+ requestTimeout: 0,
249
+ // Node only checks headersTimeout on this interval, so keep it short enough to matter.
250
+ connectionsCheckingInterval: Math.max(50, Math.min(1000, Math.floor(handshakeTimeoutMs / 2)))
251
+ });
252
+ server.on('connection', (socket) => track(socket));
253
+ server.on('connect', async (request, clientSocket, head) => {
254
+ clientSocket.on('error', () => undefined);
255
+ const client = authenticate(request.headers['proxy-authorization']);
256
+ if (!client) {
257
+ clientSocket.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="pi-web-agent"\r\nContent-Length: 0\r\n\r\n');
258
+ return;
259
+ }
260
+ const authority = parseAuthority(request.url ?? '');
261
+ if (!authority) {
262
+ clientSocket.end('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n');
263
+ return;
264
+ }
265
+ const destination = await decide(authority.host);
266
+ if (closed) {
267
+ clientSocket.destroy();
268
+ return;
269
+ }
270
+ if (destination.action === 'refuse') {
271
+ const blocked = refuse(client, destination.error.host, destination.error);
272
+ clientSocket.end(`HTTP/1.1 403 Forbidden\r\n${BLOCKED_HEADER}: ${blocked}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`);
273
+ return;
274
+ }
275
+ let outbound;
276
+ try {
277
+ outbound = await openTunnel(destination, authority.port);
278
+ }
279
+ catch (error) {
280
+ if (closed) {
281
+ clientSocket.destroy();
282
+ return;
283
+ }
284
+ if (error instanceof UpstreamProxyRefusedError) {
285
+ const blocked = refuse(client, destination.host, error);
286
+ clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n${BLOCKED_HEADER}: ${blocked}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`);
287
+ return;
288
+ }
289
+ clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n');
290
+ return;
291
+ }
292
+ if (closed || clientSocket.destroyed) {
293
+ outbound.destroy();
294
+ clientSocket.destroy();
295
+ return;
296
+ }
297
+ outbound.on('error', () => clientSocket.destroy());
298
+ clientSocket.on('error', () => outbound.destroy());
299
+ outbound.on('close', () => clientSocket.destroy());
300
+ clientSocket.on('close', () => outbound.destroy());
301
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
302
+ if (head.length)
303
+ outbound.write(head);
304
+ outbound.pipe(clientSocket);
305
+ clientSocket.pipe(outbound);
306
+ });
307
+ server.on('request', async (request, response) => {
308
+ const client = authenticate(request.headers['proxy-authorization']);
309
+ if (!client) {
310
+ response.writeHead(407, { 'proxy-authenticate': 'Basic realm="pi-web-agent"', 'content-length': '0' }).end();
311
+ return;
312
+ }
313
+ let target;
314
+ try {
315
+ target = new URL(request.url ?? '');
316
+ }
317
+ catch {
318
+ response.writeHead(400, { 'content-length': '0' }).end();
319
+ return;
320
+ }
321
+ if (target.protocol !== 'http:') {
322
+ response.writeHead(400, { 'content-length': '0' }).end();
323
+ return;
324
+ }
325
+ const host = target.hostname.replace(/^\[|\]$/g, '');
326
+ const port = Number(target.port) || 80;
327
+ const destination = await decide(host);
328
+ if (closed) {
329
+ response.destroy();
330
+ return;
331
+ }
332
+ if (destination.action === 'refuse') {
333
+ const blocked = refuse(client, destination.error.host, destination.error);
334
+ response.writeHead(403, { [BLOCKED_HEADER]: blocked, 'content-length': '0' }).end();
335
+ return;
336
+ }
337
+ const headers = stripHopByHop({ ...request.headers });
338
+ const path = `${target.pathname}${target.search}`;
339
+ const address = destination.action === 'connect' ? destination.address : destination.host;
340
+ const endpoint = upstream ? upstreamEndpoint() : undefined;
341
+ const useTls = endpoint?.url.protocol === 'https:';
342
+ // The socket is opened (and timed, and tracked) here, then handed to http.request.
343
+ let socket;
344
+ try {
345
+ socket = endpoint
346
+ ? await openSocket(endpoint.host, endpoint.port, useTls, isIP(endpoint.host) ? undefined : endpoint.host)
347
+ : await openSocket(address, port, false);
348
+ }
349
+ catch (error) {
350
+ if (closed) {
351
+ response.destroy();
352
+ return;
353
+ }
354
+ if (upstream) {
355
+ const refusal = new UpstreamProxyRefusedError(destination.host, formatAuthority(address, port), error instanceof Error ? error.message : 'error');
356
+ const blocked = refuse(client, destination.host, refusal);
357
+ response.writeHead(502, { [BLOCKED_HEADER]: blocked, 'content-length': '0' }).end();
358
+ return;
359
+ }
360
+ response.writeHead(502, { 'content-length': '0' }).end();
361
+ return;
362
+ }
363
+ const authorization = upstream ? upstreamAuthorization(upstream) : undefined;
364
+ // Plain http.request even for an https upstream: the socket is already TLS.
365
+ const outbound = httpRequest({
366
+ createConnection: () => socket,
367
+ method: request.method,
368
+ path: endpoint ? `http://${formatAuthority(address, port)}${path}` : path,
369
+ headers: { ...headers, ...(authorization ? { 'proxy-authorization': authorization } : {}) },
370
+ // No `agent`: with agent: false Node builds a fresh Agent and ignores createConnection.
371
+ setHost: false
372
+ });
373
+ outbound.on('response', (upstreamResponse) => {
374
+ response.writeHead(upstreamResponse.statusCode ?? 502, stripHopByHop({ ...upstreamResponse.headers }));
375
+ // pipe() doesn't carry an aborted body over: the client would wait forever
376
+ // for the rest. Cut it off the same way the destination did.
377
+ const abort = () => {
378
+ response.destroy();
379
+ socket.destroy();
380
+ };
381
+ upstreamResponse.on('error', abort);
382
+ upstreamResponse.on('close', () => {
383
+ if (!upstreamResponse.complete)
384
+ abort();
385
+ });
386
+ upstreamResponse.pipe(response);
387
+ });
388
+ // Drop the client connection like the destination did, rather than inventing a 502
389
+ // (or ending a truncated body as if it were complete).
390
+ outbound.on('error', () => response.destroy());
391
+ response.on('close', () => socket.destroy());
392
+ request.pipe(outbound);
393
+ });
394
+ await new Promise((resolve, reject) => {
395
+ server.once('error', reject);
396
+ server.listen(0, '127.0.0.1', () => resolve());
397
+ });
398
+ server.unref();
399
+ const url = `http://127.0.0.1:${server.address().port}`;
400
+ return {
401
+ url,
402
+ client(name = 'client') {
403
+ clientCounter += 1;
404
+ return { server: url, username: `${name}-${clientCounter}-${randomBytes(4).toString('hex')}`, password };
405
+ },
406
+ sequence: () => seq,
407
+ refusalsSince: (username, since) => refusals.filter((entry) => entry.client === username && entry.seq > since),
408
+ close() {
409
+ if (closing)
410
+ return closing;
411
+ closed = true;
412
+ closing = new Promise((resolve) => {
413
+ server.close(() => resolve());
414
+ for (const socket of sockets)
415
+ socket.destroy();
416
+ });
417
+ return closing;
418
+ }
419
+ };
420
+ }
@@ -0,0 +1,7 @@
1
+ import { type NetworkGuard } from './network-guard.js';
2
+ export declare const MAX_REDIRECTS = 5;
3
+ /**
4
+ * Follows redirects itself so every hop gets checked. A check on the first URL
5
+ * alone is worthless: a public page can 302 to http://169.254.169.254/.
6
+ */
7
+ export declare function createGuardedFetch(baseFetch: typeof fetch, guard: NetworkGuard): typeof fetch;
@@ -0,0 +1,75 @@
1
+ export const MAX_REDIRECTS = 5;
2
+ function requestUrl(input) {
3
+ if (typeof input === 'string')
4
+ return input;
5
+ if (input instanceof URL)
6
+ return input.toString();
7
+ return input.url;
8
+ }
9
+ const CROSS_ORIGIN_UNSAFE_HEADERS = ['authorization', 'cookie', 'proxy-authorization'];
10
+ function originOf(url) {
11
+ const parsed = new URL(url);
12
+ return `${parsed.protocol}//${parsed.host}`;
13
+ }
14
+ /** Every hop must be plain http/https. Unparseable URLs are left to fail naturally, as before. */
15
+ function assertHttpProtocol(url) {
16
+ let parsed;
17
+ try {
18
+ parsed = new URL(url);
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
24
+ throw new Error(`Unsupported protocol: ${parsed.protocol}`);
25
+ }
26
+ }
27
+ /**
28
+ * Follows redirects itself so every hop gets checked. A check on the first URL
29
+ * alone is worthless: a public page can 302 to http://169.254.169.254/.
30
+ */
31
+ export function createGuardedFetch(baseFetch, guard) {
32
+ return (async (input, init) => {
33
+ let currentInit = { ...init };
34
+ if (input instanceof Request) {
35
+ // A Request's body is a stream that can only be read once, but a 307/308
36
+ // redirect must resend it. Buffer it up front instead of passing the stream
37
+ // through, which also lets us drop `duplex` (only needed for streaming bodies).
38
+ const body = input.body ? await input.clone().arrayBuffer() : undefined;
39
+ const seeded = {
40
+ method: input.method,
41
+ headers: input.headers,
42
+ body,
43
+ signal: input.signal
44
+ };
45
+ currentInit = { ...seeded, ...init };
46
+ }
47
+ let url = requestUrl(input);
48
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
49
+ assertHttpProtocol(url);
50
+ await guard.assertUrlAllowed(url);
51
+ const response = await baseFetch(url, { ...currentInit, redirect: 'manual' });
52
+ const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null;
53
+ if (!location)
54
+ return response;
55
+ await response.body?.cancel().catch(() => undefined);
56
+ const previousUrl = url;
57
+ const nextUrl = new URL(location, url).toString();
58
+ const method = (currentInit.method ?? 'GET').toUpperCase();
59
+ const headers = new Headers(currentInit.headers);
60
+ let nextInit = { ...currentInit, headers };
61
+ if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
62
+ nextInit = { ...nextInit, method: 'GET', body: undefined };
63
+ headers.delete('content-type');
64
+ headers.delete('content-length');
65
+ }
66
+ if (originOf(nextUrl) !== originOf(previousUrl)) {
67
+ for (const name of CROSS_ORIGIN_UNSAFE_HEADERS)
68
+ headers.delete(name);
69
+ }
70
+ currentInit = nextInit;
71
+ url = nextUrl;
72
+ }
73
+ throw new Error(`Too many redirects (more than ${MAX_REDIRECTS}).`);
74
+ });
75
+ }
@@ -1,13 +1,19 @@
1
1
  import { type BrowserResolutionResult } from './browser-resolution.js';
2
+ import { type GuardProxy } from './guard-proxy.js';
3
+ import { type NetworkGuard } from './network-guard.js';
2
4
  import type { WebFetchHeadlessResponse } from '../types.js';
3
5
  export type BrowserProxyOptions = {
4
6
  server: string;
5
7
  username?: string;
6
8
  password?: string;
9
+ bypass?: string;
7
10
  };
8
- export declare function headlessFetch(url: string, { configuredPath, proxy, resolveBrowser, launchBrowser, now }?: {
11
+ export declare function headlessFetch(url: string, { configuredPath, proxy, guard, guardProxy, resolveBrowser, launchBrowser, now }?: {
9
12
  configuredPath?: string;
13
+ /** Only used without a guard. With a guard, Chromium always goes through the guard proxy, which chains upstream itself. */
10
14
  proxy?: BrowserProxyOptions;
15
+ guard?: NetworkGuard;
16
+ guardProxy?: () => Promise<GuardProxy>;
11
17
  resolveBrowser?: (options?: {
12
18
  configuredPath?: string;
13
19
  }) => Promise<BrowserResolutionResult>;
@@ -16,7 +22,9 @@ export declare function headlessFetch(url: string, { configuredPath, proxy, reso
16
22
  headless: true;
17
23
  proxy?: BrowserProxyOptions;
18
24
  }) => Promise<{
19
- newContext: () => Promise<{
25
+ newContext: (options?: {
26
+ serviceWorkers?: 'block';
27
+ }) => Promise<{
20
28
  newPage: () => Promise<any>;
21
29
  close: () => Promise<void>;
22
30
  }>;