@bitbaum/ai-kit 0.15.0 → 0.16.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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * readPage — fetch one web page and return its readable text.
3
+ *
4
+ * This is the other half of search. A result list gives an agent titles and a
5
+ * sentence of snippet; almost every real question needs the page. Without a
6
+ * reader the agent either answers from the snippet (which is how a plausible
7
+ * wrong number gets into a reply) or says it cannot know, and both are worse
8
+ * than reading.
9
+ *
10
+ * Three things it refuses to do, each because the alternative failed somewhere:
11
+ *
12
+ * - It never follows a redirect automatically. Every hop goes back through the
13
+ * SSRF guard, because a public URL that 302s to localhost is the cheapest
14
+ * bypass there is.
15
+ * - It never returns partial bytes as though they were the page. When the cap
16
+ * is hit the result says `truncated: true`, so a model summarising it can say
17
+ * "the first part of the page says…" instead of implying it read the whole.
18
+ * - It never throws. The caller is an agent loop; an unreadable page is a fact
19
+ * about the world to reason about, not an exception to unwind through.
20
+ *
21
+ * Extraction is deliberately regex-based and dependency-free. A DOM parser
22
+ * would read more sites (SPAs especially) at the cost of making this package
23
+ * depend on one, and the failure it would fix — a JS-rendered page yielding
24
+ * nothing — is honestly reportable: empty text with `ok: true` tells the model
25
+ * the page had nothing readable, which is true and useful.
26
+ */
27
+ import type { PageOutcome, ReadOptions, WebFailure } from "./types.js";
28
+ import { validateFetchTarget, type LookupFn, defaultLookup } from "./ssrf.js";
29
+
30
+ const DEFAULT_TIMEOUT_MS = 10_000;
31
+ const DEFAULT_MAX_CHARS = 12_000;
32
+ const DEFAULT_MAX_BYTES = 2_000_000;
33
+ const MAX_REDIRECTS = 3;
34
+
35
+ /** Content types worth extracting text from. Anything else is refused unread. */
36
+ const READABLE_TYPES = [
37
+ "text/html",
38
+ "application/xhtml",
39
+ "text/plain",
40
+ "text/markdown",
41
+ "application/json",
42
+ "application/xml",
43
+ "text/xml",
44
+ ];
45
+
46
+ export type ReadDeps = {
47
+ fetch?: typeof globalThis.fetch;
48
+ lookup?: LookupFn;
49
+ };
50
+
51
+ export async function readPage(
52
+ rawUrl: string,
53
+ opts: ReadOptions = {},
54
+ deps: ReadDeps = {},
55
+ ): Promise<PageOutcome> {
56
+ const doFetch = deps.fetch ?? globalThis.fetch;
57
+ const lookup = deps.lookup ?? defaultLookup;
58
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
+ const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
60
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
61
+
62
+ let target = rawUrl;
63
+
64
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
65
+ const verdict = await validateFetchTarget(target, lookup);
66
+ if (!verdict.ok) {
67
+ return { ok: false, url: target, failure: { kind: "blocked", reason: verdict.reason } };
68
+ }
69
+ const url = verdict.url.toString();
70
+
71
+ let res: Response;
72
+ try {
73
+ res = await doFetch(url, {
74
+ method: "GET",
75
+ redirect: "manual",
76
+ signal: AbortSignal.timeout(timeoutMs),
77
+ headers: {
78
+ // Identifying the agent is the polite half of scraping and the half
79
+ // that lets a site block us deliberately rather than by accident.
80
+ "User-Agent": "ai-kit/web (+https://github.com/bitbaum/ai-kit)",
81
+ Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
82
+ "Accept-Language": "en,de;q=0.8",
83
+ },
84
+ });
85
+ } catch (err) {
86
+ return { ok: false, url, failure: fetchFailure(err) };
87
+ }
88
+
89
+ if (res.status >= 300 && res.status < 400) {
90
+ const location = res.headers.get("location");
91
+ if (!location) {
92
+ return {
93
+ ok: false,
94
+ url,
95
+ failure: { kind: "bad_response", reason: `Redirect ${res.status} with no destination.` },
96
+ };
97
+ }
98
+ // Resolve relative Locations against the hop we are on, then loop — the
99
+ // next iteration re-runs the full SSRF check on the new address.
100
+ target = new URL(location, url).toString();
101
+ continue;
102
+ }
103
+
104
+ if (res.status === 429) {
105
+ return {
106
+ ok: false,
107
+ url,
108
+ failure: { kind: "rate_limited", reason: "The site asked us to slow down (429)." },
109
+ };
110
+ }
111
+ if (res.status === 401 || res.status === 403) {
112
+ return {
113
+ ok: false,
114
+ url,
115
+ failure: { kind: "blocked", reason: `The site refused the request (${res.status}).` },
116
+ };
117
+ }
118
+ if (!res.ok) {
119
+ return {
120
+ ok: false,
121
+ url,
122
+ failure: { kind: "bad_response", reason: `The site answered ${res.status}.` },
123
+ };
124
+ }
125
+
126
+ const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
127
+ if (contentType && !READABLE_TYPES.some((t) => contentType.includes(t))) {
128
+ return {
129
+ ok: false,
130
+ url,
131
+ failure: {
132
+ kind: "bad_response",
133
+ reason: `That is a ${contentType.split(";")[0]} file, not a readable page.`,
134
+ },
135
+ };
136
+ }
137
+
138
+ let body: string;
139
+ try {
140
+ body = await readCapped(res, maxBytes);
141
+ } catch (err) {
142
+ return { ok: false, url, failure: fetchFailure(err) };
143
+ }
144
+
145
+ const extracted = extractReadableText(body, maxChars);
146
+ return {
147
+ ok: true,
148
+ url,
149
+ title: extracted.title,
150
+ text: extracted.text,
151
+ truncated: extracted.truncated,
152
+ };
153
+ }
154
+
155
+ return {
156
+ ok: false,
157
+ url: target,
158
+ failure: { kind: "blocked", reason: `More than ${MAX_REDIRECTS} redirects.` },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Read the body but stop at `maxBytes`. Buffering `res.text()` first and
164
+ * slicing after would already have paid for the whole download, which is the
165
+ * cost the cap exists to avoid — a 400 MB file would be in memory before the
166
+ * limit was consulted.
167
+ */
168
+ async function readCapped(res: Response, maxBytes: number): Promise<string> {
169
+ const reader = res.body?.getReader();
170
+ if (!reader) {
171
+ const text = await res.text();
172
+ return text.length > maxBytes ? text.slice(0, maxBytes) : text;
173
+ }
174
+ const chunks: Uint8Array[] = [];
175
+ let total = 0;
176
+ for (;;) {
177
+ const { done, value } = await reader.read();
178
+ if (done) break;
179
+ if (value) {
180
+ chunks.push(value);
181
+ total += value.byteLength;
182
+ if (total >= maxBytes) {
183
+ await reader.cancel().catch(() => {});
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ const joined = new Uint8Array(total);
189
+ let offset = 0;
190
+ for (const chunk of chunks) {
191
+ joined.set(chunk, offset);
192
+ offset += chunk.byteLength;
193
+ }
194
+ return new TextDecoder("utf-8", { fatal: false }).decode(joined);
195
+ }
196
+
197
+ function fetchFailure(err: unknown): WebFailure {
198
+ const name = err instanceof Error ? err.name : "";
199
+ const message = err instanceof Error ? err.message : String(err);
200
+ if (name === "TimeoutError" || name === "AbortError" || /timed? ?out/i.test(message)) {
201
+ return { kind: "timeout", reason: "The site took too long to answer." };
202
+ }
203
+ return { kind: "unreachable", reason: `The site could not be reached (${message}).` };
204
+ }
205
+
206
+ const BLOCK_TAGS = "script|style|noscript|template|svg|canvas|iframe|form|nav|footer|header|aside";
207
+
208
+ export type ExtractedPage = { title: string; text: string; truncated: boolean };
209
+
210
+ /**
211
+ * Strip a document to the text a reader would see.
212
+ *
213
+ * Order matters and is load-bearing: comments go first (they can contain
214
+ * unbalanced tags that desync a later pass), then whole non-content elements
215
+ * WITH their contents, then remaining tags, then entities. Doing entities
216
+ * before tag-stripping would turn an encoded `&lt;script&gt;` in page text into
217
+ * a real tag mid-pass.
218
+ */
219
+ export function extractReadableText(html: string, maxChars = DEFAULT_MAX_CHARS): ExtractedPage {
220
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
221
+ // Collapse whitespace: stripping an inline tag leaves a space behind, so a
222
+ // title with any markup in it ("A <b>bold</b> title") otherwise arrives with
223
+ // double spaces and fails to match the same string anywhere else.
224
+ const title = titleMatch?.[1]
225
+ ? decodeEntities(stripTags(titleMatch[1])).replace(/\s+/g, " ").trim().slice(0, 300)
226
+ : "";
227
+
228
+ let body = html.replace(/<!--[\s\S]*?-->/g, " ");
229
+ body = body.replace(new RegExp(`<(${BLOCK_TAGS})\\b[^>]*>[\\s\\S]*?<\\/\\1>`, "gi"), " ");
230
+ // An unclosed <script> would otherwise leave its whole tail in the text.
231
+ body = body.replace(new RegExp(`<(${BLOCK_TAGS})\\b[^>]*>[\\s\\S]*$`, "i"), " ");
232
+ // Keep block boundaries as newlines so lists and paragraphs do not run
233
+ // together into one sentence the model then reads as a single claim.
234
+ body = body.replace(/<\/(p|div|li|tr|h[1-6]|section|article|br)\s*>/gi, "\n");
235
+ body = body.replace(/<br\s*\/?>/gi, "\n");
236
+ body = stripTags(body);
237
+ body = decodeEntities(body);
238
+ body = body
239
+ .split("\n")
240
+ // \u00a0 is the non-breaking space `&nbsp;` decodes to. Written as an escape,
241
+ // not the literal character: a raw NBSP in source is invisible to a reviewer
242
+ // and to a diff, which is how one survives a cleanup that silently changes
243
+ // what the regex matches.
244
+ .map((line) => line.replace(/[ \t\u00a0]+/g, " ").trim())
245
+ .filter((line) => line.length > 0)
246
+ .join("\n")
247
+ .replace(/\n{3,}/g, "\n\n");
248
+
249
+ const truncated = body.length > maxChars;
250
+ return { title, text: truncated ? body.slice(0, maxChars) : body, truncated };
251
+ }
252
+
253
+ function stripTags(input: string): string {
254
+ return input.replace(/<[^>]*>/g, " ");
255
+ }
256
+
257
+ const ENTITIES: Record<string, string> = {
258
+ amp: "&",
259
+ lt: "<",
260
+ gt: ">",
261
+ quot: '"',
262
+ apos: "'",
263
+ nbsp: " ",
264
+ ndash: "–",
265
+ mdash: "—",
266
+ hellip: "…",
267
+ rsquo: "’",
268
+ lsquo: "‘",
269
+ ldquo: "“",
270
+ rdquo: "”",
271
+ };
272
+
273
+ function decodeEntities(input: string): string {
274
+ return input.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, body: string) => {
275
+ if (body.startsWith("#")) {
276
+ const code =
277
+ body[1]?.toLowerCase() === "x" ? parseInt(body.slice(2), 16) : Number(body.slice(1));
278
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff
279
+ ? String.fromCodePoint(code)
280
+ : whole;
281
+ }
282
+ return ENTITIES[body.toLowerCase()] ?? whole;
283
+ });
284
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * webSearch — walk the provider chain and come back with one of THREE answers.
3
+ *
4
+ * The three-answer shape is the whole point of this file, and it is the same
5
+ * lesson the model chain in `chain.ts` learned: a list nobody walks is not a
6
+ * fallback, and a walk that cannot say why it ended is not an answer.
7
+ *
8
+ * found — results, and which backend produced them.
9
+ * nothing — at least one backend ANSWERED and had nothing. This is a
10
+ * real negative the model may state as one.
11
+ * could_not — no backend answered at all (none configured, all rate-
12
+ * limited, all unreachable). The model must NOT report this
13
+ * as "there is nothing about X"; it must say it could not
14
+ * look, and the `attempts` list says why for each backend.
15
+ *
16
+ * Collapsing the last two into `[]` is the specific bug this prevents. It has
17
+ * a track record: an assistant whose search silently returned nothing told a
18
+ * user their question had no answer on the internet, and the actual cause was
19
+ * an expired API key. The user cannot fix a key they were never told about,
20
+ * and neither can the operator, because the failure looked like a fact.
21
+ *
22
+ * Walking past an EMPTY-but-successful backend is deliberate. A self-hosted
23
+ * metasearch instance whose upstream engines refused it returns `{results: []}`
24
+ * with HTTP 200 — a success that carries no information — so the chain tries
25
+ * the next backend before concluding, and only reports `nothing` when every
26
+ * backend that answered agreed there was nothing.
27
+ */
28
+ import type { SearchOptions, SearchProvider, WebEnv, WebFailure, WebResult } from "./types.js";
29
+ import { defaultProviders } from "./providers.js";
30
+
31
+ /** What one backend did, kept for the report even when a later one succeeded. */
32
+ export type SearchAttempt = {
33
+ provider: string;
34
+ outcome: "results" | "empty" | "failed";
35
+ count?: number;
36
+ failure?: WebFailure;
37
+ };
38
+
39
+ export type WebSearchResult =
40
+ | {
41
+ status: "found";
42
+ results: WebResult[];
43
+ provider: string;
44
+ query: string;
45
+ attempts: SearchAttempt[];
46
+ }
47
+ | { status: "nothing"; query: string; attempts: SearchAttempt[] }
48
+ | { status: "could_not_look"; query: string; attempts: SearchAttempt[] };
49
+
50
+ export type WebSearchDeps = {
51
+ env?: WebEnv;
52
+ providers?: SearchProvider[];
53
+ fetch?: typeof globalThis.fetch;
54
+ };
55
+
56
+ export async function webSearch(
57
+ query: string,
58
+ opts: SearchOptions = {},
59
+ deps: WebSearchDeps = {},
60
+ ): Promise<WebSearchResult> {
61
+ const trimmed = query.trim();
62
+ const attempts: SearchAttempt[] = [];
63
+
64
+ if (!trimmed) {
65
+ return { status: "could_not_look", query, attempts };
66
+ }
67
+
68
+ const env = deps.env ?? (process.env as WebEnv);
69
+ const providers = deps.providers ?? defaultProviders(env, deps.fetch ?? globalThis.fetch);
70
+
71
+ if (providers.length === 0) {
72
+ return {
73
+ status: "could_not_look",
74
+ query: trimmed,
75
+ attempts: [
76
+ {
77
+ provider: "none",
78
+ outcome: "failed",
79
+ failure: {
80
+ kind: "not_configured",
81
+ reason:
82
+ "No search backend is configured (set SEARXNG_URL, BRAVE_SEARCH_API_KEY or TAVILY_API_KEY).",
83
+ },
84
+ },
85
+ ],
86
+ };
87
+ }
88
+
89
+ let anyBackendAnswered = false;
90
+
91
+ for (const provider of providers) {
92
+ const outcome = await provider.search(trimmed, opts);
93
+ if (!outcome.ok) {
94
+ attempts.push({ provider: provider.name, outcome: "failed", failure: outcome.failure });
95
+ continue;
96
+ }
97
+ anyBackendAnswered = true;
98
+ if (outcome.results.length === 0) {
99
+ attempts.push({ provider: provider.name, outcome: "empty", count: 0 });
100
+ continue;
101
+ }
102
+ attempts.push({ provider: provider.name, outcome: "results", count: outcome.results.length });
103
+ return {
104
+ status: "found",
105
+ results: outcome.results,
106
+ provider: provider.name,
107
+ query: trimmed,
108
+ attempts,
109
+ };
110
+ }
111
+
112
+ return anyBackendAnswered
113
+ ? { status: "nothing", query: trimmed, attempts }
114
+ : { status: "could_not_look", query: trimmed, attempts };
115
+ }
116
+
117
+ /**
118
+ * One line per backend, for a log or a health route: "searxng empty; brave 6".
119
+ * Kept here rather than at call sites so every adopter's logs read the same.
120
+ */
121
+ export function describeAttempts(attempts: SearchAttempt[]): string {
122
+ return attempts
123
+ .map((a) => {
124
+ if (a.outcome === "results") return `${a.provider} ${a.count}`;
125
+ if (a.outcome === "empty") return `${a.provider} empty`;
126
+ return `${a.provider} ${a.failure?.kind ?? "failed"}`;
127
+ })
128
+ .join("; ");
129
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SSRF guard for agent-chosen URLs.
3
+ *
4
+ * Why this is stricter than the usual "block 127.0.0.1" check. An app that
5
+ * fetches a URL the USER pasted has a human in the loop who can be blamed for
6
+ * typing it. An agent that fetches a URL it reasoned its way to from a search
7
+ * result has no such human: the model picks the address, and any page on the
8
+ * open web can hand it a link to `http://169.254.169.254/latest/meta-data/`
9
+ * and read the answer back out of the model's next sentence. On a box that
10
+ * also runs Postgres, Supabase, Caddy and eight app servers on localhost, the
11
+ * blast radius of one missed range is the whole fleet.
12
+ *
13
+ * So the rule here is allow-list-shaped rather than deny-list-shaped wherever
14
+ * it can be: http(s) only, ports 80/443 only, no credentials, and — the part
15
+ * that actually matters — EVERY address the hostname resolves to must be
16
+ * public, checked again on every redirect hop rather than once at the start.
17
+ *
18
+ * The classic bypass this closes: a hostname that resolves to a public address
19
+ * on the first lookup and a private one on the second (DNS rebinding), or a
20
+ * public URL that 302s to `http://localhost:5432`. Validating the first URL
21
+ * and then handing the rest to `fetch`'s automatic redirect following defeats
22
+ * the whole check, which is why the reader does `redirect: "manual"` and comes
23
+ * back through here for each hop.
24
+ *
25
+ * Pure except for DNS. No allow-list of "good" domains: a fleet whose thesis is
26
+ * that anyone may publish anywhere cannot ship an agent that can only read the
27
+ * sites we thought of.
28
+ */
29
+ import { lookup as dnsLookup } from "node:dns/promises";
30
+ import { isIP } from "node:net";
31
+
32
+ /** Injectable so tests need no network and no hosts-file tricks. */
33
+ export type LookupFn = (hostname: string) => Promise<Array<{ address: string }>>;
34
+
35
+ export const defaultLookup: LookupFn = async (hostname) => {
36
+ const entries = await dnsLookup(hostname, { all: true, verbatim: true });
37
+ return entries.map((e) => ({ address: e.address }));
38
+ };
39
+
40
+ export type UrlVerdict =
41
+ { ok: true; url: URL; addresses: string[] } | { ok: false; reason: string };
42
+
43
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
44
+ const ALLOWED_PORTS = new Set(["", "80", "443"]);
45
+
46
+ /**
47
+ * Is this a literal address we must never connect to?
48
+ *
49
+ * Each range is here because it addresses something that is not "the public
50
+ * internet" — loopback, the link-local metadata service, RFC1918, carrier NAT,
51
+ * benchmark and documentation nets, multicast and the reserved top of the
52
+ * space. A response from any of them is a response from inside the trust
53
+ * boundary, whatever the hostname said.
54
+ */
55
+ export function isPrivateAddress(address: string): boolean {
56
+ const family = isIP(address);
57
+ if (family === 4) {
58
+ return isPrivateIpv4(address);
59
+ }
60
+ if (family === 6) {
61
+ return isPrivateIpv6(address);
62
+ }
63
+ // Not a parseable address at all. Refusing is the only safe reading: we
64
+ // cannot prove it is public, and "unknown" must never mean "allowed".
65
+ return true;
66
+ }
67
+
68
+ function isPrivateIpv4(address: string): boolean {
69
+ const parts = address.split(".").map((p) => Number(p));
70
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
71
+ return true;
72
+ }
73
+ const [a, b] = parts as [number, number, number, number];
74
+ if (a === 0) return true; // "this network"
75
+ if (a === 10) return true; // RFC1918
76
+ if (a === 127) return true; // loopback
77
+ if (a === 169 && b === 254) return true; // link-local — cloud metadata lives here
78
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
79
+ if (a === 192 && b === 168) return true; // RFC1918
80
+ if (a === 192 && b === 0) return true; // IETF protocol assignments (incl. 192.0.0.0/24)
81
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
82
+ if (a === 198 && b === 51) return true; // TEST-NET-2
83
+ if (a === 203 && b === 0) return true; // TEST-NET-3
84
+ if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
85
+ if (a >= 224) return true; // multicast + reserved
86
+ return false;
87
+ }
88
+
89
+ function isPrivateIpv6(address: string): boolean {
90
+ const lower = address.toLowerCase().split("%")[0] ?? "";
91
+
92
+ // IPv4-mapped (::ffff:10.0.0.1) and IPv4-compatible forms are IPv4 questions
93
+ // wearing an IPv6 spelling. Judge the embedded address, not the wrapper.
94
+ const mapped = lower.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
95
+ if (mapped?.[1]) {
96
+ return isPrivateIpv4(mapped[1]);
97
+ }
98
+
99
+ if (lower === "::" || lower === "::1") return true; // unspecified, loopback
100
+ if (lower.startsWith("fe8") || lower.startsWith("fe9")) return true; // link-local
101
+ if (lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local
102
+ if (/^f[cd]/.test(lower)) return true; // unique local (fc00::/7)
103
+ if (lower.startsWith("ff")) return true; // multicast
104
+
105
+ // 6to4 (2002::/16) and NAT64 (64:ff9b::/96) carry an IPv4 address inside.
106
+ // Left unchecked they are a clean tunnel to 127.0.0.1.
107
+ if (lower.startsWith("2002:")) return true;
108
+ if (lower.startsWith("64:ff9b:")) return true;
109
+
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Validate a URL for agent fetching: shape first (cheap, no network), then
115
+ * resolve and judge every address behind the hostname.
116
+ *
117
+ * Returns the resolved addresses on success so the caller can log what it
118
+ * actually talked to — a hostname proves nothing about where the bytes came
119
+ * from, and the address is the thing an incident review needs.
120
+ */
121
+ export async function validateFetchTarget(
122
+ raw: string,
123
+ lookup: LookupFn = defaultLookup,
124
+ ): Promise<UrlVerdict> {
125
+ let url: URL;
126
+ try {
127
+ url = new URL(raw);
128
+ } catch {
129
+ return { ok: false, reason: "That is not a valid URL." };
130
+ }
131
+
132
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
133
+ return { ok: false, reason: `Only http and https can be fetched, not ${url.protocol}` };
134
+ }
135
+ if (!ALLOWED_PORTS.has(url.port)) {
136
+ return { ok: false, reason: `Only the standard web ports are allowed, not ${url.port}.` };
137
+ }
138
+ if (url.username || url.password) {
139
+ return { ok: false, reason: "A URL carrying credentials is never fetched." };
140
+ }
141
+
142
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
143
+ if (!hostname) {
144
+ return { ok: false, reason: "That URL has no host." };
145
+ }
146
+
147
+ // A literal address needs no lookup — and must not get one, since a resolver
148
+ // asked about "127.0.0.1" will happily hand it back and the check would then
149
+ // be judging its own input.
150
+ if (isIP(hostname)) {
151
+ if (isPrivateAddress(hostname)) {
152
+ return { ok: false, reason: "That address is not on the public internet." };
153
+ }
154
+ return { ok: true, url, addresses: [hostname] };
155
+ }
156
+
157
+ let addresses: string[];
158
+ try {
159
+ const entries = await lookup(hostname);
160
+ addresses = entries.map((e) => e.address);
161
+ } catch {
162
+ return { ok: false, reason: `The host ${hostname} could not be resolved.` };
163
+ }
164
+
165
+ if (addresses.length === 0) {
166
+ return { ok: false, reason: `The host ${hostname} resolved to no addresses.` };
167
+ }
168
+ // EVERY address, not the first: a hostname with one public and one private A
169
+ // record is a rebinding attack with the work already done for it.
170
+ if (addresses.some((a) => isPrivateAddress(a))) {
171
+ return { ok: false, reason: `The host ${hostname} resolves inside a private network.` };
172
+ }
173
+
174
+ return { ok: true, url, addresses };
175
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Web module types — the three answers a lookup can give.
3
+ *
4
+ * The distinction this file exists to preserve: "I found nothing" and "I could
5
+ * not look" are DIFFERENT answers, and collapsing them is how an agent comes to
6
+ * report a confident negative it never earned. A search whose provider was
7
+ * rate-limited returns zero results, exactly like a search for a phrase nobody
8
+ * has ever written; if both arrive as `[]`, the model says "there is nothing
9
+ * about X on the web" in both cases, and one of those sentences is a lie.
10
+ *
11
+ * So every outcome here is a tagged union with `ok`, and the failure branch
12
+ * carries a `reason` the model is shown verbatim. Nothing in this module
13
+ * throws — a lookup failing is an ordinary result, not an exception, because
14
+ * the caller is an agent loop that must keep going either way.
15
+ */
16
+
17
+ /** One result from a search engine. Snippets are the engine's, never rewritten. */
18
+ export type WebResult = {
19
+ title: string;
20
+ url: string;
21
+ /** The engine's own summary of the page. May be empty. */
22
+ snippet: string;
23
+ /** ISO date when the engine reports one. Absent is normal, not an error. */
24
+ published?: string;
25
+ /** Which upstream engine surfaced it, where the provider tells us. */
26
+ engine?: string;
27
+ };
28
+
29
+ /** Why a lookup could not be performed. Shown to the model, so phrase for reading. */
30
+ export type WebFailure =
31
+ | { kind: "not_configured"; reason: string }
32
+ | { kind: "rate_limited"; reason: string }
33
+ | { kind: "auth"; reason: string }
34
+ | { kind: "timeout"; reason: string }
35
+ | { kind: "blocked"; reason: string }
36
+ | { kind: "unreachable"; reason: string }
37
+ | { kind: "bad_response"; reason: string };
38
+
39
+ export type SearchOutcome =
40
+ | { ok: true; results: WebResult[]; provider: string; query: string }
41
+ | { ok: false; failure: WebFailure; provider: string; query: string };
42
+
43
+ export type PageOutcome =
44
+ | {
45
+ ok: true;
46
+ url: string;
47
+ title: string;
48
+ /** Readable text, tags stripped, whitespace collapsed. */
49
+ text: string;
50
+ /** True when `text` hit the character cap and the tail was dropped. */
51
+ truncated: boolean;
52
+ }
53
+ | { ok: false; url: string; failure: WebFailure };
54
+
55
+ /**
56
+ * A search backend. Deliberately one method: everything else a provider might
57
+ * offer (news verticals, autocomplete, "AI answers") is a different product
58
+ * decision and belongs to the caller, not to a seam whose whole job is to make
59
+ * the backends interchangeable.
60
+ */
61
+ export type SearchProvider = {
62
+ /** Stable id used in logs, Fact sources and the chain's report: "searxng". */
63
+ readonly name: string;
64
+ /** False when the env it needs is absent — the chain skips it without a call. */
65
+ configured(): boolean;
66
+ search(query: string, opts: SearchOptions): Promise<SearchOutcome>;
67
+ };
68
+
69
+ export type SearchOptions = {
70
+ /** Upper bound on results. Providers may return fewer; none may return more. */
71
+ limit?: number;
72
+ /** Per-provider deadline in milliseconds. */
73
+ timeoutMs?: number;
74
+ /** Restrict to one site, e.g. "docs.python.org". Applied as the engine allows. */
75
+ site?: string;
76
+ /** Language hint, BCP-47-ish ("en", "de-CH"). Best-effort per provider. */
77
+ lang?: string;
78
+ };
79
+
80
+ export type ReadOptions = {
81
+ timeoutMs?: number;
82
+ /** Character cap on extracted text. */
83
+ maxChars?: number;
84
+ /** Bytes to read off the wire before giving up on an oversized document. */
85
+ maxBytes?: number;
86
+ };
87
+
88
+ /** Env bag. Defaults to `process.env`; injectable so tests need no globals. */
89
+ export type WebEnv = Record<string, string | undefined>;