diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 22cf2cbe20..1f19ed29f9 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -2,6 +2,7 @@ import { Buffer, isUtf8 } from 'buffer' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' +import JSZip from 'jszip' import { type NextRequest, NextResponse } from 'next/server' import { fileManageContract } from '@/lib/api/contracts/tools/file' import { parseRequest } from '@/lib/api/server' @@ -133,6 +134,98 @@ const MAX_GET_CONTENT_FILE_BYTES = 64 * 1024 * 1024 /** Combined extracted-text cap so the content array stays within the large-value-ref ceiling. */ const MAX_GET_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024 +/** Per-file download cap for the compress operation. */ +const MAX_COMPRESS_FILE_BYTES = 100 * 1024 * 1024 +/** Combined input cap for the compress operation to bound in-memory archiving. */ +const MAX_COMPRESS_TOTAL_BYTES = 100 * 1024 * 1024 + +/** Ensure an archive name ends with a single `.zip` extension. */ +const ensureZipExtension = (name: string): string => + name.toLowerCase().endsWith('.zip') ? name : `${name}.zip` + +/** Strip the trailing extension from a file name (e.g., "report.pdf" -> "report"). */ +const stripExtension = (name: string): string => { + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(0, dot) : name +} + +/** + * Reduce an arbitrary name to a safe, flat file name: takes the final path + * segment, drops directory and traversal components, and falls back when the + * result would be empty or a dot segment. Used for zip entry names and the + * compress archive name so untrusted input cannot introduce nested or + * zip-slip-style paths. + */ +const toFlatFileName = (name: string, fallback: string): string => { + const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim() + if (!leaf || leaf === '.' || leaf === '..') return fallback + return leaf +} + +/** + * Return a zip entry name unique within `usedNames`, appending a numeric suffix + * before the extension on collision (e.g., "data.csv" -> "data (1).csv"). + */ +const uniqueZipEntryName = (name: string, usedNames: Set): string => { + if (!usedNames.has(name)) { + usedNames.add(name) + return name + } + + const dot = name.lastIndexOf('.') + const base = dot > 0 ? name.slice(0, dot) : name + const ext = dot > 0 ? name.slice(dot) : '' + let counter = 1 + let candidate = `${base} (${counter})${ext}` + while (usedNames.has(candidate)) { + counter += 1 + candidate = `${base} (${counter})${ext}` + } + usedNames.add(candidate) + return candidate +} + +/** Input archive download cap for the decompress operation. */ +const MAX_DECOMPRESS_ARCHIVE_BYTES = 100 * 1024 * 1024 +/** Maximum number of entries extracted from a single archive. */ +const MAX_DECOMPRESS_ENTRIES = 1000 +/** Maximum uncompressed size for any single archive entry. */ +const MAX_DECOMPRESS_ENTRY_BYTES = 100 * 1024 * 1024 +/** Maximum total uncompressed size across all entries, to bound zip-bomb expansion. */ +const MAX_DECOMPRESS_TOTAL_BYTES = 200 * 1024 * 1024 + +const S_IFMT = 0o170000 +const S_IFLNK = 0o120000 + +/** Read a zip entry's declared uncompressed size without materializing it (zip-bomb pre-check). */ +const readEntryUncompressedSize = (entry: JSZip.JSZipObject): number | undefined => { + const data = (entry as JSZip.JSZipObject & { _data?: { uncompressedSize?: number } })._data + const size = data?.uncompressedSize + return typeof size === 'number' && Number.isFinite(size) ? size : undefined +} + +/** True when a zip entry's unix mode marks it as a symlink (never extracted). */ +const isSymlinkEntry = (entry: JSZip.JSZipObject): boolean => { + const mode = (entry as JSZip.JSZipObject & { unixPermissions?: number | null }).unixPermissions + return typeof mode === 'number' && (mode & S_IFMT) === S_IFLNK +} + +/** + * Normalize a zip entry path into safe workspace folder segments, guarding against + * zip-slip. Returns null for traversal (`..`), so the entry is skipped rather than + * written outside its intended location. + */ +const sanitizeArchiveEntryPath = (rawPath: string): string[] | null => { + const segments = rawPath + .replace(/\\/g, '/') + .split('/') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== '.') + + if (segments.length === 0 || segments.includes('..')) return null + return segments +} + const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0) /** @@ -462,6 +555,299 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await releaseLock(lockKey, lockValue) } } + + case 'compress': { + const { fileId, fileInput, archiveName } = body + const requestId = generateRequestId() + + const selectedFileIds = Array.isArray(fileId) + ? fileId.map((id) => id.trim()).filter(Boolean) + : fileId + ? normalizeFileIdList(fileId) + : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const workspaceFiles = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const userFiles: UserFile[] = workspaceFiles + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles) + + const zip = new JSZip() + const usedNames = new Set() + let totalBytes = 0 + for (const userFile of userFiles) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied + + const buffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_COMPRESS_FILE_BYTES, + }) + totalBytes += buffer.length + if (totalBytes > MAX_COMPRESS_TOTAL_BYTES) { + return NextResponse.json( + { + success: false, + error: `Combined input is too large to compress. Maximum is ${ + MAX_COMPRESS_TOTAL_BYTES / (1024 * 1024) + } MB.`, + }, + { status: 413 } + ) + } + zip.file(uniqueZipEntryName(toFlatFileName(userFile.name, 'file'), usedNames), buffer) + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }) + + const requestedName = typeof archiveName === 'string' ? archiveName.trim() : '' + const baseName = requestedName + ? toFlatFileName(requestedName, 'archive') + : userFiles.length === 1 + ? stripExtension(toFlatFileName(userFiles[0].name, 'archive')) + : 'archive' + const leafName = ensureZipExtension(baseName) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: [], + }) + const result = await uploadWorkspaceFile( + workspaceId, + userId, + zipBuffer, + leafName, + 'application/zip', + { folderId } + ) + + const compressedFile: UserFile = { + ...result, + url: ensureAbsoluteUrl(result.url), + size: zipBuffer.length, + } + + logger.info('Files compressed', { + fileId: result.id, + name: result.name, + fileCount: userFiles.length, + size: zipBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: compressedFile.id, + name: compressedFile.name, + size: compressedFile.size, + url: compressedFile.url, + files: [compressedFile], + }, + }) + } + + case 'decompress': { + const { fileId, fileInput } = body + const requestId = generateRequestId() + + const selectedFileIds = fileId ? [fileId] : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + if (selectedFileIds.length + selectedInputFiles.length > 1) { + return NextResponse.json( + { success: false, error: 'Decompress accepts a single .zip archive at a time' }, + { status: 400 } + ) + } + + const workspaceFiles = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const archive = workspaceFiles + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles)[0] + + if (!archive) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const denied = await assertToolFileAccess(archive.key, userId, requestId, logger) + if (denied) return denied + + const archiveBuffer = await downloadFileFromStorage(archive, requestId, logger, { + maxBytes: MAX_DECOMPRESS_ARCHIVE_BYTES, + }) + + let zip: JSZip + try { + zip = await JSZip.loadAsync(archiveBuffer) + } catch { + return NextResponse.json( + { success: false, error: `"${archive.name}" is not a valid .zip archive` }, + { status: 400 } + ) + } + + const entries = Object.values(zip.files).filter( + (entry) => !entry.dir && !isSymlinkEntry(entry) + ) + if (entries.length > MAX_DECOMPRESS_ENTRIES) { + return NextResponse.json( + { + success: false, + error: `Archive has too many entries to extract. Maximum is ${MAX_DECOMPRESS_ENTRIES}.`, + }, + { status: 413 } + ) + } + + const entryTooLargeResponse = (name: string) => + NextResponse.json( + { + success: false, + error: `Archive entry "${name}" is too large to extract. Maximum is ${ + MAX_DECOMPRESS_ENTRY_BYTES / (1024 * 1024) + } MB per file.`, + }, + { status: 413 } + ) + const totalTooLargeResponse = () => + NextResponse.json( + { + success: false, + error: `Archive expands to more than the ${ + MAX_DECOMPRESS_TOTAL_BYTES / (1024 * 1024) + } MB extraction limit.`, + }, + { status: 413 } + ) + + // Resolve which entries are safe to extract first, so unsafe entries + // (skipped below) never count toward the size caps. + const safeEntries: Array<{ entry: JSZip.JSZipObject; segments: string[] }> = [] + let skippedCount = 0 + for (const entry of entries) { + const segments = sanitizeArchiveEntryPath(entry.name) + if (!segments) { + skippedCount += 1 + logger.warn('Skipping unsafe archive entry', { name: entry.name }) + continue + } + safeEntries.push({ entry, segments }) + } + + // Reject standard zip bombs up front using the declared uncompressed sizes, + // before materializing any entry into memory. + let declaredTotal = 0 + for (const { entry } of safeEntries) { + const declaredSize = readEntryUncompressedSize(entry) + if (declaredSize === undefined) continue + if (declaredSize > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) + declaredTotal += declaredSize + if (declaredTotal > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() + } + + // Read and validate every safe entry before writing anything, so a cap + // breach never leaves partially-extracted files behind in the workspace. + const pending: Array<{ segments: string[]; buffer: Buffer }> = [] + let totalBytes = 0 + for (const { entry, segments } of safeEntries) { + const buffer = await entry.async('nodebuffer') + // Enforce the per-entry cap on the materialized size too, covering + // entries that omit a declared uncompressed size. + if (buffer.length > MAX_DECOMPRESS_ENTRY_BYTES) return entryTooLargeResponse(entry.name) + totalBytes += buffer.length + if (totalBytes > MAX_DECOMPRESS_TOTAL_BYTES) return totalTooLargeResponse() + + pending.push({ segments, buffer }) + } + + if (pending.length === 0) { + return NextResponse.json( + { + success: false, + error: `No files could be extracted from "${archive.name}".`, + }, + { status: 422 } + ) + } + + const folderIdCache = new Map() + const extractedFiles: UserFile[] = [] + for (const { segments, buffer } of pending) { + const leafName = segments[segments.length - 1] + const folderSegments = segments.slice(0, -1) + const folderKey = folderSegments.join('/') + let folderId = folderIdCache.get(folderKey) + if (folderId === undefined) { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + folderIdCache.set(folderKey, folderId) + } + + const mimeType = getMimeTypeFromExtension(getFileExtension(leafName)) + const uploaded = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + leafName, + mimeType, + { folderId } + ) + extractedFiles.push({ ...uploaded, url: ensureAbsoluteUrl(uploaded.url) }) + } + + logger.info('Archive decompressed', { + fileId: archive.id, + name: archive.name, + extractedCount: extractedFiles.length, + skippedCount, + }) + + return NextResponse.json({ + success: true, + data: { + files: extractedFiles, + }, + }) + } } } catch (error) { if (isWorkspaceAccessDeniedError(error)) { diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 79dce03022..9799ef72eb 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -172,7 +172,11 @@ describe.concurrent('Blocks Module', () => { 'file_fetch', 'file_write', 'file_append', + 'file_compress', + 'file_decompress', ]) + expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress') + expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress') expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content') diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index b315411c61..541192bfe2 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -822,9 +822,9 @@ export const FileV5Block: BlockConfig = { ...FileV4Block, type: 'file_v5', name: 'File', - description: 'Read, get content, fetch, write, and append files', + description: 'Read, get content, fetch, write, append, compress, and decompress files', longDescription: - 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.', + 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, or extract a .zip archive into the workspace.', hideFromToolbar: false, bestPractices: ` - Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments). @@ -833,6 +833,8 @@ export const FileV5Block: BlockConfig = { - Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. - Use Write to create a new workspace file and Append to add content to an existing one. + - Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output. + - Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks. `, subBlocks: [ { @@ -845,6 +847,8 @@ export const FileV5Block: BlockConfig = { { label: 'Fetch', id: 'file_fetch' }, { label: 'Write', id: 'file_write' }, { label: 'Append', id: 'file_append' }, + { label: 'Compress', id: 'file_compress' }, + { label: 'Decompress', id: 'file_decompress' }, ], value: () => 'file_read', }, @@ -962,9 +966,67 @@ export const FileV5Block: BlockConfig = { condition: { field: 'operation', value: 'file_append' }, required: { field: 'operation', value: 'file_append' }, }, + { + id: 'compressFile', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'compressInput', + acceptedTypes: '*', + placeholder: 'Select workspace files', + multiple: true, + mode: 'basic', + condition: { field: 'operation', value: 'file_compress' }, + required: { field: 'operation', value: 'file_compress' }, + }, + { + id: 'compressFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'compressInput', + placeholder: 'Workspace file ID or JSON array of IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'file_compress' }, + required: { field: 'operation', value: 'file_compress' }, + }, + { + id: 'archiveName', + title: 'Archive Name', + type: 'short-input' as SubBlockType, + placeholder: 'archive.zip (auto-named from source if omitted)', + condition: { field: 'operation', value: 'file_compress' }, + }, + { + id: 'decompressFile', + title: 'Archive', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'decompressInput', + acceptedTypes: '.zip', + placeholder: 'Select a .zip archive', + mode: 'basic', + condition: { field: 'operation', value: 'file_decompress' }, + required: { field: 'operation', value: 'file_decompress' }, + }, + { + id: 'decompressFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'decompressInput', + placeholder: 'Workspace file ID of the .zip archive', + mode: 'advanced', + condition: { field: 'operation', value: 'file_decompress' }, + required: { field: 'operation', value: 'file_decompress' }, + }, ], tools: { - access: ['file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append'], + access: [ + 'file_read', + 'file_get_content', + 'file_fetch', + 'file_write', + 'file_append', + 'file_compress', + 'file_decompress', + ], config: { tool: (params) => params.operation || 'file_read', params: (params) => { @@ -1005,6 +1067,70 @@ export const FileV5Block: BlockConfig = { } } + if (operation === 'file_compress') { + const compressInput = params.compressInput + if (!compressInput) { + throw new Error('File is required for compress') + } + + const archiveName = + typeof params.archiveName === 'string' && params.archiveName.trim() + ? params.archiveName.trim() + : undefined + + const fileIds = parseReadFileIds(compressInput) + if (fileIds) { + return { + fileId: fileIds, + archiveName, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(compressInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for compress') + } + + return { + fileInput: normalized, + archiveName, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_decompress') { + const decompressInput = params.decompressInput + if (!decompressInput) { + throw new Error('File is required for decompress') + } + + const fileIds = parseReadFileIds(decompressInput) + if (fileIds) { + const ids = Array.isArray(fileIds) ? fileIds : [fileIds] + if (ids.length > 1) { + throw new Error('Decompress accepts a single .zip archive at a time') + } + return { + fileId: ids[0], + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(decompressInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for decompress') + } + if (normalized.length > 1) { + throw new Error('Decompress accepts a single .zip archive at a time') + } + + return { + fileInput: normalized[0], + workspaceId: params._context?.workspaceId, + } + } + if (operation === 'file_fetch') { const fileUrl = resolveHttpFileUrl(params.fileUrl) @@ -1089,11 +1215,21 @@ export const FileV5Block: BlockConfig = { contentType: { type: 'string', description: 'MIME content type for write' }, appendFileInput: { type: 'json', description: 'File to append to' }, appendContent: { type: 'string', description: 'Content to append to file' }, + compressInput: { + type: 'json', + description: 'Selected workspace files or canonical file IDs to compress', + }, + archiveName: { type: 'string', description: 'Name for the compressed .zip archive' }, + decompressInput: { + type: 'json', + description: 'Selected .zip archive or canonical file ID to extract', + }, }, outputs: { files: { type: 'file[]', - description: 'Workspace file objects (read) or fetched file objects (fetch)', + description: + 'Workspace file objects (read), fetched file objects (fetch), the compressed archive (compress), or extracted files (decompress)', }, contents: { type: 'array', diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 1a151342e3..0b7a439615 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -64,6 +64,29 @@ export const fileManageContentBodySchema = z message: 'Either fileId or fileInput is required for content operation', }) +export const fileManageCompressBodySchema = z + .object({ + operation: z.literal('compress'), + workspaceId: z.string().min(1).optional(), + fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), + fileInput: z.unknown().optional(), + archiveName: z.string().min(1).max(255).optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for compress operation', + }) + +export const fileManageDecompressBodySchema = z + .object({ + operation: z.literal('decompress'), + workspaceId: z.string().min(1).optional(), + fileId: z.string().min(1).optional(), + fileInput: z.unknown().optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for decompress operation', + }) + export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, @@ -71,6 +94,8 @@ export const fileManageBodySchema = z.union([ fileManageMoveBodySchema, fileManageReadBodySchema, fileManageContentBodySchema, + fileManageCompressBodySchema, + fileManageDecompressBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index b275845928..0fd254f2e2 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -241,6 +241,10 @@ const EXTENSION_TO_MIME: Record = { yml: 'application/x-yaml', rtf: 'application/rtf', + // Archives + zip: 'application/zip', + gz: 'application/gzip', + // Code / plain-text source py: 'text/x-python', js: 'text/javascript', diff --git a/apps/sim/tools/file/compress.test.ts b/apps/sim/tools/file/compress.test.ts new file mode 100644 index 0000000000..318b85bccf --- /dev/null +++ b/apps/sim/tools/file/compress.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { fileCompressTool, fileDecompressTool } from '@/tools/file/compress' + +describe('fileCompressTool', () => { + it('builds a compress request body from file IDs and archive name', () => { + const body = fileCompressTool.request.body?.({ + fileId: ['wf_a', 'wf_b'], + archiveName: 'documents.zip', + _context: { workspaceId: 'ws_1' }, + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'compress', + fileId: ['wf_a', 'wf_b'], + archiveName: 'documents.zip', + workspaceId: 'ws_1', + }) + }) + + it('forwards a selected file object when no IDs are provided', () => { + const fileInput = { id: 'wf_c', name: 'report.pdf' } + const body = fileCompressTool.request.body?.({ + fileInput, + workspaceId: 'ws_2', + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'compress', + fileInput, + workspaceId: 'ws_2', + }) + }) + + it('returns the compressed archive on success', async () => { + const archive = { + id: 'wf_zip', + name: 'archive.zip', + size: 1024, + url: 'https://example.com/archive.zip', + type: 'application/zip', + key: 'workspace/ws_1/archive.zip', + } + + const result = await fileCompressTool.transformResponse?.( + Response.json({ + success: true, + data: { + id: archive.id, + name: archive.name, + size: archive.size, + url: archive.url, + files: [archive], + }, + }) + ) + + expect(result).toMatchObject({ + success: true, + output: { id: 'wf_zip', name: 'archive.zip', size: 1024, files: [archive] }, + }) + }) + + it('propagates route failures as tool failures', async () => { + const result = await fileCompressTool.transformResponse?.( + Response.json({ success: false, error: 'Combined input is too large to compress.' }) + ) + + expect(result).toMatchObject({ + success: false, + error: 'Combined input is too large to compress.', + output: {}, + }) + }) +}) + +describe('fileDecompressTool', () => { + it('builds a decompress request body from a file ID', () => { + const body = fileDecompressTool.request.body?.({ + fileId: 'wf_zip', + _context: { workspaceId: 'ws_1' }, + } as Parameters>[0]) + + expect(body).toMatchObject({ + operation: 'decompress', + fileId: 'wf_zip', + workspaceId: 'ws_1', + }) + }) + + it('returns the extracted files on success', async () => { + const extracted = [ + { id: 'wf_a', name: 'a.txt', url: 'https://example.com/a.txt', key: 'k/a.txt' }, + { id: 'wf_b', name: 'b.txt', url: 'https://example.com/b.txt', key: 'k/b.txt' }, + ] + + const result = await fileDecompressTool.transformResponse?.( + Response.json({ success: true, data: { files: extracted } }) + ) + + expect(result).toMatchObject({ + success: true, + output: { files: extracted }, + }) + }) + + it('propagates route failures as tool failures', async () => { + const result = await fileDecompressTool.transformResponse?.( + Response.json({ success: false, error: '"data.txt" is not a valid .zip archive' }) + ) + + expect(result).toMatchObject({ + success: false, + error: '"data.txt" is not a valid .zip archive', + output: {}, + }) + }) +}) diff --git a/apps/sim/tools/file/compress.ts b/apps/sim/tools/file/compress.ts new file mode 100644 index 0000000000..3dc28ef58d --- /dev/null +++ b/apps/sim/tools/file/compress.ts @@ -0,0 +1,125 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileCompressParams { + fileId?: string | string[] + fileInput?: unknown + archiveName?: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileCompressTool: ToolConfig = { + id: 'file_compress', + name: 'File Compress', + description: + 'Compress one or more workspace files into a single .zip archive stored in the workspace, for bundling files to download, transfer, or store.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID, or an array of canonical workspace file IDs.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object, or an array of file objects.', + }, + archiveName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Name for the .zip archive (e.g., "documents.zip"). Defaults to the source file name when compressing a single file, otherwise "archive.zip".', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'compress', + fileId: params.fileId, + fileInput: params.fileInput, + archiveName: params.archiveName, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to compress files' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'Compressed archive file ID' }, + name: { type: 'string', description: 'Compressed archive file name' }, + size: { type: 'number', description: 'Compressed archive size in bytes' }, + url: { type: 'string', description: 'URL to access the compressed archive', optional: true }, + files: { + type: 'file[]', + description: 'Compressed archive file object, as a single-item array', + }, + }, +} + +interface FileDecompressParams { + fileId?: string + fileInput?: unknown + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileDecompressTool: ToolConfig = { + id: 'file_decompress', + name: 'File Decompress', + description: + 'Extract the contents of a .zip archive into the workspace, preserving the archive folder structure.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID of the .zip archive to extract.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected .zip archive file object.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'decompress', + fileId: params.fileId, + fileInput: params.fileInput, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to decompress archive' } + } + return { success: true, output: data.data } + }, + + outputs: { + files: { type: 'file[]', description: 'Extracted workspace file objects' }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index c05a3a1995..853b0c8669 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -6,6 +6,7 @@ import { } from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' +export { fileCompressTool, fileDecompressTool } from '@/tools/file/compress' export { fileGetContentTool, fileGetTool, fileReadTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e7c8f70198..bb80d09d21 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -850,6 +850,8 @@ import { } from '@/tools/fathom' import { fileAppendTool, + fileCompressTool, + fileDecompressTool, fileFetchTool, fileGetContentTool, fileGetTool, @@ -3958,6 +3960,8 @@ export const tools: Record = { file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, file_append: fileAppendTool, + file_compress: fileCompressTool, + file_decompress: fileDecompressTool, file_fetch: fileFetchTool, file_get: fileGetTool, file_get_content: fileGetContentTool,