// Web tools for the Discord Sage (row 23, Jason's word 2026-09-16: "The // agent needs to be able to research"). Two tools, no dependencies: // // web_fetch(url) GET one https page and return it as text // web_search(query) ask the operator's SearXNG instance, JSON, no key // // The fence, decided here and tested without the network: // - https only, an absolute url, no user:password part // - the host is resolved first and every address must be public: loopback, // private, link-local, multicast and mapped forms refuse the fetch; the // connection then goes to the vetted address, not to a second lookup // - at most MAX_REDIRECTS hops, each one re-checked by the same rules // - the body stops at maxFetchBytes; html is reduced to text; anything // that is not text, html, json or xml is refused // - one fixed User-Agent, no cookies, no auth headers, no POST // - the whole call ends within timeoutMs, whatever the server does // - the SearXNG instance must be https or loopback http; only its answer's // title, url and snippet reach the model, at most SEARCH_MAX_RESULTS // // Web content is data, like file content and Discord text; the prompt says // so. A refusal is a normal result with one fixed reason. import { request as httpsRequest } from "node:https"; import { request as httpRequest } from "node:http"; import { lookup as dnsLookup } from "node:dns/promises"; import { isIP } from "node:net"; export const WEB_TOOL_NAMES = Object.freeze(["web_fetch", "web_search"]); export const WEB_DEFAULTS = Object.freeze({ maxFetchBytes: 1048576, timeoutMs: 15000 }); export const MAX_REDIRECTS = 3; export const FETCH_MAX_TEXT_CHARS = 12000; export const SEARCH_MAX_RESULTS = 10; export const SEARCH_MAX_QUERY_CHARS = 400; export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector)"; export const WEB_REFUSAL = Object.freeze({ BAD_URL: "url must be an absolute https url without a user or password part", PRIVATE: "host resolves to a private, loopback or link-local address", UNRESOLVED: "host could not be resolved", REDIRECTS: `more than ${MAX_REDIRECTS} redirects`, BAD_REDIRECT: "redirect target is not an https url", TIMEOUT: "no complete response within the time limit", STATUS: "server answered with an error status", NOT_TEXT: "response is not text, html, json or xml", NETWORK: "the request failed", BAD_QUERY: `query must be a non-empty string of at most ${SEARCH_MAX_QUERY_CHARS} characters`, SEARCH_DOWN: "search is unavailable", SEARCH_BAD: "search returned an unusable answer", }); export class WebRefusal extends Error { constructor(reason, extra = {}) { super(reason); this.reason = reason; Object.assign(this, extra); } } const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); // The `web` key of the tools config. `searxng` is the instance base url; // `maxFetchBytes` caps one page. Both fixed at pi start like the roots. export function loadWebConfig(raw, where = "web") { if (!isObject(raw)) throw new Error(`${where}: not an object`); for (const k of Object.keys(raw)) { if (!["searxng", "maxFetchBytes"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`); } if (typeof raw.searxng !== "string") throw new Error(`${where}.searxng: must be a url string`); let u; try { u = new URL(raw.searxng); } catch { throw new Error(`${where}.searxng: not a valid url`); } const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]"; if (u.username || u.password || u.search || u.hash) throw new Error(`${where}.searxng: must be a bare base url`); if (!(u.protocol === "https:" || (u.protocol === "http:" && loopback))) throw new Error(`${where}.searxng: must be https, or http on loopback`); const maxFetchBytes = raw.maxFetchBytes === undefined ? WEB_DEFAULTS.maxFetchBytes : raw.maxFetchBytes; if (!Number.isInteger(maxFetchBytes) || maxFetchBytes < 4096 || maxFetchBytes > 8 * 1024 * 1024) throw new Error(`${where}.maxFetchBytes: must be an integer between 4096 and 8388608`); return Object.freeze({ searxng: u.href.replace(/\/+$/, ""), maxFetchBytes, timeoutMs: WEB_DEFAULTS.timeoutMs }); } // --- address vetting --- function v4Parts(s) { const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s); if (!m) return null; const p = m.slice(1).map(Number); return p.every((n) => n <= 255) ? p : null; } export function isPublicAddress(addr) { const fam = isIP(addr); if (fam === 4) { const p = v4Parts(addr); if (!p) return false; const [a, b] = p; if (a === 0 || a === 10 || a === 127) return false; if (a === 100 && b >= 64 && b <= 127) return false; if (a === 169 && b === 254) return false; if (a === 172 && b >= 16 && b <= 31) return false; if (a === 192 && b === 168) return false; if (a === 192 && b === 0 && p[2] === 0) return false; if (a === 198 && (b === 18 || b === 19)) return false; if (a >= 224) return false; return true; } if (fam === 6) { const s = addr.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0]; if (s === "::" || s === "::1") return false; const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(s); if (mapped) return isPublicAddress(mapped[1]); if (/^64:ff9b::/.test(s)) return false; const head = parseInt(s.split(":")[0] || "0", 16); if ((head & 0xfe00) === 0xfc00) return false; // fc00::/7 unique local if ((head & 0xffc0) === 0xfe80) return false; // fe80::/10 link local if ((head & 0xffc0) === 0xfec0) return false; // fec0::/10 site local if ((head & 0xff00) === 0xff00) return false; // multicast return true; } return false; } // Resolve a hostname and refuse unless every address is public. Returns // the address the connection must use. async function vetHost(hostname, deps) { const bare = hostname.replace(/^\[|\]$/g, ""); if (isIP(bare)) { if (!isPublicAddress(bare)) throw new WebRefusal(WEB_REFUSAL.PRIVATE); return { address: bare, family: isIP(bare) }; } let found; try { found = await deps.lookup(hostname, { all: true }); } catch { throw new WebRefusal(WEB_REFUSAL.UNRESOLVED); } if (!Array.isArray(found) || found.length === 0) throw new WebRefusal(WEB_REFUSAL.UNRESOLVED); for (const f of found) if (!isPublicAddress(f.address)) throw new WebRefusal(WEB_REFUSAL.PRIVATE); return { address: found[0].address, family: found[0].family }; } function parseHttpsUrl(url) { if (typeof url !== "string" || url.length > 2048) throw new WebRefusal(WEB_REFUSAL.BAD_URL); let u; try { u = new URL(url); } catch { throw new WebRefusal(WEB_REFUSAL.BAD_URL); } if (u.protocol !== "https:" || u.username || u.password || !u.hostname) throw new WebRefusal(WEB_REFUSAL.BAD_URL); return u; } // --- one GET, capped, timed, no redirect following --- const TEXT_TYPES = /^(text\/[a-z0-9.+-]+|application\/(json|xml|ld\+json|xhtml\+xml|rss\+xml|atom\+xml))(\s*;.*)?$/i; function getOnce(u, pin, config, deps) { return new Promise((resolve, reject) => { const mod = u.protocol === "https:" ? deps.httpsRequest : deps.httpRequest; const opts = { method: "GET", hostname: u.hostname.replace(/^\[|\]$/g, ""), port: u.port || (u.protocol === "https:" ? 443 : 80), path: `${u.pathname}${u.search}`, servername: u.protocol === "https:" ? u.hostname.replace(/^\[|\]$/g, "") : undefined, headers: { host: u.host, "user-agent": USER_AGENT, accept: "text/html, text/plain, application/json;q=0.9, application/xml;q=0.8, */*;q=0.1", "accept-encoding": "identity", }, }; if (pin) { // The connection goes to the address that was vetted, not to a second // lookup that a rebinding name could answer differently. opts.lookup = (host, options, cb) => { if (options && options.all) cb(null, [{ address: pin.address, family: pin.family }]); else cb(null, pin.address, pin.family); }; } let done = false; const finish = (fn, v) => { if (done) return; done = true; clearTimeout(timer); fn(v); }; const req = mod(opts); const timer = setTimeout(() => { req.destroy(); finish(reject, new WebRefusal(WEB_REFUSAL.TIMEOUT)); }, config.timeoutMs); req.on("error", () => finish(reject, new WebRefusal(WEB_REFUSAL.NETWORK))); req.on("response", (res) => { const chunks = []; let size = 0; let truncated = false; res.on("data", (c) => { if (truncated) return; if (size + c.length > config.maxFetchBytes) { chunks.push(c.subarray(0, config.maxFetchBytes - size)); size = config.maxFetchBytes; truncated = true; res.destroy(); finish(resolve, { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks), truncated }); return; } chunks.push(c); size += c.length; }); res.on("end", () => finish(resolve, { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks), truncated })); res.on("error", () => finish(reject, new WebRefusal(WEB_REFUSAL.NETWORK))); }); req.end(); }); } // --- html to text --- const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "-", mdash: "-", hellip: "...", copy: "(c)", rsquo: "'", lsquo: "'", rdquo: '"', ldquo: '"' }; function decodeEntities(s) { return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => { if (e[0] === "#") { const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; } return ENTITIES[e.toLowerCase()] ?? m; }); } export function htmlToText(html) { let s = String(html); const title = /