mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
fix(vm): use node child process for RCE (#2389)
* fix(vm): use node child process for RCE * ack PR comments * cleanup oprhaned processes * cleaned up * ack pr comment * fix path * use spawn instead of fork * acked PR comments
This commit is contained in:
@@ -895,11 +895,12 @@ export async function POST(req: NextRequest) {
|
|||||||
userCodeStartLine = wrapperLines.length + 1
|
userCodeStartLine = wrapperLines.length + 1
|
||||||
|
|
||||||
let codeToExecute = resolvedCode
|
let codeToExecute = resolvedCode
|
||||||
|
let prependedLineCount = 0
|
||||||
if (isCustomTool) {
|
if (isCustomTool) {
|
||||||
const paramDestructuring = Object.keys(executionParams)
|
const paramKeys = Object.keys(executionParams)
|
||||||
.map((key) => `const ${key} = params.${key};`)
|
const paramDestructuring = paramKeys.map((key) => `const ${key} = params.${key};`).join('\n')
|
||||||
.join('\n')
|
|
||||||
codeToExecute = `${paramDestructuring}\n${resolvedCode}`
|
codeToExecute = `${paramDestructuring}\n${resolvedCode}`
|
||||||
|
prependedLineCount = paramKeys.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const isolatedResult = await executeInIsolatedVM({
|
const isolatedResult = await executeInIsolatedVM({
|
||||||
@@ -920,14 +921,25 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const ivmError = isolatedResult.error
|
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 = {
|
const enhancedError: EnhancedError = {
|
||||||
message: ivmError.message,
|
message: ivmError.message,
|
||||||
name: ivmError.name,
|
name: ivmError.name,
|
||||||
stack: ivmError.stack,
|
stack: ivmError.stack,
|
||||||
originalError: ivmError,
|
originalError: ivmError,
|
||||||
line: ivmError.line,
|
line: adjustedLine,
|
||||||
column: ivmError.column,
|
column: ivmError.column,
|
||||||
lineContent: ivmError.lineContent,
|
lineContent: adjustedLineContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
||||||
|
|||||||
357
apps/sim/lib/execution/isolated-vm-worker.cjs
Normal file
357
apps/sim/lib/execution/isolated-vm-worker.cjs
Normal file
@@ -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(/(?:<isolated-vm>|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+(?:<isolated-vm>|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*\[<isolated-vm>:\d+:\d+\]/g, '')
|
||||||
|
.replace(/\s*\(<isolated-vm>:\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(/<isolated-vm>:(\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 <isolated-vm>:(\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' })
|
||||||
|
}
|
||||||
@@ -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 { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('IsolatedVMExecution')
|
const logger = createLogger('IsolatedVMExecution')
|
||||||
|
|
||||||
async function loadIsolatedVM(): Promise<typeof ivm> {
|
|
||||||
const ivmModule = await import('isolated-vm')
|
|
||||||
return ivmModule.default
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IsolatedVMExecutionRequest {
|
export interface IsolatedVMExecutionRequest {
|
||||||
code: string
|
code: string
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
@@ -33,367 +31,257 @@ export interface IsolatedVMError {
|
|||||||
lineContent?: string
|
lineContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface PendingExecution {
|
||||||
* Number of lines added before user code in the wrapper.
|
resolve: (result: IsolatedVMExecutionResult) => void
|
||||||
* The wrapper structure is:
|
timeout: ReturnType<typeof setTimeout>
|
||||||
* Line 1: (empty - newline after template literal backtick)
|
}
|
||||||
* Line 2: (async () => {
|
|
||||||
* Line 3: try {
|
let worker: ChildProcess | null = null
|
||||||
* Line 4: const __userResult = await (async () => {
|
let workerReady = false
|
||||||
* Line 5+: user code starts here
|
let workerReadyPromise: Promise<void> | null = null
|
||||||
*/
|
let workerIdleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
const USER_CODE_START_LINE = 4
|
const pendingExecutions = new Map<number, PendingExecution>()
|
||||||
|
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
|
* Secure fetch wrapper that validates URLs to prevent SSRF attacks
|
||||||
*/
|
*/
|
||||||
async function secureFetch(
|
async function secureFetch(requestId: string, url: string, options?: RequestInit): Promise<string> {
|
||||||
requestId: string,
|
|
||||||
url: string,
|
|
||||||
options?: RequestInit
|
|
||||||
): Promise<{
|
|
||||||
ok: boolean
|
|
||||||
status: number
|
|
||||||
statusText: string
|
|
||||||
body: string
|
|
||||||
headers: Record<string, string>
|
|
||||||
}> {
|
|
||||||
const validation = validateProxyUrl(url)
|
const validation = validateProxyUrl(url)
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
logger.warn(`[${requestId}] Blocked fetch request due to SSRF validation`, {
|
logger.warn(`[${requestId}] Blocked fetch request due to SSRF validation`, {
|
||||||
url: url.substring(0, 100),
|
url: url.substring(0, 100),
|
||||||
error: validation.error,
|
error: validation.error,
|
||||||
})
|
})
|
||||||
throw new Error(`Security Error: ${validation.error}`)
|
return JSON.stringify({ error: `Security Error: ${validation.error}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, options)
|
try {
|
||||||
const body = await response.text()
|
const response = await fetch(url, options)
|
||||||
const headers: Record<string, string> = {}
|
const body = await response.text()
|
||||||
response.headers.forEach((value, key) => {
|
const headers: Record<string, string> = {}
|
||||||
headers[key] = value
|
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<string, unknown>
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
if (workerReady && worker) return
|
||||||
|
if (workerReadyPromise) return workerReadyPromise
|
||||||
|
|
||||||
|
workerReadyPromise = new Promise<void>((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 {
|
return workerReadyPromise
|
||||||
ok: response.ok,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
body,
|
|
||||||
headers,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract line and column from error stack or message
|
* Execute JavaScript code in an isolated V8 isolate via Node.js subprocess.
|
||||||
*/
|
* The worker's V8 isolate enforces timeoutMs internally. The parent timeout
|
||||||
function extractLineInfo(errorMessage: string, stack?: string): { line?: number; column?: number } {
|
* (timeoutMs + 1000) is a safety buffer for IPC communication.
|
||||||
if (stack) {
|
|
||||||
const stackMatch = stack.match(/(?:<isolated-vm>|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+(?:<isolated-vm>|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*\[<isolated-vm>:\d+:\d+\]/g, '')
|
|
||||||
.replace(/\s*\(<isolated-vm>:\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(/<isolated-vm>:(\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 <isolated-vm>:(\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
|
|
||||||
*/
|
*/
|
||||||
export async function executeInIsolatedVM(
|
export async function executeInIsolatedVM(
|
||||||
req: IsolatedVMExecutionRequest
|
req: IsolatedVMExecutionRequest
|
||||||
): Promise<IsolatedVMExecutionResult> {
|
): Promise<IsolatedVMExecutionResult> {
|
||||||
const { code, params, envVars, contextVariables, timeoutMs, requestId } = req
|
if (workerIdleTimeout) {
|
||||||
|
clearTimeout(workerIdleTimeout)
|
||||||
|
workerIdleTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
const stdoutChunks: string[] = []
|
await ensureWorker()
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!worker) {
|
||||||
return {
|
return {
|
||||||
result: null,
|
result: null,
|
||||||
stdout,
|
stdout: '',
|
||||||
error: {
|
error: { message: 'Failed to start isolated-vm worker', name: 'WorkerError' },
|
||||||
message: String(err),
|
|
||||||
name: 'Error',
|
|
||||||
line: 1,
|
|
||||||
lineContent: code.split('\n')[0]?.trim(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isolate) {
|
|
||||||
isolate.dispose()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -1651,6 +1651,8 @@
|
|||||||
|
|
||||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"c12/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"chrome-launcher/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||||
|
|||||||
@@ -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
|
# Dependencies Stage: Install Dependencies
|
||||||
# ========================================
|
# ========================================
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
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 ./
|
COPY package.json bun.lock turbo.json ./
|
||||||
RUN mkdir -p apps packages/db
|
RUN mkdir -p apps packages/db
|
||||||
COPY apps/sim/package.json ./apps/sim/package.json
|
COPY apps/sim/package.json ./apps/sim/package.json
|
||||||
COPY packages/db/package.json ./packages/db/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 \
|
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
|
||||||
bun install -g turbo && \
|
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
|
# Builder Stage: Build the Application
|
||||||
@@ -51,7 +58,7 @@ COPY packages ./packages
|
|||||||
# Required for standalone nextjs build
|
# Required for standalone nextjs build
|
||||||
WORKDIR /app/apps/sim
|
WORKDIR /app/apps/sim
|
||||||
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
|
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 \
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
VERCEL_TELEMETRY_DISABLED=1 \
|
VERCEL_TELEMETRY_DISABLED=1 \
|
||||||
@@ -78,21 +85,30 @@ RUN bun run build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python and dependencies for guardrails PII detection (cached separately)
|
# Install Node.js 22 (for isolated-vm worker), Python, and other runtime dependencies
|
||||||
# Also install ffmpeg for audio/video processing in STT
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN apk add --no-cache python3 py3-pip bash ffmpeg
|
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
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Create non-root user and group (cached separately)
|
# Create non-root user and group
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
RUN groupadd -g 1001 nodejs && \
|
||||||
adduser -S nextjs -u 1001
|
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/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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/static ./apps/sim/.next/static
|
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)
|
# 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/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
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt
|
||||||
|
|||||||
Reference in New Issue
Block a user