openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
229 lines (228 loc) • 8.22 kB
JavaScript
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 };