UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

229 lines (228 loc) 8.22 kB
import { f as saveMediaBuffer } from "./store-Ph57PZ9P.js"; import { r as ArchiveLimitError, t as ARCHIVE_LIMIT_ERROR_CODE, u as extractArchive } from "./archive-BfjFIxAJ.js"; import { a as wrapExternalContent } from "./external-content-CpqslxXH.js"; import "./archive-B_Dee2x6.js"; import "./media-store-4cRisQA1.js"; import "./security-runtime-Ckf0kc0h.js"; import { l as FILE_TRANSFER_SUBDIR, n as DIR_FETCH_HARD_MAX_BYTES, r as DIR_FETCH_TOOL_DESCRIPTOR, t as DIR_FETCH_DEFAULT_MAX_BYTES } from "./descriptors-Cun0f4Hd.js"; import { r as mimeFromExtension, t as IMAGE_MIME_INLINE_SET } from "./mime-CLTFW5pb.js"; import { t as appendFileTransferAudit } from "./audit-Cw95_l64.js"; import { i as readClampedInt, n as readRequiredNodePath, t as invokeNodeToolPayload } from "./node-tool-invoke-CBLfLEy7.js"; import path from "node:path"; import fs from "node:fs/promises"; import crypto from "node:crypto"; //#region extensions/file-transfer/src/tools/dir-fetch-tool.ts const MEDIA_URL_CAP = 25; const DIRECTORY_TEXT_MAX_BYTES = 8192; const TAR_UNPACK_TIMEOUT_MS = 6e4; const TAR_UNPACK_MAX_ENTRIES = 5e3; const DIR_FETCH_MAX_UNCOMPRESSED_BYTES = 67108864; const DIR_FETCH_MAX_SINGLE_FILE_BYTES = 16777216; function classifyArchiveFailure(error) { const reason = error instanceof Error ? error.message : String(error); if (error instanceof ArchiveLimitError && error.code !== ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT) return { auditCode: "TREE_TOO_LARGE", publicCode: "UNCOMPRESSED_TOO_LARGE", reason }; return { auditCode: "UNSAFE_ARCHIVE", publicCode: "UNSAFE_ARCHIVE", reason }; } async function computeFileSha256(filePath) { const hash = crypto.createHash("sha256"); const handle = await fs.open(filePath, "r"); try { const chunkSize = 65536; const buf = Buffer.allocUnsafe(chunkSize); while (true) { const { bytesRead } = await handle.read(buf, 0, chunkSize, null); if (bytesRead === 0) break; hash.update(buf.subarray(0, bytesRead)); } } finally { await handle.close(); } return hash.digest("hex"); } function savedDirectoryText(rootDir, files) { const visible = []; const render = () => { const manifest = JSON.stringify({ rootDir, fileCount: files.length, displayedCount: visible.length, files: visible }); const note = `${files.length - visible.length} saved files omitted from this text (byte limit or reserved path markers). All remain under rootDir; inspect them with available local file or directory capabilities.`; const wrapped = wrapExternalContent(`Fetched ${files.length} files.\n${manifest}\n${note}`, { source: "unknown" }); return wrapped.includes(manifest) && Buffer.byteLength(wrapped, "utf8") <= DIRECTORY_TEXT_MAX_BYTES ? wrapped : void 0; }; let text = render(); for (const { relPath, size } of files.toSorted((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)) { visible.push({ relPath, size }); const candidate = render(); if (!candidate) break; text = candidate; } return text ?? wrapExternalContent(`Fetched ${files.length} files. Saved paths omitted: rootDir cannot be represented safely within the 8192-byte text limit. No usable local path is shown.`, { source: "unknown" }); } /** * Walk a directory recursively, collecting file entries (skips directories). * Skips symlinks — we don't want to follow links the archive might have * carried in. Files only. */ async function walkDir(dir, rootDir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const results = []; for (const entry of entries) { const absPath = path.join(dir, entry.name); if (entry.isDirectory()) { const nested = await walkDir(absPath, rootDir); results.push(...nested); } else if (entry.isFile()) { const relPath = path.relative(rootDir, absPath); results.push({ relPath, absPath }); } } return results; } function createDirFetchTool() { return { ...DIR_FETCH_TOOL_DESCRIPTOR, execute: async (_toolCallId, args) => { const params = args; const { node, requestedPath: dirPath } = readRequiredNodePath(params); const maxBytes = readClampedInt({ input: params, key: "maxBytes", defaultValue: DIR_FETCH_DEFAULT_MAX_BYTES, hardMin: 1, hardMax: DIR_FETCH_HARD_MAX_BYTES }); const { nodeId, nodeDisplayName, payload, startedAt } = await invokeNodeToolPayload({ node, params, command: "dir.fetch", commandParams: { path: dirPath, maxBytes }, requestedPath: dirPath }); const canonicalPath = typeof payload.path === "string" ? payload.path : ""; const tarBase64 = typeof payload.tarBase64 === "string" ? payload.tarBase64 : ""; const tarBytes = typeof payload.tarBytes === "number" ? payload.tarBytes : -1; const sha256 = typeof payload.sha256 === "string" ? payload.sha256 : ""; if (!canonicalPath || !tarBase64 || tarBytes < 0 || !sha256) throw new Error("invalid dir.fetch payload (missing fields)"); const tarBuffer = Buffer.from(tarBase64, "base64"); if (tarBuffer.byteLength !== tarBytes) throw new Error(`dir.fetch size mismatch: payload says ${tarBytes} bytes, decoded ${tarBuffer.byteLength}`); if (crypto.createHash("sha256").update(tarBuffer).digest("hex") !== sha256) throw new Error("dir.fetch sha256 mismatch (integrity failure)"); const savedTar = await saveMediaBuffer(tarBuffer, "application/gzip", FILE_TRANSFER_SUBDIR, DIR_FETCH_HARD_MAX_BYTES); const tarDir = path.dirname(savedTar.path); const unpackId = `dir-fetch-${path.basename(savedTar.path, path.extname(savedTar.path))}`; const rootDir = path.join(tarDir, unpackId); await fs.mkdir(rootDir, { recursive: true, mode: 448 }); try { await extractArchive({ archivePath: savedTar.path, destDir: rootDir, kind: "tar", tarGzip: true, timeoutMs: TAR_UNPACK_TIMEOUT_MS, entryModes: "clamp", entryFilter: ({ kind }) => kind === "file" || kind === "directory" ? "extract" : "skip", onFiltered: "reject-archive", limits: { maxArchiveBytes: DIR_FETCH_HARD_MAX_BYTES, maxEntries: TAR_UNPACK_MAX_ENTRIES, maxExtractedBytes: DIR_FETCH_MAX_UNCOMPRESSED_BYTES, maxEntryBytes: DIR_FETCH_MAX_SINGLE_FILE_BYTES } }); } catch (error) { await Promise.all([fs.rm(rootDir, { recursive: true, force: true }).catch(() => void 0), fs.rm(savedTar.path, { force: true }).catch(() => void 0)]); const failure = classifyArchiveFailure(error); await appendFileTransferAudit({ op: "dir.fetch", nodeId, nodeDisplayName, requestedPath: dirPath, canonicalPath, decision: "error", errorCode: failure.auditCode, errorMessage: failure.reason, sizeBytes: tarBytes, sha256, durationMs: Date.now() - startedAt }); throw new Error(`dir.fetch ${failure.publicCode}: ${failure.reason}`, { cause: error }); } const walked = await walkDir(rootDir, rootDir); const files = []; for (const { relPath, absPath } of walked) { let size; try { size = (await fs.stat(absPath)).size; } catch { continue; } const mimeType = mimeFromExtension(relPath); const fileSha256 = await computeFileSha256(absPath); files.push({ relPath, size, mimeType, sha256: fileSha256, localPath: absPath }); } const fileCount = files.length; const imageFiles = files.filter((f) => IMAGE_MIME_INLINE_SET.has(f.mimeType)); const nonImageFiles = files.filter((f) => !IMAGE_MIME_INLINE_SET.has(f.mimeType)); const mediaUrls = [...imageFiles, ...nonImageFiles].slice(0, MEDIA_URL_CAP).map((f) => f.localPath); await appendFileTransferAudit({ op: "dir.fetch", nodeId, nodeDisplayName, requestedPath: dirPath, canonicalPath, decision: "allowed", sizeBytes: tarBytes, sha256, durationMs: Date.now() - startedAt }); return { content: [{ type: "text", text: savedDirectoryText(rootDir, files) }], details: { path: canonicalPath, rootDir, fileCount, tarBytes, sha256, files, media: { mediaUrls } } }; } }; } //#endregion export { createDirFetchTool };