feat(e2b-execution): add remote code execution to support Python + Imports (#1226)

* feat(e2b-execution): add remote code execution via e2b

* ux improvements

* fix streaming

* progress

* fix tooltip text

* make supported languages an enum

* fix error handling

* fix tests
This commit is contained in:
Vikhyath Mondreti
2025-09-02 18:15:29 -07:00
committed by GitHub
parent 9de0d91f9a
commit 1a5d5ddffa
17 changed files with 658 additions and 70 deletions

View File

@@ -32,6 +32,14 @@ describe('Function Execute API Route', () => {
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/execution/e2b', () => ({
executeInE2B: vi.fn().mockResolvedValue({
result: 'e2b success',
stdout: 'e2b output',
sandboxId: 'test-sandbox-id',
}),
}))
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
@@ -45,6 +53,7 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return "Hello World"',
timeout: 5000,
useLocalVM: true,
})
const { POST } = await import('@/app/api/function/execute/route')
@@ -74,6 +83,7 @@ describe('Function Execute API Route', () => {
it('should use default timeout when not provided', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
useLocalVM: true,
})
const { POST } = await import('@/app/api/function/execute/route')
@@ -93,6 +103,7 @@ describe('Function Execute API Route', () => {
it('should resolve environment variables with {{var_name}} syntax', async () => {
const req = createMockRequest('POST', {
code: 'return {{API_KEY}}',
useLocalVM: true,
envVars: {
API_KEY: 'secret-key-123',
},
@@ -108,6 +119,7 @@ describe('Function Execute API Route', () => {
it('should resolve tag variables with <tag_name> syntax', async () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: {
email: { id: '123', subject: 'Test Email' },
},
@@ -123,6 +135,7 @@ describe('Function Execute API Route', () => {
it('should NOT treat email addresses as template variables', async () => {
const req = createMockRequest('POST', {
code: 'return "Email sent to user"',
useLocalVM: true,
params: {
email: {
from: 'Waleed Latif <waleed@sim.ai>',
@@ -141,6 +154,7 @@ describe('Function Execute API Route', () => {
it('should only match valid variable names in angle brackets', async () => {
const req = createMockRequest('POST', {
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
useLocalVM: true,
params: {
validVar: 'hello',
another_valid: 'world',
@@ -178,6 +192,7 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: gmailData,
})
@@ -200,6 +215,7 @@ describe('Function Execute API Route', () => {
const req = createMockRequest('POST', {
code: 'return <email>',
useLocalVM: true,
params: complexEmailData,
})
@@ -214,6 +230,7 @@ describe('Function Execute API Route', () => {
it('should handle custom tool execution with direct parameter access', async () => {
const req = createMockRequest('POST', {
code: 'return location + " weather is sunny"',
useLocalVM: true,
params: {
location: 'San Francisco',
},
@@ -245,6 +262,7 @@ describe('Function Execute API Route', () => {
it('should handle timeout parameter', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
useLocalVM: true,
timeout: 10000,
})
@@ -262,6 +280,7 @@ describe('Function Execute API Route', () => {
it('should handle empty parameters object', async () => {
const req = createMockRequest('POST', {
code: 'return "no params"',
useLocalVM: true,
params: {},
})
@@ -295,6 +314,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;',
useLocalVM: true,
timeout: 5000,
})
@@ -338,6 +358,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = null;\nreturn obj.someMethod();',
useLocalVM: true,
timeout: 5000,
})
@@ -379,6 +400,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const x = 42;\nreturn undefinedVariable + x;',
useLocalVM: true,
timeout: 5000,
})
@@ -409,6 +431,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'return "test";',
useLocalVM: true,
timeout: 5000,
})
@@ -445,6 +468,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
useLocalVM: true,
timeout: 5000,
})
@@ -476,6 +500,7 @@ SyntaxError: Invalid or unexpected token
const req = createMockRequest('POST', {
code: 'const obj = {\n name: "test"\n// Missing closing brace',
useLocalVM: true,
timeout: 5000,
})
@@ -496,6 +521,7 @@ SyntaxError: Invalid or unexpected token
// This tests the escapeRegExp function indirectly
const req = createMockRequest('POST', {
code: 'return {{special.chars+*?}}',
useLocalVM: true,
envVars: {
'special.chars+*?': 'escaped-value',
},
@@ -512,6 +538,7 @@ SyntaxError: Invalid or unexpected token
// Test with complex but not circular data first
const req = createMockRequest('POST', {
code: 'return <complexData>',
useLocalVM: true,
params: {
complexData: {
special: 'chars"with\'quotes',

View File

@@ -1,5 +1,7 @@
import { createContext, Script } from 'vm'
import { type NextRequest, NextResponse } from 'next/server'
import { executeInE2B } from '@/lib/execution/e2b'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
@@ -8,6 +10,10 @@ export const maxDuration = 60
const logger = createLogger('FunctionExecuteAPI')
// Constants for E2B code wrapping line counts
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
/**
* Enhanced error information interface
*/
@@ -124,6 +130,103 @@ function extractEnhancedError(
return enhanced
}
/**
* Parse and format E2B error message
* Removes E2B-specific line references and adds correct user line numbers
*/
function formatE2BError(
errorMessage: string,
errorOutput: string,
language: CodeLanguage,
userCode: string,
prologueLineCount: number
): { formattedError: string; cleanedOutput: string } {
// Calculate line offset based on language and prologue
const wrapperLines =
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
const totalOffset = prologueLineCount + wrapperLines
let userLine: number | undefined
let cleanErrorType = ''
let cleanErrorMsg = ''
if (language === CodeLanguage.Python) {
// Python error format: "Cell In[X], line Y" followed by error details
// Extract line number from the Cell reference
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
if (cellMatch) {
const originalLine = Number.parseInt(cellMatch[1], 10)
userLine = originalLine - totalOffset
}
// Extract clean error message from the error string
// Remove file references like "(detected at line X) (file.py, line Y)"
cleanErrorMsg = errorMessage
.replace(/\s*\(detected at line \d+\)/g, '')
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
.trim()
} else if (language === CodeLanguage.JavaScript) {
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
// First, extract the error type and message from the first line
const firstLineEnd = errorMessage.indexOf('\n')
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
if (jsErrorMatch) {
cleanErrorType = jsErrorMatch[1]
cleanErrorMsg = jsErrorMatch[2].trim()
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
userLine = originalLine - totalOffset
} else {
// Fallback: look for line number in the arrow pointer line (> 11 |)
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
if (arrowMatch) {
const originalLine = Number.parseInt(arrowMatch[1], 10)
userLine = originalLine - totalOffset
}
// Try to extract error type and message
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
if (errorMatch) {
cleanErrorType = errorMatch[1]
cleanErrorMsg = errorMatch[2]
.replace(/^[^:]+:\s*/, '') // Remove file path
.replace(/\s*\(\d+:\d+\)\s*$/, '') // Remove line:col at end
.trim()
} else {
cleanErrorMsg = firstLine
}
}
}
// Build the final clean error message
const finalErrorMsg =
cleanErrorType && cleanErrorMsg
? `${cleanErrorType}: ${cleanErrorMsg}`
: cleanErrorMsg || errorMessage
// Format with line number if available
let formattedError = finalErrorMsg
if (userLine && userLine > 0) {
const codeLines = userCode.split('\n')
// Clamp userLine to the actual user code range
const actualUserLine = Math.min(userLine, codeLines.length)
if (actualUserLine > 0 && actualUserLine <= codeLines.length) {
const lineContent = codeLines[actualUserLine - 1]?.trim()
if (lineContent) {
formattedError = `Line ${actualUserLine}: \`${lineContent}\` - ${finalErrorMsg}`
} else {
formattedError = `Line ${actualUserLine} - ${finalErrorMsg}`
}
}
}
// For stdout, just return the clean error message without the full traceback
const cleanedOutput = finalErrorMsg
return { formattedError, cleanedOutput }
}
/**
* Create a detailed error message for users
*/
@@ -442,6 +545,8 @@ export async function POST(req: NextRequest) {
code,
params = {},
timeout = 5000,
language = DEFAULT_CODE_LANGUAGE,
useLocalVM = false,
envVars = {},
blockData = {},
blockNameMapping = {},
@@ -474,19 +579,163 @@ export async function POST(req: NextRequest) {
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
const executionMethod = 'vm' // Default execution method
const e2bEnabled = process.env.E2B_ENABLED === 'true'
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
const useE2B =
e2bEnabled &&
!useLocalVM &&
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
logger.info(`[${requestId}] Using VM for code execution`, {
hasEnvVars: Object.keys(envVars).length > 0,
hasWorkflowVariables: Object.keys(workflowVariables).length > 0,
})
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: e2bEnabled,
hasApiKey: Boolean(process.env.E2B_API_KEY),
language: lang,
})
let prologue = ''
const epilogue = ''
// Create a secure context with console logging
if (lang === CodeLanguage.JavaScript) {
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += `const params = JSON.parse(${JSON.stringify(JSON.stringify(executionParams))});\n`
prologueLineCount++
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
prologueLineCount++
}
const wrapped = [
';(async () => {',
' try {',
' const __sim_result = await (async () => {',
` ${resolvedCode.split('\n').join('\n ')}`,
' })();',
" console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));",
' } catch (error) {',
' console.log(String((error && (error.stack || error.message)) || error));',
' throw error;',
' }',
'})();',
].join('\n')
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
result: e2bResult,
stdout: e2bStdout,
sandboxId,
error: e2bError,
} = await executeInE2B({
code: codeForE2B,
language: CodeLanguage.JavaScript,
timeoutMs: timeout,
})
const executionTime = Date.now() - execStart
stdout += e2bStdout
logger.info(`[${requestId}] E2B JS sandbox`, {
sandboxId,
stdoutPreview: e2bStdout?.slice(0, 200),
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
e2bStdout,
lang,
resolvedCode,
prologueLineCount
)
return NextResponse.json(
{
success: false,
error: formattedError,
output: { result: null, stdout: cleanedOutput, executionTime },
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
output: { result: e2bResult ?? null, stdout, executionTime },
})
}
// Track prologue lines for error adjustment
let prologueLineCount = 0
prologue += 'import json\n'
prologueLineCount++
prologue += `params = json.loads(${JSON.stringify(JSON.stringify(executionParams))})\n`
prologueLineCount++
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
prologueLineCount++
}
const wrapped = [
'def __sim_main__():',
...resolvedCode.split('\n').map((l) => ` ${l}`),
'__sim_result__ = __sim_main__()',
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
].join('\n')
const codeForE2B = prologue + wrapped + epilogue
const execStart = Date.now()
const {
result: e2bResult,
stdout: e2bStdout,
sandboxId,
error: e2bError,
} = await executeInE2B({
code: codeForE2B,
language: CodeLanguage.Python,
timeoutMs: timeout,
})
const executionTime = Date.now() - execStart
stdout += e2bStdout
logger.info(`[${requestId}] E2B Py sandbox`, {
sandboxId,
stdoutPreview: e2bStdout?.slice(0, 200),
error: e2bError,
})
// If there was an execution error, format it properly
if (e2bError) {
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
e2bStdout,
lang,
resolvedCode,
prologueLineCount
)
return NextResponse.json(
{
success: false,
error: formattedError,
output: { result: null, stdout: cleanedOutput, executionTime },
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
output: { result: e2bResult ?? null, stdout, executionTime },
})
}
const executionMethod = 'vm'
const context = createContext({
params: executionParams,
environmentVariables: envVars,
...contextVariables, // Add resolved variables directly to context
fetch: globalThis.fetch || require('node-fetch').default,
...contextVariables,
fetch: (globalThis as any).fetch || require('node-fetch').default,
console: {
log: (...args: any[]) => {
const logMessage = `${args
@@ -504,23 +753,17 @@ export async function POST(req: NextRequest) {
},
})
// Calculate line offset for user code to provide accurate error reporting
const wrapperLines = ['(async () => {', ' try {']
// Add custom tool parameter declarations if needed
if (isCustomTool) {
wrapperLines.push(' // For custom tools, make parameters directly accessible')
Object.keys(executionParams).forEach((key) => {
wrapperLines.push(` const ${key} = params.${key};`)
})
}
userCodeStartLine = wrapperLines.length + 1 // +1 because user code starts on next line
// Build the complete script with proper formatting for line numbers
userCodeStartLine = wrapperLines.length + 1
const fullScript = [
...wrapperLines,
` ${resolvedCode.split('\n').join('\n ')}`, // Indent user code
` ${resolvedCode.split('\n').join('\n ')}`,
' } catch (error) {',
' console.error(error);',
' throw error;',
@@ -529,33 +772,26 @@ export async function POST(req: NextRequest) {
].join('\n')
const script = new Script(fullScript, {
filename: 'user-function.js', // This filename will appear in stack traces
lineOffset: 0, // Start line numbering from 0
columnOffset: 0, // Start column numbering from 0
filename: 'user-function.js',
lineOffset: 0,
columnOffset: 0,
})
const result = await script.runInContext(context, {
timeout,
displayErrors: true,
breakOnSigint: true, // Allow breaking on SIGINT for better debugging
breakOnSigint: true,
})
// }
const executionTime = Date.now() - startTime
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
executionTime,
})
const response = {
return NextResponse.json({
success: true,
output: {
result,
stdout,
executionTime,
},
}
return NextResponse.json(response)
output: { result, stdout, executionTime },
})
} catch (error: any) {
const executionTime = Date.now() - startTime
logger.error(`[${requestId}] Function execution failed`, {