// The broker-side web service (REQ-HARN-2): what answers a role's web.search and web.fetch. // The fetch is packages/discord/src/web.mjs's, with all of row 23's fences: https only, public // addresses only (checked once, then the connection goes to the vetted address), at most 3 // redirects, a capped body, a time limit, text types only. The search goes to a "search-source" // plug-in (packages/plugins): any object with `search(query)` that resolves // { query, total, results: [{ title, url, snippet }] } or throws a WebRefusal. // // A fence refusal is a normal result with one fixed reason, as in the Discord connector: it is // `{ refused: reason }` for the session and outcome "refused" in the broker's evidence. A bug or an // unexpected error throws, and the broker answers web-failed. The broker (not this file) records // every call and labels every reply untrusted (broker.mjs, requestWeb). import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs"; import { currentProcessing } from '../../processing-control/src/effects.mjs'; export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business"; // Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply. export const WEB_DEADLINE_MS = 25_000; // webService({ search, maxFetchBytes, deadlineMs, deps }) // search a search-source plug-in instance, or null when none is configured // maxFetchBytes the cap on one fetched body (web.mjs's default when left out) // deps web.mjs's network seams (lookup, httpsRequest), for tests // Returns { timeout, handle(verb, args) -> { result, summary } }. export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetchBytes, deadlineMs = WEB_DEADLINE_MS, deps } = {}) { const config = Object.freeze({ maxFetchBytes, timeoutMs: Math.min(WEB_DEFAULTS.timeoutMs, deadlineMs) }); const refused = (reason, extra = {}) => ({ result: { refused: reason }, summary: { outcome: "refused", reason, ...extra } }); // `start(signal)` begins the call with an AbortSignal that fires at the deadline (vikunja:19/307), // so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a // search-source module gets the signal as the `signal` of its last argument. const within = (start) => { const processing = currentProcessing(); processing?.guard(); const controller = new AbortController(); let timer; const late = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new WebRefusal(WEB_REFUSAL.TIMEOUT)); }, deadlineMs); }); const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal; return Promise.race([start(signal), late]).finally(() => clearTimeout(timer)); }; async function run(verb, args) { if (verb === "web.fetch") { const page = await within((signal) => webFetch({ ...config, signal }, { url: args.url }, deps)); return { result: page, summary: { outcome: "ok", status: page.status, bytes: page.bytes, redirects: page.redirects, finalUrl: page.finalUrl, truncated: page.truncated || page.textTruncated }, }; } if (verb === "web.search") { if (!search) return refused(WEB_SEARCH_NOT_CONFIGURED); // The one query limit, web.mjs's, applied before any backend is touched: the same refusal // and the same reason as a module's own check. const q = args.query; if (q.trim().length === 0 || q.length > SEARCH_MAX_QUERY_CHARS || q.includes("\0")) return refused(WEB_REFUSAL.BAD_QUERY); const found = await within((signal) => search.search(q, { signal })); return { result: found, summary: { outcome: "ok", total: found.total, returned: found.results.length } }; } throw new Error(`unknown web verb ${verb}`); } return { timeout: deadlineMs + 5000, async handle(verb, args) { try { return await run(verb, args); } catch (error) { if (error instanceof WebRefusal) return refused(error.reason, error.status ? { status: error.status } : {}); throw error; } }, }; }