Skip to content

Commit fe66099

Browse files
committed
fix(copilot): mount chat uploads in code sandboxes
Resolve uploads through the current chat, enforce workspace ownership, and reuse the existing URL or buffered sandbox mount path so function_execute and run_code can process uploaded files directly.
1 parent ba6fe69 commit fe66099

2 files changed

Lines changed: 97 additions & 14 deletions

File tree

apps/sim/lib/copilot/tools/handlers/function-execute.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
mockFetchWorkspaceFileBuffer,
1919
mockGetSandboxWorkspaceFilePath,
2020
mockListWorkspaceFileFolders,
21+
mockResolveChatUpload,
2122
} = vi.hoisted(() => ({
2223
mockIsFeatureEnabled: vi.fn(),
2324
mockGetTableById: vi.fn(),
@@ -33,6 +34,7 @@ const {
3334
mockFetchWorkspaceFileBuffer: vi.fn(),
3435
mockGetSandboxWorkspaceFilePath: vi.fn(),
3536
mockListWorkspaceFileFolders: vi.fn(),
37+
mockResolveChatUpload: vi.fn(),
3638
}))
3739

3840
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled }))
@@ -71,6 +73,9 @@ vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({
7173
isPlanAliasPath: () => false,
7274
workflowAliasSandboxPath: (p: string) => p,
7375
}))
76+
vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({
77+
resolveChatUpload: mockResolveChatUpload,
78+
}))
7479

7580
import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute'
7681

@@ -268,6 +273,14 @@ const fileRecord = {
268273
storageContext: 'workspace' as const,
269274
}
270275

