import { l as errorMessage } from "./errors-DkR6GPJw.js";
import { Buffer } from "node:buffer";
import { request } from "node:http";
import { promises } from "node:dns";
import { request as request$1 } from "node:https";
import { isIP } from "node:net";
//#region src/tools/_html.ts
/**
* Shared HTML → plain-text helpers used by the web-egress tools (`web_search`
* snippet extraction, `fetch_url` body decoding).
*
* Best-effort by contract: a real HTML parser would be more correct but the
* payload is always model context, never user-facing rendered output, so a
* tag-stripper plus a curated entity table covers the practical cases
* (Wikipedia, GitHub READMEs, MDN docs, Stack Overflow answers). When a tool
* needs richer parsing it should reach for an actual parser rather than
* extending these helpers ad-hoc.
*/
/**
* Curated named-entity table. Kept narrow on purpose — the long tail
* (`&Aring;`, `&times;`, mathematical refs, …) is out of scope because the
* model handles unknown entities fine when they pass through verbatim. Add
* entries when an entity is observed mangling real output, not speculatively.
*
* Keys are stored without the leading `&` / trailing `;` and matched
* case-insensitively by the decoder.
*/
const NAMED_ENTITIES = {
	amp: "&",
	apos: "'",
	bull: "•",
	copy: "©",
	ensp: " ",
	emsp: " ",
	gt: ">",
	hellip: "…",
	laquo: "«",
	ldquo: "“",
	lsquo: "‘",
	lt: "<",
	mdash: "—",
	middot: "·",
	nbsp: " ",
	ndash: "–",
	quot: "\"",
	raquo: "»",
	rdquo: "”",
	reg: "®",
	rsquo: "’",
	thinsp: " ",
	trade: "™"
};
/**
* `String.fromCodePoint` raises on out-of-range / non-finite inputs; the
* agent's untrusted-payload assumption means we can't let a single broken
* numeric reference crash the whole decode. Empty string on rejection is
* the safest outcome (matches what most browsers do for invalid refs).
*/
function safeFromCodePoint(cp) {
	if (!Number.isFinite(cp) || cp < 0 || cp > 1114111) return "";
	try {
		return String.fromCodePoint(cp);
	} catch {
		return "";
	}
}
/**
* Decode numeric character references (`&#x...;` / `&#NNN;`) and the curated
* subset of named entities in {@link NAMED_ENTITIES}. Unknown named entities
* are left as-is so the model can detect them rather than seeing a silently
* corrupted token.
*
* `&amp;` is decoded LAST so a double-escaped string like `&amp;lt;` resolves
* to `&lt;` (not `<`) — that's the spec-correct single-pass decode shape.
*/
function decodeHtmlEntities(text) {
	let s = text.replace(/&#x([0-9a-f]+);/gi, (_, h) => safeFromCodePoint(Number.parseInt(h, 16))).replace(/&#(\d+);/g, (_, n) => safeFromCodePoint(Number.parseInt(n, 10)));
	s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name) => {
		const key = name.toLowerCase();
		if (key === "amp") return full;
		return NAMED_ENTITIES[key] ?? full;
	});
	return s.replace(/&amp;/gi, "&");
}
/**
* Strip `<script>`, `<style>`, `<noscript>`, comments, and every other tag.
* Block-level openers (`<br>`, `<p>`, `<div>`, `<li>`, `<tr>`, `<h1-6>`) are
* replaced with newlines so the resulting plaintext preserves readable
* structure. Other tags collapse to spaces.
*/
function stripHtml(html) {
	return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<noscript[\s\S]*?<\/noscript>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<(?:br|p|div|li|tr|h[1-6])[^>]*>/gi, "\n").replace(/<[^>]+>/g, " ");
}
/**
* Inline variant — strip tags + decode entities + collapse all whitespace
* (including line breaks) into single spaces. For short snippets where
* structural breaks would be visual noise (search-result titles, link text).
*/
function stripHtmlInline(html) {
	return decodeHtmlEntities(stripHtml(html)).replace(/\s+/g, " ").trim();
}
/**
* Block variant — strip tags + decode entities, preserve newlines emitted
* by block-level openers, then collapse inline whitespace per-line and drop
* empty lines. For full-page reductions where structure aids reading.
*/
function stripHtmlBlock(html) {
	return decodeHtmlEntities(stripHtml(html)).split("\n").map((line) => line.replace(/[ \t]+/g, " ").trim()).filter((line) => line.length > 0).join("\n");
}
//#endregion
//#region src/tools/fetch-url.ts
/**
* Fetch a URL and return its text content.
*
* Companion to `web_search` (when registered) — after the model finds a
* promising result it fetches the page to read the body in full. HTML is
* reduced to plain text because the model rarely benefits from the markup
* and we want to keep the payload inside the per-turn output budget.
*
* Restrictions:
* - Only http(s) URLs. Anything else (file://, data:, ftp:) is rejected so
*   the tool can't be coerced into reading local files or arbitrary
*   resources.
* - SSRF guard with TOCTOU defense: the host is DNS-resolved against the
*   loopback / link-local / private / reserved blocklist BEFORE the
*   request, and the resolved IP is then PINNED at the connection layer
*   via `node:http(s).request({ host: <resolved IP>, headers: { Host: ... },
*   servername: ... })`. This closes the classic DNS-rebinding gap where a
*   `globalThis.fetch` re-resolves the hostname and connects to a freshly
*   minted private IP. TLS verification still runs against the original
*   hostname via the explicit `servername` option.
*
*   Note: the more idiomatic Node fix (an `undici.Agent` with a custom
*   `connect.lookup`) is silently a no-op on Bun ≤1.3 — both
*   `undici.Agent` and `node:https.Agent.lookup` are stubbed out. The
*   explicit `host: IP` + `Host:` header dance is the only mechanism that
*   actually pins on the current Bun runtime.
* - Redirects are followed manually (up to `MAX_REDIRECTS`) and each hop
*   is re-validated, so a public hostname that 302s into a metadata
*   endpoint is rejected at the redirect step.
* - 15s hard timeout per request.
* - Output capped at `max_bytes` (default 200 KiB) — long pages are
*   truncated with an explicit marker. The pinned reader also stops
*   buffering past 2× the cap so a multi-MB body can't OOM us.
*/
const DEFAULT_MAX_BYTES = 200 * 1024;
const HARD_MAX_BYTES = 1024 * 1024;
const HTTP_TIMEOUT_MS = 15e3;
const MAX_REDIRECTS = 5;
/**
* IPv4 ranges that the SSRF guard refuses to fetch from. Covers the cloud
* metadata services (AWS/Azure 169.254.169.254, GCP routes through the same
* link-local block) plus every RFC1918 / loopback / reserved block a model
* could be prompt-injected into hitting.
*/
function isBlockedIPv4(ip) {
	const parts = ip.split(".").map(Number);
	if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return true;
	const [a, b] = parts;
	if (a === 0) return true;
	if (a === 10) return true;
	if (a === 127) return true;
	if (a === 169 && b === 254) return true;
	if (a === 172 && b >= 16 && b <= 31) return true;
	if (a === 192 && b === 168) return true;
	if (a === 192 && b === 0 && parts[2] === 0) return true;
	if (a === 198 && (b === 18 || b === 19)) return true;
	if (a >= 224) return true;
	return false;
}
function isBlockedIPv6(ip) {
	const lower = ip.toLowerCase();
	const v4MappedDotted = lower.match(/^::ffff:([0-9.]+)$/);
	if (v4MappedDotted) return isBlockedIPv4(v4MappedDotted[1]);
	const v4MappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
	if (v4MappedHex) {
		const hi = Number.parseInt(v4MappedHex[1], 16);
		const lo = Number.parseInt(v4MappedHex[2], 16);
		return isBlockedIPv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
	}
	if (lower === "::" || lower === "::1") return true;
	if (/^f[cd][0-9a-f]{0,2}:/.test(lower)) return true;
	if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
	if (lower.startsWith("ff")) return true;
	return false;
}
function isBlockedAddress(ip) {
	const family = isIP(ip);
	if (family === 4) return isBlockedIPv4(ip);
	if (family === 6) return isBlockedIPv6(ip);
	return true;
}
/**
* Normalize a hostname for allowlist comparison: lowercase, strip a trailing
* dot (FQDN root) and IPv6 brackets, drop a leading dot on allowlist entries.
*/
function normalizeHost(host) {
	let h = host.trim().toLowerCase();
	if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
	if (h.endsWith(".")) h = h.slice(0, -1);
	if (h.startsWith(".")) h = h.slice(1);
	return h;
}
/**
* Site identity for cross-host redirect detection: the lowercased hostname
* with a leading `www.` stripped, so `example.com` ⇄ `www.example.com` and a
* path-only redirect aren't flagged as cross-host. Returns `null` for an
* unparseable URL (caller skips the note rather than guessing).
*/
function sameSiteHost(url) {
	try {
		return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
	} catch {
		return null;
	}
}
/**
* Host-suffix allowlist check. An empty / undefined list means "no allowlist"
* → every host passes (the SSRF blocklist is the only gate). Otherwise the
* host must equal, or be a subdomain of, one of the entries. Subdomain match
* is on a dot boundary so `example.com` matches `docs.example.com` but not
* `notexample.com`.
*/
function isHostAllowed(hostname, allowHosts) {
	if (!allowHosts || allowHosts.length === 0) return true;
	const host = normalizeHost(hostname);
	for (const entry of allowHosts) {
		const allowed = normalizeHost(entry);
		if (allowed.length === 0) continue;
		if (host === allowed || host.endsWith(`.${allowed}`)) return true;
	}
	return false;
}
/**
* Resolve `hostname` and refuse if any answer falls in a blocked range.
*
* Returns the first allowed `{ address, family }` so the caller can pin
* the connection to it. Throws on any blocked answer so a multi-record
* response with one private record fails closed.
*/
async function resolveAndCheck(hostname, allowHosts) {
	if (!isHostAllowed(hostname, allowHosts)) throw new Error(`refused: ${hostname} is not in the configured egress allowlist`);
	const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
	const family = isIP(bare);
	if (family === 4 || family === 6) {
		if (isBlockedAddress(bare)) throw new Error(`refused: ${bare} is in a blocked range`);
		return {
			address: bare,
			family
		};
	}
	const answers = await promises.lookup(bare, {
		all: true,
		verbatim: true
	});
	if (answers.length === 0) throw new Error(`DNS lookup returned no records for ${bare}`);
	for (const { address } of answers) if (isBlockedAddress(address)) throw new Error(`refused: ${bare} resolves to blocked address ${address}`);
	const first = answers[0];
	const fam = first.family === 6 ? 6 : 4;
	return {
		address: first.address,
		family: fam
	};
}
function buildHostHeader(url) {
	if (!url.port) return url.hostname;
	const defaultPort = url.protocol === "https:" ? "443" : "80";
	return url.port === defaultPort ? url.hostname : `${url.hostname}:${url.port}`;
}
function pinnedRequest(url, pinnedIp, family, headers, signal, maxBufferBytes) {
	return new Promise((resolve, reject) => {
		const isHttps = url.protocol === "https:";
		const options = {
			host: pinnedIp,
			port: url.port ? Number.parseInt(url.port, 10) : isHttps ? 443 : 80,
			path: `${url.pathname}${url.search}`,
			method: "GET",
			family,
			headers: {
				...headers,
				Host: buildHostHeader(url)
			},
			...isHttps ? { servername: url.hostname } : {}
		};
		const request$2 = isHttps ? request$1 : request;
		const chunks = [];
		let received = 0;
		let settled = false;
		let req;
		let timer;
		const teardown = () => {
			try {
				req?.destroy();
			} catch {}
		};
		const finish = (settler) => {
			if (settled) return;
			settled = true;
			if (timer) clearTimeout(timer);
			signal.removeEventListener("abort", onSignalAbort);
			settler();
		};
		function onSignalAbort() {
			finish(() => reject(/* @__PURE__ */ new Error("request aborted")));
			teardown();
		}
		if (signal.aborted) {
			reject(/* @__PURE__ */ new Error("request aborted"));
			return;
		}
		timer = setTimeout(() => {
			finish(() => reject(/* @__PURE__ */ new Error(`request timed out after ${HTTP_TIMEOUT_MS}ms`)));
			teardown();
		}, HTTP_TIMEOUT_MS);
		signal.addEventListener("abort", onSignalAbort, { once: true });
		req = request$2(options, (res) => {
			const status = res.statusCode ?? 0;
			const headersOut = res.headers;
			const contentType = (headersOut["content-type"] ?? "").toString().toLowerCase();
			const location = typeof headersOut.location === "string" ? headersOut.location : null;
			if (status >= 300 && status < 400 && location) {
				res.resume();
				finish(() => resolve({
					status,
					url: url.toString(),
					contentType,
					body: "",
					location
				}));
				return;
			}
			let bodyTruncated = false;
			res.on("data", (chunk) => {
				if (received >= maxBufferBytes) {
					bodyTruncated = true;
					teardown();
					return;
				}
				const room = maxBufferBytes - received;
				if (chunk.length > room) {
					chunks.push(chunk.subarray(0, room));
					received += room;
					bodyTruncated = true;
					teardown();
					return;
				}
				received += chunk.length;
				chunks.push(chunk);
			});
			res.on("end", () => {
				const body = Buffer.concat(chunks).toString("utf-8");
				finish(() => resolve({
					status,
					url: url.toString(),
					contentType,
					body,
					location: null,
					truncated: bodyTruncated
				}));
			});
			res.on("close", () => {
				if (!settled) {
					const body = Buffer.concat(chunks).toString("utf-8");
					finish(() => resolve({
						status,
						url: url.toString(),
						contentType,
						body,
						location: null,
						truncated: bodyTruncated
					}));
				}
			});
			res.on("error", (err) => finish(() => reject(err)));
		});
		req.on("error", (err) => finish(() => reject(err)));
		req.end();
	});
}
async function fetchWithSsrfGuard(startUrl, headers, signal, maxBufferBytes, deps = {}, allowHosts) {
	const resolver = deps.resolver ?? ((hostname) => resolveAndCheck(hostname, allowHosts));
	const requestImpl = deps.requestImpl ?? pinnedRequest;
	let current = startUrl;
	for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
		const { address, family } = await resolver(current.hostname);
		const res = await requestImpl(current, address, family, headers, signal, maxBufferBytes);
		if (!res.location) return res;
		if (hop === MAX_REDIRECTS) throw new Error(`too many redirects (>${MAX_REDIRECTS})`);
		let next;
		try {
			next = new URL(res.location, current);
		} catch {
			throw new Error(`invalid redirect target: ${res.location}`);
		}
		if (next.protocol !== "http:" && next.protocol !== "https:") throw new Error(`refused redirect to non-http(s) target: ${next.protocol}`);
		current = next;
	}
	throw new Error("redirect loop exited unexpectedly");
}
/**
* Opt-in, process-local response cache for `fetch_url`. Disabled unless
* `behavior.fetchUrlCacheTtlMs` is set (> 0). Bounded by entry count and total
* cached bytes; eviction is oldest-first (insertion order via `Map`, which is
* a good-enough LRU because we delete-then-set on hit to refresh recency).
*
* Keyed on `<url>\u0000<cap>\u0000<egress-policy>` so two calls with different
* `max_bytes` (hence different truncation) don't alias, and — because the
* cache is module-global — an agent with a permissive `fetchUrlAllowHosts`
* can't populate entries that a co-resident agent with a stricter allowlist
* would then be served. Only successful text responses are stored — the
* caller gates on status before writing.
*/
const FETCH_CACHE_MAX_ENTRIES = 64;
const FETCH_CACHE_MAX_BYTES = 16 * 1024 * 1024;
const fetchCache = /* @__PURE__ */ new Map();
let fetchCacheBytes = 0;
function cacheKey(url, cap, allowHosts) {
	return `${url}\u0000${cap}\u0000${(allowHosts ?? []).map(normalizeHost).sort().join(",")}`;
}
function cacheGet(key) {
	const entry = fetchCache.get(key);
	if (!entry) return null;
	if (Date.now() >= entry.expiresAt) {
		fetchCache.delete(key);
		fetchCacheBytes -= entry.bytes;
		return null;
	}
	fetchCache.delete(key);
	fetchCache.set(key, entry);
	return entry.output;
}
function cacheSet(key, output, ttlMs) {
	const bytes = Buffer.byteLength(output, "utf-8");
	if (bytes > FETCH_CACHE_MAX_BYTES) return;
	const prev = fetchCache.get(key);
	if (prev) {
		fetchCache.delete(key);
		fetchCacheBytes -= prev.bytes;
	}
	fetchCache.set(key, {
		output,
		expiresAt: Date.now() + ttlMs,
		bytes
	});
	fetchCacheBytes += bytes;
	while (fetchCache.size > FETCH_CACHE_MAX_ENTRIES || fetchCacheBytes > FETCH_CACHE_MAX_BYTES) {
		const oldest = fetchCache.keys().next().value;
		if (oldest === void 0) break;
		const victim = fetchCache.get(oldest);
		fetchCache.delete(oldest);
		fetchCacheBytes -= victim.bytes;
	}
}
/** Test seam — drop all cached entries. */
function clearFetchUrlCache() {
	fetchCache.clear();
	fetchCacheBytes = 0;
}
/**
* Test seam — exercise the cache get/set/eviction in isolation without
* standing up the full SSRF + HTTP path. Not part of the public tool surface;
* `execute` uses the module-private functions directly.
*/
const __fetchCacheTestApi = {
	key: cacheKey,
	get: cacheGet,
	set: cacheSet,
	size: () => fetchCache.size,
	bytes: () => fetchCacheBytes
};
const fetchUrl = {
	isConcurrencySafe: true,
	spec: {
		name: "fetch_url",
		description: "Fetch a public URL and return its body as plain text. HTML is reduced to text (scripts, styles, tags stripped; entities decoded). If a `web_search` tool is also available, use it first to find the URL. Only http(s) URLs are accepted; loopback, link-local, private, and cloud-metadata addresses are refused. Output is capped (default 200 KiB).",
		inputSchema: {
			type: "object",
			properties: {
				url: {
					type: "string",
					description: "HTTP or HTTPS URL to fetch."
				},
				max_bytes: {
					type: "number",
					description: `Truncate the response body beyond this many bytes. Default: ${DEFAULT_MAX_BYTES}. Hard cap: ${HARD_MAX_BYTES}.`
				}
			},
			required: ["url"]
		}
	},
	async execute({ url, max_bytes }, ctx) {
		const raw = typeof url === "string" ? url.trim() : "";
		if (!raw) return "fetch_url error: `url` is required";
		let parsed;
		try {
			parsed = new URL(raw);
		} catch {
			return `fetch_url error: invalid URL: ${raw}`;
		}
		if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return `fetch_url error: only http(s) URLs are allowed (got ${parsed.protocol})`;
		const cap = Math.min(typeof max_bytes === "number" && max_bytes > 0 ? Math.floor(max_bytes) : DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
		const bufferCap = Math.min(cap * 2, HARD_MAX_BYTES * 2);
		const allowHosts = ctx.behavior?.fetchUrlAllowHosts;
		const ttlMs = ctx.behavior?.fetchUrlCacheTtlMs;
		const cacheEnabled = typeof ttlMs === "number" && ttlMs > 0;
		const key = cacheKey(parsed.toString(), cap, allowHosts);
		if (cacheEnabled && isHostAllowed(parsed.hostname, allowHosts)) {
			const hit = cacheGet(key);
			if (hit !== null) return hit;
		}
		try {
			const res = await fetchWithSsrfGuard(parsed, {
				"user-agent": "Mozilla/5.0 (compatible; zidane/1.0)",
				"accept": "text/html,text/plain,application/json;q=0.9,*/*;q=0.5",
				"accept-encoding": "identity"
			}, ctx.signal, bufferCap, {}, allowHosts);
			const body = res.body;
			const text = res.contentType.includes("html") || /^\s*<!doctype html|^\s*<html/i.test(body) ? stripHtmlBlock(body) : body;
			const out = text.length > cap ? `${text.slice(0, cap)}\n\n[truncated at ${cap} bytes; full length ${text.length}]` : res.truncated ? `${text}\n\n[response truncated: body exceeded the ${bufferCap}-byte transfer buffer]` : text;
			const finalHost = sameSiteHost(res.url);
			const requestedHost = sameSiteHost(parsed.toString());
			const redirectNote = finalHost && requestedHost && finalHost !== requestedHost ? `Note: ${parsed.hostname} redirected to a different host (${new URL(res.url).hostname}).\n` : "";
			const formatted = `URL: ${res.url}\nStatus: ${res.status}\nContent-Type: ${res.contentType || "(none)"}\n${redirectNote}\n` + out;
			if (cacheEnabled && res.status >= 200 && res.status < 300 && res.truncated !== true) cacheSet(key, formatted, ttlMs);
			return formatted;
		} catch (err) {
			return `fetch_url error: ${errorMessage(err)}`;
		}
	}
};
//#endregion
export { isBlockedAddress as a, stripHtmlInline as c, fetchWithSsrfGuard as i, clearFetchUrlCache as n, isHostAllowed as o, fetchUrl as r, pinnedRequest as s, __fetchCacheTestApi as t };

//# sourceMappingURL=fetch-url-DPP6-Ruo.js.map