UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

388 lines (387 loc) • 16.9 kB
import { t as FsSafeError, u as openLocalFileSafely } from "./fs-safe-CS1DrPTK.mjs"; import { n as isAbortError } from "./abort-signal-Z3A36sLL.mjs"; import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { r as resolveStateDir } from "./state-dir-CfAW6S2K.mjs"; import "./paths-FklbMVOH.mjs"; import { a as shouldLogVerbose, r as logVerbose } from "./globals-D2Pr1KIl.mjs"; import { t as buildRandomTempFilePath$1 } from "./temp-download-mMDiWbKA.mjs"; import { n as normalizeMediaReferenceSource } from "./inbound-media-uri-Bc4RWtcM.mjs"; import { r as mergeInboundPathRoots, t as isInboundPathAllowed } from "./inbound-path-policy-CN2LLfoX.mjs"; import { a as getSessionSafeDefaultMediaLocalRoots } from "./local-roots-CQtu4chZ.mjs"; import { n as classifyMediaReferenceSource, r as resolveInboundMediaReference } from "./media-reference-Dfnbn2bP.mjs"; import { r as readRemoteMediaBuffer, t as MediaFetchError } from "./fetch-Bb8xDoaq.mjs"; import { i as normalizeAttachmentPath, n as isImageAttachment, r as isVideoAttachment, t as isAudioAttachment } from "./attachments.normalize-BxV3g4kG.mjs"; import { n as classifyAttachmentBytes } from "./attachment-classify-CnNuBUq0.mjs"; import { realpathSync, statSync } from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { readFileHandleBounded } from "@openclaw/fs-safe/advanced"; //#region src/media-understanding/attachments.select.ts const DEFAULT_MAX_ATTACHMENTS = 1; function orderAttachments(attachments, prefer) { if (prefer === "last") return attachments.toReversed(); if (prefer === "path" || prefer === "url") { const preferred = []; const remaining = []; for (const item of attachments) (item[prefer] ? preferred : remaining).push(item); return [...preferred, ...remaining]; } return attachments; } function isAttachmentRecord(value) { if (!value || typeof value !== "object") return false; const entry = value; if (typeof entry.index !== "number") return false; if (entry.path !== void 0 && typeof entry.path !== "string") return false; if (entry.url !== void 0 && typeof entry.url !== "string") return false; if (entry.mime !== void 0 && typeof entry.mime !== "string") return false; if (entry.alreadyTranscribed !== void 0 && typeof entry.alreadyTranscribed !== "boolean") return false; return true; } /** Selects attachments for a media-understanding capability under configured ordering limits. */ function selectAttachments(params) { const { capability, attachments, policy } = params; const matches = (Array.isArray(attachments) ? attachments : []).filter((item) => { if (!isAttachmentRecord(item)) return false; if (capability === "audio" && item.alreadyTranscribed) return false; if (capability === "image") return isImageAttachment(item); if (capability === "audio") return isAudioAttachment(item); return isVideoAttachment(item); }); if (matches.length === 0) return { selected: [], droppedAttachmentIndexes: [] }; const ordered = orderAttachments(matches, policy?.prefer); const mode = policy?.mode ?? "first"; const maxAttachments = policy?.maxAttachments ?? DEFAULT_MAX_ATTACHMENTS; const limit = mode === "all" ? Math.max(1, maxAttachments) : 1; return { selected: ordered.slice(0, limit), droppedAttachmentIndexes: ordered.slice(limit).map((attachment) => attachment.index) }; } //#endregion //#region packages/media-understanding-common/src/errors.ts /** Error used when a media attachment should be skipped without failing the whole request. */ var MediaUnderstandingSkipError = class extends Error { constructor(reason, message) { super(message); this.reason = reason; this.name = "MediaUnderstandingSkipError"; } }; /** Narrow unknown errors to media-understanding skip errors. */ function isMediaUnderstandingSkipError(err) { return err instanceof MediaUnderstandingSkipError; } //#endregion //#region src/media-understanding/attachments.cache.ts const REMOTE_MEDIA_FETCH_RETRY = { attempts: 3, minDelayMs: 500, maxDelayMs: 3e3, jitter: .2 }; function maxBytesError(params) { return new MediaUnderstandingSkipError("maxBytes", `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`); } let defaultLocalPathRoots; function inboundStoreRef(url) { const value = normalizeMediaReferenceSource(url ?? ""); return value && classifyMediaReferenceSource(value).isMediaStoreUrl ? value : void 0; } /** Returns the attachment URL only when it is an HTTP(S) remote source. */ function remoteFetchUrl(url) { const value = normalizeMediaReferenceSource(url ?? ""); return value && classifyMediaReferenceSource(value).isHttpUrl ? value : void 0; } function concreteMime(mime) { const normalized = mime?.trim(); if (!normalized || normalized.endsWith("/*") || normalized === "application/octet-stream") return; return normalized; } function getDefaultLocalPathRoots() { defaultLocalPathRoots ??= mergeInboundPathRoots(getSessionSafeDefaultMediaLocalRoots()); return defaultLocalPathRoots; } function resolveUsableLocalCandidate(candidate, roots) { try { const realPath = realpathSync(candidate); const canonicalRoots = roots.map((root) => { if (root.includes("*")) return root; try { return realpathSync(root); } catch { return root; } }); return statSync(realPath).isFile() && isInboundPathAllowed({ filePath: realPath, roots: canonicalRoots }) ? candidate : void 0; } catch { return; } } /** * Lazy resolver for media-understanding attachments. * * The cache prefers allowed local paths, falls back to remote URLs when a local path is blocked * or missing, and owns any temporary files created for providers that require a filesystem path. */ var MediaAttachmentCache = class { constructor(attachments, options) { this.entries = /* @__PURE__ */ new Map(); this.stagedPaths = /* @__PURE__ */ new Set(); this.attachments = attachments; this.ssrfPolicy = options?.ssrfPolicy; this.localPathRoots = options?.includeDefaultLocalPathRoots === false ? mergeInboundPathRoots(options.localPathRoots) : mergeInboundPathRoots(options?.localPathRoots, getDefaultLocalPathRoots()); this.fallbackWorkspaceDir = options?.workspaceDir; for (const attachment of attachments) this.entries.set(attachment.index, { attachment }); } /** Returns attachment bytes, MIME hint, filename, and size within the requested byte limit. */ async getBuffer(params) { const entry = await this.ensureEntry(params.attachmentIndex); const url = remoteFetchUrl(entry.attachment.url); if (entry.bufferResult) { if (entry.bufferResult.size > params.maxBytes) throw maxBytesError(params); return entry.bufferResult; } const local = await this.withLocalFile(entry, () => this.readEntryLocalBuffer(entry, params)); if (local) return local; if (!url) throw entry.lastLocalError ?? new MediaUnderstandingSkipError("empty", `Attachment ${params.attachmentIndex + 1} has no path or URL.`); try { const fetched = await readRemoteMediaBuffer({ url, timeoutMs: params.timeoutMs, maxBytes: params.maxBytes, ssrfPolicy: this.ssrfPolicy, retry: REMOTE_MEDIA_FETCH_RETRY }); const classification = await classifyAttachmentBytes({ buffer: fetched.buffer, name: fetched.fileName ?? url, declaredMime: concreteMime(entry.attachment.mime), additionalMimeHints: [fetched.contentType] }); entry.bufferResult = { buffer: fetched.buffer, classification, mime: classification.mime, fileName: fetched.fileName ?? `media-${params.attachmentIndex + 1}`, size: fetched.buffer.length }; return entry.bufferResult; } catch (err) { if (err instanceof MediaFetchError && err.code === "max_bytes") throw maxBytesError(params); if (isAbortError(err)) throw new MediaUnderstandingSkipError("timeout", `Attachment ${params.attachmentIndex + 1} timed out while fetching.`); throw err; } } /** Reads the entry's currently resolved local file, or undefined once it is ruled out. */ async readEntryLocalBuffer(entry, params) { let opened = await this.prepareLocalFile(entry); let buffer; try { if (!entry.resolvedPath) return; if (entry.statSize !== void 0 && entry.statSize > params.maxBytes) throw maxBytesError(params); opened ??= await openLocalFileSafely({ filePath: entry.resolvedPath }); if (opened.stat.size > params.maxBytes) throw maxBytesError(params); const canonicalRoots = await this.getCanonicalLocalPathRoots(); if (!isInboundPathAllowed({ filePath: opened.realPath, roots: canonicalRoots })) throw new MediaUnderstandingSkipError("blocked", `Attachment ${params.attachmentIndex + 1} path is outside allowed roots.`); buffer = await readFileHandleBounded(opened.handle, params.maxBytes); } catch (err) { if (err instanceof FsSafeError) { if (err.code === "too-large") throw maxBytesError(params); if (err.code === "not-file" || err.code === "not-found") throw new MediaUnderstandingSkipError("empty", `Attachment ${params.attachmentIndex + 1} path is not a regular file.`); throw new MediaUnderstandingSkipError("blocked", `Attachment ${params.attachmentIndex + 1} path is outside allowed roots.`); } throw err; } finally { await opened?.handle.close().catch(() => {}); } const filePath = opened.realPath; entry.resolvedPath = filePath; const classification = await classifyAttachmentBytes({ buffer, name: filePath, declaredMime: concreteMime(entry.attachment.mime) }); entry.bufferResult = { buffer, classification, mime: classification.mime, fileName: path.basename(filePath) || `media-${params.attachmentIndex + 1}`, size: buffer.length, localPath: filePath }; return entry.bufferResult; } async withLocalFile(entry, read) { do { if (!entry.resolvedPath) continue; try { const result = await read(); if (result !== void 0) return result; } catch (err) { if (!(err instanceof MediaUnderstandingSkipError) || err.reason !== "blocked" && err.reason !== "empty") throw err; entry.lastLocalError = err; } } while (await this.activateStoreAlias(entry)); } async activateStoreAlias(entry) { if (entry.storeAliasAttempted) return false; entry.storeAliasAttempted = true; const storeRef = inboundStoreRef(entry.attachment.url); if (!storeRef) return false; const inboundReference = await resolveInboundMediaReference(storeRef).catch(() => null); if (!inboundReference || inboundReference.physicalPath === entry.resolvedPath) return false; entry.resolvedPath = inboundReference.physicalPath; entry.statSize = void 0; return true; } /** Returns a local path for providers that cannot accept buffers, creating a temp file if needed. */ async getPath(params) { const entry = await this.ensureEntry(params.attachmentIndex); const local = await this.withLocalFile(entry, async () => { await (await this.prepareLocalFile(entry))?.handle.close().catch(() => {}); const size = entry.statSize; if (entry.resolvedPath) { if (size !== void 0 && size > params.maxBytes) throw maxBytesError(params); return entry.resolvedPath; } }); if (local) return local; if (entry.tempPath) { if (entry.bufferResult && entry.bufferResult.size > params.maxBytes) throw maxBytesError(params); return entry.tempPath; } const bufferResult = await this.getBuffer(params); const extension = path.extname(bufferResult.fileName || "") || ""; const tmpPath = buildRandomTempFilePath$1({ prefix: "openclaw-media", extension }); this.stagedPaths.add(tmpPath); await fs$1.writeFile(tmpPath, bufferResult.buffer).catch(async (error) => { await this.removeStagedPath(tmpPath); throw error; }); entry.tempPath = tmpPath; return tmpPath; } /** Removes temporary files created by `getPath`; callers should run this after provider use. */ async cleanup() { const paths = [...this.stagedPaths]; for (const entry of this.entries.values()) entry.tempPath = void 0; await Promise.all(paths.map((tmpPath) => this.removeStagedPath(tmpPath))); } async removeStagedPath(tmpPath) { try { await fs$1.unlink(tmpPath); } catch (error) { if (!hasErrnoCode(error, "ENOENT")) return; } this.stagedPaths.delete(tmpPath); } /** Drops this cache's bytes after terminal file processing; earlier borrowers keep ownership. */ releaseBuffer(attachmentIndex) { const entry = this.entries.get(attachmentIndex); if (entry) entry.bufferResult = void 0; } async ensureEntry(attachmentIndex) { const existing = this.entries.get(attachmentIndex); if (existing) { if (!existing.localResolutionAttempted) { existing.resolvedPath = await this.resolveLocalPath(existing.attachment); existing.localResolutionAttempted = true; } return existing; } const attachment = this.attachments.find((item) => item.index === attachmentIndex) ?? { index: attachmentIndex }; const entry = { attachment, resolvedPath: await this.resolveLocalPath(attachment), localResolutionAttempted: true }; this.entries.set(attachmentIndex, entry); return entry; } async resolveLocalPath(attachment) { const rawPath = normalizeAttachmentPath(attachment.path); if (!rawPath) return; const inboundReference = await resolveInboundMediaReference(rawPath).catch(() => null); if (inboundReference) return inboundReference.physicalPath; const workspaceDir = attachment.workspaceDir ?? this.fallbackWorkspaceDir; if (workspaceDir) return path.resolve(workspaceDir, rawPath); if (!path.isAbsolute(rawPath)) { const usableCwdCandidate = resolveUsableLocalCandidate(path.resolve(rawPath), this.localPathRoots); if (usableCwdCandidate) return usableCwdCandidate; const usableStateCandidate = resolveUsableLocalCandidate(path.resolve(resolveStateDir(), rawPath), this.localPathRoots); if (usableStateCandidate) return usableStateCandidate; } return path.resolve(rawPath); } /** Transfers a newly validated handle to the caller; cached path metadata needs no open. */ async prepareLocalFile(entry) { if (!entry.resolvedPath) return; if (!isInboundPathAllowed({ filePath: entry.resolvedPath, roots: this.localPathRoots })) { const canonicalRoots = await this.getCanonicalLocalPathRoots(); const candidatePath = entry.resolvedPath; const canonicalPath = await fs$1.realpath(candidatePath).catch(() => candidatePath); if (!isInboundPathAllowed({ filePath: canonicalPath, roots: canonicalRoots })) { entry.resolvedPath = void 0; if (shouldLogVerbose()) logVerbose(`Blocked attachment path outside allowed roots: ${entry.attachment.path ?? entry.attachment.url ?? "(unknown)"}`); throw new MediaUnderstandingSkipError("blocked", `Attachment ${entry.attachment.index + 1} path is outside allowed roots.`); } } if (entry.statSize !== void 0) return; let opened; try { opened = await openLocalFileSafely({ filePath: entry.resolvedPath }); const canonicalRoots = await this.getCanonicalLocalPathRoots(); if (!isInboundPathAllowed({ filePath: opened.realPath, roots: canonicalRoots })) { entry.resolvedPath = void 0; if (shouldLogVerbose()) logVerbose(`Blocked canonicalized attachment path outside allowed roots: ${opened.realPath}`); throw new MediaUnderstandingSkipError("blocked", `Attachment ${entry.attachment.index + 1} path is outside allowed roots.`); } entry.resolvedPath = opened.realPath; entry.statSize = opened.stat.size; return opened; } catch (err) { await opened?.handle.close().catch(() => {}); if (err instanceof MediaUnderstandingSkipError) throw err; if (err instanceof FsSafeError) { entry.resolvedPath = void 0; if (err.code === "not-file") throw new MediaUnderstandingSkipError("empty", `Attachment ${entry.attachment.index + 1} path is not a regular file.`); if (err.code !== "not-found") throw new MediaUnderstandingSkipError("blocked", `Attachment ${entry.attachment.index + 1} path is outside allowed roots.`); } else throw new MediaUnderstandingSkipError("blocked", `Attachment ${entry.attachment.index + 1} could not be canonicalized.`); entry.resolvedPath = void 0; if (shouldLogVerbose()) logVerbose(`Failed to read attachment ${entry.attachment.index + 1}: ${String(err)}`); return; } } async getCanonicalLocalPathRoots() { if (this.canonicalLocalPathRoots) return await this.canonicalLocalPathRoots; this.canonicalLocalPathRoots = (async () => mergeInboundPathRoots(this.localPathRoots, await Promise.all(this.localPathRoots.map(async (root) => { if (root.includes("*")) return root; return await fs$1.realpath(root).catch(() => root); }))))(); return await this.canonicalLocalPathRoots; } }; //#endregion export { selectAttachments as i, MediaUnderstandingSkipError as n, isMediaUnderstandingSkipError as r, MediaAttachmentCache as t };