UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

281 lines (280 loc) • 12.7 kB
import { c as isRecord } from "./record-coerce-TIsquoVR.mjs"; import { a as openRootFile, s as readFileDescriptorBounded } from "./boundary-file-read-CCth2Hwg.mjs"; import { t as LruCache } from "./lru-cache-C3Xgvc4Q.mjs"; import { n as startsWithSvgRootElement } from "./svg-image-1iOdCRUI.mjs"; import { a as isPluginActivityToolName, t as PLUGIN_ACTIVITY_ICON_MAX_BYTES } from "./portable-icon-paths-BZ0z5wfc.mjs"; import { t as normalizeHostname } from "./hostname-Db1ZcSGB.mjs"; import { o as isBlockedHostnameOrIp } from "./ssrf-BUqrVNdU.mjs"; import "./image-processor-config-CVYcOkGc.mjs"; import { d as readImageMetadataFromHeader, m as createImageProcessor } from "./image-ops-C9MTaUYr.mjs"; import { f as readClawHubStringField, i as fetchClawHubJson, y as resolveClawHubImageUrl } from "./clawhub-client-3QLl6MrZ.mjs"; import { r as readRemoteMediaBuffer } from "./fetch-JqywN-Gd.mjs"; import { i as parseControlUiResourcePath } from "./control-ui-resource-routes-B42sqIG-.mjs"; import "./control-ui-contract-IX_0OA0h.mjs"; import { r as respondNotFound } from "./control-ui-http-utils-CpDIFFwm.mjs"; import { d as sendMethodNotAllowed } from "./http-common-Cr4G2eKt.mjs"; import { r as resolveClawHubCatalogIconUrl } from "./catalog-icon-registry-QbEIkWWZ.mjs"; import { a as resolveManagedPluginActivityIconSource, c as resolveManagedSetupCatalogIconUrl, o as resolveManagedPluginIconSources } from "./management-service-DPDSeGVQ.mjs"; import { t as authorizeControlUiReadRequestOrReply } from "./http-auth-utils-xEG2Jlr0.mjs"; import "./http-utils-CtzRELxw.mjs"; import { a as resolveHttpImageMimeType, i as createHttpImageRepresentation, s as sendHttpImageResponse } from "./http-image-response-BL-Tc_n7.mjs"; import { closeSync } from "node:fs"; import { isIP } from "node:net"; import pLimit from "p-limit"; import { fileTypeFromBuffer } from "file-type"; //#region src/infra/clawhub-plugin-icons.ts /** Branding needs one package read, not versions, README or security enrichment. */ async function fetchClawHubPluginIconUrls(params) { const value = await fetchClawHubJson({ ...params, path: `/api/v1/packages/${encodeURIComponent(params.packageName)}`, retryTransientReads: false }); if (!isRecord(value) || !isRecord(value.package)) throw new Error("Malformed ClawHub plugin icon response: expected a package object."); if (value.package.name !== params.packageName) throw new Error("ClawHub plugin icon response changed the requested package identity."); const owner = value.owner; if (owner != null && !isRecord(owner)) throw new Error("Malformed ClawHub plugin icon response: expected an owner object."); return [readClawHubStringField(value.package, "icon", "plugin icon"), owner ? readClawHubStringField(owner, "image", "plugin owner") : void 0].flatMap((url) => url ? [resolveClawHubImageUrl(url, params.baseUrl) ?? url] : []); } //#endregion //#region src/gateway/plugin-icon-http.ts const PLUGIN_ID_RE = /^(?:[a-z0-9][a-z0-9._-]{0,127}|@[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127})$/iu; const SVG_MIME_TYPE = "image/svg+xml"; const PLUGIN_ICON_CACHE_MAX_ENTRIES = 128; const LINK_FAVICON_MAX_BYTES = 65536; const LINK_FAVICON_NEGATIVE_CACHE_TTL_MS = 3e5; const LINK_FAVICON_MAX_OUTSTANDING_FETCHES = 32; const linkFaviconFetchLimit = pLimit(4); const PLUGIN_ICON_MAX_BYTES = 262144; const PLUGIN_ICON_MAX_REDIRECTS = 3; const PLUGIN_ICON_REQUEST_TIMEOUT_MS = 5e3; const PLUGIN_ICON_CACHE_TTL_MS = 36e5; let pluginIconCache = new LruCache(PLUGIN_ICON_CACHE_MAX_ENTRIES); const pluginIconImageProcessor = createImageProcessor(); function normalizeLinkFaviconHostname(value) { if (value.length > 253) return null; const normalized = normalizeHostname(value); if (!normalized || isIP(normalized) !== 0 || isBlockedHostnameOrIp(normalized)) return null; return URL.parse(`https://${normalized}/`)?.hostname === normalized ? normalized : null; } async function validateImageMime(body, contentType) { if (contentType === SVG_MIME_TYPE) { const text = body.toString("utf8"); return !text.includes("\0") && !/<!doctype|<!entity/iu.test(text) && startsWithSvgRootElement(text); } const detected = await fileTypeFromBuffer(body); return resolveHttpImageMimeType(detected?.mime) === contentType; } function rememberIcon(cacheKey, promise, expiresAt, retainFailureForMs) { const entry = { expiresAt, promise }; pluginIconCache.set(cacheKey, entry); return promise.then((result) => { if (!result && pluginIconCache.peek(cacheKey) === entry) { if (retainFailureForMs) entry.expiresAt = Date.now() + retainFailureForMs; else pluginIconCache.delete(cacheKey); } return result; }); } function readCachedIcon(cacheKey, now) { const cached = pluginIconCache.get(cacheKey); if (cached && cached.expiresAt > now) return cached; pluginIconCache.delete(cacheKey); } async function normalizeIconPayload(params) { const contentType = resolveHttpImageMimeType(params.contentType); if (!contentType || !await validateImageMime(params.body, contentType)) return null; if (contentType === SVG_MIME_TYPE || contentType === "image/x-icon") return createHttpImageRepresentation(params.body, contentType); const metadata = readImageMetadataFromHeader(params.body); if (!metadata || !Number.isInteger(metadata.width) || !Number.isInteger(metadata.height) || metadata.width <= 0 || metadata.height <= 0 || metadata.width > 25e6 / metadata.height) return null; const normalized = await pluginIconImageProcessor.encode(params.body, { format: "png", compressionLevel: 9, resize: { fit: "inside", maxSide: 256, enlarge: false } }); if (normalized.data.byteLength > params.maxBytes) return null; return createHttpImageRepresentation(normalized.data, "image/png"); } async function loadPackageIcon(params) { const cacheKey = `${params.cacheScope}\0file:${params.rootPath}\0${params.iconPath}`; const now = Date.now(); const cached = readCachedIcon(cacheKey, now); if (cached) return await cached.promise; return await rememberIcon(cacheKey, (async () => { const maxBytes = params.activity ? PLUGIN_ACTIVITY_ICON_MAX_BYTES : PLUGIN_ICON_MAX_BYTES; const opened = await openRootFile({ absolutePath: params.iconPath, rootPath: params.rootPath, boundaryLabel: "plugin package directory", maxBytes, rejectHardlinks: true }); if (!opened.ok) return null; try { const body = await readFileDescriptorBounded(opened.fd, maxBytes); if (body.byteLength < 1) return null; return await normalizeIconPayload({ body, contentType: params.activity ? SVG_MIME_TYPE : "image/png", maxBytes }); } catch { return null; } finally { closeSync(opened.fd); } })(), now + PLUGIN_ICON_CACHE_TTL_MS); } async function loadCatalogIcon(params) { const parsed = URL.parse(params.iconUrl); if (!parsed || parsed.protocol !== "https:" || parsed.username || parsed.password || !parsed.hostname || parsed.hash) return null; const cacheKey = `${params.cacheScope}\0${parsed.href}`; const now = Date.now(); const cached = readCachedIcon(cacheKey, now); if (cached) return await cached.promise; const load = async () => { try { const loaded = await readRemoteMediaBuffer({ url: parsed.href, maxBytes: params.maxBytes ?? 262144, maxRedirects: 3, requireHttps: params.requireHttps, timeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, responseHeaderTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, readIdleTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, requestInit: { headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml" } } }); return await normalizeIconPayload({ body: loaded.buffer, contentType: loaded.contentType, maxBytes: params.maxBytes ?? 262144 }); } catch { return null; } }; if (params.limitConcurrency && linkFaviconFetchLimit.activeCount + linkFaviconFetchLimit.pendingCount >= LINK_FAVICON_MAX_OUTSTANDING_FETCHES) return null; return await rememberIcon(cacheKey, params.limitConcurrency ? linkFaviconFetchLimit(load) : load(), now + PLUGIN_ICON_CACHE_TTL_MS, params.retainFailureForMs); } function clearPluginIconCacheForTest() { pluginIconCache = new LruCache(PLUGIN_ICON_CACHE_MAX_ENTRIES); } async function loadPluginIcon(cacheScope, sources) { const cacheKey = `${cacheScope}\0sources:${JSON.stringify(sources)}`; const cached = pluginIconCache.peek(cacheKey); if (cached && cached.expiresAt > Date.now()) return await cached.promise; return await rememberIcon(cacheKey, (async () => { for (const source of sources) if (source.kind === "file") { const icon = await loadPackageIcon({ cacheScope, iconPath: source.path, rootPath: source.rootPath }); if (icon) return icon; } else try { const urls = await fetchClawHubPluginIconUrls({ ...source, skipAuth: true, timeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS }); for (const iconUrl of urls) { const icon = await loadCatalogIcon({ cacheScope, iconUrl }); if (icon) return icon; } } catch {} return null; })(), Date.now() + PLUGIN_ICON_CACHE_TTL_MS); } async function handlePluginIconHttpRequest(req, res, opts) { const requestUrl = req.url ? new URL(req.url, "http://localhost") : void 0; const pathname = requestUrl?.pathname; const pluginRequest = parseControlUiResourcePath("pluginIcon", pathname, opts.basePath); const activityRequest = parseControlUiResourcePath("pluginActivityIcon", pathname, opts.basePath); const catalogRequest = parseControlUiResourcePath("catalogIcon", pathname, opts.basePath); const faviconRequest = parseControlUiResourcePath("linkFavicon", pathname, opts.basePath); if (!pluginRequest.matched && !activityRequest.matched && !catalogRequest.matched && !faviconRequest.matched) return false; const packageRequest = activityRequest.matched ? activityRequest : pluginRequest; const pluginId = packageRequest.matched && packageRequest.value && PLUGIN_ID_RE.test(packageRequest.value) ? packageRequest.value : null; const toolNames = activityRequest.matched ? requestUrl?.searchParams.getAll("tool") ?? [] : []; const toolName = toolNames[0]; const catalogIconUrl = catalogRequest.matched ? catalogRequest.value : null; const faviconHostname = faviconRequest.matched ? faviconRequest.value ? normalizeLinkFaviconHostname(faviconRequest.value) : null : null; const method = req.method; if (method !== "GET" && method !== "HEAD") { sendMethodNotAllowed(res, "GET, HEAD"); return true; } const requestAuth = await authorizeControlUiReadRequestOrReply({ ...opts, req, res, cfg: opts.cfg ?? opts.config }); if (!requestAuth) return true; requestAuth.assertCurrent(); if (activityRequest.matched && (toolNames.length > 1 || toolName !== void 0 && !isPluginActivityToolName(toolName))) { respondNotFound(res); return true; } if (faviconRequest.matched && opts.config.gateway?.controlUi?.automaticallyFetchFavicons === false) { respondNotFound(res); return true; } const pluginIcon = pluginId ? activityRequest.matched ? await resolveManagedPluginActivityIconSource({ config: opts.config, pluginId, toolName }) : await resolveManagedPluginIconSources({ config: opts.config, pluginId }) : void 0; requestAuth.assertCurrent(); const remoteIconUrl = catalogIconUrl ? resolveManagedSetupCatalogIconUrl({ config: opts.config, iconUrl: catalogIconUrl }) ?? resolveClawHubCatalogIconUrl(catalogIconUrl) : faviconHostname ? `https://${faviconHostname}/favicon.ico` : void 0; if (!pluginIcon && !remoteIconUrl) { respondNotFound(res); return true; } const cacheScope = pluginId ? `${activityRequest.matched ? "plugin-activity" : "plugin"}:${pluginId}` : faviconHostname ? "favicon" : "catalog"; const icon = Array.isArray(pluginIcon) ? await loadPluginIcon(cacheScope, pluginIcon) : pluginIcon ? await loadPackageIcon({ cacheScope, iconPath: pluginIcon.path, rootPath: pluginIcon.rootPath, activity: activityRequest.matched }) : await loadCatalogIcon({ cacheScope, iconUrl: remoteIconUrl, ...faviconHostname ? { maxBytes: LINK_FAVICON_MAX_BYTES, requireHttps: true, retainFailureForMs: LINK_FAVICON_NEGATIVE_CACHE_TTL_MS, limitConcurrency: true } : {} }); requestAuth.assertCurrent(); if (!icon) { respondNotFound(res); return true; } sendHttpImageResponse({ req, res, image: icon, filename: faviconHostname ? "link-favicon" : activityRequest.matched ? "plugin-activity-icon" : "plugin-icon" }); return true; } //#endregion export { LINK_FAVICON_MAX_BYTES, PLUGIN_ICON_CACHE_TTL_MS, PLUGIN_ICON_MAX_BYTES, PLUGIN_ICON_MAX_REDIRECTS, PLUGIN_ICON_REQUEST_TIMEOUT_MS, clearPluginIconCacheForTest, handlePluginIconHttpRequest };