// 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<ResolvedHostInput[]>; 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<BoundedResponse>) | 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 = <A>(promise: Promise<A>, signal?: AbortSignal): Promise<A> => { if (!signal) return promise; if (signal.aborted) return Promise.reject(abortError(signal)); return new Promise<A>((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<ResolvedHostAddress[]> { 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 (<scr<script>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]*?</${tag}\\s*>`, "gi"), "", ); } const titleMatch = noScripts.match(/<title[^>]*>([\s\S]*?)<\/title>/i); const title = decodeEntities((titleMatch?.[1] ?? "").trim()) || baseUrl; const bodyMatch = noScripts.match(/<body[^>]*>([\s\S]*?)<\/body>/i); const body = bodyMatch?.[1] ?? noScripts; const withLinks = body.replace( /<a\s+[^>]*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(/<br\s*\/?>(?!\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(/<img\b[^>]*\balt=["']([^"']*)["'][^>]*>/gi, (_match, alt: string) => alt.trim() ? alt.trim() : "", ) .replace(/<\/?(p|div|span|center|picture|source)\b[^>]*>/gi, "") .replace(/<br\s*\/?>/gi, "\n") .replace(/<!--[\s\S]*?-->/g, "") .trim();} async function fetchBoundedUrl( url: string, redirects = 0, signal?: AbortSignal,): Promise<BoundedResponse> { 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<ResolvedHostAddress[]> { 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<BoundedResponse> { 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<ReaderResult> { 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);}
// 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<ResolvedHostInput[]>;
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<BoundedResponse>)
| 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 = <A>(promise: Promise<A>, signal?: AbortSignal): Promise<A> => {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(abortError(signal));
return new Promise<A>((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<ResolvedHostAddress[]> {
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 (<scr<script>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]*?</${tag}\\s*>`, "gi"),
"",
);
}
const titleMatch = noScripts.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = decodeEntities((titleMatch?.[1] ?? "").trim()) || baseUrl;
const bodyMatch = noScripts.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const body = bodyMatch?.[1] ?? noScripts;
const withLinks = body.replace(
/<a\s+[^>]*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(/<br\s*\/?>(?!\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(/<img\b[^>]*\balt=["']([^"']*)["'][^>]*>/gi, (_match, alt: string) =>
alt.trim() ? alt.trim() : "",
)
.replace(/<\/?(p|div|span|center|picture|source)\b[^>]*>/gi, "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
}
async function fetchBoundedUrl(
url: string,
redirects = 0,
signal?: AbortSignal,
): Promise<BoundedResponse> {
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<ResolvedHostAddress[]> {
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<BoundedResponse> {
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<ReaderResult> {
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);
}