diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 77fa9e44091..221083c728e 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1269,14 +1269,14 @@ export const Ffmpeg: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1286,6 +1286,7 @@ export const Ffmpeg: ToolCatalogEntry = { }, required: ['path'], }, + minItems: 1, }, tables: { type: 'array', @@ -1303,6 +1304,7 @@ export const Ffmpeg: ToolCatalogEntry = { }, }, }, + required: ['files'], }, loopToVideo: { type: 'boolean', @@ -1358,7 +1360,8 @@ export const Ffmpeg: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1368,8 +1371,11 @@ export const Ffmpeg: ToolCatalogEntry = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, position: { type: 'string', @@ -1450,14 +1456,14 @@ export const FunctionExecute: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1523,7 +1529,8 @@ export const FunctionExecute: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1617,14 +1624,14 @@ export const GenerateAudio: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1634,6 +1641,8 @@ export const GenerateAudio: ToolCatalogEntry = { }, required: ['path'], }, + minItems: 1, + maxItems: 1, }, tables: { type: 'array', @@ -1651,6 +1660,7 @@ export const GenerateAudio: ToolCatalogEntry = { }, }, }, + required: ['files'], }, instrumental: { type: 'boolean', @@ -1695,7 +1705,8 @@ export const GenerateAudio: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1705,8 +1716,11 @@ export const GenerateAudio: ToolCatalogEntry = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -1767,14 +1781,14 @@ export const GenerateImage: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1784,6 +1798,7 @@ export const GenerateImage: ToolCatalogEntry = { }, required: ['path'], }, + minItems: 1, }, tables: { type: 'array', @@ -1801,6 +1816,7 @@ export const GenerateImage: ToolCatalogEntry = { }, }, }, + required: ['files'], }, outputs: { type: 'object', @@ -1830,7 +1846,8 @@ export const GenerateImage: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1840,8 +1857,11 @@ export const GenerateImage: ToolCatalogEntry = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -1905,14 +1925,14 @@ export const GenerateVideo: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1922,6 +1942,8 @@ export const GenerateVideo: ToolCatalogEntry = { }, required: ['path'], }, + minItems: 1, + maxItems: 1, }, tables: { type: 'array', @@ -1939,6 +1961,7 @@ export const GenerateVideo: ToolCatalogEntry = { }, }, }, + required: ['files'], }, model: { type: 'string', @@ -1989,7 +2012,8 @@ export const GenerateVideo: ToolCatalogEntry = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1999,8 +2023,11 @@ export const GenerateVideo: ToolCatalogEntry = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -3424,14 +3451,14 @@ export const RunCode: ToolCatalogEntry = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 00646f08622..e586abe380d 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1084,14 +1084,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1101,6 +1101,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path'], }, + minItems: 1, }, tables: { type: 'array', @@ -1124,6 +1125,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + required: ['files'], }, loopToVideo: { type: 'boolean', @@ -1179,7 +1181,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1189,8 +1192,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, position: { type: 'string', @@ -1268,14 +1274,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1347,7 +1353,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1431,14 +1438,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1448,6 +1455,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path'], }, + minItems: 1, + maxItems: 1, }, tables: { type: 'array', @@ -1471,6 +1480,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + required: ['files'], }, instrumental: { type: 'boolean', @@ -1515,7 +1525,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1525,8 +1536,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -1584,14 +1598,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1601,6 +1615,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path'], }, + minItems: 1, }, tables: { type: 'array', @@ -1624,6 +1639,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + required: ['files'], }, outputs: { type: 'object', @@ -1653,7 +1669,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1663,8 +1680,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -1722,14 +1742,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', @@ -1739,6 +1759,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path'], }, + minItems: 1, + maxItems: 1, }, tables: { type: 'array', @@ -1762,6 +1784,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + required: ['files'], }, model: { type: 'string', @@ -1812,7 +1835,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + description: + 'Canonical destination VFS path under "files/", e.g. "files/Reports/chart.png".', }, sandboxPath: { type: 'string', @@ -1822,8 +1846,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['path', 'mode'], }, + minItems: 1, + maxItems: 1, }, }, + required: ['files'], }, prompt: { type: 'string', @@ -3227,14 +3254,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, files: { type: 'array', - description: 'Workspace files to mount into the sandbox.', + description: 'Workspace files or chat uploads to mount into the sandbox.', items: { type: 'object', properties: { path: { type: 'string', description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "uploads/sales.csv". By default this mounts at "/home/user/{path}".', }, sandboxPath: { type: 'string', diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index f6494b14aa0..ec5e9a1d47e 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -18,6 +18,7 @@ const { mockFetchWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockResolveChatUpload, } = vi.hoisted(() => ({ mockIsFeatureEnabled: vi.fn(), mockGetTableById: vi.fn(), @@ -33,6 +34,7 @@ const { mockFetchWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockResolveChatUpload: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled })) @@ -71,6 +73,9 @@ vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({ isPlanAliasPath: () => false, workflowAliasSandboxPath: (p: string) => p, })) +vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ + resolveChatUpload: mockResolveChatUpload, +})) import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' @@ -268,6 +273,14 @@ const fileRecord = { storageContext: 'workspace' as const, } +const uploadRecord = { + ...fileRecord, + id: 'upload_1', + name: 'My Report.csv', + key: 'mothership/chat_1/my-report.csv', + storageContext: 'mothership' as const, +} + describe('executeFunctionExecute file mounts', () => { beforeEach(() => { vi.clearAllMocks() @@ -309,6 +322,51 @@ describe('executeFunctionExecute file mounts', () => { expect(file.type).toBeUndefined() }) + it('mounts a chat upload at its canonical sandbox path', async () => { + mockResolveChatUpload.mockResolvedValue(uploadRecord) + + await executeFunctionExecute({ inputs: { files: [{ path: 'uploads/My%20Report.csv' }] } }, { + ...context, + chatId: 'chat_1', + } as never) + + expect(mockResolveChatUpload).toHaveBeenCalledWith('My%20Report.csv', 'chat_1') + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( + 'mothership/chat_1/my-report.csv', + 'mothership', + expect.any(Number) + ) + expect(mountedFiles()[0]).toEqual({ + type: 'url', + path: '/home/user/uploads/My%20Report.csv', + url: 'https://s3.example/file?sig=abc', + }) + }) + + it('reports when a chat upload is no longer available', async () => { + mockResolveChatUpload.mockResolvedValue(null) + + await expect( + executeFunctionExecute({ inputFiles: ['uploads/missing.csv'] }, { + ...context, + chatId: 'chat_1', + } as never) + ).rejects.toThrow('Upload not found: "uploads/missing.csv"') + }) + + it('rejects a chat upload from another workspace', async () => { + mockResolveChatUpload.mockResolvedValue({ ...uploadRecord, workspaceId: 'ws_2' }) + + await expect( + executeFunctionExecute({ inputFiles: ['uploads/My%20Report.csv'] }, { + ...context, + chatId: 'chat_1', + } as never) + ).rejects.toThrow('Upload does not belong to the current workspace') + expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() + }) + it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => { mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 }) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index bc32699d66a..ee5d1b21fd6 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { resolveChatUpload } from '@/lib/copilot/tools/handlers/upload-file-reader' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' @@ -29,6 +30,7 @@ const logger = createLogger('CopilotFunctionExecute') const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_TOTAL_SIZE = 50 * 1024 * 1024 const MAX_MOUNTED_FILES = 500 +const UPLOADS_PREFIX = 'uploads/' /** * Below this row count a table mounts via the direct inline CSV path — the version-keyed snapshot @@ -170,7 +172,8 @@ export async function resolveInputFiles( workspaceId: string, inputFiles?: unknown[], inputTables?: unknown[], - inputDirectories?: unknown[] + inputDirectories?: unknown[], + chatId?: string ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } @@ -182,9 +185,7 @@ export async function resolveInputFiles( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId, { - includeReservedSystemFiles: betaEnabled, - }) + let allFiles: WorkspaceFileRecord[] | undefined for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -193,6 +194,35 @@ export async function resolveInputFiles( ? (fileRef as CanonicalFileInput).path : undefined if (!filePath) continue + const explicitSandboxPath = + typeof fileRef === 'object' && fileRef !== null + ? (fileRef as CanonicalFileInput).sandboxPath + : undefined + if (filePath.startsWith(UPLOADS_PREFIX)) { + const fileName = filePath.slice(UPLOADS_PREFIX.length) + if (!fileName || fileName.includes('/')) { + throw new Error(`Upload input path must identify one file: "${filePath}"`) + } + if (!chatId) { + throw new Error(`Chat context is required to mount upload: "${filePath}"`) + } + const record = await resolveChatUpload(fileName, chatId) + if (!record) { + throw new Error( + `Upload not found: "${filePath}". The chat or upload may no longer exist.` + ) + } + if (record.workspaceId !== workspaceId) { + throw new Error(`Upload does not belong to the current workspace: "${filePath}"`) + } + await pushWorkspaceFileMount( + sandboxFiles, + record, + explicitSandboxPath || `/home/user/${filePath}`, + mounted + ) + continue + } const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath }) if (!alias && isPlanAliasPath(filePath)) { logger.warn('Unsupported plan alias input file path', { filePath }) @@ -202,21 +232,16 @@ export async function resolveInputFiles( logger.warn('Input file is a plan alias directory', { filePath }) continue } + // Upload mounts bypass the workspace listing, so load it only for persistent files. + allFiles ??= await listWorkspaceFiles(workspaceId, { + includeReservedSystemFiles: betaEnabled, + }) const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath) if (!record) { - if (filePath.startsWith('uploads/')) { - throw new Error( - `Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.` - ) - } throw new Error( `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` ) } - const explicitSandboxPath = - typeof fileRef === 'object' && fileRef !== null - ? (fileRef as CanonicalFileInput).sandboxPath - : undefined const mountPath = explicitSandboxPath || (alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record)) @@ -441,7 +466,8 @@ export async function executeFunctionExecute( context.workspaceId, inputFiles, inputTables, - inputDirectories + inputDirectories, + context.chatId ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts index 171a1589a25..95a391eeb52 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts @@ -96,6 +96,17 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa } } +/** + * Resolve a chat-scoped upload to the same file-record shape used by workspace files. + */ +export async function resolveChatUpload( + filename: string, + chatId: string +): Promise { + const row = await findMothershipUploadRowByChatAndName(chatId, filename) + return row ? toWorkspaceFileRecord(row) : null +} + /** * Resolve a mothership upload row by VFS name (the collision-disambiguated `displayName` * for new rows, or `originalName` for legacy rows that predate the column). Prefers an diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.test.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.test.ts new file mode 100644 index 00000000000..e97cc4f52f2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchWorkspaceFileBuffer: vi.fn(), + generateContent: vi.fn(), + resolveChatUpload: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + validateWorkspaceFileWriteTarget: vi.fn(), + writeWorkspaceFileByPath: vi.fn(), +})) + +vi.mock('@google/genai', () => ({ + GoogleGenAI: class { + models = { generateContent: mocks.generateContent } + }, +})) + +vi.mock('@/lib/core/config/api-keys', () => ({ + getRotatingApiKey: vi.fn(() => 'test-api-key'), +})) + +vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ + resolveChatUpload: mocks.resolveChatUpload, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + validateWorkspaceFileWriteTarget: mocks.validateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath: mocks.writeWorkspaceFileByPath, +})) + +import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' + +const CONTEXT = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', +} + +const IMAGE_RECORD = { + id: 'wf_image', + workspaceId: 'workspace-1', + name: 'person.png', + key: 'uploads/person.png', + path: '/api/files/serve/uploads%2Fperson.png', + size: 3, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + storageContext: 'mothership' as const, +} + +describe('generateImageServerTool', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('image')) + mocks.validateWorkspaceFileWriteTarget.mockResolvedValue({}) + mocks.generateContent.mockResolvedValue({ + candidates: [ + { content: { parts: [{ inlineData: { data: 'aW1hZ2U=', mimeType: 'image/png' } }] } }, + ], + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'wf_result', + name: 'result.png', + vfsPath: 'files/result.png', + mode: 'create', + downloadUrl: '/download/result.png', + }) + }) + + it('loads a chat upload when inputs.files uses an uploads/ path', async () => { + mocks.resolveChatUpload.mockResolvedValue(IMAGE_RECORD) + + const result = await generateImageServerTool.execute( + { + prompt: 'Preserve this person and put them in a library', + inputs: { files: [{ path: 'uploads/person.png' }] }, + outputs: { files: [{ path: 'files/result.png' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('person.png', 'chat-1') + expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() + expect(mocks.fetchWorkspaceFileBuffer).toHaveBeenCalledWith(IMAGE_RECORD) + }) + + it('fails before generation when an input path cannot be resolved', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue(null) + + const result = await generateImageServerTool.execute( + { + prompt: 'Edit the reference image', + inputs: { files: [{ path: 'files/missing.png' }] }, + outputs: { files: [{ path: 'files/result.png' }] }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: 'Failed to generate image: Input file not found: files/missing.png', + }) + expect(mocks.generateContent).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('fails before generation when outputs.files targets uploads/', async () => { + const result = await generateImageServerTool.execute( + { + prompt: 'Create a portrait', + outputs: { files: [{ path: 'uploads/result.png' }] }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: + 'Failed to generate image: Media output paths must start with "files/"; uploads/ paths are read-only: uploads/result.png', + }) + expect(mocks.generateContent).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('fails before generation when an explicit output path is empty', async () => { + mocks.validateWorkspaceFileWriteTarget.mockRejectedValue( + new Error('Workspace file paths must start with "files/"') + ) + + const result = await generateImageServerTool.execute( + { + prompt: 'Create a portrait', + outputs: { files: [{ path: '' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(false) + expect(mocks.generateContent).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('rejects additional output declarations before generation', async () => { + const result = await generateImageServerTool.execute( + { + prompt: 'Create a portrait', + outputs: { + files: [{ path: 'files/one.png' }, { path: 'uploads/two.png' }], + }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: 'Failed to generate image: Output requires exactly one file; received 2', + }) + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + expect(mocks.generateContent).not.toHaveBeenCalled() + }) + + it('rejects an explicitly empty input container before generation', async () => { + const emptyInputs = await generateImageServerTool.execute( + { + prompt: 'Edit the supplied image', + inputs: {}, + outputs: { files: [{ path: 'files/result.png' }] }, + }, + CONTEXT + ) + + expect(emptyInputs).toEqual({ + success: false, + message: 'Failed to generate image: Input requires at least one file', + }) + expect(mocks.generateContent).not.toHaveBeenCalled() + }) + + it('uses the default image output path when outputs are omitted', async () => { + const result = await generateImageServerTool.execute({ prompt: 'Create a portrait' }, CONTEXT) + + expect(result.success).toBe(true) + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + target: { path: 'files/generated-image.png', mode: 'create', mimeType: undefined }, + }) + ) + }) + + it('fails before generation when canonical output preflight rejects a files/ target', async () => { + mocks.validateWorkspaceFileWriteTarget.mockRejectedValue( + new Error('File already exists at files/result.png') + ) + + const result = await generateImageServerTool.execute( + { + prompt: 'Create a portrait', + outputs: { files: [{ path: 'files/result.png' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(false) + expect(mocks.generateContent).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index 3a6c25b8240..ca0f04dd215 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -1,18 +1,20 @@ import { GoogleGenAI, type Part } from '@google/genai' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { GenerateImage } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { + prepareMediaOutput, + requireAtLeastOneMediaFile, + resolveMediaInputFile, +} from '@/lib/copilot/tools/server/media/file-paths' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { getRotatingApiKey } from '@/lib/core/config/api-keys' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('GenerateImageTool') @@ -74,6 +76,15 @@ export const generateImageServerTool: BaseServerTool file.path) ?? [] + const referencePaths = params.inputs + ? requireAtLeastOneMediaFile(params.inputs.files, 'Input').map((file) => file.path) + : [] if (referencePaths.length) { for (const filePath of referencePaths) { - try { - const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) - if (fileRecord) { - const buffer = await fetchWorkspaceFileBuffer(fileRecord) - const base64 = buffer.toString('base64') - const mime = fileRecord.type || 'image/png' - parts.push({ - inlineData: { mimeType: mime, data: base64 }, - }) - logger.info('Loaded reference image', { - filePath, - name: fileRecord.name, - size: buffer.length, - mimeType: mime, - }) - } else { - logger.warn('Reference file not found, skipping', { filePath }) - } - } catch (err) { - logger.warn('Failed to load reference image, skipping', { - filePath, - error: toError(err).message, - }) - } + const fileRecord = await resolveMediaInputFile({ + workspaceId, + chatId: context.chatId, + path: filePath, + }) + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + const base64 = buffer.toString('base64') + const mime = fileRecord.type || 'image/png' + parts.push({ + inlineData: { mimeType: mime, data: base64 }, + }) + logger.info('Loaded reference image', { + filePath, + name: fileRecord.name, + size: buffer.length, + mimeType: mime, + }) } } @@ -161,7 +167,6 @@ export const generateImageServerTool: BaseServerTool = { } try { + // Preflight explicit outputs so invalid paths fail before media processing begins. + const outputFile = + params.operation === 'probe' || !params.outputs?.files?.length + ? undefined + : await prepareMediaOutput({ + output: params.outputs, + workspaceId, + userId: context.userId, + }) + const mediaFiles: MediaFile[] = [] for (const filePath of inputPaths) { - const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) - if (!fileRecord) { - return { success: false, message: `Input file not found: ${filePath}` } - } + const fileRecord = await resolveMediaInputFile({ + workspaceId, + chatId: context.chatId, + path: filePath, + }) const buffer = await fetchWorkspaceFileBuffer(fileRecord) mediaFiles.push({ buffer, @@ -123,7 +135,6 @@ export const ffmpegServerTool: BaseServerTool = { return { success: false, message: `ffmpeg ${params.operation} produced no output` } } - const outputFile = params.outputs?.files?.[0] const outputPath = outputFile?.path || `files/ffmpeg-${params.operation}.${result.ext}` const mode = outputFile?.mode ?? 'create' diff --git a/apps/sim/lib/copilot/tools/server/media/file-paths.test.ts b/apps/sim/lib/copilot/tools/server/media/file-paths.test.ts new file mode 100644 index 00000000000..4193c038fbe --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/media/file-paths.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveChatUpload: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + validateWorkspaceFileWriteTarget: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ + resolveChatUpload: mocks.resolveChatUpload, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + validateWorkspaceFileWriteTarget: mocks.validateWorkspaceFileWriteTarget, +})) + +import { + prepareMediaOutput, + requireExactlyOneMediaFile, + resolveMediaInputFile, + validateMediaOutputFile, +} from '@/lib/copilot/tools/server/media/file-paths' + +const FILE_RECORD = { id: 'wf_file', name: 'portrait.png', workspaceId: 'workspace-1' } + +describe('media file paths', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.validateWorkspaceFileWriteTarget.mockResolvedValue({}) + }) + + it('resolves uploads/ inputs within the current chat', async () => { + mocks.resolveChatUpload.mockResolvedValue(FILE_RECORD) + + await expect( + resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'uploads/My%20Portrait.png', + }) + ).resolves.toBe(FILE_RECORD) + + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('My%20Portrait.png', 'chat-1') + expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() + }) + + it('resolves uploads/ inputs with the read-compatible /content suffix', async () => { + mocks.resolveChatUpload.mockResolvedValue(FILE_RECORD) + + await expect( + resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'uploads/My%20Portrait.png/content', + }) + ).resolves.toBe(FILE_RECORD) + + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('My%20Portrait.png', 'chat-1') + }) + + it('rejects uploads from a different workspace', async () => { + mocks.resolveChatUpload.mockResolvedValue({ ...FILE_RECORD, workspaceId: 'workspace-2' }) + + await expect( + resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'uploads/My%20Portrait.png', + }) + ).rejects.toThrow( + 'Upload does not belong to the current workspace: "uploads/My%20Portrait.png"' + ) + }) + + it('rejects unresolved inputs instead of dropping them', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue(null) + + await expect( + resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'files/missing.png', + }) + ).rejects.toThrow('Input file not found: files/missing.png') + }) + + it('preserves legacy workspace IDs and plain-name references', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue(FILE_RECORD) + + await expect( + resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'portrait.png', + }) + ).resolves.toBe(FILE_RECORD) + + await resolveMediaInputFile({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + path: 'wf_file', + }) + + expect(mocks.resolveWorkspaceFileReference).toHaveBeenNthCalledWith( + 1, + 'workspace-1', + 'portrait.png' + ) + expect(mocks.resolveWorkspaceFileReference).toHaveBeenNthCalledWith(2, 'workspace-1', 'wf_file') + }) + + it('preflights outputs through the canonical workspace writer policy', async () => { + await validateMediaOutputFile({ + workspaceId: 'workspace-1', + userId: 'user-1', + path: 'files/Campaign/portrait.png', + mode: 'overwrite', + mimeType: 'image/png', + }) + + expect(mocks.validateWorkspaceFileWriteTarget).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Campaign/portrait.png', + mode: 'overwrite', + mimeType: 'image/png', + }, + }) + }) + + it('rejects uploads/ output paths before canonical resolution', async () => { + await expect( + validateMediaOutputFile({ + workspaceId: 'workspace-1', + userId: 'user-1', + path: 'uploads/portrait.png', + mode: 'create', + }) + ).rejects.toThrow('Media output paths must start with "files/"') + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + }) + + it('propagates canonical writer validation errors for files/ paths', async () => { + mocks.validateWorkspaceFileWriteTarget.mockRejectedValue( + new Error('File already exists at files/portrait.png') + ) + + await expect( + validateMediaOutputFile({ + workspaceId: 'workspace-1', + userId: 'user-1', + path: 'files/portrait.png', + mode: 'create', + }) + ).rejects.toThrow('File already exists at files/portrait.png') + expect(mocks.validateWorkspaceFileWriteTarget).toHaveBeenCalledOnce() + }) + + it('rejects non-file output namespaces before canonical resolution', async () => { + await expect( + validateMediaOutputFile({ + workspaceId: 'workspace-1', + userId: 'user-1', + path: 'workflows/My%20Flow/state.json', + mode: 'overwrite', + }) + ).rejects.toThrow('Media output paths must start with "files/"') + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + }) + + it('rejects extra declarations for single-file media parameters', () => { + expect(() => + requireExactlyOneMediaFile([{ path: 'files/a.png' }, { path: 'files/b.png' }], 'Output') + ).toThrow('Output requires exactly one file; received 2') + expect(() => requireExactlyOneMediaFile([], 'Output')).toThrow( + 'Output requires exactly one file; received 0' + ) + }) + + it('requires an explicit output before provider work can begin', async () => { + await expect( + prepareMediaOutput({ + workspaceId: 'workspace-1', + userId: 'user-1', + }) + ).rejects.toThrow('Output requires exactly one file; received 0') + + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/media/file-paths.ts b/apps/sim/lib/copilot/tools/server/media/file-paths.ts new file mode 100644 index 00000000000..eca35fdab73 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/media/file-paths.ts @@ -0,0 +1,110 @@ +import { resolveChatUpload } from '@/lib/copilot/tools/handlers/upload-file-reader' +import { + validateWorkspaceFileWriteTarget, + type WorkspaceFileWriteMode, +} from '@/lib/copilot/vfs/resource-writer' +import { + resolveWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const UPLOADS_PREFIX = 'uploads/' +const FILES_PREFIX = 'files/' + +interface MediaOutputDeclaration { + path: string + mode?: WorkspaceFileWriteMode + mimeType?: string +} + +export function requireExactlyOneMediaFile( + files: T[] | undefined, + label: 'Input' | 'Output' +): T { + if (files?.length !== 1) { + throw new Error(`${label} requires exactly one file; received ${files?.length ?? 0}`) + } + return files[0] +} + +export function requireAtLeastOneMediaFile( + files: T[] | undefined, + label: 'Input' | 'Output' +): T[] { + if (!files?.length) { + throw new Error(`${label} requires at least one file`) + } + return files +} + +/** + * Resolve a media input from either persistent workspace files or chat-scoped uploads. + */ +export async function resolveMediaInputFile(args: { + workspaceId: string + chatId?: string + path: string +}): Promise { + const path = args.path.trim() + let file: WorkspaceFileRecord | null + + if (path.startsWith(UPLOADS_PREFIX)) { + const filename = path.slice(UPLOADS_PREFIX.length).split('/')[0] + if (!filename) { + throw new Error(`Upload input path must identify a file: ${args.path}`) + } + if (!args.chatId) { + throw new Error(`Chat context is required for upload input: ${path}`) + } + file = await resolveChatUpload(filename, args.chatId) + if (file && file.workspaceId !== args.workspaceId) { + throw new Error(`Upload does not belong to the current workspace: "${path}"`) + } + } else { + file = await resolveWorkspaceFileReference(args.workspaceId, path) + } + + if (!file) { + throw new Error(`Input file not found: ${args.path}`) + } + return file +} + +/** + * Preflight a media output through the canonical workspace writer policy. + */ +export async function validateMediaOutputFile(args: { + workspaceId: string + userId: string + path: string + mode: WorkspaceFileWriteMode + mimeType?: string +}): Promise { + if (!args.path.trim().startsWith(FILES_PREFIX)) { + throw new Error( + `Media output paths must start with "files/"; uploads/ paths are read-only: ${args.path}` + ) + } + await validateWorkspaceFileWriteTarget({ + workspaceId: args.workspaceId, + userId: args.userId, + target: { path: args.path, mode: args.mode, mimeType: args.mimeType }, + }) +} + +export async function prepareMediaOutput(args: { + output?: { files?: T[] } + workspaceId: string + userId: string +}): Promise { + const file = requireExactlyOneMediaFile(args.output?.files, 'Output') + const output = { ...file, mode: file.mode ?? 'create' } + await validateMediaOutputFile({ + workspaceId: args.workspaceId, + userId: args.userId, + path: output.path, + mode: output.mode, + mimeType: output.mimeType, + }) + return output +} diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index 6d224686397..e2f6c1abdd5 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -6,12 +6,14 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { + prepareMediaOutput, + requireExactlyOneMediaFile, + resolveMediaInputFile, +} from '@/lib/copilot/tools/server/media/file-paths' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('GenerateAudioTool') @@ -80,20 +82,32 @@ export const generateAudioServerTool: BaseServerTool ({ + fetchWorkspaceFileBuffer: vi.fn(), + generateFalAudio: vi.fn(), + generateFalVideo: vi.fn(), + resolveChatUpload: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + runFfmpegOperation: vi.fn(), + validateWorkspaceFileWriteTarget: vi.fn(), + writeWorkspaceFileByPath: vi.fn(), +})) + +vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mocks.generateFalAudio })) +vi.mock('@/lib/media/falai-video', () => ({ generateFalVideo: mocks.generateFalVideo })) +vi.mock('@/lib/media/ffmpeg', () => ({ runFfmpegOperation: mocks.runFfmpegOperation })) +vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ + resolveChatUpload: mocks.resolveChatUpload, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + validateWorkspaceFileWriteTarget: mocks.validateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath: mocks.writeWorkspaceFileByPath, +})) + +import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' +import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' +import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' + +const CONTEXT = { userId: 'user-1', workspaceId: 'workspace-1', chatId: 'chat-1' } +const UPLOAD_RECORD = { + id: 'wf_upload', + name: 'start.png', + type: 'image/png', + workspaceId: 'workspace-1', + storageContext: 'mothership', +} + +describe('media tool path validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.validateWorkspaceFileWriteTarget.mockResolvedValue({}) + mocks.fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('media')) + mocks.generateFalVideo.mockResolvedValue({ + buffer: Buffer.from('video'), + contentType: 'video/mp4', + model: 'veo-3.1-fast', + cost: { costDollars: 0.1 }, + }) + mocks.generateFalAudio.mockResolvedValue({ + buffer: Buffer.from('audio'), + contentType: 'audio/mpeg', + model: 'fal-ai/f5-tts', + cost: { costDollars: 0.05 }, + }) + mocks.runFfmpegOperation.mockResolvedValue({ + buffer: Buffer.from('converted'), + contentType: 'video/mp4', + ext: 'mp4', + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'wf_output', + name: 'output.mp4', + vfsPath: 'files/output.mp4', + mode: 'create', + }) + }) + + it('passes uploads/ start frames to video generation', async () => { + mocks.resolveChatUpload.mockResolvedValue(UPLOAD_RECORD) + + const result = await generateVideoServerTool.execute( + { + prompt: 'Animate this portrait', + inputs: { files: [{ path: 'uploads/start.png' }] }, + outputs: { files: [{ path: 'files/output.mp4' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('start.png', 'chat-1') + expect(mocks.generateFalVideo).toHaveBeenCalledWith( + expect.objectContaining({ imageDataUri: 'data:image/png;base64,bWVkaWE=' }) + ) + }) + + it('rejects empty audio reference paths before calling the provider', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue(null) + + const result = await generateAudioServerTool.execute( + { + prompt: 'Clone this voice', + inputs: { files: [{ path: '' }] }, + outputs: { files: [{ path: 'files/voice.mp3' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(false) + expect(mocks.generateFalAudio).not.toHaveBeenCalled() + }) + + it('passes uploads/ voice samples to audio generation', async () => { + mocks.resolveChatUpload.mockResolvedValue({ + ...UPLOAD_RECORD, + name: 'voice.wav', + type: 'audio/wav', + }) + + const result = await generateAudioServerTool.execute( + { + prompt: 'Read this line in the supplied voice', + inputs: { files: [{ path: 'uploads/voice.wav' }] }, + outputs: { files: [{ path: 'files/voice.mp3' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('voice.wav', 'chat-1') + expect(mocks.generateFalAudio).toHaveBeenCalledWith( + expect.objectContaining({ voiceSampleDataUri: 'data:audio/wav;base64,bWVkaWE=' }) + ) + }) + + it('rejects invalid ffmpeg output paths before processing inputs', async () => { + const result = await ffmpegServerTool.execute( + { + operation: 'convert', + inputs: { files: [{ path: 'files/input.mov' }] }, + outputs: { files: [{ path: 'uploads/output.mp4' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(false) + expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() + expect(mocks.runFfmpegOperation).not.toHaveBeenCalled() + }) + + it('ignores unused output declarations for ffmpeg probes', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue(UPLOAD_RECORD) + mocks.runFfmpegOperation.mockResolvedValue({ probe: { duration: 3 } }) + + const result = await ffmpegServerTool.execute( + { + operation: 'probe', + inputs: { files: [{ path: 'files/input.mov' }] }, + outputs: { files: [{ path: 'uploads/unused.json' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.validateWorkspaceFileWriteTarget).not.toHaveBeenCalled() + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('uses the default video output path when outputs are omitted', async () => { + const result = await generateVideoServerTool.execute( + { prompt: 'Create a launch video' }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + target: { path: 'files/generated-video.mp4', mode: 'create', mimeType: undefined }, + }) + ) + }) + + it('uses a content-type-derived default audio output path when outputs are omitted', async () => { + const result = await generateAudioServerTool.execute({ prompt: 'Create a voiceover' }, CONTEXT) + + expect(result.success).toBe(true) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + target: { path: 'files/generated-audio.mp3', mode: 'create', mimeType: undefined }, + }) + ) + }) + + it('passes uploads/ media files to ffmpeg', async () => { + mocks.resolveChatUpload.mockResolvedValue(UPLOAD_RECORD) + + const result = await ffmpegServerTool.execute( + { + operation: 'convert', + inputs: { files: [{ path: 'uploads/start.png' }] }, + outputs: { files: [{ path: 'files/output.mp4' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.resolveChatUpload).toHaveBeenCalledWith('start.png', 'chat-1') + expect(mocks.runFfmpegOperation).toHaveBeenCalledWith( + 'convert', + [expect.objectContaining({ buffer: Buffer.from('media'), name: 'start.png' })], + expect.any(Object) + ) + }) + + it('uses the default ffmpeg output path when outputs are omitted', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ + ...UPLOAD_RECORD, + name: 'input.mov', + type: 'video/quicktime', + }) + + const result = await ffmpegServerTool.execute( + { + operation: 'convert', + inputs: { files: [{ path: 'files/input.mov' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(true) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith( + expect.objectContaining({ + target: { path: 'files/ffmpeg-convert.mp4', mode: 'create', mimeType: undefined }, + }) + ) + }) + + it('rejects extra video inputs before calling the provider', async () => { + const result = await generateVideoServerTool.execute( + { + prompt: 'Animate these portraits', + inputs: { files: [{ path: 'files/one.png' }, { path: 'files/two.png' }] }, + outputs: { files: [{ path: 'files/output.mp4' }] }, + }, + CONTEXT + ) + + expect(result.success).toBe(false) + expect(mocks.generateFalVideo).not.toHaveBeenCalled() + }) +})