diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 9cc78d6ca..b09fde257 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -895,11 +895,12 @@ export async function POST(req: NextRequest) { userCodeStartLine = wrapperLines.length + 1 let codeToExecute = resolvedCode + let prependedLineCount = 0 if (isCustomTool) { - const paramDestructuring = Object.keys(executionParams) - .map((key) => `const ${key} = params.${key};`) - .join('\n') + const paramKeys = Object.keys(executionParams) + const paramDestructuring = paramKeys.map((key) => `const ${key} = params.${key};`).join('\n') codeToExecute = `${paramDestructuring}\n${resolvedCode}` + prependedLineCount = paramKeys.length } const isolatedResult = await executeInIsolatedVM({ @@ -920,14 +921,25 @@ export async function POST(req: NextRequest) { }) const ivmError = isolatedResult.error + // Adjust line number for prepended param destructuring in custom tools + let adjustedLine = ivmError.line + let adjustedLineContent = ivmError.lineContent + if (prependedLineCount > 0 && ivmError.line !== undefined) { + adjustedLine = Math.max(1, ivmError.line - prependedLineCount) + // Get line content from original user code, not the prepended code + const codeLines = resolvedCode.split('\n') + if (adjustedLine <= codeLines.length) { + adjustedLineContent = codeLines[adjustedLine - 1]?.trim() + } + } const enhancedError: EnhancedError = { message: ivmError.message, name: ivmError.name, stack: ivmError.stack, originalError: ivmError, - line: ivmError.line, + line: adjustedLine, column: ivmError.column, - lineContent: ivmError.lineContent, + lineContent: adjustedLineContent, } const userFriendlyErrorMessage = createUserFriendlyErrorMessage( diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs new file mode 100644 index 000000000..53aa5b6fc --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -0,0 +1,357 @@ +/** + * Node.js worker for isolated-vm execution. + * Runs in a separate Node.js process, communicates with parent via IPC. + */ + +const ivm = require('isolated-vm') + +const USER_CODE_START_LINE = 4 +const pendingFetches = new Map() +let fetchIdCounter = 0 +const FETCH_TIMEOUT_MS = 30000 + +/** + * Extract line and column from error stack or message + */ +function extractLineInfo(errorMessage, stack) { + if (stack) { + const stackMatch = stack.match(/(?:|user-function\.js):(\d+):(\d+)/) + if (stackMatch) { + return { + line: Number.parseInt(stackMatch[1], 10), + column: Number.parseInt(stackMatch[2], 10), + } + } + const atMatch = stack.match(/at\s+(?:|user-function\.js):(\d+):(\d+)/) + if (atMatch) { + return { + line: Number.parseInt(atMatch[1], 10), + column: Number.parseInt(atMatch[2], 10), + } + } + } + + const msgMatch = errorMessage.match(/:(\d+):(\d+)/) + if (msgMatch) { + return { + line: Number.parseInt(msgMatch[1], 10), + column: Number.parseInt(msgMatch[2], 10), + } + } + + return {} +} + +/** + * Convert isolated-vm error info to a format compatible with the route's error handling + */ +function convertToCompatibleError(errorInfo, userCode) { + const { name } = errorInfo + let { message, stack } = errorInfo + + message = message + .replace(/\s*\[user-function\.js:\d+:\d+\]/g, '') + .replace(/\s*\[:\d+:\d+\]/g, '') + .replace(/\s*\(:\d+:\d+\)/g, '') + .trim() + + const lineInfo = extractLineInfo(errorInfo.message, stack) + + let userLine + let lineContent + + if (lineInfo.line !== undefined) { + userLine = lineInfo.line - USER_CODE_START_LINE + const codeLines = userCode.split('\n') + if (userLine > 0 && userLine <= codeLines.length) { + lineContent = codeLines[userLine - 1]?.trim() + } else if (userLine <= 0) { + userLine = 1 + lineContent = codeLines[0]?.trim() + } else { + userLine = codeLines.length + lineContent = codeLines[codeLines.length - 1]?.trim() + } + } + + if (stack) { + stack = stack.replace(/:(\d+):(\d+)/g, (_, line, col) => { + const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE + return `user-function.js:${Math.max(1, adjustedLine)}:${col}` + }) + stack = stack.replace(/at :(\d+):(\d+)/g, (_, line, col) => { + const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE + return `at user-function.js:${Math.max(1, adjustedLine)}:${col}` + }) + } + + return { + message, + name, + stack, + line: userLine, + column: lineInfo.column, + lineContent, + } +} + +/** + * Execute code in isolated-vm + */ +async function executeCode(request) { + const { code, params, envVars, contextVariables, timeoutMs, requestId } = request + const stdoutChunks = [] + let isolate = null + + try { + isolate = new ivm.Isolate({ memoryLimit: 128 }) + const context = await isolate.createContext() + const jail = context.global + + await jail.set('global', jail.derefInto()) + + const logCallback = new ivm.Callback((...args) => { + const message = args + .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) + .join(' ') + stdoutChunks.push(`${message}\n`) + }) + await jail.set('__log', logCallback) + + const errorCallback = new ivm.Callback((...args) => { + const message = args + .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) + .join(' ') + stdoutChunks.push(`ERROR: ${message}\n`) + }) + await jail.set('__error', errorCallback) + + await jail.set('params', new ivm.ExternalCopy(params).copyInto()) + await jail.set('environmentVariables', new ivm.ExternalCopy(envVars).copyInto()) + + for (const [key, value] of Object.entries(contextVariables)) { + await jail.set(key, new ivm.ExternalCopy(value).copyInto()) + } + + const fetchCallback = new ivm.Reference(async (url, optionsJson) => { + return new Promise((resolve) => { + const fetchId = ++fetchIdCounter + const timeout = setTimeout(() => { + if (pendingFetches.has(fetchId)) { + pendingFetches.delete(fetchId) + resolve(JSON.stringify({ error: 'Fetch request timed out' })) + } + }, FETCH_TIMEOUT_MS) + pendingFetches.set(fetchId, { resolve, timeout }) + if (process.send && process.connected) { + process.send({ type: 'fetch', fetchId, requestId, url, optionsJson }) + } else { + clearTimeout(timeout) + pendingFetches.delete(fetchId) + resolve(JSON.stringify({ error: 'Parent process disconnected' })) + } + }) + }) + await jail.set('__fetchRef', fetchCallback) + + const bootstrap = ` + // Set up console object + const console = { + log: (...args) => __log(...args), + error: (...args) => __error(...args), + warn: (...args) => __log('WARN:', ...args), + info: (...args) => __log(...args), + }; + + // Set up fetch function that uses the host's secure fetch + async function fetch(url, options) { + let optionsJson; + if (options) { + try { + optionsJson = JSON.stringify(options); + } catch { + throw new Error('fetch options must be JSON-serializable'); + } + } + const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } }); + let result; + try { + result = JSON.parse(resultJson); + } catch { + throw new Error('Invalid fetch response'); + } + + if (result.error) { + throw new Error(result.error); + } + + // Create a Response-like object + return { + ok: result.ok, + status: result.status, + statusText: result.statusText, + headers: { + get: (name) => result.headers[name.toLowerCase()] || null, + entries: () => Object.entries(result.headers), + }, + text: async () => result.body, + json: async () => { + try { + return JSON.parse(result.body); + } catch (e) { + throw new Error('Failed to parse response as JSON: ' + e.message); + } + }, + blob: async () => { throw new Error('blob() not supported in sandbox'); }, + arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); }, + }; + } + + // Prevent access to dangerous globals with stronger protection + const undefined_globals = [ + 'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference', + 'ExternalCopy', 'process', 'require', 'module', 'exports', '__dirname', '__filename' + ]; + for (const name of undefined_globals) { + try { + Object.defineProperty(global, name, { + value: undefined, + writable: false, + configurable: false + }); + } catch {} + } + ` + + const bootstrapScript = await isolate.compileScript(bootstrap) + await bootstrapScript.run(context) + + const wrappedCode = ` + (async () => { + try { + const __userResult = await (async () => { + ${code} + })(); + return JSON.stringify({ success: true, result: __userResult }); + } catch (error) { + // Capture full error details including stack trace + const errorInfo = { + message: error.message || String(error), + name: error.name || 'Error', + stack: error.stack || '' + }; + console.error(error.stack || error.message || error); + return JSON.stringify({ success: false, errorInfo }); + } + })() + ` + + const userScript = await isolate.compileScript(wrappedCode, { filename: 'user-function.js' }) + const resultJson = await userScript.run(context, { timeout: timeoutMs, promise: true }) + + let result = null + let error + + if (typeof resultJson === 'string') { + try { + const parsed = JSON.parse(resultJson) + if (parsed.success) { + result = parsed.result + } else if (parsed.errorInfo) { + error = convertToCompatibleError(parsed.errorInfo, code) + } else { + error = { message: 'Unknown error', name: 'Error' } + } + } catch { + result = resultJson + } + } + + const stdout = stdoutChunks.join('') + + if (error) { + return { result: null, stdout, error } + } + + return { result, stdout } + } catch (err) { + const stdout = stdoutChunks.join('') + + if (err instanceof Error) { + const errorInfo = { + message: err.message, + name: err.name, + stack: err.stack, + } + + if (err.message.includes('Script execution timed out')) { + return { + result: null, + stdout, + error: { + message: `Execution timed out after ${timeoutMs}ms`, + name: 'TimeoutError', + }, + } + } + + return { + result: null, + stdout, + error: convertToCompatibleError(errorInfo, code), + } + } + + return { + result: null, + stdout, + error: { + message: String(err), + name: 'Error', + line: 1, + lineContent: code.split('\n')[0]?.trim(), + }, + } + } finally { + if (isolate) { + isolate.dispose() + } + } +} + +process.on('message', async (msg) => { + try { + if (msg.type === 'execute') { + const result = await executeCode(msg.request) + if (process.send && process.connected) { + process.send({ type: 'result', executionId: msg.executionId, result }) + } + } else if (msg.type === 'fetchResponse') { + const pending = pendingFetches.get(msg.fetchId) + if (pending) { + clearTimeout(pending.timeout) + pendingFetches.delete(msg.fetchId) + pending.resolve(msg.response) + } + } + } catch (err) { + if (msg.type === 'execute' && process.send && process.connected) { + process.send({ + type: 'result', + executionId: msg.executionId, + result: { + result: null, + stdout: '', + error: { + message: err instanceof Error ? err.message : 'Worker error', + name: 'WorkerError', + }, + }, + }) + } + } +}) + +if (process.send) { + process.send({ type: 'ready' }) +} diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 5411fc125..6af990eae 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -1,14 +1,12 @@ -import type ivm from 'isolated-vm' +import { type ChildProcess, spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { validateProxyUrl } from '@/lib/core/security/input-validation' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('IsolatedVMExecution') -async function loadIsolatedVM(): Promise { - const ivmModule = await import('isolated-vm') - return ivmModule.default -} - export interface IsolatedVMExecutionRequest { code: string params: Record @@ -33,367 +31,257 @@ export interface IsolatedVMError { lineContent?: string } -/** - * Number of lines added before user code in the wrapper. - * The wrapper structure is: - * Line 1: (empty - newline after template literal backtick) - * Line 2: (async () => { - * Line 3: try { - * Line 4: const __userResult = await (async () => { - * Line 5+: user code starts here - */ -const USER_CODE_START_LINE = 4 +interface PendingExecution { + resolve: (result: IsolatedVMExecutionResult) => void + timeout: ReturnType +} + +let worker: ChildProcess | null = null +let workerReady = false +let workerReadyPromise: Promise | null = null +let workerIdleTimeout: ReturnType | null = null +const pendingExecutions = new Map() +let executionIdCounter = 0 + +const WORKER_IDLE_TIMEOUT_MS = 60000 + +function cleanupWorker() { + if (workerIdleTimeout) { + clearTimeout(workerIdleTimeout) + workerIdleTimeout = null + } + if (worker) { + worker.kill() + worker = null + } + workerReady = false + workerReadyPromise = null +} + +function resetIdleTimeout() { + if (workerIdleTimeout) { + clearTimeout(workerIdleTimeout) + } + workerIdleTimeout = setTimeout(() => { + if (pendingExecutions.size === 0) { + logger.info('Cleaning up idle isolated-vm worker') + cleanupWorker() + } + }, WORKER_IDLE_TIMEOUT_MS) +} /** * Secure fetch wrapper that validates URLs to prevent SSRF attacks */ -async function secureFetch( - requestId: string, - url: string, - options?: RequestInit -): Promise<{ - ok: boolean - status: number - statusText: string - body: string - headers: Record -}> { +async function secureFetch(requestId: string, url: string, options?: RequestInit): Promise { const validation = validateProxyUrl(url) if (!validation.isValid) { logger.warn(`[${requestId}] Blocked fetch request due to SSRF validation`, { url: url.substring(0, 100), error: validation.error, }) - throw new Error(`Security Error: ${validation.error}`) + return JSON.stringify({ error: `Security Error: ${validation.error}` }) } - const response = await fetch(url, options) - const body = await response.text() - const headers: Record = {} - response.headers.forEach((value, key) => { - headers[key] = value + try { + const response = await fetch(url, options) + const body = await response.text() + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + return JSON.stringify({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + body, + headers, + }) + } catch (error: unknown) { + return JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown fetch error' }) + } +} + +/** + * Handle IPC messages from the Node.js worker + */ +function handleWorkerMessage(message: unknown) { + if (typeof message !== 'object' || message === null) return + const msg = message as Record + + if (msg.type === 'result') { + const pending = pendingExecutions.get(msg.executionId as number) + if (pending) { + clearTimeout(pending.timeout) + pendingExecutions.delete(msg.executionId as number) + pending.resolve(msg.result as IsolatedVMExecutionResult) + } + return + } + + if (msg.type === 'fetch') { + const { fetchId, requestId, url, optionsJson } = msg as { + fetchId: number + requestId: string + url: string + optionsJson?: string + } + let options: RequestInit | undefined + if (optionsJson) { + try { + options = JSON.parse(optionsJson) + } catch { + worker?.send({ + type: 'fetchResponse', + fetchId, + response: JSON.stringify({ error: 'Invalid fetch options JSON' }), + }) + return + } + } + secureFetch(requestId, url, options) + .then((response) => { + try { + worker?.send({ type: 'fetchResponse', fetchId, response }) + } catch (err) { + logger.error('Failed to send fetch response to worker', { err, fetchId }) + } + }) + .catch((err) => { + try { + worker?.send({ + type: 'fetchResponse', + fetchId, + response: JSON.stringify({ + error: err instanceof Error ? err.message : 'Fetch failed', + }), + }) + } catch (sendErr) { + logger.error('Failed to send fetch error to worker', { sendErr, fetchId }) + } + }) + } +} + +/** + * Start the Node.js worker process + */ +async function ensureWorker(): Promise { + if (workerReady && worker) return + if (workerReadyPromise) return workerReadyPromise + + workerReadyPromise = new Promise((resolve, reject) => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const workerPath = path.join(currentDir, 'isolated-vm-worker.cjs') + + if (!fs.existsSync(workerPath)) { + reject(new Error(`Worker file not found at ${workerPath}`)) + return + } + + worker = spawn(process.execPath, [workerPath], { + stdio: ['ignore', 'pipe', 'inherit', 'ipc'], + serialization: 'json', + }) + + worker.on('message', handleWorkerMessage) + + const startTimeout = setTimeout(() => { + worker?.kill() + worker = null + workerReady = false + workerReadyPromise = null + reject(new Error('Worker failed to start within timeout')) + }, 10000) + + const readyHandler = (message: unknown) => { + if ( + typeof message === 'object' && + message !== null && + (message as { type?: string }).type === 'ready' + ) { + workerReady = true + clearTimeout(startTimeout) + worker?.off('message', readyHandler) + resolve() + } + } + worker.on('message', readyHandler) + + worker.on('exit', (code) => { + logger.warn('Isolated-vm worker exited', { code }) + if (workerIdleTimeout) { + clearTimeout(workerIdleTimeout) + workerIdleTimeout = null + } + worker = null + workerReady = false + workerReadyPromise = null + for (const [id, pending] of pendingExecutions) { + clearTimeout(pending.timeout) + pending.resolve({ + result: null, + stdout: '', + error: { message: 'Worker process exited unexpectedly', name: 'WorkerError' }, + }) + pendingExecutions.delete(id) + } + }) }) - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - body, - headers, - } + return workerReadyPromise } /** - * Extract line and column from error stack or message - */ -function extractLineInfo(errorMessage: string, stack?: string): { line?: number; column?: number } { - if (stack) { - const stackMatch = stack.match(/(?:|user-function\.js):(\d+):(\d+)/) - if (stackMatch) { - return { - line: Number.parseInt(stackMatch[1], 10), - column: Number.parseInt(stackMatch[2], 10), - } - } - const atMatch = stack.match(/at\s+(?:|user-function\.js):(\d+):(\d+)/) - if (atMatch) { - return { - line: Number.parseInt(atMatch[1], 10), - column: Number.parseInt(atMatch[2], 10), - } - } - } - - const msgMatch = errorMessage.match(/:(\d+):(\d+)/) - if (msgMatch) { - return { - line: Number.parseInt(msgMatch[1], 10), - column: Number.parseInt(msgMatch[2], 10), - } - } - - return {} -} - -/** - * Convert isolated-vm error info to a format compatible with the route's error handling - */ -function convertToCompatibleError( - errorInfo: { - message: string - name: string - stack?: string - }, - userCode: string -): IsolatedVMError { - const { name } = errorInfo - let { message, stack } = errorInfo - - message = message - .replace(/\s*\[user-function\.js:\d+:\d+\]/g, '') - .replace(/\s*\[:\d+:\d+\]/g, '') - .replace(/\s*\(:\d+:\d+\)/g, '') - .trim() - - const lineInfo = extractLineInfo(errorInfo.message, stack) - - let userLine: number | undefined - let lineContent: string | undefined - - if (lineInfo.line !== undefined) { - userLine = lineInfo.line - USER_CODE_START_LINE - const codeLines = userCode.split('\n') - if (userLine > 0 && userLine <= codeLines.length) { - lineContent = codeLines[userLine - 1]?.trim() - } else if (userLine <= 0) { - userLine = 1 - lineContent = codeLines[0]?.trim() - } else { - userLine = codeLines.length - lineContent = codeLines[codeLines.length - 1]?.trim() - } - } - - if (stack) { - stack = stack.replace(/:(\d+):(\d+)/g, (_, line, col) => { - const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE - return `user-function.js:${Math.max(1, adjustedLine)}:${col}` - }) - stack = stack.replace(/at :(\d+):(\d+)/g, (_, line, col) => { - const adjustedLine = Number.parseInt(line, 10) - USER_CODE_START_LINE - return `at user-function.js:${Math.max(1, adjustedLine)}:${col}` - }) - } - - return { - message, - name, - stack, - line: userLine, - column: lineInfo.column, - lineContent, - } -} - -/** - * Execute JavaScript code in an isolated V8 isolate + * Execute JavaScript code in an isolated V8 isolate via Node.js subprocess. + * The worker's V8 isolate enforces timeoutMs internally. The parent timeout + * (timeoutMs + 1000) is a safety buffer for IPC communication. */ export async function executeInIsolatedVM( req: IsolatedVMExecutionRequest ): Promise { - const { code, params, envVars, contextVariables, timeoutMs, requestId } = req + if (workerIdleTimeout) { + clearTimeout(workerIdleTimeout) + workerIdleTimeout = null + } - const stdoutChunks: string[] = [] - let isolate: ivm.Isolate | null = null - - const ivm = await loadIsolatedVM() - - try { - isolate = new ivm.Isolate({ memoryLimit: 128 }) - const context = await isolate.createContext() - - const jail = context.global - - await jail.set('global', jail.derefInto()) - - const logCallback = new ivm.Callback((...args: unknown[]) => { - const message = args - .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) - .join(' ') - stdoutChunks.push(`${message}\n`) - }) - await jail.set('__log', logCallback) - - const errorCallback = new ivm.Callback((...args: unknown[]) => { - const message = args - .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) - .join(' ') - logger.error(`[${requestId}] Code Console Error: ${message}`) - stdoutChunks.push(`ERROR: ${message}\n`) - }) - await jail.set('__error', errorCallback) - - await jail.set('params', new ivm.ExternalCopy(params).copyInto()) - - await jail.set('environmentVariables', new ivm.ExternalCopy(envVars).copyInto()) - - for (const [key, value] of Object.entries(contextVariables)) { - await jail.set(key, new ivm.ExternalCopy(value).copyInto()) - } - - const fetchCallback = new ivm.Reference(async (url: string, optionsJson?: string) => { - try { - const options = optionsJson ? JSON.parse(optionsJson) : undefined - const result = await secureFetch(requestId, url, options) - return JSON.stringify(result) - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown fetch error' - return JSON.stringify({ error: errorMessage }) - } - }) - await jail.set('__fetchRef', fetchCallback) - - const bootstrap = ` - // Set up console object - const console = { - log: (...args) => __log(...args), - error: (...args) => __error(...args), - warn: (...args) => __log('WARN:', ...args), - info: (...args) => __log(...args), - }; - - // Set up fetch function that uses the host's secure fetch - async function fetch(url, options) { - let optionsJson; - if (options) { - try { - optionsJson = JSON.stringify(options); - } catch { - throw new Error('fetch options must be JSON-serializable'); - } - } - const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } }); - let result; - try { - result = JSON.parse(resultJson); - } catch { - throw new Error('Invalid fetch response'); - } - - if (result.error) { - throw new Error(result.error); - } - - // Create a Response-like object - return { - ok: result.ok, - status: result.status, - statusText: result.statusText, - headers: { - get: (name) => result.headers[name.toLowerCase()] || null, - entries: () => Object.entries(result.headers), - }, - text: async () => result.body, - json: async () => { - try { - return JSON.parse(result.body); - } catch (e) { - throw new Error('Failed to parse response as JSON: ' + e.message); - } - }, - blob: async () => { throw new Error('blob() not supported in sandbox'); }, - arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); }, - }; - } - - // Prevent access to dangerous globals with stronger protection - const undefined_globals = [ - 'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference', - 'ExternalCopy', 'process', 'require', 'module', 'exports', '__dirname', '__filename' - ]; - for (const name of undefined_globals) { - try { - Object.defineProperty(global, name, { - value: undefined, - writable: false, - configurable: false - }); - } catch {} - } - ` - - const bootstrapScript = await isolate.compileScript(bootstrap) - await bootstrapScript.run(context) - - const wrappedCode = ` - (async () => { - try { - const __userResult = await (async () => { - ${code} - })(); - return JSON.stringify({ success: true, result: __userResult }); - } catch (error) { - // Capture full error details including stack trace - const errorInfo = { - message: error.message || String(error), - name: error.name || 'Error', - stack: error.stack || '' - }; - console.error(error.stack || error.message || error); - return JSON.stringify({ success: false, errorInfo }); - } - })() - ` - - const userScript = await isolate.compileScript(wrappedCode, { filename: 'user-function.js' }) - const resultJson = await userScript.run(context, { timeout: timeoutMs, promise: true }) - - let result: unknown = null - let error: IsolatedVMError | undefined - - if (typeof resultJson === 'string') { - try { - const parsed = JSON.parse(resultJson) - if (parsed.success) { - result = parsed.result - } else if (parsed.errorInfo) { - error = convertToCompatibleError(parsed.errorInfo, code) - } else { - error = { message: 'Unknown error', name: 'Error' } - } - } catch { - result = resultJson - } - } - - const stdout = stdoutChunks.join('') - - if (error) { - return { result: null, stdout, error } - } - - return { result, stdout } - } catch (err: unknown) { - const stdout = stdoutChunks.join('') - - if (err instanceof Error) { - const errorInfo = { - message: err.message, - name: err.name, - stack: err.stack, - } - - if (err.message.includes('Script execution timed out')) { - return { - result: null, - stdout, - error: { - message: `Execution timed out after ${timeoutMs}ms`, - name: 'TimeoutError', - }, - } - } - - return { - result: null, - stdout, - error: convertToCompatibleError(errorInfo, code), - } - } + await ensureWorker() + if (!worker) { return { result: null, - stdout, - error: { - message: String(err), - name: 'Error', - line: 1, - lineContent: code.split('\n')[0]?.trim(), - }, - } - } finally { - if (isolate) { - isolate.dispose() + stdout: '', + error: { message: 'Failed to start isolated-vm worker', name: 'WorkerError' }, } } + + const executionId = ++executionIdCounter + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + pendingExecutions.delete(executionId) + resolve({ + result: null, + stdout: '', + error: { message: `Execution timed out after ${req.timeoutMs}ms`, name: 'TimeoutError' }, + }) + }, req.timeoutMs + 1000) + + pendingExecutions.set(executionId, { resolve, timeout }) + + try { + worker!.send({ type: 'execute', executionId, request: req }) + } catch { + clearTimeout(timeout) + pendingExecutions.delete(executionId) + resolve({ + result: null, + stdout: '', + error: { message: 'Failed to send execution request to worker', name: 'WorkerError' }, + }) + return + } + + resetIdleTimeout() + }) } diff --git a/bun.lock b/bun.lock index 004b8e3d5..fa7ace079 100644 --- a/bun.lock +++ b/bun.lock @@ -1651,6 +1651,8 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], @@ -3781,6 +3783,8 @@ "body-parser/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], + "bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], @@ -4237,6 +4241,8 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "chrome-launcher/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index ba1236860..4d5a7be32 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -1,24 +1,31 @@ # ======================================== -# Base Stage: Alpine Linux with Bun +# Base Stage: Debian-based Bun # ======================================== -FROM oven/bun:1.3.3-alpine AS base +FROM oven/bun:1.3.3-slim AS base # ======================================== # Dependencies Stage: Install Dependencies # ======================================== FROM base AS deps -RUN apk add --no-cache libc6-compat WORKDIR /app +# Install Node.js 22 for isolated-vm compilation (requires node-gyp and V8) +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + COPY package.json bun.lock turbo.json ./ RUN mkdir -p apps packages/db COPY apps/sim/package.json ./apps/sim/package.json COPY packages/db/package.json ./packages/db/package.json -# Install turbo globally and dependencies with cache mount for faster builds +# Install turbo globally, then dependencies, then rebuild isolated-vm for Node.js RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ bun install -g turbo && \ - bun install --omit=dev --ignore-scripts + HUSKY=0 bun install --omit=dev --ignore-scripts && \ + cd $(readlink -f node_modules/isolated-vm) && npx node-gyp rebuild --release && cd /app # ======================================== # Builder Stage: Build the Application @@ -51,7 +58,7 @@ COPY packages ./packages # Required for standalone nextjs build WORKDIR /app/apps/sim RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ - bun install sharp + HUSKY=0 bun install sharp ENV NEXT_TELEMETRY_DISABLED=1 \ VERCEL_TELEMETRY_DISABLED=1 \ @@ -78,21 +85,30 @@ RUN bun run build FROM base AS runner WORKDIR /app -# Install Python and dependencies for guardrails PII detection (cached separately) -# Also install ffmpeg for audio/video processing in STT -RUN apk add --no-cache python3 py3-pip bash ffmpeg +# Install Node.js 22 (for isolated-vm worker), Python, and other runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv bash ffmpeg curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production -# Create non-root user and group (cached separately) -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nextjs -u 1001 +# Create non-root user and group +RUN groupadd -g 1001 nodejs && \ + useradd -u 1001 -g nodejs nextjs -# Copy application artifacts from builder (these change on every build) +# Copy application artifacts from builder COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/public ./apps/sim/public COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/static ./apps/sim/.next/static +# Copy isolated-vm native module (compiled for Node.js in deps stage) +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/isolated-vm ./node_modules/isolated-vm + +# Copy the isolated-vm worker script +COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-worker.cjs ./apps/sim/lib/execution/isolated-vm-worker.cjs + # Guardrails setup (files need to be owned by nextjs for runtime) COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/setup.sh ./apps/sim/lib/guardrails/setup.sh COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt