isolated-function
Version:
Run JavaScript from AI agents, plugins, and workflows in a separate Node.js process with strict safety limits.
154 lines (132 loc) • 4.96 kB
JavaScript
const { deserializeError, serializeError } = require('serialize-error')
const timeSpan = require('@kikobeats/time-span')()
const { Readable } = require('node:stream')
const { rm } = require('fs/promises')
const $ = require('tinyspawn')
const compile = require('./compile')
const { debug } = require('./debug')
const createError = ({ name, message, ...props }) => {
const error = new Error(message)
error.name = name
Object.assign(error, props)
return error
}
/* V8 aborts (SIGABRT) on heap exhaustion for any realistic --max-old-space-size,
and only traps (SIGTRAP) when the limit is too small to boot the heap. Gate the
abort on V8's message so an unrelated abort is not reported as a memory error. */
const isOutOfMemory = ({ signalCode, stderr }) =>
signalCode === 'SIGTRAP' || (signalCode === 'SIGABRT' && /out of memory/i.test(stderr ?? ''))
const [nodeMajor] = process.version.slice(1).split('.').map(Number)
const PERMISSION_FLAG = nodeMajor >= 24 ? '--permission' : '--experimental-permission'
const roundMs = entries =>
Object.fromEntries(entries.map(([key, value]) => [key, Math.round(value)]))
const flags = ({ memory, permissions }) => {
const flags = ['--disable-warning=ExperimentalWarning', PERMISSION_FLAG]
if (memory) flags.push(`--max-old-space-size=${memory}`)
if (permissions.includes('ffi')) flags.push('--experimental-ffi')
permissions.forEach(resource => flags.push(`--allow-${resource}`))
return flags.join(' ')
}
const spawn = ({ args, env, timeout }) => {
const spawnOpts = { env, timeout, killSignal: 'SIGKILL' }
if (Number.isFinite(timeout)) {
const seconds = Math.ceil(timeout / 1000)
return $('sh', ['-c', `ulimit -t ${seconds} && exec node "$@"`, '_', '-', args], spawnOpts)
}
return $('node', ['-', args], spawnOpts)
}
module.exports = ({ tmpdir, nodePaths } = {}) => {
const isolatedFunction = (snippet, { timeout, memory, throwError = true, allow = {} } = {}) => {
if (!['function', 'string'].includes(typeof snippet)) throw new TypeError('Expected a function')
const { permissions = [] } = allow
const compilePromise = compile(snippet, { tmpdir, allow, nodePaths })
return async (...args) => {
let total
try {
total = timeSpan()
const compiled = await compilePromise
const spawnElapsed = timeSpan()
const subprocess = spawn({
args: JSON.stringify(args),
env: {
...process.env,
NODE_OPTIONS: flags({ memory, permissions })
},
timeout
})
subprocess.stdin.on('error', () => {})
Readable.from(compiled.content).pipe(subprocess.stdin)
const { stdout } = await subprocess
const spawnMs = spawnElapsed()
const { isFulfilled, value, profiling, logging } = JSON.parse(stdout)
const { run, ...rest } = profiling
const result = {
...rest,
size: Buffer.byteLength(compiled.content),
phases: {
...compiled.phases,
spawn: spawnMs - run,
run,
total: total()
}
}
debug('node', {
...result,
cpu: Math.round(result.cpu),
phases: roundMs(Object.entries(result.phases))
})
return isFulfilled
? { isFulfilled, value, profiling: result, logging }
: throwError
? (() => {
throw deserializeError(value)
})()
: { isFulfilled: false, value: deserializeError(value), profiling: result, logging }
} catch (error) {
debug.error(serializeError(error))
const profiling = { phases: { total: total() } }
if (isOutOfMemory(error)) {
throw createError({
name: 'MemoryError',
message: 'Out of memory',
profiling
})
}
if (error.signalCode === 'SIGKILL') {
throw createError({
name: 'TimeoutError',
message: 'Execution timed out',
profiling
})
}
if (error.signalCode === 'SIGXCPU') {
throw createError({
name: 'CpuTimeError',
message: 'CPU time limit exceeded',
profiling
})
}
if (error.code === 'ERR_ACCESS_DENIED') {
const permission = error.permission
? error.permission
: error.message.includes('getaddrinfo')
? 'network'
: undefined
throw createError({
name: 'PermissionError',
message: `Access to '${permission}' has been restricted`,
profiling
})
}
throw error
}
}
}
isolatedFunction.teardown = async () => {
const { DEFAULT_TMPDIR } = compile
const dir = tmpdir || DEFAULT_TMPDIR
await rm(dir, { recursive: true, force: true })
}
return isolatedFunction
}