@demigodmode/pi-web-agent 1.11.0 → 1.13.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/CHANGELOG.md +29 -0
- package/README.md +2 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/doctor.js +27 -29
- package/dist/backends/factory.d.ts +18 -7
- package/dist/backends/factory.js +126 -99
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +31 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +110 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +14 -1
- package/dist/commands/web-agent-config.js +75 -3
- package/dist/extension.js +47 -3
- package/dist/extract/bot-check.d.ts +1 -0
- package/dist/extract/bot-check.js +10 -0
- package/dist/extract/readability.d.ts +4 -0
- package/dist/extract/readability.js +67 -3
- package/dist/extract/section-selector.d.ts +14 -0
- package/dist/extract/section-selector.js +233 -0
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.d.ts +1 -1
- package/dist/fetch/firecrawl-fetch.js +74 -46
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +11 -2
- package/dist/fetch/headless-fetch.js +190 -13
- package/dist/fetch/http-fetch.d.ts +1 -1
- package/dist/fetch/http-fetch.js +29 -8
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +26 -7
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +23 -7
- package/dist/orchestration/research-orchestrator.js +60 -26
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.d.ts +2 -4
- package/dist/orchestration/research-worker.js +51 -15
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +6 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/readers/resolver.d.ts +3 -7
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +4 -2
- package/dist/search/youcom.js +49 -75
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-fetch-headless.d.ts +3 -5
- package/dist/tools/web-fetch-headless.js +3 -3
- package/dist/tools/web-fetch.d.ts +3 -5
- package/dist/tools/web-fetch.js +3 -3
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +48 -0
- package/package.json +3 -3
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
-
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
2
|
+
import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
|
|
3
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
3
4
|
import { resolveBrowserExecutable } from './browser-resolution.js';
|
|
5
|
+
import { BLOCKED_HEADER } from './guard-proxy.js';
|
|
6
|
+
import { BLOCKED_PRIVATE_ADDRESS, BlockedAddressError, UPSTREAM_PROXY_REFUSED } from './network-guard.js';
|
|
4
7
|
function cleanupRenderedText(text) {
|
|
5
8
|
let cleaned = text.replace(/(Show more)(\s+\1){1,}/gi, '$1');
|
|
6
9
|
cleaned = cleaned.replace(/(Privacy Terms)(\s+\1){1,}/gi, '$1');
|
|
7
10
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
8
11
|
return cleaned;
|
|
9
12
|
}
|
|
10
|
-
|
|
13
|
+
function errorResult(url, code, message) {
|
|
14
|
+
const guard = code === BLOCKED_PRIVATE_ADDRESS || code === UPSTREAM_PROXY_REFUSED;
|
|
15
|
+
return {
|
|
16
|
+
status: 'error',
|
|
17
|
+
url,
|
|
18
|
+
metadata: { method: 'headless', cacheHit: false },
|
|
19
|
+
error: { code, message, ...(guard ? { failure: { kind: 'guard_refused' } } : {}) }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function hostnameOf(url) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(url).hostname;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Same normalization the guard applies, so refusal hosts and navigation hosts compare equal. */
|
|
31
|
+
function normalizeHost(host) {
|
|
32
|
+
return host.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
|
|
33
|
+
}
|
|
34
|
+
export async function headlessFetch(url, { configuredPath, query, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
|
|
35
|
+
if (guard) {
|
|
36
|
+
const hostname = hostnameOf(url);
|
|
37
|
+
if (!guardProxy) {
|
|
38
|
+
// Enforcement lives in the guard proxy. Without it, loading the page would be unguarded.
|
|
39
|
+
return errorResult(url, BLOCKED_PRIVATE_ADDRESS, `Blocked ${hostname ?? url}: the browser could not enforce the private address guard.`);
|
|
40
|
+
}
|
|
41
|
+
if (hostname) {
|
|
42
|
+
// Early, clearer refusal for an obviously blocked main url. The guard
|
|
43
|
+
// proxy is what actually enforces the policy for every connection.
|
|
44
|
+
const verdict = await guard.checkHost(hostname);
|
|
45
|
+
if (!verdict.allowed) {
|
|
46
|
+
const error = new BlockedAddressError(verdict.host, verdict.address);
|
|
47
|
+
return errorResult(url, error.code, error.message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
11
51
|
const resolved = await resolveBrowser({ configuredPath });
|
|
12
52
|
if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
|
|
13
53
|
return {
|
|
@@ -17,29 +57,159 @@ export async function headlessFetch(url, { configuredPath, proxy, resolveBrowser
|
|
|
17
57
|
error: resolved.error
|
|
18
58
|
};
|
|
19
59
|
}
|
|
60
|
+
let enforcement;
|
|
61
|
+
if (guard && guardProxy) {
|
|
62
|
+
let activeProxy;
|
|
63
|
+
try {
|
|
64
|
+
activeProxy = await guardProxy();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// The guard proxy could not start (backend set closed, or the listener
|
|
68
|
+
// failed). Enforcement lives there, so the browser must not launch.
|
|
69
|
+
return errorResult(url, BLOCKED_PRIVATE_ADDRESS, `Blocked ${hostnameOf(url) ?? url}: the private address guard is not available, so the browser was not started.`);
|
|
70
|
+
}
|
|
71
|
+
const client = activeProxy.client('headless');
|
|
72
|
+
enforcement = {
|
|
73
|
+
proxy: activeProxy,
|
|
74
|
+
username: client.username,
|
|
75
|
+
since: activeProxy.sequence(),
|
|
76
|
+
// `<-loopback>` removes Chromium's implicit loopback bypass, so localhost
|
|
77
|
+
// and link-local connections go through the guard proxy too.
|
|
78
|
+
launchProxy: { server: client.server, username: client.username, password: client.password, bypass: '<-loopback>' }
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const effectiveProxy = enforcement ? enforcement.launchProxy : proxy;
|
|
20
82
|
const browserName = resolved.ok ? resolved.browser : 'chromium';
|
|
21
83
|
const launchOptions = resolved.ok
|
|
22
|
-
? { executablePath: resolved.executablePath, headless: true, ...(
|
|
23
|
-
: { headless: true, ...(
|
|
84
|
+
? { executablePath: resolved.executablePath, headless: true, ...(effectiveProxy ? { proxy: effectiveProxy } : {}) }
|
|
85
|
+
: { headless: true, ...(effectiveProxy ? { proxy: effectiveProxy } : {}) };
|
|
86
|
+
// Browser requests that failed or were refused by the proxy, from passive
|
|
87
|
+
// page events only. Navigation hosts decide whether a refusal caused the
|
|
88
|
+
// navigation error; subresource failures are counted one per request.
|
|
89
|
+
const failedNavigationHosts = new Set();
|
|
90
|
+
const failedSubresourceHosts = [];
|
|
91
|
+
const seenRequests = new WeakSet();
|
|
92
|
+
const refusals = () => (enforcement ? enforcement.proxy.refusalsSince(enforcement.username, enforcement.since) : []);
|
|
93
|
+
const navigationRefusal = () => refusals().find((entry) => failedNavigationHosts.has(entry.host));
|
|
94
|
+
const subresourceRefusals = () => {
|
|
95
|
+
const refusedHosts = new Set(refusals().map((entry) => entry.host));
|
|
96
|
+
return failedSubresourceHosts.filter((host) => refusedHosts.has(host)).length;
|
|
97
|
+
};
|
|
24
98
|
let browser;
|
|
25
99
|
let context;
|
|
26
100
|
let page;
|
|
27
101
|
try {
|
|
28
102
|
browser = await launchBrowser(launchOptions);
|
|
29
|
-
|
|
30
|
-
|
|
103
|
+
// Service workers can fetch on a page's behalf; blocking them keeps the page's traffic simple to account for.
|
|
104
|
+
context = await browser.newContext(enforcement ? { serviceWorkers: 'block' } : undefined);
|
|
105
|
+
if (enforcement) {
|
|
106
|
+
// Watch every page in the context: popups opened by the page can hit blocked
|
|
107
|
+
// hosts too. Only the primary page's main-frame navigation is "the navigation".
|
|
108
|
+
let primaryPage;
|
|
109
|
+
const recordRequest = (request) => {
|
|
110
|
+
try {
|
|
111
|
+
if (!request || seenRequests.has(request))
|
|
112
|
+
return;
|
|
113
|
+
seenRequests.add(request);
|
|
114
|
+
const host = normalizeHost(new URL(request.url()).hostname);
|
|
115
|
+
if (primaryPage && request.isNavigationRequest() && request.frame() === primaryPage.mainFrame()) {
|
|
116
|
+
failedNavigationHosts.add(host);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
failedSubresourceHosts.push(host);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Unparseable URL or a detached frame: nothing to attribute.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const watchPage = (watched) => {
|
|
127
|
+
watched?.on?.('requestfailed', recordRequest);
|
|
128
|
+
watched?.on?.('response', (response) => {
|
|
129
|
+
try {
|
|
130
|
+
if (response.headers()[BLOCKED_HEADER])
|
|
131
|
+
recordRequest(response.request());
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// ignore
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// Best effort: Playwright doesn't say why a WebSocket died, so an error or a
|
|
138
|
+
// close before any frame counts as failed. Only refused hosts get counted.
|
|
139
|
+
watched?.on?.('websocket', (ws) => {
|
|
140
|
+
let host;
|
|
141
|
+
try {
|
|
142
|
+
host = normalizeHost(new URL(ws.url()).hostname);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let framed = false;
|
|
148
|
+
let recorded = false;
|
|
149
|
+
const fail = () => {
|
|
150
|
+
if (recorded)
|
|
151
|
+
return;
|
|
152
|
+
recorded = true;
|
|
153
|
+
failedSubresourceHosts.push(host);
|
|
154
|
+
};
|
|
155
|
+
ws.on?.('framereceived', () => (framed = true));
|
|
156
|
+
ws.on?.('framesent', () => (framed = true));
|
|
157
|
+
ws.on?.('socketerror', fail);
|
|
158
|
+
ws.on?.('close', () => {
|
|
159
|
+
if (!framed)
|
|
160
|
+
fail();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const watchedPages = new WeakSet();
|
|
165
|
+
const watchOnce = (candidate) => {
|
|
166
|
+
if (!candidate || watchedPages.has(candidate))
|
|
167
|
+
return;
|
|
168
|
+
watchedPages.add(candidate);
|
|
169
|
+
watchPage(candidate);
|
|
170
|
+
};
|
|
171
|
+
// Registered before newPage(), so the primary page's own 'page' event is covered too.
|
|
172
|
+
context.on?.('page', watchOnce);
|
|
173
|
+
page = await context.newPage();
|
|
174
|
+
primaryPage = page;
|
|
175
|
+
watchOnce(page);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
page = await context.newPage();
|
|
179
|
+
}
|
|
31
180
|
const startedAt = now();
|
|
32
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
181
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
182
|
+
if (enforcement && response?.headers?.()[BLOCKED_HEADER]) {
|
|
183
|
+
// Normally already recorded by the 'response' event; this covers it if not.
|
|
184
|
+
try {
|
|
185
|
+
const request = response.request?.();
|
|
186
|
+
if (request && !seenRequests.has(request)) {
|
|
187
|
+
seenRequests.add(request);
|
|
188
|
+
failedNavigationHosts.add(normalizeHost(new URL(request.url()).hostname));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
const cause = navigationRefusal();
|
|
195
|
+
if (cause)
|
|
196
|
+
return errorResult(url, cause.error.code, cause.error.message);
|
|
197
|
+
}
|
|
33
198
|
await page.waitForLoadState('load', { timeout: 10000 });
|
|
34
199
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
|
|
35
200
|
const html = await page.content();
|
|
36
201
|
const finishedAt = now();
|
|
37
|
-
const
|
|
202
|
+
const blockedSubresources = subresourceRefusals();
|
|
203
|
+
const baselineExtraction = extractReadableContentSafely(html);
|
|
204
|
+
const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
|
|
205
|
+
const extraction = queryExtraction ?? baselineExtraction;
|
|
206
|
+
const cleanedBaselineText = cleanupRenderedText(baselineExtraction.content.text);
|
|
38
207
|
const cleanedContent = {
|
|
39
208
|
...extraction.content,
|
|
40
|
-
text: cleanupRenderedText(extraction.content.text)
|
|
209
|
+
text: cleanupRenderedText(extraction.content.text),
|
|
210
|
+
...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
|
|
41
211
|
};
|
|
42
|
-
if (!
|
|
212
|
+
if (!cleanedBaselineText || cleanedBaselineText.length < 40) {
|
|
43
213
|
return {
|
|
44
214
|
status: 'blocked',
|
|
45
215
|
url,
|
|
@@ -47,7 +217,8 @@ export async function headlessFetch(url, { configuredPath, proxy, resolveBrowser
|
|
|
47
217
|
method: 'headless',
|
|
48
218
|
cacheHit: false,
|
|
49
219
|
browser: browserName,
|
|
50
|
-
navigationMs: finishedAt - startedAt
|
|
220
|
+
navigationMs: finishedAt - startedAt,
|
|
221
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
51
222
|
},
|
|
52
223
|
error: {
|
|
53
224
|
code: 'HEADLESS_EXTRACTION_WEAK',
|
|
@@ -64,18 +235,24 @@ export async function headlessFetch(url, { configuredPath, proxy, resolveBrowser
|
|
|
64
235
|
cacheHit: false,
|
|
65
236
|
browser: browserName,
|
|
66
237
|
navigationMs: finishedAt - startedAt,
|
|
67
|
-
truncated: cleanedContent.text.length >= 4000
|
|
238
|
+
truncated: queryExtraction?.omitted ?? cleanedContent.text.length >= 4000,
|
|
239
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
68
240
|
}
|
|
69
241
|
};
|
|
70
242
|
}
|
|
71
243
|
catch (error) {
|
|
244
|
+
const cause = navigationRefusal();
|
|
245
|
+
if (cause)
|
|
246
|
+
return errorResult(url, cause.error.code, cause.error.message);
|
|
247
|
+
const blockedSubresources = subresourceRefusals();
|
|
72
248
|
return {
|
|
73
249
|
status: 'error',
|
|
74
250
|
url,
|
|
75
251
|
metadata: {
|
|
76
252
|
method: 'headless',
|
|
77
253
|
cacheHit: false,
|
|
78
|
-
browser: browserName
|
|
254
|
+
browser: browserName,
|
|
255
|
+
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
79
256
|
},
|
|
80
257
|
error: {
|
|
81
258
|
code: 'HEADLESS_NAVIGATION_FAILED',
|
package/dist/fetch/http-fetch.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
1
|
+
import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
|
|
2
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
3
|
+
import { findGuardError } from './network-guard.js';
|
|
2
4
|
function looksLikeScriptShell(html) {
|
|
3
5
|
const lower = html.toLowerCase();
|
|
4
6
|
return lower.includes('<script') && (lower.includes('id="app"') || lower.includes('id="root"'));
|
|
@@ -14,8 +16,22 @@ function isWeakHttpContent(options) {
|
|
|
14
16
|
return veryShortBody && (lowDensity || hasGenericShellMarker);
|
|
15
17
|
}
|
|
16
18
|
export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
17
|
-
return async function httpFetch(url) {
|
|
18
|
-
|
|
19
|
+
return async function httpFetch(url, query) {
|
|
20
|
+
let response;
|
|
21
|
+
try {
|
|
22
|
+
response = await fetchImpl(url);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const blocked = findGuardError(error);
|
|
26
|
+
if (!blocked)
|
|
27
|
+
throw error;
|
|
28
|
+
return {
|
|
29
|
+
status: 'error',
|
|
30
|
+
url,
|
|
31
|
+
metadata: { method: 'http', cacheHit: false },
|
|
32
|
+
error: { code: blocked.code, message: blocked.message, failure: { kind: 'guard_refused' } }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
19
35
|
const contentType = response.headers.get('content-type') ?? '';
|
|
20
36
|
if (!contentType.includes('text/html')) {
|
|
21
37
|
return {
|
|
@@ -25,11 +41,16 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
|
25
41
|
};
|
|
26
42
|
}
|
|
27
43
|
const html = await response.text();
|
|
28
|
-
const
|
|
29
|
-
const
|
|
44
|
+
const baselineExtraction = extractReadableContentSafely(html);
|
|
45
|
+
const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
|
|
46
|
+
const extraction = queryExtraction ?? baselineExtraction;
|
|
47
|
+
const content = {
|
|
48
|
+
...extraction.content,
|
|
49
|
+
...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
|
|
50
|
+
};
|
|
30
51
|
if (looksLikeScriptShell(html) ||
|
|
31
|
-
content.text.length < 40 ||
|
|
32
|
-
isWeakHttpContent({ html, title: content.title, text: content.text })) {
|
|
52
|
+
baselineExtraction.content.text.length < 40 ||
|
|
53
|
+
isWeakHttpContent({ html, title: baselineExtraction.content.title, text: baselineExtraction.content.text })) {
|
|
33
54
|
return {
|
|
34
55
|
status: 'needs_headless',
|
|
35
56
|
url: response.url,
|
|
@@ -44,7 +65,7 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
|
44
65
|
status: 'ok',
|
|
45
66
|
url: response.url,
|
|
46
67
|
content,
|
|
47
|
-
metadata: { method: 'http', cacheHit: false, contentType, truncated: content.text.length >= 4000 }
|
|
68
|
+
metadata: { method: 'http', cacheHit: false, contentType, truncated: queryExtraction?.omitted ?? content.text.length >= 4000 }
|
|
48
69
|
};
|
|
49
70
|
};
|
|
50
71
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a destination the *model* chose is safe to fetch (#53).
|
|
3
|
+
*
|
|
4
|
+
* web_explore fetches URLs the model picks, partly from pages it just read, so
|
|
5
|
+
* a hostile page can point it at cloud metadata (169.254.169.254), services on
|
|
6
|
+
* localhost, or the user's LAN. This module only answers "is this address
|
|
7
|
+
* allowed"; guarded-fetch.ts and headless-fetch.ts enforce it. User-configured
|
|
8
|
+
* endpoints (search APIs, SearXNG, Firecrawl, the proxy) never go through it.
|
|
9
|
+
*/
|
|
10
|
+
export type Cidr = {
|
|
11
|
+
family: 4 | 6;
|
|
12
|
+
network: bigint;
|
|
13
|
+
prefix: number;
|
|
14
|
+
};
|
|
15
|
+
export type LookupFn = (host: string) => Promise<Array<{
|
|
16
|
+
address: string;
|
|
17
|
+
family: number;
|
|
18
|
+
}>>;
|
|
19
|
+
export type NetworkGuardConfig = {
|
|
20
|
+
allowRanges?: string[];
|
|
21
|
+
};
|
|
22
|
+
export type GuardVerdict = {
|
|
23
|
+
allowed: true;
|
|
24
|
+
unresolved?: true;
|
|
25
|
+
} | {
|
|
26
|
+
allowed: false;
|
|
27
|
+
host: string;
|
|
28
|
+
address: string;
|
|
29
|
+
};
|
|
30
|
+
export type HostResolution = {
|
|
31
|
+
status: 'allowed';
|
|
32
|
+
host: string;
|
|
33
|
+
addresses: string[];
|
|
34
|
+
} | {
|
|
35
|
+
status: 'blocked';
|
|
36
|
+
host: string;
|
|
37
|
+
address: string;
|
|
38
|
+
} | {
|
|
39
|
+
status: 'unresolved';
|
|
40
|
+
host: string;
|
|
41
|
+
};
|
|
42
|
+
export type NetworkGuard = {
|
|
43
|
+
isBlockedAddress(address: string): boolean;
|
|
44
|
+
/** Resolves once and checks every answer. The guard proxy connects to one of `addresses`, never re-resolving. */
|
|
45
|
+
resolveHost(host: string): Promise<HostResolution>;
|
|
46
|
+
checkHost(host: string): Promise<GuardVerdict>;
|
|
47
|
+
assertUrlAllowed(url: string): Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
export declare const BLOCKED_PRIVATE_ADDRESS = "BLOCKED_PRIVATE_ADDRESS";
|
|
50
|
+
export declare class BlockedAddressError extends Error {
|
|
51
|
+
readonly host: string;
|
|
52
|
+
readonly address: string;
|
|
53
|
+
readonly code = "BLOCKED_PRIVATE_ADDRESS";
|
|
54
|
+
constructor(host: string, address: string);
|
|
55
|
+
}
|
|
56
|
+
/** No address could be verified, so we refuse rather than let something else resolve it. */
|
|
57
|
+
export declare class UnverifiedDestinationError extends Error {
|
|
58
|
+
readonly host: string;
|
|
59
|
+
readonly code = "BLOCKED_PRIVATE_ADDRESS";
|
|
60
|
+
constructor(host: string);
|
|
61
|
+
}
|
|
62
|
+
export declare const UPSTREAM_PROXY_REFUSED = "UPSTREAM_PROXY_REFUSED";
|
|
63
|
+
/** The user's upstream proxy would not accept the approved IP. We never retry by hostname. */
|
|
64
|
+
export declare class UpstreamProxyRefusedError extends Error {
|
|
65
|
+
readonly host: string;
|
|
66
|
+
readonly target: string;
|
|
67
|
+
readonly status: number | string;
|
|
68
|
+
readonly code = "UPSTREAM_PROXY_REFUSED";
|
|
69
|
+
constructor(host: string, target: string, status: number | string);
|
|
70
|
+
}
|
|
71
|
+
export type GuardError = BlockedAddressError | UnverifiedDestinationError | UpstreamProxyRefusedError;
|
|
72
|
+
export declare function findGuardError(error: unknown): GuardError | undefined;
|
|
73
|
+
/** undici wraps connect errors as `TypeError: fetch failed` with the real error in `cause`. */
|
|
74
|
+
export declare function findBlockedAddressError(error: unknown): BlockedAddressError | undefined;
|
|
75
|
+
export declare function parseCidr(text: string): Cidr | undefined;
|
|
76
|
+
/** Parses an allow list, dropping invalid entries and anything that allows every address. */
|
|
77
|
+
export declare function usableAllowRanges(allowRanges?: string[]): Cidr[];
|
|
78
|
+
export declare function createNetworkGuard(config?: NetworkGuardConfig, { lookup, lookupTimeoutMs }?: {
|
|
79
|
+
lookup?: LookupFn;
|
|
80
|
+
/** A lookup that hasn't answered by then counts as unresolved, so no caller waits on DNS forever. */
|
|
81
|
+
lookupTimeoutMs?: number;
|
|
82
|
+
}): NetworkGuard;
|