Skip to content

Commit 2f4e80c

Browse files
committed
VFS updates
1 parent 160b2b8 commit 2f4e80c

39 files changed

Lines changed: 2691 additions & 1063 deletions

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,22 @@ import {
1212
import { NextRequest } from 'next/server'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

15-
const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({
15+
const {
16+
mockExecuteInE2B,
17+
mockExecuteInIsolatedVM,
18+
mockGetWorkspaceFile,
19+
mockUpdateWorkspaceFileContent,
20+
mockUploadFile,
21+
mockValidateWorkspaceFileWriteTarget,
22+
mockWriteWorkspaceFileByPath,
23+
} = vi.hoisted(() => ({
1624
mockExecuteInE2B: vi.fn(),
1725
mockExecuteInIsolatedVM: vi.fn(),
26+
mockGetWorkspaceFile: vi.fn(),
27+
mockUpdateWorkspaceFileContent: vi.fn(),
1828
mockUploadFile: vi.fn(),
29+
mockValidateWorkspaceFileWriteTarget: vi.fn(),
30+
mockWriteWorkspaceFileByPath: vi.fn(),
1931
}))
2032

2133
vi.mock('@/lib/execution/isolated-vm', () => ({
@@ -37,9 +49,40 @@ vi.mock('@/lib/copilot/request/tools/files', () => ({
3749
},
3850
normalizeOutputWorkspaceFileName: vi.fn((p: string) => p.replace(/^files\//, '')),
3951
resolveOutputFormat: vi.fn(() => 'json'),
52+
getOutputFileDeclarations: vi.fn((params: Record<string, any>) => {
53+
if (Array.isArray(params.outputs?.files)) {
54+
return params.outputs.files.map((file: Record<string, any>) => ({
55+
path: file.path,
56+
mode: file.mode === 'overwrite' ? 'overwrite' : 'create',
57+
sandboxPath: file.sandboxPath,
58+
mimeType: file.mimeType,
59+
format: file.format,
60+
}))
61+
}
62+
return params.outputPath
63+
? [
64+
{
65+
path: params.overwriteFileId || params.outputPath,
66+
mode: params.overwriteFileId ? 'overwrite' : 'create',
67+
sandboxPath: params.outputSandboxPath,
68+
mimeType: params.outputMimeType,
69+
format: params.outputFormat,
70+
formatPath: params.outputPath,
71+
overwriteFileId: params.overwriteFileId,
72+
},
73+
]
74+
: []
75+
}),
76+
}))
77+
78+
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
79+
validateWorkspaceFileWriteTarget: mockValidateWorkspaceFileWriteTarget,
80+
writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath,
4081
}))
4182

4283
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
84+
getWorkspaceFile: mockGetWorkspaceFile,
85+
updateWorkspaceFileContent: mockUpdateWorkspaceFileContent,
4386
uploadWorkspaceFile: vi.fn(),
4487
}))
4588

@@ -79,6 +122,35 @@ describe('Function Execute API Route', () => {
79122
stdout: 'e2b output',
80123
sandboxId: 'test-sandbox-id',
81124
})
125+
mockGetWorkspaceFile.mockResolvedValue({
126+
id: 'wf_existing',
127+
name: 'existing.png',
128+
size: 10,
129+
type: 'image/png',
130+
url: '/api/files/view/existing',
131+
key: 'workspace/existing.png',
132+
})
133+
mockUpdateWorkspaceFileContent.mockResolvedValue({
134+
id: 'wf_existing',
135+
name: 'existing.png',
136+
size: 20,
137+
type: 'image/png',
138+
url: '/api/files/view/existing',
139+
key: 'workspace/existing.png',
140+
})
141+
mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({
142+
mode: target.mode,
143+
vfsPath: target.path,
144+
}))
145+
mockWriteWorkspaceFileByPath.mockImplementation(async ({ target, buffer }) => ({
146+
id: `wf_${String(target.path).split('/').pop()?.replace(/\W+/g, '_') || 'file'}`,
147+
name: String(target.path).split('/').pop() || 'file',
148+
vfsPath: target.path,
149+
downloadUrl: `/api/files/view/${encodeURIComponent(target.path)}`,
150+
mode: target.mode,
151+
size: buffer.length,
152+
contentType: target.mimeType || 'application/octet-stream',
153+
}))
82154
})
83155

