UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

308 lines (303 loc) 10.4 kB
import { st as resolvePreferredOpenClawTmpDir } from "./registry-C8pj8ctW.js"; import { s as resolveStateDir } from "./paths-DJmOcr7Q.js"; import { d as detectMime, f as extensionForMime } from "./fs-safe-CGcLaY9D.js"; import { M as logWarn, s as resolveAgentWorkspaceDir } from "./agent-scope-OWMdRegz.js"; import { t as bindAbortRelay } from "./fetch-timeout-BKZjgek-.js"; import { n as closeDispatcher, r as createPinnedDispatcher, s as resolvePinnedHostnameWithPolicy, t as SsrFBlockedError } from "./ssrf-DxmqkHJJ.js"; import path from "node:path"; //#region src/infra/net/fetch-guard.ts const DEFAULT_MAX_REDIRECTS = 3; const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ "authorization", "proxy-authorization", "cookie", "cookie2" ]; function isRedirectStatus(status) { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } function stripSensitiveHeadersForCrossOriginRedirect(init) { if (!init?.headers) return init; const headers = new Headers(init.headers); for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) headers.delete(header); return { ...init, headers }; } function buildAbortSignal(params) { const { timeoutMs, signal } = params; if (!timeoutMs && !signal) return { signal: void 0, cleanup: () => {} }; if (!timeoutMs) return { signal, cleanup: () => {} }; const controller = new AbortController(); const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs); const onAbort = bindAbortRelay(controller); if (signal) if (signal.aborted) controller.abort(); else signal.addEventListener("abort", onAbort, { once: true }); const cleanup = () => { clearTimeout(timeoutId); if (signal) signal.removeEventListener("abort", onAbort); }; return { signal: controller.signal, cleanup }; } async function fetchWithSsrFGuard(params) { const fetcher = params.fetchImpl ?? globalThis.fetch; if (!fetcher) throw new Error("fetch is not available"); const maxRedirects = typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects) ? Math.max(0, Math.floor(params.maxRedirects)) : DEFAULT_MAX_REDIRECTS; const { signal, cleanup } = buildAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal }); let released = false; const release = async (dispatcher) => { if (released) return; released = true; cleanup(); await closeDispatcher(dispatcher ?? void 0); }; const visited = /* @__PURE__ */ new Set(); let currentUrl = params.url; let currentInit = params.init ? { ...params.init } : void 0; let redirectCount = 0; while (true) { let parsedUrl; try { parsedUrl = new URL(currentUrl); } catch { await release(); throw new Error("Invalid URL: must be http or https"); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { await release(); throw new Error("Invalid URL: must be http or https"); } let dispatcher = null; try { const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, { lookupFn: params.lookupFn, policy: params.policy }); if (params.pinDns !== false) dispatcher = createPinnedDispatcher(pinned); const init = { ...currentInit ? { ...currentInit } : {}, redirect: "manual", ...dispatcher ? { dispatcher } : {}, ...signal ? { signal } : {} }; const response = await fetcher(parsedUrl.toString(), init); if (isRedirectStatus(response.status)) { const location = response.headers.get("location"); if (!location) { await release(dispatcher); throw new Error(`Redirect missing location header (${response.status})`); } redirectCount += 1; if (redirectCount > maxRedirects) { await release(dispatcher); throw new Error(`Too many redirects (limit: ${maxRedirects})`); } const nextParsedUrl = new URL(location, parsedUrl); const nextUrl = nextParsedUrl.toString(); if (visited.has(nextUrl)) { await release(dispatcher); throw new Error("Redirect loop detected"); } if (nextParsedUrl.origin !== parsedUrl.origin) currentInit = stripSensitiveHeadersForCrossOriginRedirect(currentInit); visited.add(nextUrl); response.body?.cancel(); await closeDispatcher(dispatcher); currentUrl = nextUrl; continue; } return { response, finalUrl: currentUrl, release: async () => release(dispatcher) }; } catch (err) { if (err instanceof SsrFBlockedError) logWarn(`security: blocked URL fetch (${params.auditContext ?? "url-fetch"}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`); await release(dispatcher); throw err; } } } //#endregion //#region src/media/read-response-with-limit.ts async function readResponseWithLimit(res, maxBytes, opts) { const onOverflow = opts?.onOverflow ?? ((params) => /* @__PURE__ */ new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); const body = res.body; if (!body || typeof body.getReader !== "function") { const fallback = Buffer.from(await res.arrayBuffer()); if (fallback.length > maxBytes) throw onOverflow({ size: fallback.length, maxBytes, res }); return fallback; } const reader = body.getReader(); const chunks = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value?.length) { total += value.length; if (total > maxBytes) { try { await reader.cancel(); } catch {} throw onOverflow({ size: total, maxBytes, res }); } chunks.push(value); } } } finally { try { reader.releaseLock(); } catch {} } return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); } //#endregion //#region src/media/fetch.ts var MediaFetchError = class extends Error { constructor(code, message) { super(message); this.code = code; this.name = "MediaFetchError"; } }; function stripQuotes(value) { return value.replace(/^["']|["']$/g, ""); } function parseContentDispositionFileName(header) { if (!header) return; const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header); if (starMatch?.[1]) { const cleaned = stripQuotes(starMatch[1].trim()); const encoded = cleaned.split("''").slice(1).join("''") || cleaned; try { return path.basename(decodeURIComponent(encoded)); } catch { return path.basename(encoded); } } const match = /filename\s*=\s*([^;]+)/i.exec(header); if (match?.[1]) return path.basename(stripQuotes(match[1].trim())); } async function readErrorBodySnippet(res, maxChars = 200) { try { const text = await res.text(); if (!text) return; const collapsed = text.replace(/\s+/g, " ").trim(); if (!collapsed) return; if (collapsed.length <= maxChars) return collapsed; return `${collapsed.slice(0, maxChars)}…`; } catch { return; } } async function fetchRemoteMedia(options) { const { url, fetchImpl, filePathHint, maxBytes, maxRedirects, ssrfPolicy, lookupFn } = options; let res; let finalUrl = url; let release = null; try { const result = await fetchWithSsrFGuard({ url, fetchImpl, maxRedirects, policy: ssrfPolicy, lookupFn }); res = result.response; finalUrl = result.finalUrl; release = result.release; } catch (err) { throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`); } try { if (!res.ok) { const statusText = res.statusText ? ` ${res.statusText}` : ""; const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : ""; let detail = `HTTP ${res.status}${statusText}`; if (!res.body) detail = `HTTP ${res.status}${statusText}; empty response body`; else { const snippet = await readErrorBodySnippet(res); if (snippet) detail += `; body: ${snippet}`; } throw new MediaFetchError("http_error", `Failed to fetch media from ${url}${redirected}: ${detail}`); } const contentLength = res.headers.get("content-length"); if (maxBytes && contentLength) { const length = Number(contentLength); if (Number.isFinite(length) && length > maxBytes) throw new MediaFetchError("max_bytes", `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`); } const buffer = maxBytes ? await readResponseWithLimit(res, maxBytes, { onOverflow: ({ maxBytes, res }) => new MediaFetchError("max_bytes", `Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`) }) : Buffer.from(await res.arrayBuffer()); let fileNameFromUrl; try { const parsed = new URL(finalUrl); fileNameFromUrl = path.basename(parsed.pathname) || void 0; } catch {} const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition")); let fileName = headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : void 0); const filePathForMime = headerFileName && path.extname(headerFileName) ? headerFileName : filePathHint ?? finalUrl; const contentType = await detectMime({ buffer, headerMime: res.headers.get("content-type"), filePath: filePathForMime }); if (fileName && !path.extname(fileName) && contentType) { const ext = extensionForMime(contentType); if (ext) fileName = `${fileName}${ext}`; } return { buffer, contentType: contentType ?? void 0, fileName }; } finally { if (release) await release(); } } //#endregion //#region src/media/local-roots.ts function buildMediaLocalRoots(stateDir) { const resolvedStateDir = path.resolve(stateDir); return [ resolvePreferredOpenClawTmpDir(), path.join(resolvedStateDir, "media"), path.join(resolvedStateDir, "agents"), path.join(resolvedStateDir, "workspace"), path.join(resolvedStateDir, "sandboxes") ]; } function getDefaultMediaLocalRoots() { return buildMediaLocalRoots(resolveStateDir()); } function getAgentScopedMediaLocalRoots(cfg, agentId) { const roots = buildMediaLocalRoots(resolveStateDir()); if (!agentId?.trim()) return roots; const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); if (!workspaceDir) return roots; const normalizedWorkspaceDir = path.resolve(workspaceDir); if (!roots.includes(normalizedWorkspaceDir)) roots.push(normalizedWorkspaceDir); return roots; } //#endregion export { readResponseWithLimit as a, fetchRemoteMedia as i, getDefaultMediaLocalRoots as n, fetchWithSsrFGuard as o, MediaFetchError as r, getAgentScopedMediaLocalRoots as t };