UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

295 lines (294 loc) 9.98 kB
import { d as normalizeStringEntries } from "../string-normalization-DsCfAx8q.js"; import { n as isAbortError, t as createAbortError } from "../abort-signal-D2k14JsD.js"; import "../http-body-D3IMwTJJ.js"; import { i as readResponseWithLimit, t as cancelUnreadResponseBody } from "../http-response-body-CwT_cCNz.js"; import { i as shouldLogVerbose, r as logVerbose } from "../globals-CTaGxEqj.js"; import { r as runCommandWithTimeout } from "../exec-BIE-3oLG.js"; import { c as isBlockedHostnameOrIp } from "../ssrf-0QyXWOVG.js"; import { i as fetchWithSsrFGuard, t as GUARDED_FETCH_MODE } from "../fetch-guard-BMdGQhbb.js"; import { t as fromMarkdown } from "../lib-qHlwCPeE.js"; import { t as finalizeInboundContext } from "../inbound-context-BJXKd4x8.js"; import "../defaults-pXqEdm9c.js"; import { t as CLI_OUTPUT_MAX_BUFFER } from "../defaults.constants-Cof2g8lZ.js"; import { c as resolveTimeoutMs, s as resolveScopeDecision } from "../resolve-DyQF7rKc.js"; import { t as applyTemplate } from "../templating-DocmBuN3.js"; //#region src/link-understanding/format.ts /** Appends normalized link-understanding outputs to the agent-visible body. */ function formatLinkUnderstandingBody(params) { const outputs = normalizeStringEntries(params.outputs); if (outputs.length === 0) return params.body ?? ""; const base = (params.body ?? "").trim(); if (!base) return outputs.join("\n"); return `${base}\n\n${outputs.join("\n")}`; } //#endregion //#region packages/markdown-core/src/link-spans.ts /** Returns parser-owned source spans for inline links, autolinks, and images. */ function findMarkdownLinkSourceSpans(markdown) { const spans = []; const pending = [fromMarkdown(markdown)]; while (pending.length > 0) { const node = pending.pop(); if (!node) continue; if (node.type === "link" || node.type === "image") { const start = node.position?.start?.offset; const end = node.position?.end?.offset; if (start !== void 0 && end !== void 0) spans.push([start, end]); continue; } for (const child of node.children ?? []) pending.push(child); } return spans.toSorted((left, right) => left[0] - right[0]); } //#endregion //#region src/link-understanding/detect.ts const BARE_LINK_RE = /https?:\/\/\S+/gi; function stripMarkdownLinks(message) { const chunks = []; let cursor = 0; for (const [start, end] of findMarkdownLinkSourceSpans(message)) { chunks.push(message.slice(cursor, start), " "); cursor = end; } chunks.push(message.slice(cursor)); return chunks.join(""); } function resolveMaxLinks(value) { if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value); return 3; } function isAllowedUrl(raw) { try { const parsed = new URL(raw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; if (isBlockedHostnameOrIp(parsed.hostname)) return false; return true; } catch { return false; } } /** * Extracts unique, SSRF-filtered bare HTTP(S) links from inbound text. * Markdown links are ignored so display-only citations do not trigger fetches. */ function extractLinksFromMessage(message, opts) { const source = message?.trim(); if (!source) return []; const maxLinks = resolveMaxLinks(opts?.maxLinks); const sanitized = stripMarkdownLinks(source); const seen = /* @__PURE__ */ new Set(); const results = []; for (const match of sanitized.matchAll(BARE_LINK_RE)) { const raw = match[0]?.trim(); if (!raw) continue; if (!isAllowedUrl(raw)) continue; if (seen.has(raw)) continue; seen.add(raw); results.push(raw); if (results.length >= maxLinks) break; } return results; } //#endregion //#region src/link-understanding/runner.ts function resolveTimeoutMsFromConfig(params) { const configured = params.entry.timeoutSeconds ?? params.config?.timeoutSeconds; return resolveTimeoutMs(configured, 30); } function resolveFetchTimeoutMsFromConfig(params) { if (params.config?.timeoutSeconds != null) return resolveTimeoutMs(params.config.timeoutSeconds, 30); return Math.max(...params.entries.map((entry) => resolveTimeoutMsFromConfig({ config: params.config, entry }))); } function isLinkUrlTemplate(value) { return value.includes("LinkUrl") || value.includes("LinkFinalUrl"); } function commandName(command) { return (command.split(/[\\/]/).pop() ?? command).toLowerCase(); } function isUrlFetcherCommand(command) { return commandName(command) === "curl" || commandName(command) === "wget"; } function buildLinkCliArgs(params) { const templCtx = { ...params.ctx, LinkFinalUrl: params.finalUrl, LinkUrl: params.url }; return params.args.filter((arg) => !isLinkUrlTemplate(arg)).map((arg) => applyTemplate(arg, templCtx)); } async function fetchLinkContent(params) { const { response, finalUrl, release } = await fetchWithSsrFGuard({ url: params.url, timeoutMs: params.timeoutMs, mode: GUARDED_FETCH_MODE.STRICT, auditContext: "link-understanding", signal: params.signal, init: { headers: { Accept: "text/*,application/json,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "User-Agent": "OpenClaw-LinkUnderstanding/1.0" } } }); try { if (!response.ok) { cancelUnreadResponseBody(response); throw new Error(`Link fetch failed with HTTP ${response.status}`); } const buffer = await readResponseWithLimit(response, CLI_OUTPUT_MAX_BUFFER); const content = new TextDecoder().decode(buffer).trim(); if (!content) return null; return { content, finalUrl }; } finally { await release(); } } async function runCliEntry(params) { if ((params.entry.type ?? "cli") !== "cli") return null; const command = params.entry.command.trim(); if (!command) return null; const args = params.entry.args ?? []; const timeoutMs = resolveTimeoutMsFromConfig({ config: params.config, entry: params.entry }); if (isUrlFetcherCommand(command) && args.some(isLinkUrlTemplate)) return params.content; const argv = [command, ...buildLinkCliArgs({ args, ctx: params.ctx, finalUrl: params.finalUrl, url: params.url })]; if (shouldLogVerbose()) logVerbose(`Link understanding via CLI: ${argv.join(" ")}`); const result = await runCommandWithTimeout(argv, { timeoutMs, input: params.content, signal: params.signal, killProcessTree: true, env: { OPENCLAW_LINK_FINAL_URL: params.finalUrl, OPENCLAW_LINK_URL: params.url } }); if (params.signal?.aborted) throw createAbortError("Link understanding command aborted", { cause: params.signal.reason }); if (result.code !== 0) throw new Error(`Link understanding command exited with code ${result.code ?? "unknown"}`); return result.stdout.trim() || null; } async function runLinkEntries(params) { let lastError; for (const entry of params.entries) { if (params.signal?.aborted) break; try { const output = await runCliEntry({ content: params.content, entry, finalUrl: params.finalUrl, ctx: params.ctx, url: params.url, config: params.config, signal: params.signal }); if (output) return output; } catch (err) { if (isAbortError(err)) throw err; lastError = err; if (shouldLogVerbose()) logVerbose(`Link understanding failed for ${params.url}: ${String(err)}`); } } if (lastError && shouldLogVerbose()) logVerbose(`Link understanding exhausted for ${params.url}`); return null; } /** * Fetches detected links through the SSRF guard and runs configured CLI processors. * Returns detected URLs even when processors are absent so callers can report discovery. */ async function runLinkUnderstanding(params) { const config = params.cfg.tools?.links; if (!config || config.enabled === false) return { urls: [], outputs: [] }; if (resolveScopeDecision({ scope: config.scope, ctx: params.ctx }) === "deny") { if (shouldLogVerbose()) logVerbose("Link understanding disabled by scope policy."); return { urls: [], outputs: [] }; } const links = extractLinksFromMessage(params.message ?? params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? "", { maxLinks: config?.maxLinks }); if (links.length === 0) return { urls: [], outputs: [] }; const entries = config?.models ?? []; if (entries.length === 0) return { urls: links, outputs: [] }; const outputs = []; const timeoutMs = resolveFetchTimeoutMsFromConfig({ config, entries }); for (const url of links) { if (params.signal?.aborted) break; let fetched; try { fetched = await fetchLinkContent({ url, timeoutMs, signal: params.signal }); } catch (err) { if (params.signal?.aborted) throw createAbortError("Link understanding fetch aborted", { cause: params.signal.reason }); if (isAbortError(err)) throw err; if (shouldLogVerbose()) logVerbose(`Link understanding fetch blocked or failed for ${url}: ${String(err)}`); continue; } if (!fetched) continue; const output = await runLinkEntries({ content: fetched.content, entries, finalUrl: fetched.finalUrl, ctx: params.ctx, url, config, signal: params.signal }) ?? fetched.content; if (params.signal?.aborted) break; if (output) outputs.push(output); } return { urls: links, outputs }; } //#endregion //#region src/link-understanding/apply.ts /** Runs link understanding and folds successful outputs into the inbound context. */ async function applyLinkUnderstanding(params) { const result = await runLinkUnderstanding({ cfg: params.cfg, ctx: params.ctx, signal: params.signal }); if (result.outputs.length === 0 || params.signal?.aborted) return result; params.ctx.LinkUnderstanding = [...params.ctx.LinkUnderstanding ?? [], ...result.outputs]; const enrich = (body) => formatLinkUnderstandingBody({ body, outputs: result.outputs }); params.ctx.agentText = enrich(params.ctx.agentText ?? params.ctx.BodyForAgent ?? params.ctx.Body); params.ctx.Body = enrich(params.ctx.Body); finalizeInboundContext(params.ctx, { forceBodyForCommands: true }); return result; } //#endregion export { applyLinkUnderstanding };