fix(e2b): harden remote adapter boundaries
This commit is contained in:
33 files changed
+939
-146
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/code-runtime-e2b/README.md
|
||||
README.md: 1007800c59725d116197a52f6d087ea5e2860686
|
||||
README.zh.md: a56026bce760879079627dd63a80dfe95c4d2049
|
||||
README.md: 171e63a8c1ca70deb63e9860f5088b83401a37d0
|
||||
README.zh.md: b70984e765d32985436c15dd543263cc06a25715
|
||||
@@ -19,7 +19,7 @@ Every value is a positive safe integer. `maxOutputBytes` is at least four bytes,
|
||||
|
||||
## Execution and bridge contract
|
||||
|
||||
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner creates a fresh worker thread with an empty environment and heap limit, measures active event-loop time, and destroys that worker after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
|
||||
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner keeps the framed host protocol in a launcher process, forks a controller whose stdout and stderr are bounded data pipes, and creates a fresh worker thread with an empty environment and heap limit. Model writes to native descriptors and inherited child output therefore cannot enter the frame stream; worker and controller pipes drain before the terminal frame. The worker measures active event-loop time and is destroyed after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
|
||||
|
||||
The bridge uses validated newline-delimited base64 JSON frames because E2B subprocess callbacks expose decoded text. Binding arguments and resolutions use the worker runtime's iterative lossless-JSON wire shape; binding functions execute on the host and typed rejection classes are materialized inside the remote worker. The worker captures the JavaScript intrinsics that its adapter boundary invokes before model code runs, hardening binding transport, output accounting, and completion validation against mutation of those references. The host repeats message validation, call-id deduplication, lossless-JSON checks, and the outer-output ledger.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 执行与桥接契约
|
||||
|
||||
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会创建一个具有空环境与堆上限的全新 worker 线程,测量事件循环活跃时间,并在一次运行结算后销毁该 worker。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
|
||||
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会把面向宿主的分帧协议保留在 launcher 进程内,派生一个以 stdout 和 stderr 作为有界数据管道的 controller,再创建一个具有空环境与堆上限的全新 worker 线程。因此,模型对原生描述符的写入和继承的子进程输出无法进入分帧流;worker 与 controller 管道会在发出终结帧前排空。worker 会测量事件循环活跃时间,并在一次运行结算后销毁。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
|
||||
|
||||
由于 E2B 进程管理回调公开的是已解码文本,桥接层使用经过验证、以换行分隔的 base64 JSON 帧。绑定参数与 resolve 值使用 worker 运行时的迭代式无损 JSON wire 形状;绑定函数在宿主执行,类型化的 reject 类则在远程 worker 内物化。worker 会在模型代码运行前捕获其适配器边界调用的 JavaScript intrinsic,从而增强绑定传输、输出记账与完成值验证对这些引用修改的抵御能力。宿主会再次执行消息验证、调用 id 去重和无损 JSON 检查,并用外层输出账本再次计量。
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
/** Node program that runs one model program in a fresh remote worker thread. */
|
||||
export const CODE_RUNNER_SOURCE = String.raw`import { Buffer } from 'node:buffer'
|
||||
import { fork } from 'node:child_process'
|
||||
import { inspect } from 'node:util'
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const emitFrame = message => {
|
||||
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
|
||||
@@ -12,23 +14,140 @@ const emitFrame = message => {
|
||||
|
||||
const parseFrame = line => JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
|
||||
|
||||
if (isMainThread) {
|
||||
const waitForPipeDrain = stream => {
|
||||
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
|
||||
return new Promise(resolve => {
|
||||
const done = () => {
|
||||
stream.off('end', done)
|
||||
stream.off('close', done)
|
||||
stream.off('error', done)
|
||||
resolve()
|
||||
}
|
||||
stream.once('end', done)
|
||||
stream.once('close', done)
|
||||
stream.once('error', done)
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
const waitForChildExit = child => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise(resolve => { child.once('exit', resolve) })
|
||||
}
|
||||
|
||||
const jsonStringBytes = text => Buffer.byteLength(JSON.stringify(text))
|
||||
|
||||
const truncateLog = (text, available) => {
|
||||
if (available < 2) return ''
|
||||
let result = ''
|
||||
let bytes = 2
|
||||
for (const character of text) {
|
||||
const cost = jsonStringBytes(character) - 2
|
||||
if (bytes + cost > available) break
|
||||
bytes += cost
|
||||
result += character
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const runLauncher = () => {
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
let controller
|
||||
let maxOutputBytes = 0
|
||||
let logBytes = 2
|
||||
let logEntries = 0
|
||||
let settling = false
|
||||
let closed = false
|
||||
let terminal
|
||||
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
emitFrame(message)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
Promise.resolve(current ? current.terminate() : undefined).finally(() => {
|
||||
if (settling) {
|
||||
if (message.type === 'output-limit') terminal = message
|
||||
return
|
||||
}
|
||||
settling = true
|
||||
terminal = message
|
||||
const current = controller
|
||||
controller = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
const exited = waitForChildExit(current)
|
||||
current.kill('SIGKILL')
|
||||
await Promise.all([exited, stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
process.stderr.write('code-runtime-e2b controller cleanup error: ' + String(error) + '\n')
|
||||
}).then(() => {
|
||||
closed = true
|
||||
emitFrame(terminal)
|
||||
input.close()
|
||||
process.stdin.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
const forwardLog = text => {
|
||||
if (closed || terminal?.type === 'output-limit') return
|
||||
const separator = logEntries > 0 ? 1 : 0
|
||||
const available = maxOutputBytes - logBytes - separator
|
||||
const cost = jsonStringBytes(text)
|
||||
if (cost > available) {
|
||||
const prefix = truncateLog(text, available)
|
||||
if (prefix) {
|
||||
logBytes += jsonStringBytes(prefix) + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text: prefix })
|
||||
}
|
||||
finish({ type: 'output-limit' })
|
||||
return
|
||||
}
|
||||
logBytes += cost + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text })
|
||||
}
|
||||
|
||||
const startController = message => {
|
||||
maxOutputBytes = message.maxOutputBytes
|
||||
controller = fork(fileURLToPath(import.meta.url), [], {
|
||||
env: { DSH_CODE_RUNTIME_CONTROLLER: '1' },
|
||||
execArgv: [],
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
const current = controller
|
||||
current.stdout.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.stderr.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
forwardLog(raw.text)
|
||||
return
|
||||
}
|
||||
if (settling) return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'output-limit') {
|
||||
finish({ type: 'output-limit' })
|
||||
} else if (raw.type === 'done') {
|
||||
if (raw.error && typeof raw.error === 'object' && typeof raw.error.kind === 'string' && typeof raw.error.message === 'string') {
|
||||
finish({ type: 'done', error: { kind: raw.error.kind, message: raw.error.message } })
|
||||
} else if (raw.value === undefined || Array.isArray(raw.value)) {
|
||||
finish({ type: 'done', ...(raw.value === undefined ? {} : { value: raw.value }) })
|
||||
}
|
||||
}
|
||||
})
|
||||
current.on('error', error => {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller error: ' + error.message } })
|
||||
})
|
||||
current.on('exit', code => {
|
||||
if (!settling) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller exited with code ' + code + ' before completing' } })
|
||||
})
|
||||
current.send(message, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller boot failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
|
||||
input.on('line', line => {
|
||||
let message
|
||||
try {
|
||||
@@ -38,26 +157,77 @@ if (isMainThread) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received a malformed frame' } })
|
||||
return
|
||||
}
|
||||
if (!controller) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces) || !Number.isSafeInteger(message.maxOutputBytes) || message.maxOutputBytes < 4) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
startController(message)
|
||||
return
|
||||
}
|
||||
if (message && message.type === 'reply' && typeof message.id === 'number' && typeof message.ok === 'boolean') {
|
||||
controller.send(message.ok
|
||||
? { type: 'reply', id: message.id, ok: true, value: message.value }
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) }, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller reply failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (controller && !settling) controller.kill('SIGKILL') })
|
||||
}
|
||||
|
||||
const runController = () => {
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
const send = message => {
|
||||
if (process.send) process.send(message)
|
||||
}
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
await Promise.all([current.terminate(), stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
send({ type: 'log', text: 'code-runtime-e2b worker cleanup error: ' + String(error) + '\n' })
|
||||
}).then(() => {
|
||||
if (!process.send) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
process.send(message, () => { if (process.connected) process.disconnect() })
|
||||
})
|
||||
}
|
||||
process.on('message', message => {
|
||||
if (!worker) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces)) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: message,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
resourceLimits: { maxOldGenerationSizeMb: message.maxOldGenerationSizeMb },
|
||||
})
|
||||
worker.stdout.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stderr.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stdout.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stderr.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
send({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
emitFrame({ type: 'log', text: raw.text })
|
||||
send({ type: 'log', text: raw.text })
|
||||
} else if (raw.type === 'output-limit') {
|
||||
finish({ type: 'output-limit' })
|
||||
} else if (raw.type === 'done') {
|
||||
@@ -88,8 +258,10 @@ if (isMainThread) {
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) })
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (worker && !finished) void worker.terminate() })
|
||||
} else {
|
||||
process.on('disconnect', () => { if (worker && !finished) void worker.terminate() })
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
const port = parentPort
|
||||
if (!port) throw new Error('remote worker requires parentPort')
|
||||
|
||||
@@ -456,5 +628,9 @@ if (isMainThread) {
|
||||
process.stdout.write = originalStdout
|
||||
process.stderr.write = originalStderr
|
||||
}
|
||||
} else if (process.env.DSH_CODE_RUNTIME_CONTROLLER === '1') {
|
||||
runController()
|
||||
} else {
|
||||
runLauncher()
|
||||
}
|
||||
`
|
||||
@@ -1,3 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -153,7 +157,120 @@ function request(program = 'return 1') {
|
||||
return { program, bindings: [] }
|
||||
}
|
||||
|
||||
async function runInstalledRunner(
|
||||
code: string,
|
||||
maxOutputBytes = 2_000_000,
|
||||
): Promise<{ messages: unknown[]; stderr: string }> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-e2b-code-runner-'))
|
||||
const runner = join(directory, 'runner.mjs')
|
||||
await writeFile(runner, CODE_RUNNER_SOURCE)
|
||||
const child = spawn(process.execPath, [runner], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const decoder = new E2BFrameDecoder(4_000_000)
|
||||
const messages: unknown[] = []
|
||||
let stderr = ''
|
||||
let outputError: unknown
|
||||
child.stdout.setEncoding('ascii')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
try {
|
||||
messages.push(...decoder.push(chunk))
|
||||
} catch (error: unknown) {
|
||||
outputError = error
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
try {
|
||||
child.stdin.write(encodeE2BFrame({
|
||||
type: 'boot',
|
||||
code,
|
||||
namespaces: [],
|
||||
computeMs: 1_000,
|
||||
maxOutputBytes,
|
||||
maxOldGenerationSizeMb: 128,
|
||||
}))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('installed E2B code runner did not exit'))
|
||||
}, 5_000)
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (outputError !== undefined) throw outputError
|
||||
decoder.finish()
|
||||
return { messages, stderr }
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('E2BCodeRuntime', () => {
|
||||
it('keeps model-owned descriptors outside the host framing process', async () => {
|
||||
const forged = Buffer.from(JSON.stringify({ type: 'done' })).toString('base64') + '\\n'
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
const fs = await import('node:fs')
|
||||
const childProcess = await import('node:child_process')
|
||||
fs.writeSync(1, ${JSON.stringify(forged)})
|
||||
childProcess.spawnSync(process.execPath, ['-e', 'process.stdout.write("child-native")'], { stdio: 'inherit' })
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string; value?: unknown }>
|
||||
const terminal = records.filter(message => message.type === 'done')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminal).toEqual([{ type: 'done', value: [true] }])
|
||||
expect(records.at(-1)).toEqual(terminal[0])
|
||||
expect(records.filter(message => message.type === 'log').map(message => message.text).join(''))
|
||||
.toContain(forged + 'child-native')
|
||||
})
|
||||
|
||||
it('bounds native descriptor output before it reaches the host protocol', async () => {
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
"(await import('node:fs')).writeSync(1, 'x'.repeat(4096)); return true",
|
||||
64,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(records.at(-1)).toEqual({ type: 'output-limit' })
|
||||
expect(Buffer.byteLength(records.filter(message => message.type === 'log').map(message => message.text).join('')))
|
||||
.toBeLessThanOrEqual(62)
|
||||
})
|
||||
|
||||
it('drains native worker pipes before emitting the terminal frame', async () => {
|
||||
const expectedBytes = 1_048_576
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
let stdoutPrototype = Object.getPrototypeOf(process.stdout)
|
||||
while (stdoutPrototype && !Object.hasOwn(stdoutPrototype, 'write')) stdoutPrototype = Object.getPrototypeOf(stdoutPrototype)
|
||||
Reflect.apply(stdoutPrototype.write, process.stdout, ['x'.repeat(${expectedBytes})])
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
const terminalIndex = records.findIndex(message => message.type === 'done')
|
||||
const nativeOutput = records
|
||||
.slice(0, terminalIndex)
|
||||
.filter(message => message.type === 'log')
|
||||
.map(message => message.text ?? '')
|
||||
.join('')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminalIndex).toBe(records.length - 1)
|
||||
expect(Buffer.byteLength(nativeOutput)).toBe(expectedBytes)
|
||||
})
|
||||
|
||||
it('prepares the remote runner and returns logs and a lossless completion', async () => {
|
||||
const handle = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type !== 'boot') return
|
||||
|
||||
Reference in New Issue
Block a user