Files
simstudioai--sim/apps/sim/lib/execution/e2b.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

477 lines
15 KiB
TypeScript

import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { CodeLanguage } from '@/lib/execution/languages'
/**
* A sandbox input file. `content` entries are written inline; `url` entries are fetched from inside
* the sandbox (so large mounts never pass their bytes through the web process).
*/
export type SandboxFile =
| { type?: 'content'; path: string; content: string; encoding?: 'base64' }
| { type: 'url'; path: string; url: string }
export interface E2BExecutionRequest {
code: string
language: CodeLanguage
timeoutMs: number
sandboxFiles?: SandboxFile[]
outputSandboxPath?: string
outputSandboxPaths?: string[]
// Which sandbox template to run in. Defaults to 'code' (mothership-shell).
// Document generation passes 'doc' so it runs in the doc template
// (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed.
sandboxKind?: 'code' | 'doc'
}
export interface E2BShellExecutionRequest {
code: string
envs: Record<string, string>
timeoutMs: number
sandboxFiles?: SandboxFile[]
outputSandboxPath?: string
outputSandboxPaths?: string[]
// Which sandbox template to run in. Defaults to 'shell' (mothership-shell).
// The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so
// they run in the doc template (mothership-docs).
sandboxKind?: 'shell' | 'doc'
}
export interface E2BExecutionResult {
result: unknown
stdout: string
sandboxId?: string
error?: string
exportedFileContent?: string
exportedFiles?: Record<string, string>
/** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */
images?: string[]
}
const logger = createLogger('E2BExecution')
/**
* Materializes sandbox input files before user code runs. `content` entries are written inline;
* `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the
* web process, so the mount size is bounded by sandbox disk, not web heap. The URL and paths are
* passed as env vars (never interpolated into the shell) so a presigned query string can't break or
* inject. A failed fetch throws so user code never runs against a missing mount. `rootUser` matches
* the shell sandbox's root execution context.
*/
async function writeSandboxInputs(
sandbox: E2BSandbox,
files: SandboxFile[] | undefined,
opts: { sandboxId?: string; rootUser?: boolean }
): Promise<void> {
if (!files?.length) return
const fetchedByUrl: string[] = []
const writtenInline: string[] = []
for (const file of files) {
if (file.type === 'url') {
const dir = file.path.slice(0, file.path.lastIndexOf('/'))
try {
await sandbox.commands.run(
'set -e; [ -n "$DIR" ] && mkdir -p "$DIR"; curl -fsS --retry 3 --retry-connrefused --max-time 300 "$URL" -o "$DST"',
{
envs: { URL: file.url, DST: file.path, DIR: dir },
...(opts.rootUser ? { user: 'root' } : {}),
}
)
fetchedByUrl.push(file.path)
} catch (error) {
throw new Error(
`Failed to fetch mounted file into sandbox at ${file.path}: ${getErrorMessage(error)}`
)
}
} else if (file.encoding === 'base64') {
const buf = Buffer.from(file.content, 'base64')
await sandbox.files.write(
file.path,
buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
)
writtenInline.push(file.path)
} else {
await sandbox.files.write(file.path, file.content)
writtenInline.push(file.path)
}
}
// Split counts so it's visible whether a mount was fetched in-sandbox (by presigned URL, no bytes
// through the web process) or written inline.
logger.info('Materialized sandbox inputs', {
sandboxId: opts.sandboxId,
fetchedByUrlCount: fetchedByUrl.length,
writtenInlineCount: writtenInline.length,
fetchedByUrl,
writtenInline,
})
}
async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise<E2BSandbox> {
const apiKey = env.E2B_API_KEY
if (!apiKey) {
throw new Error('E2B_API_KEY is required when E2B is enabled')
}
// Document generation uses a dedicated template (python-pptx/docx/openpyxl/
// reportlab + fonts); shell/code execution use the general shell template.
// Doc fails closed: never run LLM-authored Python in E2B's default template
// (which is not vetted for this) just because the doc template id is unset.
if (kind === 'doc' && !env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) {
throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)')
}
// Pi fails closed for the same reason: the coding agent needs the Pi CLI + git
// baked into a vetted template, never E2B's default image.
if (kind === 'pi' && !env.E2B_PI_TEMPLATE_ID) {
throw new Error('Pi cloud agent not configured (E2B_PI_TEMPLATE_ID is unset)')
}
const templateName =
kind === 'doc'
? env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID
: kind === 'pi'
? env.E2B_PI_TEMPLATE_ID
: env.MOTHERSHIP_E2B_TEMPLATE_ID
logger.info('Creating E2B sandbox', {
kind,
template: templateName || '(default)',
})
const { Sandbox } = await import('@e2b/code-interpreter')
return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey })
}
function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean {
const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase()
const binaryExts = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.pdf',
'.zip',
'.mp3',
'.mp4',
'.docx',
'.pptx',
'.xlsx',
])
return binaryExts.has(ext)
}
async function readSandboxOutputFile(
sandbox: E2BSandbox,
outputSandboxPath: string,
options?: { user?: string }
): Promise<string | undefined> {
try {
if (shouldReadSandboxPathAsBase64(outputSandboxPath)) {
const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options)
return b64Result.stdout
}
return await sandbox.files.read(outputSandboxPath)
} catch (error) {
logger.warn('Failed to read requested sandbox output file', {
outputSandboxPath,
error: getErrorMessage(error),
})
return undefined
}
}
function requestedOutputSandboxPaths(req: {
outputSandboxPath?: string
outputSandboxPaths?: string[]
}): string[] {
const paths = [...(req.outputSandboxPaths ?? [])]
if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) {
paths.push(req.outputSandboxPath)
}
return paths
}
export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecutionResult> {
const { code, language, timeoutMs } = req
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'code')
const sandboxId = sandbox.sandboxId
const stdoutChunks = []
try {
// Inside the try so a failed mount still kills the sandbox via the finally below.
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId })
const execution = await sandbox.runCode(code, {
language: language === CodeLanguage.Python ? 'python' : 'javascript',
timeoutMs,
})
if (execution.error) {
const errorMessage = `${execution.error.name}: ${execution.error.value}`
logger.error(`E2B execution error`, {
sandboxId,
error: execution.error,
errorMessage,
})
const errorOutput = execution.error.traceback || errorMessage
return {
result: null,
stdout: errorOutput,
error: errorMessage,
sandboxId,
}
}
if (execution.text) {
stdoutChunks.push(execution.text)
}
if (execution.logs?.stdout) {
stdoutChunks.push(...execution.logs.stdout)
}
if (execution.logs?.stderr) {
stdoutChunks.push(...execution.logs.stderr)
}
const stdout = stdoutChunks.join('\n')
let result: unknown = null
const prefix = '__SIM_RESULT__='
const lines = stdout.split('\n')
const marker = lines.find((l) => l.startsWith(prefix))
let cleanedStdout = stdout
if (marker) {
const jsonPart = marker.slice(prefix.length)
try {
result = JSON.parse(jsonPart)
} catch {
result = jsonPart
}
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
filteredLines.pop()
}
cleanedStdout = filteredLines.join('\n')
}
const images: string[] = []
if (execution.results?.length) {
for (const r of execution.results) {
if (r.png) {
images.push(r.png)
} else if (r.jpeg) {
images.push(r.jpeg)
}
}
}
const exportedFiles: Record<string, string> = {}
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
const content = await readSandboxOutputFile(sandbox, outputSandboxPath)
if (content !== undefined) {
exportedFiles[outputSandboxPath] = content
}
}
const exportedFileContent = req.outputSandboxPath
? exportedFiles[req.outputSandboxPath]
: undefined
return {
result,
stdout: cleanedStdout,
sandboxId,
exportedFileContent,
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
images: images.length ? images : undefined,
}
} finally {
try {
await sandbox.kill()
} catch {}
}
}
export async function executeShellInE2B(
req: E2BShellExecutionRequest
): Promise<E2BExecutionResult> {
const { code, envs, timeoutMs } = req
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'shell')
const sandboxId = sandbox.sandboxId
try {
// Inside the try so a failed mount still kills the sandbox via the finally below.
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId, rootUser: true })
let result: { stdout: string; stderr: string; exitCode: number }
try {
result = await sandbox.commands.run(code, {
envs: {
...envs,
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin',
},
timeoutMs,
user: 'root',
})
} catch (cmdError: any) {
const stderr = cmdError?.stderr || cmdError?.message || String(cmdError)
const stdout = cmdError?.stdout || ''
const exitCode = cmdError?.exitCode ?? 1
logger.error('E2B shell command error', {
sandboxId,
exitCode,
error: stderr.slice(0, 500),
})
return {
result: null,
stdout: [stdout, stderr].filter(Boolean).join('\n'),
error: stderr || `Command failed with exit code ${exitCode}`,
sandboxId,
}
}
const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n')
if (result.exitCode !== 0) {
const errorMessage = result.stderr || `Process exited with code ${result.exitCode}`
logger.error('E2B shell execution error', {
sandboxId,
exitCode: result.exitCode,
stderr: result.stderr?.slice(0, 500),
})
return {
result: null,
stdout,
error: errorMessage,
sandboxId,
}
}
let parsed: unknown = null
const prefix = '__SIM_RESULT__='
const lines = stdout.split('\n')
const marker = lines.find((l) => l.startsWith(prefix))
let cleanedStdout = stdout
if (marker) {
const jsonPart = marker.slice(prefix.length)
try {
parsed = JSON.parse(jsonPart)
} catch {
parsed = jsonPart
}
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
filteredLines.pop()
}
cleanedStdout = filteredLines.join('\n')
}
const exportedFiles: Record<string, string> = {}
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
const content = await readSandboxOutputFile(sandbox, outputSandboxPath, {
user: 'root',
})
if (content !== undefined) {
exportedFiles[outputSandboxPath] = content
}
}
const exportedFileContent = req.outputSandboxPath
? exportedFiles[req.outputSandboxPath]
: undefined
return {
result: parsed,
stdout: cleanedStdout,
sandboxId,
exportedFileContent,
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
}
} finally {
try {
await sandbox.kill()
} catch {}
}
}
const PI_SANDBOX_PATH =
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin'
/** Result of one command run inside a Pi sandbox. */
export interface PiSandboxCommandResult {
stdout: string
stderr: string
exitCode: number
}
/** Runs commands and moves files inside a live Pi sandbox. */
export interface PiSandboxRunner {
run(
command: string,
options: {
envs?: Record<string, string>
timeoutMs: number
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
}
): Promise<PiSandboxCommandResult>
readFile(path: string): Promise<string>
/**
* Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK,
* never a shell, so untrusted content (the assembled prompt, a commit message)
* is delivered without any shell parsing — callers reference it by a fixed path.
*/
writeFile(path: string, content: string): Promise<void>
}
/**
* Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned
* repo persists across the clone -> agent -> push commands), streams command
* output, and always kills the sandbox afterward. Per-command envs are isolated,
* so secrets handed to one command never leak into the next.
*/
export async function withPiSandbox<T>(fn: (runner: PiSandboxRunner) => Promise<T>): Promise<T> {
const sandbox = await createE2BSandbox('pi')
const sandboxId = sandbox.sandboxId
logger.info('Started Pi sandbox', { sandboxId })
const runner: PiSandboxRunner = {
run: async (command, options) => {
try {
const result = await sandbox.commands.run(command, {
envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH },
timeoutMs: options.timeoutMs,
user: 'root',
onStdout: options.onStdout,
onStderr: options.onStderr,
})
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
} catch (error) {
const failure = error as {
stdout?: string
stderr?: string
message?: string
exitCode?: number
}
return {
stdout: failure.stdout ?? '',
stderr: failure.stderr ?? failure.message ?? getErrorMessage(error),
exitCode: failure.exitCode ?? 1,
}
}
},
readFile: (path) => sandbox.files.read(path),
writeFile: async (path, content) => {
await sandbox.files.write(path, content)
},
}
try {
return await fn(runner)
} finally {
try {
await sandbox.kill()
} catch {}
}
}