Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { SandboxTaskId } from '@/sandbox-tasks/registry'

Expand Down Expand Up @@ -83,6 +83,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) {
})
} catch (err) {
const message = toError(err).message
if (err instanceof SandboxUserCodeError) {
logger.warn(`${config.label} preview user code failed`, {
error: message,
errorName: err.name,
workspaceId,
})
return NextResponse.json({ error: message, errorName: err.name }, { status: 422 })
}
logger.error(`${config.label} preview generation failed`, { error: message, workspaceId })
return NextResponse.json({ error: message }, { status: 500 })
}
Expand Down
40 changes: 37 additions & 3 deletions apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'

const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})

const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership

vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)

vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))

import { POST } from '@/app/api/workspaces/[id]/docx/preview/route'
Expand Down Expand Up @@ -189,4 +196,31 @@ describe('DOCX preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})

it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)

const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)

const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})

expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})
40 changes: 37 additions & 3 deletions apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'

const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})

const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership

vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)

vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))

import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route'
Expand Down Expand Up @@ -187,4 +194,31 @@ describe('PDF preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})

it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)

const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)

const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})

expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})
40 changes: 37 additions & 3 deletions apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'

const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})

const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership

vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)

vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))

import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route'
Expand Down Expand Up @@ -189,4 +196,31 @@ describe('PPTX preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})

it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)

const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/pptx/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)

const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})

expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})
24 changes: 22 additions & 2 deletions apps/sim/lib/execution/isolated-vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ export interface IsolatedVMError {
line?: number
column?: number
lineContent?: string
/**
* True when the failure is host-infrastructure caused (worker crash, IPC
* failure, pool saturation, task misconfig) rather than anything the user's
* code did. Callers use this to keep genuine server failures as 5xx while
* translating user-caused failures (code errors, timeouts, aborts, per-owner
* rate limits) into 4xx. Defaults to undefined/false — new error sites
* default to user-caused unless explicitly marked.
*/
isSystemError?: boolean
}

const POOL_SIZE = Number.parseInt(env.IVM_POOL_SIZE) || 4
Expand Down Expand Up @@ -838,7 +847,11 @@ function cleanupWorker(workerId: number) {
pending.resolve({
result: null,
stdout: '',
error: { message: 'Code execution failed unexpectedly. Please try again.', name: 'Error' },
error: {
message: 'Code execution failed unexpectedly. Please try again.',
name: 'Error',
isSystemError: true,
},
})
workerInfo.pendingExecutions.delete(id)
}
Expand Down Expand Up @@ -1125,7 +1138,11 @@ function dispatchToWorker(
resolve({
result: null,
stdout: '',
error: { message: 'Code execution failed to start. Please try again.', name: 'Error' },
error: {
message: 'Code execution failed to start. Please try again.',
name: 'Error',
isSystemError: true,
},
})
if (workerInfo.retiring && workerInfo.activeExecutions === 0) {
cleanupWorker(workerInfo.id)
Expand Down Expand Up @@ -1159,6 +1176,7 @@ function enqueueExecution(
error: {
message: 'Code execution is at capacity. Please try again in a moment.',
name: 'Error',
isSystemError: true,
},
})
return
Expand Down Expand Up @@ -1198,6 +1216,7 @@ function enqueueExecution(
error: {
message: 'Code execution timed out waiting for an available worker. Please try again.',
name: 'Error',
isSystemError: true,
},
})
}, QUEUE_TIMEOUT_MS)
Expand Down Expand Up @@ -1294,6 +1313,7 @@ export async function executeInIsolatedVM(
error: {
message: `Task "${req.task.id}" requires broker "${brokerName}" but none was provided`,
name: 'Error',
isSystemError: true,
},
}
}
Expand Down
38 changes: 33 additions & 5 deletions apps/sim/lib/execution/sandbox/run-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ export interface RunSandboxTaskOptions {
signal?: AbortSignal
}

/**
* Thrown when the sandbox failure is attributable to the caller — user code
* errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from
* user code, client aborts, or per-owner rate limits. Callers should translate
* this into a 4xx response so genuine 5xx remains a signal of server health.
*
* System-origin failures (worker crash, IPC failure, pool saturation, task
* misconfig) are tagged with `isSystemError` at the isolated-vm layer and
* surface as a plain `Error` → 500.
*/
export class SandboxUserCodeError extends Error {
constructor(message: string, name: string, stack?: string) {
super(message)
this.name = name || 'SandboxUserCodeError'
if (stack) this.stack = stack
}
}

/**
* Executes a sandbox task inside the shared isolated-vm pool and returns the
* binary result buffer. Throws with a human-readable message if the task fails
Expand Down Expand Up @@ -70,7 +88,9 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined

if (result.error) {
logger.warn('Sandbox task failed', {
const isSystemError = result.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn('Sandbox task failed', {
taskId,
requestId,
workspaceId: input.workspaceId,
Expand All @@ -79,11 +99,19 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
timings: result.timings,
error: result.error.message,
errorName: result.error.name,
isSystemError,
})
const err = new Error(result.error.message)
err.name = result.error.name || 'SandboxTaskError'
if (result.error.stack) err.stack = result.error.stack
throw err
if (isSystemError) {
const err = new Error(result.error.message)
err.name = result.error.name || 'SandboxSystemError'
if (result.error.stack) err.stack = result.error.stack
throw err
}
throw new SandboxUserCodeError(
result.error.message,
result.error.name || 'SandboxTaskError',
result.error.stack
)
}

if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {
Expand Down
Loading