276+
const uploadRecord = {
277+
...fileRecord,
278+
id: 'upload_1',
279+
name: 'My Report.csv',
280+
key: 'mothership/chat_1/my-report.csv',
281+
storageContext: 'mothership' as const,
282+
}
283+
271284
describe('executeFunctionExecute file mounts', () => {
272285
beforeEach(() => {
273286
vi.clearAllMocks()
@@ -309,6 +322,51 @@ describe('executeFunctionExecute file mounts', () => {
309322
expect(file.type).toBeUndefined()
310323
})
311324

325+
it('mounts a chat upload at its canonical sandbox path', async () => {
326+
mockResolveChatUpload.mockResolvedValue(uploadRecord)
327+
328+
await executeFunctionExecute({ inputs: { files: [{ path: 'uploads/My%20Report.csv' }] } }, {
329+
...context,
330+
chatId: 'chat_1',
331+
} as never)
332+
333+
expect(mockResolveChatUpload).toHaveBeenCalledWith('My%20Report.csv', 'chat_1')
334+
expect(mockListWorkspaceFiles).not.toHaveBeenCalled()
335+
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(
336+
'mothership/chat_1/my-report.csv',
337+
'mothership',
338+
expect.any(Number)
339+
)
340+
expect(mountedFiles()[0]).toEqual({
341+
type: 'url',
342+
path: '/home/user/uploads/My%20Report.csv',
343+
url: 'https://s3.example/file?sig=abc',
344+
})
345+
})
346+
347+
it('reports when a chat upload is no longer available', async () => {
348+
mockResolveChatUpload.mockResolvedValue(null)
349+
350+
await expect(
351+
executeFunctionExecute({ inputFiles: ['uploads/missing.csv'] }, {
352+
...context,
353+
chatId: 'chat_1',
354+
} as never)
355+
).rejects.toThrow('Upload not found: "uploads/missing.csv"')
356+
})
357+
358+
it('rejects a chat upload from another workspace', async () => {
359+
mockResolveChatUpload.mockResolvedValue({ ...uploadRecord, workspaceId: 'ws_2' })
360+
361+
await expect(
362+
executeFunctionExecute({ inputFiles: ['uploads/My%20Report.csv'] }, {
363+
...context,
364+
chatId: 'chat_1',
365+
} as never)
366+
).rejects.toThrow('Upload does not belong to the current workspace')
367+
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
368+
})
369+
312370
it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => {
313371
mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 })
314372

apps/sim/lib/copilot/tools/handlers/function-execute.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { resolveChatUpload } from '@/lib/copilot/tools/handlers/upload-file-reader'
23
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
34
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
45
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
@@ -29,6 +30,7 @@ const logger = createLogger('CopilotFunctionExecute')
2930
const MAX_FILE_SIZE = 10 * 1024 * 1024
3031
const MAX_TOTAL_SIZE = 50 * 1024 * 1024
3132
const MAX_MOUNTED_FILES = 500
33+
const UPLOADS_PREFIX = 'uploads/'
3234

3335
/**
3436
* 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(
170172
workspaceId: string,
171173
inputFiles?: unknown[],
172174
inputTables?: unknown[],
173-
inputDirectories?: unknown[]
175+
inputDirectories?: unknown[],
176+
chatId?: string
174177
): Promise<SandboxFile[]> {
175178
const sandboxFiles: SandboxFile[] = []
176179
const mounted: MountedBytes = { buffered: 0, url: 0 }
@@ -182,9 +185,7 @@ export async function resolveInputFiles(
182185
`Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.`
183186
)
184187
}
185-
const allFiles = await listWorkspaceFiles(workspaceId, {
186-
includeReservedSystemFiles: betaEnabled,
187-
})
188+
let allFiles: WorkspaceFileRecord[] | undefined
188189
for (const fileRef of inputFiles) {
189190
const filePath =
190191
typeof fileRef === 'string'
@@ -193,6 +194,35 @@ export async function resolveInputFiles(
193194
? (fileRef as CanonicalFileInput).path
194195
: undefined
195196
if (!filePath) continue
197+
const explicitSandboxPath =
198+
typeof fileRef === 'object' && fileRef !== null
199+
? (fileRef as CanonicalFileInput).sandboxPath
200+
: undefined
201+
if (filePath.startsWith(UPLOADS_PREFIX)) {
202+
const fileName = filePath.slice(UPLOADS_PREFIX.length)
203+
if (!fileName || fileName.includes('/')) {
204+
throw new Error(`Upload input path must identify one file: "${filePath}"`)
205+
}
206+
if (!chatId) {
207+
throw new Error(`Chat context is required to mount upload: "${filePath}"`)
208+
}
209+
const record = await resolveChatUpload(fileName, chatId)
210+
if (!record) {
211+
throw new Error(
212+
`Upload not found: "${filePath}". The chat or upload may no longer exist.`
213+
)
214+
}
215+
if (record.workspaceId !== workspaceId) {
216+
throw new Error(`Upload does not belong to the current workspace: "${filePath}"`)
217+
}
218+
await pushWorkspaceFileMount(
219+
sandboxFiles,
220+
record,
221+
explicitSandboxPath || `/home/user/${filePath}`,
222+
mounted
223+
)
224+
continue
225+
}
196226
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath })
197227
if (!alias && isPlanAliasPath(filePath)) {
198228
logger.warn('Unsupported plan alias input file path', { filePath })
@@ -202,21 +232,15 @@ export async function resolveInputFiles(
202232
logger.warn('Input file is a plan alias directory', { filePath })
203233
continue
204234
}
235+
allFiles ??= await listWorkspaceFiles(workspaceId, {
236+
includeReservedSystemFiles: betaEnabled,
237+
})
205238
const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath)
206239
if (!record) {
207-
if (filePath.startsWith('uploads/')) {
208-
throw new Error(
209-
`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.`
210-
)
211-
}
212240
throw new Error(
213241
`Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").`
214242
)
215243
}
216-
const explicitSandboxPath =
217-
typeof fileRef === 'object' && fileRef !== null
218-
? (fileRef as CanonicalFileInput).sandboxPath
219-
: undefined
220244
const mountPath =
221245
explicitSandboxPath ||
222246
(alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record))
@@ -441,7 +465,8 @@ export async function executeFunctionExecute(
441465
context.workspaceId,
442466
inputFiles,
443467
inputTables,
444-
inputDirectories
468+
inputDirectories,
469+
context.chatId
445470
)
446471
if (resolved.length > 0) {
447472
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []

0 commit comments

Comments
 (0)