84156
describe('Security Tests', () => {
@@ -268,6 +340,196 @@ describe('Function Execute API Route', () => {
268340
expect(isLargeValueRef(data.output.result.text)).toBe(true)
269341
})
270342

343+
it('exports multiple declared sandbox output files', async () => {
344+
featureFlagsMock.isE2bEnabled = true
345+
mockExecuteInE2B.mockResolvedValueOnce({
346+
result: 'done',
347+
stdout: 'ok',
348+
sandboxId: 'sandbox-123',
349+
exportedFiles: {
350+
'/home/user/chart.png': 'iVBORw0KGgo=',
351+
'/home/user/summary.json': '{"ok":true}',
352+
},
353+
})
354+
355+
const req = createMockRequest('POST', {
356+
code: 'print("done")',
357+
language: 'python',
358+
workspaceId: 'workspace-1',
359+
outputs: {
360+
files: [
361+
{
362+
path: 'files/reports/chart.png',
363+
mode: 'create',
364+
sandboxPath: '/home/user/chart.png',
365+
mimeType: 'image/png',
366+
},
367+
{
368+
path: 'files/reports/summary.json',
369+
mode: 'overwrite',
370+
sandboxPath: '/home/user/summary.json',
371+
mimeType: 'application/json',
372+
},
373+
],
374+
},
375+
})
376+
377+
const response = await POST(req)
378+
const data = await response.json()
379+
380+
expect(response.status).toBe(200)
381+
expect(data.success).toBe(true)
382+
expect(mockExecuteInE2B).toHaveBeenCalledWith(
383+
expect.objectContaining({
384+
outputSandboxPaths: ['/home/user/chart.png', '/home/user/summary.json'],
385+
})
386+
)
387+
expect(mockValidateWorkspaceFileWriteTarget).toHaveBeenCalledTimes(2)
388+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2)
389+
expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith(
390+
1,
391+
expect.objectContaining({
392+
target: expect.objectContaining({ path: 'files/reports/chart.png', mode: 'create' }),
393+
})
394+
)
395+
expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith(
396+
2,
397+
expect.objectContaining({
398+
target: expect.objectContaining({
399+
path: 'files/reports/summary.json',
400+
mode: 'overwrite',
401+
}),
402+
})
403+
)
404+
expect(data.output.result.files).toHaveLength(2)
405+
expect(data.resources).toEqual([
406+
expect.objectContaining({ path: 'files/reports/chart.png' }),
407+
expect.objectContaining({ path: 'files/reports/summary.json' }),
408+
])
409+
})
410+
411+
it('prevalidates all sandbox output destinations before writing any files', async () => {
412+
featureFlagsMock.isE2bEnabled = true
413+
mockExecuteInE2B.mockResolvedValueOnce({
414+
result: 'done',
415+
stdout: 'ok',
416+
sandboxId: 'sandbox-123',
417+
exportedFiles: {
418+
'/home/user/first.json': '{"first":true}',
419+
'/home/user/second.json': '{"second":true}',
420+
},
421+
})
422+
mockValidateWorkspaceFileWriteTarget
423+
.mockResolvedValueOnce({ mode: 'create', vfsPath: 'files/first.json' })
424+
.mockRejectedValueOnce(new Error('Directory not yet created: files/missing'))
425+
426+
const req = createMockRequest('POST', {
427+
code: 'print("done")',
428+
language: 'python',
429+
workspaceId: 'workspace-1',
430+
outputs: {
431+
files: [
432+
{
433+
path: 'files/first.json',
434+
mode: 'create',
435+
sandboxPath: '/home/user/first.json',
436+
},
437+
{
438+
path: 'files/missing/second.json',
439+
mode: 'create',
440+
sandboxPath: '/home/user/second.json',
441+
},
442+
],
443+
},
444+
})
445+
446+
const response = await POST(req)
447+
const data = await response.json()
448+
449+
expect(response.status).toBe(400)
450+
expect(data.success).toBe(false)
451+
expect(data.error).toContain('Directory not yet created')
452+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
453+
})
454+
455+
it('rejects duplicate sandbox output destinations before writing files', async () => {
456+
featureFlagsMock.isE2bEnabled = true
457+
mockExecuteInE2B.mockResolvedValueOnce({
458+
result: 'done',
459+
stdout: 'ok',
460+
sandboxId: 'sandbox-123',
461+
exportedFiles: {
462+
'/home/user/first.json': '{"first":true}',
463+
'/home/user/second.json': '{"second":true}',
464+
},
465+
})
466+
mockValidateWorkspaceFileWriteTarget.mockResolvedValue({
467+
mode: 'create',
468+
vfsPath: 'files/dupe.json',
469+
})
470+
471+
const req = createMockRequest('POST', {
472+
code: 'print("done")',
473+
language: 'python',
474+
workspaceId: 'workspace-1',
475+
outputs: {
476+
files: [
477+
{
478+
path: 'files/dupe.json',
479+
mode: 'create',
480+
sandboxPath: '/home/user/first.json',
481+
},
482+
{
483+
path: 'files/dupe.json',
484+
mode: 'create',
485+
sandboxPath: '/home/user/second.json',
486+
},
487+
],
488+
},
489+
})
490+
491+
const response = await POST(req)
492+
const data = await response.json()
493+
494+
expect(response.status).toBe(400)
495+
expect(data.success).toBe(false)
496+
expect(data.error).toContain('Duplicate sandbox output destination')
497+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
498+
})
499+
500+
it('returns a targeted error when a declared sandbox output is missing', async () => {
501+
featureFlagsMock.isE2bEnabled = true
502+
mockExecuteInE2B.mockResolvedValueOnce({
503+
result: 'done',
504+
stdout: 'ok',
505+
sandboxId: 'sandbox-123',
506+
exportedFiles: {},
507+
})
508+
509+
const req = createMockRequest('POST', {
510+
code: 'print("done")',
511+
language: 'python',
512+
workspaceId: 'workspace-1',
513+
outputs: {
514+
files: [
515+
{
516+
path: 'files/missing.json',
517+
mode: 'create',
518+
sandboxPath: '/home/user/missing.json',
519+
},
520+
],
521+
},
522+
})
523+
524+
const response = await POST(req)
525+
const data = await response.json()
526+
527+
expect(response.status).toBe(500)
528+
expect(data.success).toBe(false)
529+
expect(data.error).toContain('Sandbox file "/home/user/missing.json" was not found')
530+
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
531+
})
532+
271533
it('should return computed result for multi-line code', async () => {
272534
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' })
273535

0 commit comments

Comments
 (0)