// Reading-mode fallback used when headless Chromium is unavailable (and by the // /api/agent/browser/fetch route directly). Fetches a public http(s) URL with a // vetted DNS lookup so the embedded server never SSRFs into private nets, caps // the body, and converts HTML/Markdown into readable text for the model. // // This is the fetch+sanitize core that previously lived inline in the fetch // route; it is shared so the embedded [verb] path can fall back without an HTTP // self-call. import { lookup } from "node:dns/promises"; import { request as httpRequest, type RequestOptions } from "node:http"; import { request as httpsRequest } from "node:https"; import { sanitizePublicBrowserUrl } from "../../../../shared/agent/sanitize-embedded-browser-url"; const MAX_BYTES = 512 * 1024; const FETCH_TIMEOUT_MS = 12_000; const MAX_REDIRECTS = 5; const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; const ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; export type ReaderResult = { url: string; title: string; text: string; markdown?: string; contentType: string; }; type ResolvedHostAddress = { address: string; family: 4 | 6 }; type ResolvedHostInput = string | ResolvedHostAddress; type ReaderHostResolver = (hostname: string) => Promise; type BoundedResponse = { status: number; ok: boolean; url: string; contentType: string; body: string; location?: string; }; declare global { // Test-only hooks for simulating DNS answers / responses without real network. var __LOCAL_STUDIO_BROWSER_READER_HOST_RESOLVER_FOR_TEST: ReaderHostResolver | undefined; var __LOCAL_STUDIO_BROWSER_READER_REQUEST_FOR_TEST: | ((url: string, address: ResolvedHostAddress) => Promise) | undefined; } const abortError = (signal: AbortSignal): Error => new Error("Browser fetch aborted", { cause: signal.reason }); const assertNotAborted = (signal?: AbortSignal): void => { if (signal?.aborted) throw abortError(signal); }; // Detach from a pending promise as soon as the caller aborts: the panel or // tool call that asked for the page is gone, so stop holding its socket and // buffers instead of riding the fetch to completion. const awaitWithSignal = (promise: Promise, signal?: AbortSignal): Promise => { if (!signal) return promise; if (signal.aborted) return Promise.reject(abortError(signal)); return new Promise((resolve, rejectPromise) => { const onAbort = () => rejectPromise(abortError(signal)); signal.addEventListener("abort", onAbort, { once: true }); promise.then( (value) => { signal.removeEventListener("abort", onAbort); if (signal.aborted) rejectPromise(abortError(signal)); else resolve(value); }, (error: unknown) => { signal.removeEventListener("abort", onAbort); rejectPromise(signal.aborted ? abortError(signal) : error); }, ); }); }; async function resolveReaderHost( hostname: string, signal?: AbortSignal, ): Promise { assertNotAborted(signal); const testResolver = globalThis.__LOCAL_STUDIO_BROWSER_READER_HOST_RESOLVER_FOR_TEST; if (testResolver) { const inputs = await awaitWithSignal(testResolver(hostname), signal); return inputs.map(normalizeResolvedAddress); } const results = await awaitWithSignal(lookup(hostname, { all: true, verbatim: true }), signal); return results.map((result) => ({ address: result.address, family: result.family === 6 ? 6 : 4, })); } function decodeEntities(value: string): string { // & decodes LAST: decoding it first turns "&lt;" into "<" and a // second replace then invents a "<" the source never contained. return value .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " ") .replace(/&/g, "&"); } // Repeat a removal until the input stops changing: one pass can be defeated // by nesting the pattern inside itself (ipt>), and this text is // what the model reads. function replaceUntilStable(input: string, pattern: RegExp, replacement: string): string { let previous = ""; let current = input; while (previous !== current) { previous = current; current = current.replace(pattern, replacement); } return current; } function stripTags(input: string): string { return replaceUntilStable(input, /<[^>]*>/g, ""); } // Lightweight HTML → readable text. We intentionally avoid pulling in // readability/cheerio; the goal is "good enough for the model to read". function htmlToReadable(html: string, baseUrl: string): { title: string; text: string } { let noScripts = html; for (const tag of ["script", "style", "noscript", "iframe", "svg"]) { noScripts = replaceUntilStable( noScripts, new RegExp(`<${tag}\\b[\\s\\S]*?`, "gi"), "", ); } const titleMatch = noScripts.match(/]*>([\s\S]*?)<\/title>/i); const title = decodeEntities((titleMatch?.[1] ?? "").trim()) || baseUrl; const bodyMatch = noScripts.match(/]*>([\s\S]*?)<\/body>/i); const body = bodyMatch?.[1] ?? noScripts; const withLinks = body.replace( /]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => { const text = decodeEntities(stripTags(label).trim()); const resolved = (() => { try { return new URL(href, baseUrl).toString(); } catch { return href; } })(); return text ? `[${text}](${resolved})` : resolved; }, ); const blocks = withLinks .replace(/<\/(p|h[1-6]|li|tr|div|article|section|header|footer)>/gi, "\n\n") .replace(/(?!\s*<)/gi, "\n"); const stripped = stripTags(blocks); const text = decodeEntities(stripped) .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .join("\n\n"); return { title, text }; } function isMarkdownResponse(url: string, contentType: string): boolean { return /\b(markdown|mdx?)\b/i.test(contentType) || /\.(md|mdx|markdown)(?:[?#].*)?$/i.test(url); } function markdownTitle(markdown: string, fallback: string): string { const heading = markdown.match(/^\s*#\s+(.+)$/m)?.[1]?.trim(); return heading || fallback; } function cleanMarkdown(markdown: string): string { return markdown .replace(/]*\balt=["']([^"']*)["'][^>]*>/gi, (_match, alt: string) => alt.trim() ? alt.trim() : "", ) .replace(/<\/?(p|div|span|center|picture|source)\b[^>]*>/gi, "") .replace(//gi, "\n") .replace(//g, "") .trim(); } async function fetchBoundedUrl( url: string, redirects = 0, signal?: AbortSignal, ): Promise { const addresses = await publicResolvedAddresses(url, signal); const response = await requestBoundedUrl(url, addresses[0], signal); assertNotAborted(signal); if (isRedirectStatus(response.status)) { if (redirects >= MAX_REDIRECTS) throw new Error("Too many redirects"); if (!response.location) throw new Error("Redirect missing Location header"); const nextUrl = new URL(response.location, url).toString(); const safeRedirect = sanitizePublicBrowserUrl(nextUrl); if (!safeRedirect) throw new Error("Redirect rejected (must stay public http/https)"); return fetchBoundedUrl(safeRedirect, redirects + 1, signal); } return response; } function isRedirectStatus(status: number): boolean { return status >= 300 && status < 400; } async function publicResolvedAddresses( raw: string, signal?: AbortSignal, ): Promise { const url = new URL(raw); const addresses = await resolveReaderHost(url.hostname, signal); if (!addresses.length) throw new Error("Host resolved to no addresses"); for (const address of addresses) { if (!sanitizePublicBrowserUrl(`${url.protocol}//${hostForAddress(address.address)}/`)) { throw new Error("Resolved host rejected (must stay public http/https)"); } } return addresses; } function hostForAddress(address: string): string { return address.includes(":") ? `[${address}]` : address; } function normalizeResolvedAddress(input: ResolvedHostInput): ResolvedHostAddress { if (typeof input !== "string") return input; return { address: input, family: input.includes(":") ? 6 : 4 }; } function requestBoundedUrl( url: string, address: ResolvedHostAddress, signal?: AbortSignal, ): Promise { const testRequest = globalThis.__LOCAL_STUDIO_BROWSER_READER_REQUEST_FOR_TEST; if (testRequest) return awaitWithSignal(testRequest(url, address), signal); const parsed = new URL(url); const request = parsed.protocol === "https:" ? httpsRequest : httpRequest; const options: RequestOptions = { headers: { Accept: ACCEPT, "User-Agent": USER_AGENT }, signal, lookup: (( _hostname: string, lookupOptions: unknown, callback: (...args: unknown[]) => void, ) => { const wantsAll = Boolean((lookupOptions as { all?: boolean } | undefined)?.all); if (wantsAll) callback(null, [address]); else callback(null, address.address, address.family); }) as RequestOptions["lookup"], }; return new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; let total = 0; let settled = false; const fail = (error: Error) => { if (settled) return; settled = true; reject(error); }; const req = request(parsed, options, (response) => { const status = response.statusCode ?? 0; const contentType = headerString(response.headers["content-type"]); const location = headerString(response.headers.location); response.on("data", (raw: Buffer | string) => { const chunk = typeof raw === "string" ? Buffer.from(raw) : raw; if (total >= MAX_BYTES) return; const available = MAX_BYTES - total; const stored = chunk.length > available ? chunk.subarray(0, available) : chunk; chunks.push(stored); total += stored.length; }); response.on("end", () => { if (settled) return; settled = true; const body = new TextDecoder("utf-8", { fatal: false }).decode(concatBytes(chunks, total)); resolve({ status, ok: status >= 200 && status < 300, url, contentType, body, ...(location ? { location } : {}), }); }); response.on("error", fail); }); req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy(new Error("Fetch timed out"))); req.on("error", fail); req.end(); }); } function headerString(value: string | string[] | number | undefined): string { if (Array.isArray(value)) return value[0] ?? ""; return typeof value === "string" ? value : typeof value === "number" ? String(value) : ""; } function concatBytes(chunks: Uint8Array[], total: number): Uint8Array { if (chunks.length === 1 && chunks[0]?.length === total) return chunks[0]; const output = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; } return output; } function renderReadable(response: BoundedResponse, fallbackUrl: string): ReaderResult { const contentType = response.contentType; const finalUrl = response.url || fallbackUrl; if (contentType.startsWith("text/html") || contentType.includes("xhtml")) { const { title, text } = htmlToReadable(response.body, finalUrl); return { url: finalUrl, title, text, markdown: text, contentType }; } if (contentType.startsWith("text/") || contentType.includes("application/json")) { const text = response.body.slice(0, MAX_BYTES); if (isMarkdownResponse(finalUrl, contentType)) { const markdown = cleanMarkdown(text); return { url: finalUrl, title: markdownTitle(markdown, finalUrl), text: markdown, markdown, contentType, }; } return { url: finalUrl, title: finalUrl, text, contentType }; } return { url: finalUrl, title: finalUrl, text: `Non-text response (${contentType || "unknown"}); not rendered.`, contentType, }; } // Fetch a public URL and return reading-mode text. Throws on rejected/invalid // URLs or upstream failures; callers map errors to their own response shape. export async function fetchReadable(rawUrl: string, signal?: AbortSignal): Promise { assertNotAborted(signal); const safe = sanitizePublicBrowserUrl(rawUrl); if (!safe) throw new Error("url rejected (must be public http/https)"); const response = await fetchBoundedUrl(safe, 0, signal); assertNotAborted(signal); if (!response.ok) throw new Error(`Upstream returned HTTP ${response.status}`); return renderReadable(response, safe); }