Skip to content

Commit da14027

Browse files
committed
fix(execution): run pptx/docx/pdf generation inside isolated-vm sandbox
Retires the legacy doc-worker.cjs / pptx-worker.cjs pipeline that ran user DSL via node:vm + full require() in the same UID/PID namespace as the main Next.js process. User code now runs inside the existing isolated-vm pool (V8 isolate, no process / require / fs, no /proc/1/environ reachability). Introduces a first-class SandboxTask abstraction under apps/sim/sandbox-tasks/ that mirrors apps/sim/background/ — one file per task, central typed registry, kebab-case ids. Adding a new thing that runs in the isolate is one file plus one registry entry. Runtime additions in lib/execution/: - task-mode execution in isolated-vm-worker.cjs: load pre-built library bundles, run task bootstrap, run user code, run finalize, transfer Uint8Array result as base64 via IPC - named broker IPC bridge (generalizes the existing fetch bridge) with args size, result size, and per-execution call caps - cooperative AbortSignal support: cancel IPC disposes the isolate, pool slot is freed, pending broker-call timers are swept - compiled scripts + references explicitly released per execution - isolate.isDisposed used for cancellation detection (no error-string substring matching) Library bundles (pptxgenjs, docx, pdf-lib) are built into isolate-safe IIFE bundles by apps/sim/lib/execution/sandbox/bundles/build.ts and committed; next.config.ts / trigger.config.ts / Dockerfile updated to ship them instead of the deleted dist/*-worker.cjs artifacts. Call sites migrated: - app/api/workspaces/[id]/pptx/preview/route.ts - app/api/files/serve/[...path]/route.ts (+ test mock) - lib/copilot/tools/server/files/{workspace-file,edit-content}.ts All pass owner key user:<userId> for per-user pool fairness + distributed lease accounting. Made-with: Cursor
1 parent 38864fa commit da14027

File tree

30 files changed

+1592
-742
lines changed

30 files changed

+1592
-742
lines changed

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ vi.mock('@/lib/uploads/utils/file-utils', () => ({
7575

7676
vi.mock('@/lib/uploads/setup.server', () => ({}))
7777

78-
vi.mock('@/lib/execution/doc-vm', () => ({
79-
generatePdfFromCode: vi.fn().mockResolvedValue(Buffer.from('%PDF-compiled')),
80-
generateDocxFromCode: vi.fn().mockResolvedValue(Buffer.from('PK\x03\x04compiled')),
81-
generatePptxFromCode: vi.fn().mockResolvedValue(Buffer.from('PK\x03\x04compiled')),
78+
vi.mock('@/lib/execution/sandbox/run-task', () => ({
79+
runSandboxTask: vi
80+
.fn()
81+
.mockImplementation(async (taskId: string) =>
82+
taskId === 'pdf-generate' ? Buffer.from('%PDF-compiled') : Buffer.from('PK\x03\x04compiled')
83+
),
8284
}))
8385

8486
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@ import { createLogger } from '@sim/logger'
44
import type { NextRequest } from 'next/server'
55
import { NextResponse } from 'next/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7-
import {
8-
generateDocxFromCode,
9-
generatePdfFromCode,
10-
generatePptxFromCode,
11-
} from '@/lib/execution/doc-vm'
7+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
128
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
139
import type { StorageContext } from '@/lib/uploads/config'
1410
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -22,6 +18,7 @@ import {
2218
findLocalFile,
2319
getContentType,
2420
} from '@/app/api/files/utils'
21+
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
2522

2623
const logger = createLogger('FilesServeAPI')
2724

@@ -30,24 +27,24 @@ const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF-
3027

3128
interface CompilableFormat {
3229
magic: Buffer
33-
compile: (code: string, workspaceId: string) => Promise<Buffer>
30+
taskId: SandboxTaskId
3431
contentType: string
3532
}
3633

3734
const COMPILABLE_FORMATS: Record<string, CompilableFormat> = {
3835
'.pptx': {
3936
magic: ZIP_MAGIC,
40-
compile: generatePptxFromCode,
37+
taskId: 'pptx-generate',
4138
contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
4239
},
4340
'.docx': {
4441
magic: ZIP_MAGIC,
45-
compile: generateDocxFromCode,
42+
taskId: 'docx-generate',
4643
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
4744
},
4845
'.pdf': {
4946
magic: PDF_MAGIC,
50-
compile: generatePdfFromCode,
47+
taskId: 'pdf-generate',
5148
contentType: 'application/pdf',
5249
},
5350
}
@@ -65,8 +62,9 @@ function compiledCacheSet(key: string, buffer: Buffer): void {
6562
async function compileDocumentIfNeeded(
6663
buffer: Buffer,
6764
filename: string,
68-
workspaceId?: string,
69-
raw?: boolean
65+
workspaceId: string | undefined,
66+
raw: boolean,
67+
ownerKey: string | undefined
7068
): Promise<{ buffer: Buffer; contentType: string }> {
7169
if (raw) return { buffer, contentType: getContentType(filename) }
7270

@@ -90,7 +88,11 @@ async function compileDocumentIfNeeded(
9088
return { buffer: cached, contentType: format.contentType }
9189
}
9290

93-
const compiled = await format.compile(code, workspaceId || '')
91+
const compiled = await runSandboxTask(
92+
format.taskId,
93+
{ code, workspaceId: workspaceId || '' },
94+
{ ownerKey }
95+
)
9496
compiledCacheSet(cacheKey, compiled)
9597
return { buffer: compiled, contentType: format.contentType }
9698
}
@@ -173,6 +175,7 @@ async function handleLocalFile(
173175
userId: string,
174176
raw: boolean
175177
): Promise<NextResponse> {
178+
const ownerKey = `user:${userId}`
176179
try {
177180
const contextParam: StorageContext | undefined = inferContextFromKey(filename) as
178181
| StorageContext
@@ -205,7 +208,8 @@ async function handleLocalFile(
205208
rawBuffer,
206209
displayName,
207210
workspaceId,
208-
raw
211+
raw,
212+
ownerKey
209213
)
210214

211215
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
@@ -227,6 +231,7 @@ async function handleCloudProxy(
227231
userId: string,
228232
raw = false
229233
): Promise<NextResponse> {
234+
const ownerKey = `user:${userId}`
230235
try {
231236
const context = inferContextFromKey(cloudKey)
232237
logger.info(`Inferred context: ${context} from key pattern: ${cloudKey}`)
@@ -262,7 +267,8 @@ async function handleCloudProxy(
262267
rawBuffer,
263268
displayName,
264269
workspaceId,
265-
raw
270+
raw,
271+
ownerKey
266272
)
267273

268274
logger.info('Cloud file served', {

apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getSession } from '@/lib/auth'
4-
import { generatePptxFromCode } from '@/lib/execution/doc-vm'
4+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
55
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
66

77
export const dynamic = 'force-dynamic'
@@ -44,7 +44,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
4444
return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 })
4545
}
4646

47-
const buffer = await generatePptxFromCode(code, workspaceId, req.signal)
47+
const buffer = await runSandboxTask(
48+
'pptx-generate',
49+
{ code, workspaceId },
50+
{ ownerKey: `user:${session.user.id}`, signal: req.signal }
51+
)
4852

4953
return new NextResponse(new Uint8Array(buffer), {
5054
status: 200,

apps/sim/lib/copilot/tools/server/files/edit-content.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import {
44
type BaseServerTool,
55
type ServerToolContext,
66
} from '@/lib/copilot/tools/server/base-tool'
7-
import {
8-
generateDocxFromCode,
9-
generatePdfFromCode,
10-
generatePptxFromCode,
11-
} from '@/lib/execution/doc-vm'
7+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
128
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
9+
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
1310
import { consumeLatestFileIntent } from './file-intent-store'
1411
import { inferContentType } from './workspace-file'
1512

@@ -29,31 +26,31 @@ function getDocumentFormatInfo(fileName: string): {
2926
isDoc: boolean
3027
formatName?: string
3128
sourceMime?: string
32-
generator?: (code: string, workspaceId: string, signal?: AbortSignal) => Promise<Buffer>
29+
taskId?: SandboxTaskId
3330
} {
3431
const lowerName = fileName.toLowerCase()
3532
if (lowerName.endsWith('.pptx')) {
3633
return {
3734
isDoc: true,
3835
formatName: 'PPTX',
3936
sourceMime: 'text/x-pptxgenjs',
40-
generator: generatePptxFromCode,
37+
taskId: 'pptx-generate',
4138
}
4239
}
4340
if (lowerName.endsWith('.docx')) {
4441
return {
4542
isDoc: true,
4643
formatName: 'DOCX',
4744
sourceMime: 'text/x-docxjs',
48-
generator: generateDocxFromCode,
45+
taskId: 'docx-generate',
4946
}
5047
}
5148
if (lowerName.endsWith('.pdf')) {
5249
return {
5350
isDoc: true,
5451
formatName: 'PDF',
5552
sourceMime: 'text/x-pdflibjs',
56-
generator: generatePdfFromCode,
53+
taskId: 'pdf-generate',
5754
}
5855
}
5956
return { isDoc: false }
@@ -240,7 +237,11 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
240237

241238
if (docInfo.isDoc) {
242239
try {
243-
await docInfo.generator!(finalContent, workspaceId)
240+
await runSandboxTask(
241+
docInfo.taskId!,
242+
{ code: finalContent, workspaceId },
243+
{ ownerKey: `user:${context.userId}` }
244+
)
244245
} catch (err) {
245246
const msg = err instanceof Error ? err.message : String(err)
246247
return {

apps/sim/lib/copilot/tools/server/files/workspace-file.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ import {
55
type BaseServerTool,
66
type ServerToolContext,
77
} from '@/lib/copilot/tools/server/base-tool'
8-
import {
9-
generateDocxFromCode,
10-
generatePdfFromCode,
11-
generatePptxFromCode,
12-
} from '@/lib/execution/doc-vm'
8+
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
139
import {
1410
deleteWorkspaceFile,
1511
downloadWorkspaceFile as downloadWsFile,
@@ -18,6 +14,7 @@ import {
1814
renameWorkspaceFile,
1915
uploadWorkspaceFile,
2016
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
17+
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
2118
import { storeFileIntent } from './file-intent-store'
2219

2320
const logger = createLogger('WorkspaceFileServerTool')
@@ -108,31 +105,31 @@ function getDocumentFormatInfo(fileName: string): {
108105
isDoc: boolean
109106
formatName?: 'PPTX' | 'DOCX' | 'PDF'
110107
sourceMime?: string
111-
generator?: (code: string, workspaceId: string, signal?: AbortSignal) => Promise<Buffer>
108+
taskId?: SandboxTaskId
112109
} {
113110
const lowerName = fileName.toLowerCase()
114111
if (lowerName.endsWith('.pptx')) {
115112
return {
116113
isDoc: true,
117114
formatName: 'PPTX',
118115
sourceMime: PPTX_SOURCE_MIME,
119-
generator: generatePptxFromCode,
116+
taskId: 'pptx-generate',
120117
}
121118
}
122119
if (lowerName.endsWith('.docx')) {
123120
return {
124121
isDoc: true,
125122
formatName: 'DOCX',
126123
sourceMime: DOCX_SOURCE_MIME,
127-
generator: generateDocxFromCode,
124+
taskId: 'docx-generate',
128125
}
129126
}
130127
if (lowerName.endsWith('.pdf')) {
131128
return {
132129
isDoc: true,
133130
formatName: 'PDF',
134131
sourceMime: PDF_SOURCE_MIME,
135-
generator: generatePdfFromCode,
132+
taskId: 'pdf-generate',
136133
}
137134
}
138135
return { isDoc: false }
@@ -201,7 +198,11 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
201198
let contentType = inferContentType(fileName, explicitType)
202199
if (docInfo.isDoc) {
203200
try {
204-
await docInfo.generator!(content, workspaceId)
201+
await runSandboxTask(
202+
docInfo.taskId!,
203+
{ code: content, workspaceId },
204+
{ ownerKey: `user:${context.userId}` }
205+
)
205206
} catch (err) {
206207
const msg = err instanceof Error ? err.message : String(err)
207208
return {

apps/sim/lib/core/config/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ export const env = createEnv({
232232
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: z.string().optional().default('120000'), // Min TTL for distributed in-flight leases (ms)
233233
IVM_QUEUE_TIMEOUT_MS: z.string().optional().default('300000'), // Max queue wait before rejection (ms)
234234
IVM_MAX_EXECUTIONS_PER_WORKER: z.string().optional().default('500'), // Max lifetime executions before worker is recycled
235+
IVM_MAX_BROKER_ARGS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox task broker args (isolate→host)
236+
IVM_MAX_BROKER_RESULT_JSON_CHARS: z.string().optional().default('16777216'),// Max JSON payload size for sandbox task broker results (host→isolate)
237+
IVM_MAX_BROKERS_PER_EXECUTION: z.string().optional().default('1000'), // Max broker calls per sandbox task execution
235238

236239
// Knowledge Base Processing Configuration - Shared across all processing methods
237240
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)

0 commit comments

Comments
 (0)