Download src/media-understanding/attachments.cache.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 21.1 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/media-understanding/attachments.cache.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/media-understanding/attachments.cache.ts
-
curl -L -o attachments.cache.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/media-understanding/attachments.cache.ts
21.1 kB
| // Lazy attachment cache resolves local/remote media bytes and temporary files | |
| // under local-root and SSRF policy. | |
| import { realpathSync, statSync } from "node:fs"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { | |
| classifyAttachmentBytes, | |
| type AttachmentClassification, | |
| } from "@openclaw/media-core/attachment-classify"; | |
| import { | |
| isInboundPathAllowed, | |
| mergeInboundPathRoots, | |
| } from "@openclaw/media-core/inbound-path-policy"; | |
| import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js"; | |
| import { resolveStateDir } from "../config/paths.js"; | |
| import { logVerbose, shouldLogVerbose } from "../globals.js"; | |
| import { isAbortError } from "../infra/abort-signal.js"; | |
| import { readFileHandleBounded } from "../infra/fs-safe-advanced.js"; | |
| import { FsSafeError, openLocalFileSafely, type OpenResult } from "../infra/fs-safe.js"; | |
| import type { SsrFPolicy } from "../infra/net/ssrf.js"; | |
| import { | |
| readRemoteMediaBuffer, | |
| type MediaFetchRetryOptions, | |
| MediaFetchError, | |
| } from "../media/fetch.js"; | |
| import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; | |
| import { | |
| classifyMediaReferenceSource, | |
| normalizeMediaReferenceSource, | |
| resolveInboundMediaReference, | |
| } from "../media/media-reference.js"; | |
| import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; | |
| import { normalizeAttachmentPath } from "./attachments.normalize.js"; | |
| import type { MediaAttachment } from "./types.js"; | |
| type MediaBufferResult = { | |
| buffer: Buffer; | |
| classification: AttachmentClassification; | |
| mime?: string; | |
| fileName: string; | |
| size: number; | |
| /** Set only when bytes came from an approved local read under the root policy. */ | |
| localPath?: string; | |
| }; | |
| type MediaPathResult = { | |
| path: string; | |
| cleanup?: () => Promise<void> | void; | |
| }; | |
| const REMOTE_MEDIA_FETCH_RETRY: MediaFetchRetryOptions = { | |
| attempts: 3, | |
| minDelayMs: 500, | |
| maxDelayMs: 3_000, | |
| jitter: 0.2, | |
| }; | |
| type AttachmentCacheEntry = { | |
| attachment: MediaAttachment; | |
| resolvedPath?: string; | |
| statSize?: number; | |
| bufferResult?: MediaBufferResult; | |
| tempPath?: string; | |
| tempCleanup?: () => Promise<void>; | |
| localResolutionAttempted?: boolean; | |
| storeAliasAttempted?: boolean; | |
| lastLocalError?: MediaUnderstandingSkipError; | |
| }; | |
| let defaultLocalPathRoots: readonly string[] | undefined; | |
| // A media:// URL is a local-store identity, never a remote fetch target. | |
| function inboundStoreRef(url: string | undefined): string | undefined { | |
| const value = normalizeMediaReferenceSource(url ?? ""); | |
| return value && classifyMediaReferenceSource(value).isMediaStoreUrl ? value : undefined; | |
| } | |
| /** Returns the attachment URL only when it is an HTTP(S) remote source. */ | |
| function remoteFetchUrl(url: string | undefined): string | undefined { | |
| const value = normalizeMediaReferenceSource(url ?? ""); | |
| return value && classifyMediaReferenceSource(value).isHttpUrl ? value : undefined; | |
| } | |
| function concreteMime(mime: string | undefined): string | undefined { | |
| const normalized = mime?.trim(); | |
| // octet-stream is a non-answer, not a concrete type; a generic download | |
| // header must not shadow the attachment fact's declared MIME. | |
| if (!normalized || normalized.endsWith("/*") || normalized === "application/octet-stream") { | |
| return undefined; | |
| } | |
| return normalized; | |
| } | |
| function getDefaultLocalPathRoots(): readonly string[] { | |
| // Default local roots are process-stable inbound attachment locations; merge | |
| // once and reuse for cache instances. | |
| defaultLocalPathRoots ??= mergeInboundPathRoots(getDefaultMediaLocalRoots()); | |
| return defaultLocalPathRoots; | |
| } | |
| function resolveUsableLocalCandidate( | |
| candidate: string, | |
| roots: readonly string[], | |
| ): string | undefined { | |
| 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 | |
| : undefined; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| /** Local/remote access policy used by the lazy media-understanding attachment cache. */ | |
| export type MediaAttachmentCacheOptions = { | |
| localPathRoots?: readonly string[]; | |
| includeDefaultLocalPathRoots?: boolean; | |
| ssrfPolicy?: SsrFPolicy; | |
| workspaceDir?: string; | |
| }; | |
| /** | |
| * 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. | |
| */ | |
| export class MediaAttachmentCache { | |
| private readonly entries = new Map<number, AttachmentCacheEntry>(); | |
| private readonly attachments: MediaAttachment[]; | |
| private readonly localPathRoots: readonly string[]; | |
| private readonly ssrfPolicy: SsrFPolicy | undefined; | |
| private readonly fallbackWorkspaceDir?: string; | |
| private canonicalLocalPathRoots?: Promise<readonly string[]>; | |
| constructor(attachments: MediaAttachment[], options?: MediaAttachmentCacheOptions) { | |
| 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: { | |
| attachmentIndex: number; | |
| maxBytes: number; | |
| timeoutMs: number; | |
| }): Promise<MediaBufferResult> { | |
| const entry = await this.ensureEntry(params.attachmentIndex); | |
| const url = remoteFetchUrl(entry.attachment.url); | |
| if (entry.bufferResult) { | |
| if (entry.bufferResult.size > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| return entry.bufferResult; | |
| } | |
| if (entry.resolvedPath) { | |
| try { | |
| const local = await this.readEntryLocalBuffer(entry, params); | |
| if (local) { | |
| return local; | |
| } | |
| } catch (err) { | |
| if (!this.recordRecoverableLocalError(entry, err)) { | |
| throw err; | |
| } | |
| } | |
| } | |
| if (await this.activateStoreAlias(entry)) { | |
| try { | |
| const local = await this.readEntryLocalBuffer(entry, params); | |
| if (local) { | |
| return local; | |
| } | |
| } catch (err) { | |
| if (!this.recordRecoverableLocalError(entry, err)) { | |
| throw err; | |
| } | |
| } | |
| } | |
| 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, | |
| // Channel-declared MIME leads; the transport Content-Type stays a | |
| // secondary hint so byte detection can refine stale declarations. | |
| 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 new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| 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. */ | |
| private async readEntryLocalBuffer( | |
| entry: AttachmentCacheEntry, | |
| params: { attachmentIndex: number; maxBytes: number }, | |
| ): Promise<MediaBufferResult | undefined> { | |
| let opened = await this.prepareLocalFile(entry); | |
| let buffer: Buffer; | |
| try { | |
| if (!entry.resolvedPath) { | |
| return undefined; | |
| } | |
| if (entry.statSize !== undefined && entry.statSize > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| opened ??= await openLocalFileSafely({ filePath: entry.resolvedPath }); | |
| if (opened.stat.size > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| 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 new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| 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, | |
| // Root-checked resolution the agent may be pointed at; remote-fetched | |
| // buffers never carry one so a blocked path cannot reach the prompt. | |
| localPath: filePath, | |
| }; | |
| return entry.bufferResult; | |
| } | |
| private recordRecoverableLocalError(entry: AttachmentCacheEntry, err: unknown): boolean { | |
| if ( | |
| !(err instanceof MediaUnderstandingSkipError) || | |
| (err.reason !== "blocked" && err.reason !== "empty") | |
| ) { | |
| return false; | |
| } | |
| entry.lastLocalError = err; | |
| return true; | |
| } | |
| private async activateStoreAlias(entry: AttachmentCacheEntry): Promise<boolean> { | |
| 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 = undefined; | |
| return true; | |
| } | |
| /** Returns a local path for providers that cannot accept buffers, creating a temp file if needed. */ | |
| async getPath(params: { | |
| attachmentIndex: number; | |
| maxBytes: number; | |
| timeoutMs: number; | |
| }): Promise<MediaPathResult> { | |
| const entry = await this.ensureEntry(params.attachmentIndex); | |
| if (entry.resolvedPath) { | |
| try { | |
| await (await this.prepareLocalFile(entry))?.handle.close().catch(() => {}); | |
| const size = entry.statSize; | |
| if (entry.resolvedPath && size !== undefined && size > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| } catch (err) { | |
| if (!this.recordRecoverableLocalError(entry, err)) { | |
| throw err; | |
| } | |
| } | |
| if (entry.resolvedPath) { | |
| return { path: entry.resolvedPath }; | |
| } | |
| } | |
| if (await this.activateStoreAlias(entry)) { | |
| try { | |
| await (await this.prepareLocalFile(entry))?.handle.close().catch(() => {}); | |
| const size = entry.statSize; | |
| if (entry.resolvedPath) { | |
| if (size !== undefined && size > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| return { path: entry.resolvedPath }; | |
| } | |
| } catch (err) { | |
| if (!this.recordRecoverableLocalError(entry, err)) { | |
| throw err; | |
| } | |
| } | |
| } | |
| if (entry.tempPath) { | |
| if (entry.bufferResult && entry.bufferResult.size > params.maxBytes) { | |
| throw new MediaUnderstandingSkipError( | |
| "maxBytes", | |
| `Attachment ${params.attachmentIndex + 1} exceeds maxBytes ${params.maxBytes}`, | |
| ); | |
| } | |
| return { path: entry.tempPath, cleanup: entry.tempCleanup }; | |
| } | |
| const bufferResult = await this.getBuffer(params); | |
| const extension = path.extname(bufferResult.fileName || "") || ""; | |
| const tmpPath = buildRandomTempFilePath({ | |
| prefix: "openclaw-media", | |
| extension, | |
| }); | |
| // Keep failed staging owned when model fallback retries the same attachment. | |
| const previousCleanup = entry.tempCleanup; | |
| entry.tempCleanup = async () => { | |
| // Returned cleanup callbacks may outlive a restaged file; invalidate only their path. | |
| if (entry.tempPath === tmpPath) { | |
| entry.tempPath = undefined; | |
| } | |
| await previousCleanup?.(); | |
| await fs.unlink(tmpPath).catch(() => {}); | |
| }; | |
| await fs.writeFile(tmpPath, bufferResult.buffer).catch(async (error: unknown) => { | |
| await entry.tempCleanup?.(); | |
| throw error; | |
| }); | |
| entry.tempPath = tmpPath; | |
| return { path: tmpPath, cleanup: entry.tempCleanup }; | |
| } | |
| /** Removes temporary files created by `getPath`; callers should run this after provider use. */ | |
| async cleanup(): Promise<void> { | |
| const cleanups: Promise<void>[] = []; | |
| for (const entry of this.entries.values()) { | |
| if (entry.tempCleanup) { | |
| cleanups.push(entry.tempCleanup()); | |
| entry.tempCleanup = undefined; | |
| } | |
| } | |
| await Promise.all(cleanups); | |
| } | |
| /** Drops this cache's bytes after terminal file processing; earlier borrowers keep ownership. */ | |
| releaseBuffer(attachmentIndex: number): void { | |
| const entry = this.entries.get(attachmentIndex); | |
| if (entry) { | |
| entry.bufferResult = undefined; | |
| } | |
| } | |
| private async ensureEntry(attachmentIndex: number): Promise<AttachmentCacheEntry> { | |
| 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: AttachmentCacheEntry = { | |
| attachment, | |
| resolvedPath: await this.resolveLocalPath(attachment), | |
| localResolutionAttempted: true, | |
| }; | |
| this.entries.set(attachmentIndex, entry); | |
| return entry; | |
| } | |
| private async resolveLocalPath(attachment: MediaAttachment): Promise<string | undefined> { | |
| const rawPath = normalizeAttachmentPath(attachment.path); | |
| if (!rawPath) { | |
| return undefined; | |
| } | |
| 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 cwdCandidate = path.resolve(rawPath); | |
| const usableCwdCandidate = resolveUsableLocalCandidate(cwdCandidate, this.localPathRoots); | |
| if (usableCwdCandidate) { | |
| return usableCwdCandidate; | |
| } | |
| const stateCandidate = path.resolve(resolveStateDir(), rawPath); | |
| const usableStateCandidate = resolveUsableLocalCandidate(stateCandidate, this.localPathRoots); | |
| if (usableStateCandidate) { | |
| return usableStateCandidate; | |
| } | |
| } | |
| return path.resolve(rawPath); | |
| } | |
| /** Transfers a newly validated handle to the caller; cached path metadata needs no open. */ | |
| private async prepareLocalFile(entry: AttachmentCacheEntry): Promise<OpenResult | undefined> { | |
| if (!entry.resolvedPath) { | |
| return undefined; | |
| } | |
| if (!isInboundPathAllowed({ filePath: entry.resolvedPath, roots: this.localPathRoots })) { | |
| const canonicalRoots = await this.getCanonicalLocalPathRoots(); | |
| if (!isInboundPathAllowed({ filePath: entry.resolvedPath, roots: canonicalRoots })) { | |
| entry.resolvedPath = undefined; | |
| 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 !== undefined) { | |
| return undefined; | |
| } | |
| let opened: OpenResult | undefined; | |
| try { | |
| opened = await openLocalFileSafely({ filePath: entry.resolvedPath }); | |
| const canonicalRoots = await this.getCanonicalLocalPathRoots(); | |
| if (!isInboundPathAllowed({ filePath: opened.realPath, roots: canonicalRoots })) { | |
| entry.resolvedPath = undefined; | |
| 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 = undefined; | |
| 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 = undefined; | |
| if (shouldLogVerbose()) { | |
| logVerbose(`Failed to read attachment ${entry.attachment.index + 1}: ${String(err)}`); | |
| } | |
| return undefined; | |
| } | |
| } | |
| private async getCanonicalLocalPathRoots(): Promise<readonly string[]> { | |
| 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.realpath(root).catch(() => root); | |
| }), | |
| ), | |
| ))(); | |
| return await this.canonicalLocalPathRoots; | |
| } | |
| } | |