Add E2B PTY, LSP, and code runtime providers
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/code-runtime/code-runtime-e2b/README.md
|
||||
README.md: bf26174c59c1df18f63278acb7923fd409860b44
|
||||
README.zh.md: c87945da30a6f8d77eb3722879510bb6159b3ee8
|
||||
@@ -0,0 +1,44 @@
|
||||
# @deepseek-ai/dsh-code-runtime-e2b
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
E2B implementation of [`ctx.codeRuntime`](../code-runtime/README.md). Each run executes one model-written TypeScript program in a fresh remote Node worker while binding functions, type stripping, output accounting, and lifecycle orchestration remain on the host.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `computeMs` | `60000` | Remote worker event-loop busy-time budget. |
|
||||
| `maxWallMs` | `600000` | Host-observed wall-clock ceiling. |
|
||||
| `maxOutputBytes` | `67108864` | Combined serialized outer logs/value/diagnostic cap. |
|
||||
| `maxOldGenerationSizeMb` | `512` | Remote worker old-generation heap cap in MiB. |
|
||||
| `maxFrameBytes` | `268435456` | Largest decoded bridge frame, including binding traffic. |
|
||||
| `killGraceMs` | `2000` | Remote process-group TERM-to-KILL grace. |
|
||||
|
||||
Every value is a positive safe integer. `maxOutputBytes` is at least four bytes, `maxWallMs` cannot exceed Node's maximum timer delay, and `maxFrameBytes` cannot be smaller than `maxOutputBytes`. The service requires the concrete `dsh-subprocess-e2b` backend so run cleanup has remote process-group semantics.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
Program failures resolve as `CodeRunResult.error`; only seam misuse rejects. `isolation` is reported as `container`, which is a deployment descriptor rather than a security claim.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through Code Mode in `dsh-tools`, which returns program logs, values, or typed failures through the existing `run_code` result contract.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; Code Mode owns request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Not a whole-agent runtime** — Cordis, sessions, LLM calls, binding dispatch, TypeScript stripping, output ledgers, and E2B SDK state remain on the host.
|
||||
- **No reconnectable runs** — retaining a sandbox preserves files but not worker/subprocess handles, binding calls, timers, or output cursors.
|
||||
- **Node worker internals share the model realm** — mutating realm-wide globals or prototypes that Node itself uses can terminate the worker; captured adapter intrinsics are not a separate JavaScript realm or a security boundary.
|
||||
- **Deliberate process-group escape is not captured** — model code can create a new POSIX session; that unmanaged process is outside this backend's cleanup identity.
|
||||
- **Intermediate binding traffic is memory-bounded only per frame** — it does not enter model context or the outer-output ledger, but aggregate host/remote process memory remains the limit.
|
||||
- **Experimental type stripping** — the backend shares the worker implementation's reliance on Node's experimental erasable-syntax API.
|
||||
- **Sandbox policy is template-owned** — this package adds no network, volume, snapshot, or workspace-synchronization policy.
|
||||
@@ -0,0 +1,44 @@
|
||||
# @deepseek-ai/dsh-code-runtime-e2b
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`ctx.codeRuntime`](../code-runtime/README.md) 的 E2B 实现。每次运行都会在全新的远程 Node worker 中执行一段模型编写的 TypeScript 程序;绑定函数、类型剥离、输出记账和生命周期编排仍保留在宿主侧。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `computeMs` | `60000` | 远程 worker 的事件循环忙碌时间预算。 |
|
||||
| `maxWallMs` | `600000` | 宿主观测到的墙钟时间上限。 |
|
||||
| `maxOutputBytes` | `67108864` | 外层日志、值和诊断合计的序列化上限。 |
|
||||
| `maxOldGenerationSizeMb` | `512` | 远程 worker 的老生代堆上限(MiB)。 |
|
||||
| `maxFrameBytes` | `268435456` | 已解码桥接帧的最大大小,包括绑定流量。 |
|
||||
| `killGraceMs` | `2000` | 远程进程组 TERM 到 KILL 的宽限期。 |
|
||||
|
||||
每个值都必须是正的安全整数。`maxOutputBytes` 必须至少为 4 字节,`maxWallMs` 不得超过 Node 的最大定时器延迟,且 `maxFrameBytes` 不得小于 `maxOutputBytes`。本服务要求使用具体的 `dsh-subprocess-e2b` 后端,使运行清理具备远程进程组语义。
|
||||
|
||||
## 执行与桥接契约
|
||||
|
||||
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会创建一个具有空环境与堆上限的全新 worker 线程,测量事件循环活跃时间,并在一次运行结算后销毁该 worker。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
|
||||
|
||||
由于 E2B 进程管理回调公开的是已解码文本,桥接层使用经过验证、以换行分隔的 base64 JSON 帧。绑定参数与 resolve 值使用 worker 运行时的迭代式无损 JSON wire 形状;绑定函数在宿主执行,类型化的 reject 类则在远程 worker 内物化。worker 会在模型代码运行前捕获其适配器边界调用的 JavaScript intrinsic,从而增强绑定传输、输出记账与完成值验证对这些引用修改的抵御能力。宿主会再次执行消息验证、调用 id 去重和无损 JSON 检查,并用外层输出账本再次计量。
|
||||
|
||||
程序失败会 resolve 为 `CodeRunResult.error`;只有 seam 误用才会 reject。`isolation` 报告为 `container`;这是部署描述符,不构成安全声明。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tools` 中的 Code Mode 间接影响模型;它会通过现有 `run_code` 结果契约返回程序日志、值或类型化失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由 Code Mode 负责。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **并非完整的 agent(智能体)运行时**:Cordis、会话、LLM(大语言模型)调用、绑定分发、TypeScript 类型剥离、输出账本和 E2B SDK 状态仍保留在宿主侧。
|
||||
- **运行不可重连**:保留沙箱会保留文件,但不会保留 worker/进程管理句柄、绑定调用、定时器或输出游标。
|
||||
- **Node worker 内部机制与模型共享同一 realm**:修改 Node 自身使用、影响整个 realm 的全局对象或原型可能会终止 worker;已捕获的适配器 intrinsic 并不构成独立的 JavaScript realm 或安全边界。
|
||||
- **不会捕获有意逃逸进程组的行为**:模型代码可以创建新的 POSIX 会话;该非受管进程不属于此后端的清理身份范围。
|
||||
- **中间绑定流量的内存边界仅适用于单帧**:它不会进入模型上下文或外层输出账本,但其总量仍只受宿主/远程进程内存限制。
|
||||
- **实验性类型剥离**:该后端与 worker 实现一样,依赖 Node 的实验性可擦除语法 API。
|
||||
- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-code-runtime-e2b",
|
||||
"description": "E2B code-runtime implementation for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "^0.0.1",
|
||||
"@deepseek-ai/dsh-e2b": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess-e2b": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-e2b": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/** E2B process/worker implementation of the harness code-runtime seam. */
|
||||
|
||||
import { posix } from 'node:path'
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type {
|
||||
CodeBindingNamespace,
|
||||
CodeJsonValue,
|
||||
CodeRunFailure,
|
||||
CodeRunRequest,
|
||||
CodeRunResult,
|
||||
} from '@deepseek-ai/dsh-code-runtime'
|
||||
import {
|
||||
E2BFrameDecoder,
|
||||
encodeE2BFrame,
|
||||
quoteE2BShellArg,
|
||||
resolveE2BExecutable,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import {
|
||||
decodeWorkerJson,
|
||||
encodeWorkerJson,
|
||||
OutputLedger,
|
||||
} from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import type { WorkerJsonWire } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { CODE_RUNNER_SOURCE } from './runner-source.ts'
|
||||
|
||||
/** Runtime configuration; every execution and bridge bound is deployment-tunable. */
|
||||
export interface Config {
|
||||
/** Remote worker measured event-loop busy-time budget. */
|
||||
computeMs?: number
|
||||
/** Host-observed wall-clock ceiling. */
|
||||
maxWallMs?: number
|
||||
/** Combined serialized outer logs/value/diagnostic cap. */
|
||||
maxOutputBytes?: number
|
||||
/** Remote worker old-generation heap cap in MiB. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Largest decoded bridge frame, including binding traffic. */
|
||||
maxFrameBytes?: number
|
||||
/** Remote process-group TERM-to-KILL grace. */
|
||||
killGraceMs?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
interface LiveRun {
|
||||
settle(failure: CodeRunFailure): void
|
||||
finished: Promise<void>
|
||||
}
|
||||
|
||||
interface CallMessage {
|
||||
type: 'call'
|
||||
id: number
|
||||
global: string
|
||||
name: string
|
||||
args: WorkerJsonWire
|
||||
}
|
||||
|
||||
interface LogMessage {
|
||||
type: 'log'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface DoneMessage {
|
||||
type: 'done'
|
||||
value?: WorkerJsonWire
|
||||
error?: CodeRunFailure
|
||||
}
|
||||
|
||||
type RunnerMessage = CallMessage | LogMessage | DoneMessage | { type: 'output-limit' }
|
||||
|
||||
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
/* jscpd:ignore-start -- Backends enforce the same injected-global vocabulary without coupling lifecycle implementations. */
|
||||
const RESERVED_WORDS = new Set([
|
||||
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
|
||||
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
||||
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
|
||||
'private', 'protected', 'public', 'arguments', 'eval',
|
||||
])
|
||||
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
|
||||
/* jscpd:ignore-end */
|
||||
const FAILURE_KINDS = new Set<CodeRunFailure['kind']>([
|
||||
'exception', 'timeout', 'abort', 'worker-exit', 'invalid-output', 'output-limit',
|
||||
])
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function parseRunnerMessage(raw: unknown): RunnerMessage | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
const record = raw as Record<string, unknown>
|
||||
if (record.type === 'output-limit') return { type: 'output-limit' }
|
||||
if (record.type === 'log') return typeof record.text === 'string' ? { type: 'log', text: record.text } : undefined
|
||||
if (record.type === 'call') {
|
||||
if (!Number.isSafeInteger(record.id) || (record.id as number) < 1 || typeof record.global !== 'string' || typeof record.name !== 'string' || !Array.isArray(record.args)) return undefined
|
||||
return { type: 'call', id: record.id as number, global: record.global, name: record.name, args: record.args as WorkerJsonWire }
|
||||
}
|
||||
if (record.type !== 'done') return undefined
|
||||
if (record.error === undefined) {
|
||||
return { type: 'done', ...record.value === undefined ? {} : { value: record.value as WorkerJsonWire } }
|
||||
}
|
||||
if (typeof record.error !== 'object' || record.error === null) return undefined
|
||||
const error = record.error as Record<string, unknown>
|
||||
if (typeof error.kind !== 'string' || !FAILURE_KINDS.has(error.kind as CodeRunFailure['kind']) || typeof error.message !== 'string') return undefined
|
||||
return { type: 'done', error: { kind: error.kind as CodeRunFailure['kind'], message: error.message } }
|
||||
}
|
||||
|
||||
/** E2B-backed runtime: host-side type stripping, remote worker execution, host binding dispatch. */
|
||||
export class E2BCodeRuntime extends CodeRuntime {
|
||||
static inject = ['e2b', 'subprocess']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
computeMs: z.number().default(60_000),
|
||||
maxWallMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
maxFrameBytes: z.number().default(268_435_456),
|
||||
killGraceMs: z.number().default(2_000),
|
||||
})
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'container'
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
private readonly ready: Promise<{ node: string; runner: string }>
|
||||
private readonly live = new Set<LiveRun>()
|
||||
private readonly subprocess: E2BSubprocessService
|
||||
private disposed = false
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
|
||||
throw new Error('code-runtime-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
|
||||
}
|
||||
this.subprocess = ctx.subprocess
|
||||
this.config = config as ResolvedConfig
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`code-runtime-e2b: config.${key} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
if (this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
|
||||
throw new Error(`code-runtime-e2b: config.maxOutputBytes must be at least ${MIN_OUTPUT_BYTES}`)
|
||||
}
|
||||
if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`code-runtime-e2b: config.maxWallMs must be at most ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (this.config.maxFrameBytes < this.config.maxOutputBytes) {
|
||||
throw new Error('code-runtime-e2b: config.maxFrameBytes must be at least maxOutputBytes')
|
||||
}
|
||||
this.ready = this.prepare()
|
||||
void this.ready.catch(() => {})
|
||||
ctx.effect(() => () => this.teardown(), 'E2B code-runtime teardown')
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Seam-level abort and type-strip results remain identical across execution substrates. */
|
||||
/** Execute one type-stripped program in a fresh E2B worker process. */
|
||||
async run(request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
if (this.disposed) throw new Error('code-runtime-e2b: run() after disposal')
|
||||
const bindings = this.validateBindings(request)
|
||||
if (request.signal?.aborted === true) {
|
||||
return this.failure({ kind: 'abort', message: String(request.signal.reason) })
|
||||
}
|
||||
let code: string
|
||||
try {
|
||||
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
|
||||
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
|
||||
} catch (error: unknown) {
|
||||
return this.failure({ kind: 'exception', message: messageOf(error) })
|
||||
}
|
||||
let runtime: Awaited<typeof this.ready>
|
||||
try {
|
||||
runtime = await this.ready
|
||||
} catch (error: unknown) {
|
||||
return this.failure({ kind: 'worker-exit', message: `E2B runtime setup failed: ${messageOf(error)}` })
|
||||
}
|
||||
// Disposal can race the awaited remote setup after the pre-await check.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
|
||||
return await this.execute(request, code, bindings, runtime)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
private async prepare(): Promise<{ node: string; runner: string }> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
const runner = posix.join(this.ctx.e2b.runtimeRoot, 'code-runtime-runner.mjs')
|
||||
await sandbox.files.write([{ path: runner, data: CODE_RUNNER_SOURCE }])
|
||||
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(runner)}`)
|
||||
const node = await resolveE2BExecutable(sandbox, 'node')
|
||||
return { node, runner }
|
||||
}
|
||||
|
||||
private failure(error: CodeRunFailure): CodeRunResult {
|
||||
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Binding names have one seam contract while dispatch and teardown remain backend-owned. */
|
||||
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`code-runtime-e2b: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
throw new Error(`code-runtime-e2b: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace)
|
||||
}
|
||||
const errorClassNames = new Set<string>()
|
||||
for (const namespace of request.bindings) {
|
||||
const descriptor = namespace.errorClass
|
||||
if (descriptor === undefined) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`code-runtime-e2b: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`code-runtime-e2b: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
|
||||
throw new Error(`code-runtime-e2b: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
private async execute(
|
||||
request: CodeRunRequest,
|
||||
code: string,
|
||||
bindings: Map<string, CodeBindingNamespace>,
|
||||
runtime: { node: string; runner: string },
|
||||
): Promise<CodeRunResult> {
|
||||
const handle = this.subprocess.spawn({
|
||||
argv: [runtime.node, runtime.runner],
|
||||
cwd: this.ctx.e2b.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: this.config.maxOutputBytes } },
|
||||
graceMs: this.config.killGraceMs,
|
||||
...request.signal === undefined ? {} : { signal: request.signal },
|
||||
env: {},
|
||||
})
|
||||
if (handle.stdin === undefined || handle.stdout === undefined) {
|
||||
handle.terminate()
|
||||
await Promise.allSettled([handle.done])
|
||||
try {
|
||||
await handle.waitForExit()
|
||||
} catch (error: unknown) {
|
||||
return this.failure({ kind: 'worker-exit', message: `E2B runtime cleanup failed: ${messageOf(error)}` })
|
||||
}
|
||||
return this.failure({ kind: 'worker-exit', message: 'E2B subprocess dropped a piped runtime stream' })
|
||||
}
|
||||
const stdin = handle.stdin
|
||||
const stdout = handle.stdout
|
||||
|
||||
return new Promise<CodeRunResult>((resolve) => {
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
const logs: string[] = []
|
||||
const answered = new Set<number>()
|
||||
const decoder = new E2BFrameDecoder(this.config.maxFrameBytes)
|
||||
let settled = false
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const wallTimer: { current: NodeJS.Timeout | undefined } = { current: undefined }
|
||||
const live: LiveRun = {
|
||||
finished,
|
||||
settle: (failure) => { finish(() => output.failure(logs, failure)) },
|
||||
}
|
||||
|
||||
const finish = (result: CodeRunResult | (() => CodeRunResult)): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(wallTimer.current)
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
this.live.delete(live)
|
||||
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
|
||||
handle.terminate()
|
||||
await handle.done.catch(() => {})
|
||||
let cleanupError: unknown
|
||||
try {
|
||||
await handle.waitForExit()
|
||||
} catch (error: unknown) {
|
||||
cleanupError = error
|
||||
}
|
||||
try {
|
||||
decoder.finish()
|
||||
} catch (error: unknown) {
|
||||
result = output.failure(logs, { kind: 'worker-exit', message: messageOf(error) })
|
||||
}
|
||||
if (cleanupError !== undefined) {
|
||||
result = output.failure(logs, { kind: 'worker-exit', message: `E2B runtime cleanup failed: ${messageOf(cleanupError)}` })
|
||||
}
|
||||
const final = typeof result === 'function' ? result() : result
|
||||
finishResolve()
|
||||
resolve(final)
|
||||
})
|
||||
}
|
||||
|
||||
const sendReply = (message: unknown): void => {
|
||||
if (settled) return
|
||||
stdin.write(encodeE2BFrame(message), (error?: Error | null) => {
|
||||
if (error !== undefined && error !== null) {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge write failed: ${error.message}` }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Host binding resolution mirrors worker semantics over a different transport. */
|
||||
const onCall = (message: CallMessage): void => {
|
||||
if (answered.has(message.id)) return
|
||||
answered.add(message.id)
|
||||
const functions = bindings.get(message.global)?.functions
|
||||
const fn = functions !== undefined && Object.hasOwn(functions, message.name) ? functions[message.name] : undefined
|
||||
if (typeof fn !== 'function') {
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
|
||||
return
|
||||
}
|
||||
const args = decodeWorkerJson(message.args)
|
||||
if (args === undefined) {
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await fn(args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
|
||||
} else {
|
||||
sendReply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
}
|
||||
})()
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
const onMessage = (raw: unknown): void => {
|
||||
if (settled) return
|
||||
const message = parseRunnerMessage(raw)
|
||||
if (message === undefined) return
|
||||
if (message.type === 'log') {
|
||||
if (!output.admit(message.text, logs)) finish(output.limit([...logs, message.text]))
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit') {
|
||||
finish(output.limit(logs))
|
||||
return
|
||||
}
|
||||
if (message.type === 'call') {
|
||||
onCall(message)
|
||||
return
|
||||
}
|
||||
if (message.error !== undefined) {
|
||||
finish(() => output.failure(logs, message.error as CodeRunFailure))
|
||||
} else if (message.value === undefined) {
|
||||
finish(() => output.success(logs))
|
||||
} else {
|
||||
const value = decodeWorkerJson(message.value)
|
||||
if (value === undefined) finish(() => output.failure(logs, { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
|
||||
else finish(() => output.success(logs, value))
|
||||
}
|
||||
}
|
||||
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
if (settled) return
|
||||
try {
|
||||
for (const frame of decoder.push(chunk.toString('utf8'))) onMessage(frame)
|
||||
} catch (error: unknown) {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge failed: ${messageOf(error)}` }))
|
||||
}
|
||||
})
|
||||
stdout.on('error', (error: Error) => {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime stdout failed: ${error.message}` }))
|
||||
})
|
||||
stdin.on('error', (error: Error) => {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime stdin failed: ${error.message}` }))
|
||||
})
|
||||
void handle.done.then(
|
||||
() => {
|
||||
if (!settled) {
|
||||
const stderr = handle.collected.stderr?.readFrom(0).text.trim()
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: stderr === undefined || stderr === '' ? 'E2B runtime exited before completing' : `E2B runtime exited before completing: ${stderr}` }))
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime spawn failed: ${messageOf(error)}` }))
|
||||
},
|
||||
)
|
||||
|
||||
const onAbort = (): void => {
|
||||
finish(() => output.failure(logs, { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
wallTimer.current = setTimeout(() => {
|
||||
finish(() => output.failure(logs, { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
this.live.add(live)
|
||||
if (request.signal?.aborted === true) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
sendReply({
|
||||
type: 'boot',
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, namespace]) => ({
|
||||
global,
|
||||
names: Object.keys(namespace.functions),
|
||||
...namespace.errorClass === undefined ? {} : { errorClass: namespace.errorClass },
|
||||
})),
|
||||
computeMs: this.config.computeMs,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Code-runtime backends share the service lifecycle but own different child identities. */
|
||||
private async teardown(): Promise<void> {
|
||||
this.disposed = true
|
||||
const runs = [...this.live]
|
||||
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
|
||||
await Promise.all(runs.map(run => run.finished))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
|
||||
export default E2BCodeRuntime
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-e2b`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'code-runtime-e2b-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the service owns every one-shot remote run. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,460 @@
|
||||
/** Dependency-free remote code runner installed inside the E2B sandbox. */
|
||||
|
||||
/** 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 { inspect } from 'node:util'
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const emitFrame = message => {
|
||||
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
|
||||
}
|
||||
|
||||
const parseFrame = line => JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
|
||||
|
||||
if (isMainThread) {
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
emitFrame(message)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
Promise.resolve(current ? current.terminate() : undefined).finally(() => {
|
||||
input.close()
|
||||
process.stdin.destroy()
|
||||
})
|
||||
}
|
||||
input.on('line', line => {
|
||||
let message
|
||||
try {
|
||||
message = parseFrame(line)
|
||||
} catch (error) {
|
||||
process.stderr.write('code-runtime-e2b frame error: ' + String(error) + '\n')
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received a malformed frame' } })
|
||||
return
|
||||
}
|
||||
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' } })
|
||||
return
|
||||
}
|
||||
worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: message,
|
||||
env: {},
|
||||
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.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 })
|
||||
} else if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
emitFrame({ type: 'log', text: raw.text })
|
||||
} 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 }) })
|
||||
}
|
||||
}
|
||||
})
|
||||
worker.on('error', error => {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote worker error: ' + error.message } })
|
||||
})
|
||||
worker.on('exit', code => {
|
||||
if (!finished) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote worker exited with code ' + code + ' before completing' } })
|
||||
})
|
||||
computeTimer = setInterval(() => {
|
||||
if (!worker) return
|
||||
if (worker.performance.eventLoopUtilization().active > message.computeMs) {
|
||||
finish({ type: 'done', error: { kind: 'timeout', message: 'compute budget exhausted (' + message.computeMs + 'ms busy)' } })
|
||||
}
|
||||
}, 25)
|
||||
return
|
||||
}
|
||||
if (message && message.type === 'reply' && typeof message.id === 'number' && typeof message.ok === 'boolean') {
|
||||
worker.postMessage(message.ok
|
||||
? { type: 'reply', id: message.id, ok: true, value: message.value }
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) })
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (worker && !finished) void worker.terminate() })
|
||||
} else {
|
||||
const port = parentPort
|
||||
if (!port) throw new Error('remote worker requires parentPort')
|
||||
|
||||
const CapturedError = Error
|
||||
const ArrayIsArray = Array.isArray
|
||||
const ArrayPrototype = Array.prototype
|
||||
const ObjectPrototype = Object.prototype
|
||||
const ObjectCreate = Object.create
|
||||
const ObjectDefineProperty = Object.defineProperty
|
||||
const ObjectGetPrototypeOf = Object.getPrototypeOf
|
||||
const ObjectHasOwn = Object.hasOwn
|
||||
const ObjectKeys = Object.keys
|
||||
const ObjectIs = Object.is
|
||||
const ObjectPropertyIsEnumerable = Object.prototype.propertyIsEnumerable
|
||||
const ReflectOwnKeys = Reflect.ownKeys
|
||||
const ReflectApply = Reflect.apply
|
||||
const NumberIsFinite = Number.isFinite
|
||||
const NumberIsSafeInteger = Number.isSafeInteger
|
||||
const PromiseCtor = Promise
|
||||
const PromiseReject = Promise.reject
|
||||
const QueueMicrotask = queueMicrotask
|
||||
const BufferByteLength = Buffer.byteLength
|
||||
const SetCtor = Set
|
||||
const SetAdd = Set.prototype.add
|
||||
const SetDelete = Set.prototype.delete
|
||||
const SetHas = Set.prototype.has
|
||||
const MapDelete = Map.prototype.delete
|
||||
const MapGet = Map.prototype.get
|
||||
const MapSet = Map.prototype.set
|
||||
const ArrayJoin = Array.prototype.join
|
||||
const ArrayPop = Array.prototype.pop
|
||||
const StringCharCodeAt = String.prototype.charCodeAt
|
||||
const StringSlice = String.prototype.slice
|
||||
const JSONStringify = JSON.stringify
|
||||
const StringValue = String
|
||||
|
||||
const define = (target, key, value) => {
|
||||
const descriptor = ObjectCreate(null)
|
||||
descriptor.value = value
|
||||
descriptor.enumerable = true
|
||||
descriptor.configurable = true
|
||||
descriptor.writable = true
|
||||
ObjectDefineProperty(target, key, descriptor)
|
||||
}
|
||||
const append = (target, value) => { define(target, target.length, value) }
|
||||
const pop = target => ReflectApply(ArrayPop, target, [])
|
||||
const setAdd = (target, value) => { ReflectApply(SetAdd, target, [value]) }
|
||||
const setDelete = (target, value) => { ReflectApply(SetDelete, target, [value]) }
|
||||
const setHas = (target, value) => ReflectApply(SetHas, target, [value])
|
||||
const mapDelete = (target, key) => { ReflectApply(MapDelete, target, [key]) }
|
||||
const mapGet = (target, key) => ReflectApply(MapGet, target, [key])
|
||||
const mapSet = (target, key, value) => { ReflectApply(MapSet, target, [key, value]) }
|
||||
const plainObject = value => {
|
||||
const prototype = ObjectGetPrototypeOf(value)
|
||||
return prototype === null || prototype === ObjectPrototype
|
||||
}
|
||||
const ownEnumerableStringKeys = value => {
|
||||
const keys = ReflectOwnKeys(value)
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index]
|
||||
if (typeof key !== 'string' || !ReflectApply(ObjectPropertyIsEnumerable, value, [key])) return undefined
|
||||
}
|
||||
return keys
|
||||
}
|
||||
const assign = (destination, value) => {
|
||||
if (destination.kind === 'root') destination.holder.value = value
|
||||
else define(destination.target, destination.key, value)
|
||||
}
|
||||
const snapshot = input => {
|
||||
const active = new SetCtor()
|
||||
const holder = ObjectCreate(null)
|
||||
const tasks = [{ kind: 'visit', value: input, destination: { kind: 'root', holder } }]
|
||||
while (tasks.length) {
|
||||
const task = pop(tasks)
|
||||
if (task.kind === 'leave') { setDelete(active, task.source); continue }
|
||||
const candidate = task.value
|
||||
if (candidate === null || typeof candidate === 'boolean' || typeof candidate === 'string') {
|
||||
assign(task.destination, candidate); continue
|
||||
}
|
||||
if (typeof candidate === 'number') {
|
||||
if (!NumberIsFinite(candidate) || ObjectIs(candidate, -0)) return undefined
|
||||
assign(task.destination, candidate); continue
|
||||
}
|
||||
if (typeof candidate !== 'object' || setHas(active, candidate)) return undefined
|
||||
if (ArrayIsArray(candidate)) {
|
||||
if (ObjectGetPrototypeOf(candidate) !== ArrayPrototype || ReflectOwnKeys(candidate).length !== candidate.length + 1) return undefined
|
||||
const target = []
|
||||
assign(task.destination, target)
|
||||
setAdd(active, candidate)
|
||||
append(tasks, { kind: 'leave', source: candidate })
|
||||
for (let index = candidate.length - 1; index >= 0; index--) {
|
||||
if (!ObjectHasOwn(candidate, index)) return undefined
|
||||
append(tasks, { kind: 'visit', value: candidate[index], destination: { kind: 'slot', target, key: index } })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!plainObject(candidate)) return undefined
|
||||
const keys = ownEnumerableStringKeys(candidate)
|
||||
if (!keys) return undefined
|
||||
const target = {}
|
||||
assign(task.destination, target)
|
||||
setAdd(active, candidate)
|
||||
append(tasks, { kind: 'leave', source: candidate })
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
append(tasks, { kind: 'visit', value: candidate[key], destination: { kind: 'slot', target, key } })
|
||||
}
|
||||
}
|
||||
return holder.value
|
||||
}
|
||||
const encodeWire = value => {
|
||||
const wire = []
|
||||
const pending = [value]
|
||||
while (pending.length) {
|
||||
const current = pop(pending)
|
||||
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
|
||||
append(wire, current); continue
|
||||
}
|
||||
if (ArrayIsArray(current)) {
|
||||
append(wire, { kind: 'array', length: current.length })
|
||||
for (let index = current.length - 1; index >= 0; index--) append(pending, current[index])
|
||||
} else {
|
||||
const keys = ObjectKeys(current)
|
||||
append(wire, { kind: 'object', keys })
|
||||
for (let index = keys.length - 1; index >= 0; index--) append(pending, current[keys[index]])
|
||||
}
|
||||
}
|
||||
return wire
|
||||
}
|
||||
const decodeWire = wire => {
|
||||
if (!ArrayIsArray(wire) || wire.length === 0) return undefined
|
||||
const frames = []
|
||||
let root
|
||||
let assigned = false
|
||||
const attach = value => {
|
||||
const parent = frames[frames.length - 1]
|
||||
if (!parent) {
|
||||
if (assigned) return false
|
||||
root = value; assigned = true; return true
|
||||
}
|
||||
if (parent.kind === 'array') append(parent.target, value)
|
||||
else define(parent.target, parent.keys[parent.index], value)
|
||||
parent.index += 1
|
||||
return true
|
||||
}
|
||||
for (let tokenIndex = 0; tokenIndex < wire.length; tokenIndex++) {
|
||||
const token = wire[tokenIndex]
|
||||
let value
|
||||
let frame
|
||||
if (token === null || typeof token === 'boolean' || typeof token === 'string') value = token
|
||||
else if (typeof token === 'number') {
|
||||
if (!NumberIsFinite(token) || ObjectIs(token, -0)) return undefined
|
||||
value = token
|
||||
} else {
|
||||
if (!plainObject(token)) return undefined
|
||||
const keys = ownEnumerableStringKeys(token)
|
||||
if (!keys || keys.length !== 2 || keys[0] !== 'kind') return undefined
|
||||
if (token.kind === 'array' && keys[1] === 'length' && NumberIsSafeInteger(token.length) && token.length >= 0) {
|
||||
value = []
|
||||
if (token.length > wire.length - tokenIndex - 1) return undefined
|
||||
if (token.length) frame = { kind: 'array', target: value, length: token.length, index: 0 }
|
||||
} else if (token.kind === 'object' && keys[1] === 'keys' && ArrayIsArray(token.keys)) {
|
||||
const unique = new SetCtor()
|
||||
const objectKeys = []
|
||||
for (const key of token.keys) {
|
||||
if (typeof key !== 'string' || setHas(unique, key)) return undefined
|
||||
setAdd(unique, key); append(objectKeys, key)
|
||||
}
|
||||
if (objectKeys.length > wire.length - tokenIndex - 1) return undefined
|
||||
value = {}
|
||||
if (objectKeys.length) frame = { kind: 'object', target: value, keys: objectKeys, index: 0 }
|
||||
} else return undefined
|
||||
}
|
||||
if (!attach(value)) return undefined
|
||||
if (frame) append(frames, frame)
|
||||
while (frames.length) {
|
||||
const current = frames[frames.length - 1]
|
||||
const length = current.kind === 'array' ? current.length : current.keys.length
|
||||
if (current.index < length) break
|
||||
pop(frames)
|
||||
}
|
||||
}
|
||||
return frames.length === 0 ? root : undefined
|
||||
}
|
||||
const byteLength = text => ReflectApply(BufferByteLength, Buffer, [text])
|
||||
const jsonStringBytes = text => byteLength(JSONStringify(text))
|
||||
const jsonValueBytes = value => {
|
||||
let bytes = 0
|
||||
const tasks = [{ kind: 'value', value }]
|
||||
while (tasks.length) {
|
||||
const task = pop(tasks)
|
||||
if (task.kind === 'separator') { bytes += 1; continue }
|
||||
if (task.kind === 'key') { bytes += jsonStringBytes(task.value) + 1; continue }
|
||||
const current = task.value
|
||||
if (current === null) bytes += 4
|
||||
else if (typeof current === 'string') bytes += jsonStringBytes(current)
|
||||
else if (typeof current === 'number' || typeof current === 'boolean') bytes += byteLength(StringValue(current))
|
||||
else if (ArrayIsArray(current)) {
|
||||
bytes += 2
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
append(tasks, { kind: 'value', value: current[index] })
|
||||
if (index > 0) append(tasks, { kind: 'separator' })
|
||||
}
|
||||
} else {
|
||||
bytes += 2
|
||||
const keys = ObjectKeys(current)
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
append(tasks, { kind: 'value', value: current[key] })
|
||||
append(tasks, { kind: 'key', value: key })
|
||||
if (index > 0) append(tasks, { kind: 'separator' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
const truncate = (text, available) => {
|
||||
if (available < 2) return ''
|
||||
let result = ''
|
||||
let bytes = 2
|
||||
let index = 0
|
||||
while (index < text.length) {
|
||||
const first = ReflectApply(StringCharCodeAt, text, [index])
|
||||
let end = index + 1
|
||||
if (first >= 0xd800 && first <= 0xdbff && end < text.length) {
|
||||
const second = ReflectApply(StringCharCodeAt, text, [end])
|
||||
if (second >= 0xdc00 && second <= 0xdfff) end += 1
|
||||
}
|
||||
const character = ReflectApply(StringSlice, text, [index, end])
|
||||
const cost = jsonStringBytes(character) - 2
|
||||
if (bytes + cost > available) break
|
||||
bytes += cost
|
||||
result += character
|
||||
index = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
let logBytes = 2
|
||||
let logEntries = 0
|
||||
let limited = false
|
||||
const pushLog = text => {
|
||||
if (limited) return
|
||||
const separator = logEntries > 0 ? 1 : 0
|
||||
const available = workerData.maxOutputBytes - logBytes - separator
|
||||
const cost = jsonStringBytes(text)
|
||||
if (cost > available) {
|
||||
const prefix = truncate(text, available)
|
||||
if (prefix) {
|
||||
logBytes += jsonStringBytes(prefix) + separator
|
||||
logEntries += 1
|
||||
port.postMessage({ type: 'log', text: prefix })
|
||||
}
|
||||
limited = true
|
||||
port.postMessage({ type: 'output-limit' })
|
||||
return
|
||||
}
|
||||
logBytes += cost + separator
|
||||
logEntries += 1
|
||||
port.postMessage({ type: 'log', text })
|
||||
}
|
||||
const originalStdout = process.stdout.write
|
||||
const originalStderr = process.stderr.write
|
||||
process.stdout.write = (chunk, ...rest) => {
|
||||
pushLog(typeof chunk === 'string' ? chunk : StringValue(chunk))
|
||||
let callback
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
if (typeof rest[index] === 'function') { callback = rest[index]; break }
|
||||
}
|
||||
if (callback) QueueMicrotask(() => { callback(null) })
|
||||
return true
|
||||
}
|
||||
process.stderr.write = process.stdout.write
|
||||
const consoleShim = ObjectCreate(null)
|
||||
for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
|
||||
define(consoleShim, level, (...args) => {
|
||||
const rendered = []
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const value = args[index]
|
||||
append(rendered, typeof value === 'string' ? value : inspect(value, { depth: 4, maxArrayLength: 100, maxStringLength: 10000 }))
|
||||
}
|
||||
pushLog(ReflectApply(ArrayJoin, rendered, [' ']))
|
||||
})
|
||||
}
|
||||
const pending = new Map()
|
||||
let nextId = 1
|
||||
const errorClasses = new Map()
|
||||
for (const namespace of workerData.namespaces) {
|
||||
if (!namespace.errorClass) continue
|
||||
const descriptor = namespace.errorClass
|
||||
mapSet(errorClasses, namespace.global, class BindingCallError extends CapturedError {
|
||||
constructor(memberName, message) {
|
||||
super(message)
|
||||
ObjectDefineProperty(this, 'name', { value: descriptor.name, enumerable: true })
|
||||
ObjectDefineProperty(this, descriptor.memberNameProperty, { value: memberName, enumerable: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
port.on('message', message => {
|
||||
if (!message || message.type !== 'reply' || typeof message.id !== 'number') return
|
||||
const entry = mapGet(pending, message.id)
|
||||
if (!entry) return
|
||||
mapDelete(pending, message.id)
|
||||
if (!message.ok) { entry.reject(new CapturedError(StringValue(message.message))); return }
|
||||
const value = decodeWire(message.value)
|
||||
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
|
||||
else entry.resolve(value)
|
||||
})
|
||||
const namespaces = workerData.namespaces.map(namespace => {
|
||||
const target = ObjectCreate(null)
|
||||
const ErrorClass = mapGet(errorClasses, namespace.global)
|
||||
for (const name of namespace.names) {
|
||||
define(target, name, args => {
|
||||
const detached = snapshot(args)
|
||||
if (detached === undefined) {
|
||||
return ReflectApply(PromiseReject, PromiseCtor, [ErrorClass ? new ErrorClass(name, 'binding arguments must be lossless JSON') : new CapturedError('binding arguments must be lossless JSON')])
|
||||
}
|
||||
return new PromiseCtor((resolve, reject) => {
|
||||
const id = nextId++
|
||||
mapSet(pending, id, {
|
||||
resolve,
|
||||
reject: error => { reject(ErrorClass ? new ErrorClass(name, error.message) : error) },
|
||||
})
|
||||
port.postMessage({ type: 'call', id, global: namespace.global, name, args: encodeWire(detached) })
|
||||
})
|
||||
})
|
||||
}
|
||||
return target
|
||||
})
|
||||
const errorClassNames = []
|
||||
const errorClassValues = []
|
||||
for (const namespace of workerData.namespaces) {
|
||||
if (!namespace.errorClass) continue
|
||||
append(errorClassNames, namespace.errorClass.name)
|
||||
append(errorClassValues, mapGet(errorClasses, namespace.global))
|
||||
}
|
||||
const AsyncFunction = ObjectGetPrototypeOf(async function () {}).constructor
|
||||
try {
|
||||
const fn = new AsyncFunction(...workerData.namespaces.map(value => value.global), ...errorClassNames, 'console', '"use strict";\n' + workerData.code)
|
||||
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
|
||||
if (!limited) {
|
||||
if (value === undefined) port.postMessage({ type: 'done' })
|
||||
else {
|
||||
const detached = snapshot(value)
|
||||
if (detached === undefined) {
|
||||
const message = 'program completion must be lossless JSON'
|
||||
if (jsonStringBytes(message) > workerData.maxOutputBytes - logBytes) port.postMessage({ type: 'output-limit' })
|
||||
else port.postMessage({ type: 'done', error: { kind: 'invalid-output', message } })
|
||||
} else if (jsonValueBytes(detached) > workerData.maxOutputBytes - logBytes) {
|
||||
port.postMessage({ type: 'output-limit' })
|
||||
} else {
|
||||
port.postMessage({ type: 'done', value: encodeWire(detached) })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!limited) {
|
||||
let message
|
||||
try { message = error instanceof CapturedError ? error.stack || error.message : StringValue(error) }
|
||||
catch { message = 'program threw an unrenderable value' }
|
||||
if (jsonStringBytes(message) > workerData.maxOutputBytes - logBytes) port.postMessage({ type: 'output-limit' })
|
||||
else port.postMessage({ type: 'done', error: { kind: 'exception', message } })
|
||||
}
|
||||
} finally {
|
||||
process.stdout.write = originalStdout
|
||||
process.stderr.write = originalStderr
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,460 @@
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import {
|
||||
E2BFrameDecoder,
|
||||
encodeE2BFrame,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
import {
|
||||
encodeWorkerJson,
|
||||
} from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import E2BCodeRuntime from '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
import * as E2BCodeRuntimeInvariant from '../src/invariant.ts'
|
||||
import { CODE_RUNNER_SOURCE } from '../src/runner-source.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
class FakeHandle implements SubprocessHandle {
|
||||
readonly pid = 123
|
||||
readonly stdin: Writable | undefined
|
||||
readonly stdout: PassThrough | undefined
|
||||
readonly stderr = undefined
|
||||
readonly collected: SubprocessHandle['collected']
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
readonly writes: unknown[] = []
|
||||
readonly result = Promise.withResolvers<SubprocessOutcome>()
|
||||
terminated = 0
|
||||
waitCalls = 0
|
||||
private readonly decoder = new E2BFrameDecoder(10_000_000)
|
||||
private readonly waitError: Error | undefined
|
||||
private settled = false
|
||||
|
||||
constructor(
|
||||
private readonly onMessage: (message: unknown, handle: FakeHandle) => void = () => {},
|
||||
options: { stdin?: boolean; stdout?: boolean; stderr?: string; writeError?: Error; waitError?: Error } = {},
|
||||
) {
|
||||
this.waitError = options.waitError
|
||||
this.stdin = options.stdin === false
|
||||
? undefined
|
||||
: options.writeError === undefined
|
||||
? new PassThrough()
|
||||
: new Writable({ write: (_chunk, _encoding, callback) => { callback(options.writeError) } })
|
||||
this.stdout = options.stdout === false ? undefined : new PassThrough()
|
||||
this.collected = options.stderr === undefined
|
||||
? {}
|
||||
: { stderr: { readFrom: () => ({ text: options.stderr as string, nextOffset: 0, lossy: false }) } }
|
||||
this.done = this.result.promise
|
||||
this.stdin?.on('data', (chunk: Buffer) => {
|
||||
for (const message of this.decoder.push(chunk.toString('ascii'))) {
|
||||
this.writes.push(message)
|
||||
this.onMessage(message, this)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
emit(message: unknown): void {
|
||||
this.stdout?.write(encodeE2BFrame(message))
|
||||
}
|
||||
|
||||
emitRaw(text: string): void {
|
||||
this.stdout?.write(text)
|
||||
}
|
||||
|
||||
exit(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.stdout?.end()
|
||||
this.result.resolve(outcome)
|
||||
}
|
||||
|
||||
crash(error: unknown): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.stdout?.end()
|
||||
this.result.reject(error)
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated += 1
|
||||
this.exit({ exitCode: null, signal: 'SIGTERM' })
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
this.waitCalls += 1
|
||||
if (this.waitError !== undefined) throw this.waitError
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
interface RuntimeFixture {
|
||||
ctx: Context
|
||||
fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
runtime: E2BCodeRuntime
|
||||
sandbox: Sandbox
|
||||
spawn: ReturnType<typeof vi.fn<(spec: SubprocessSpawnSpec) => SubprocessHandle>>
|
||||
write: ReturnType<typeof vi.fn>
|
||||
run: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
async function setup(
|
||||
handles: FakeHandle[] = [],
|
||||
config: Record<string, number> = {},
|
||||
sandboxOverrides: Partial<Sandbox> = {},
|
||||
getSandbox?: () => Promise<Sandbox>,
|
||||
): Promise<RuntimeFixture> {
|
||||
const write = vi.fn().mockResolvedValue([])
|
||||
const run = vi.fn().mockImplementation(async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('command -v') ? '/usr/bin/node\n' : '',
|
||||
stderr: '',
|
||||
}))
|
||||
const sandbox = {
|
||||
files: { write },
|
||||
commands: { run },
|
||||
...sandboxOverrides,
|
||||
} as unknown as Sandbox
|
||||
const e2b = {
|
||||
cwd: '/workspace',
|
||||
runtimeRoot: '/workspace/.dsh-e2b',
|
||||
getSandbox: getSandbox ?? (async () => sandbox),
|
||||
} as unknown as E2BSandboxService
|
||||
const spawn = vi.fn<(spec: SubprocessSpawnSpec) => SubprocessHandle>(() => {
|
||||
const handle = handles.shift()
|
||||
if (handle === undefined) throw new Error('no fake handle queued')
|
||||
return handle
|
||||
})
|
||||
const subprocess = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
|
||||
Object.defineProperty(subprocess, 'spawn', { value: spawn })
|
||||
const ctx = new Context()
|
||||
ctx.provide('e2b', e2b)
|
||||
ctx.provide('subprocess', subprocess)
|
||||
const fiber = await ctx.plugin(E2BCodeRuntime, config)
|
||||
return { ctx, fiber, runtime: ctx.codeRuntime as E2BCodeRuntime, sandbox, spawn, write, run }
|
||||
}
|
||||
|
||||
function request(program = 'return 1') {
|
||||
return { program, bindings: [] }
|
||||
}
|
||||
|
||||
describe('E2BCodeRuntime', () => {
|
||||
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
|
||||
current.emit({ type: 'log', text: 'remote 你好' })
|
||||
current.emitRaw(
|
||||
encodeE2BFrame({ type: 'done', value: encodeWorkerJson({ answer: 42 }) })
|
||||
+ encodeE2BFrame({ type: 'log', text: 'ignored after done' }),
|
||||
)
|
||||
current.emit({ type: 'log', text: 'also ignored after done' })
|
||||
})
|
||||
const fixture = await setup([handle])
|
||||
|
||||
await expect(fixture.runtime.run(request('const answer: number = 42; return { answer }')))
|
||||
.resolves.toEqual({ logs: ['remote 你好'], value: { answer: 42 } })
|
||||
expect(fixture.runtime.language).toBe('typescript')
|
||||
expect(fixture.runtime.isolation).toBe('container')
|
||||
expect(fixture.write).toHaveBeenCalledWith([{ path: '/workspace/.dsh-e2b/code-runtime-runner.mjs', data: CODE_RUNNER_SOURCE }])
|
||||
expect(fixture.run).toHaveBeenCalledWith("chmod 600 -- '/workspace/.dsh-e2b/code-runtime-runner.mjs'")
|
||||
expect(fixture.spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/code-runtime-runner.mjs'],
|
||||
cwd: '/workspace',
|
||||
env: {},
|
||||
}))
|
||||
expect(handle.terminated).toBe(1)
|
||||
expect(handle.waitCalls).toBe(1)
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('bridges binding success, host rejection, unknown members, and invalid values', async () => {
|
||||
const replies: unknown[] = []
|
||||
const handle = new FakeHandle((message, current) => {
|
||||
const record = message as { type?: string; id?: number; ok?: boolean }
|
||||
if (record.type === 'boot') {
|
||||
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'double', args: encodeWorkerJson({ value: 4 }) })
|
||||
current.emit({ type: 'call', id: 2, global: 'bridge', name: 'fail', args: encodeWorkerJson(null) })
|
||||
current.emit({ type: 'call', id: 3, global: 'bridge', name: 'missing', args: encodeWorkerJson(null) })
|
||||
current.emit({ type: 'call', id: 4, global: 'bridge', name: 'double', args: [] })
|
||||
current.emit({ type: 'call', id: 5, global: 'bridge', name: 'invalid', args: encodeWorkerJson(null) })
|
||||
current.emit({ type: 'call', id: 6, global: 'bridge', name: 'throwing', args: encodeWorkerJson(null) })
|
||||
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'double', args: encodeWorkerJson({ value: 99 }) })
|
||||
return
|
||||
}
|
||||
if (record.type === 'reply') {
|
||||
replies.push(message)
|
||||
if (replies.length === 6) current.emit({ type: 'done', value: encodeWorkerJson('done') })
|
||||
}
|
||||
})
|
||||
const fixture = await setup([handle])
|
||||
const result = await fixture.runtime.run({
|
||||
program: 'return await bridge.double({ value: 4 })',
|
||||
bindings: [
|
||||
{
|
||||
global: 'bridge',
|
||||
errorClass: { name: 'BridgeError', memberNameProperty: 'member' },
|
||||
functions: {
|
||||
double: async args => (args as { value: number }).value * 2,
|
||||
fail: async () => { throw 'nope' },
|
||||
invalid: (async () => undefined) as never,
|
||||
throwing: async () => Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => { throw new Error('getter failed') },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ global: 'plain', functions: {} },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ logs: [], value: 'done' })
|
||||
expect(replies.sort((left, right) => (left as { id: number }).id - (right as { id: number }).id)).toEqual([
|
||||
{ type: 'reply', id: 1, ok: true, value: encodeWorkerJson(8) },
|
||||
{ type: 'reply', id: 2, ok: false, message: 'nope' },
|
||||
{ type: 'reply', id: 3, ok: false, message: 'unknown binding "bridge.missing"' },
|
||||
{ type: 'reply', id: 4, ok: false, message: 'binding arguments must be lossless JSON' },
|
||||
{ type: 'reply', id: 5, ok: false, message: 'binding resolution must be lossless JSON' },
|
||||
{ type: 'reply', id: 6, ok: false, message: 'binding resolution must be lossless JSON' },
|
||||
])
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores malformed runner traffic and classifies terminal runner messages', async () => {
|
||||
const ignored = [
|
||||
null, 1, {}, { type: 'log' }, { type: 'call' },
|
||||
{ type: 'call', id: 0, global: 'x', name: 'y', args: [] },
|
||||
{ type: 'call', id: 1, global: 1, name: 'y', args: [] },
|
||||
{ type: 'call', id: 1, global: 'x', name: 1, args: [] },
|
||||
{ type: 'call', id: 1, global: 'x', name: 'y', args: {} },
|
||||
{ type: 'done', error: null },
|
||||
{ type: 'done', error: { kind: 'invented', message: 'x' } },
|
||||
{ type: 'done', error: { kind: 'exception', message: 1 } },
|
||||
]
|
||||
const handles = [
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type !== 'boot') return
|
||||
for (const item of ignored) current.emit(item)
|
||||
current.emit({ type: 'done' })
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done', error: { kind: 'exception', message: 'boom' } })
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done', value: [] })
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'output-limit' })
|
||||
}),
|
||||
]
|
||||
const fixture = await setup(handles, { maxOutputBytes: 64, maxFrameBytes: 128 })
|
||||
|
||||
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [] })
|
||||
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'exception', message: 'boom' } })
|
||||
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
|
||||
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('enforces the host output ledger and catches malformed bridge output', async () => {
|
||||
const handles = [
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'log', text: 'x'.repeat(1_000) })
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emitRaw('not-base64\n')
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emitRaw('é')
|
||||
}),
|
||||
new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.stdout?.emit('error', new Error('stdout broke'))
|
||||
}),
|
||||
]
|
||||
const fixture = await setup(handles, { maxOutputBytes: 128, maxFrameBytes: 4_096 })
|
||||
|
||||
expect((await fixture.runtime.run(request())).error?.kind).toBe('output-limit')
|
||||
const malformed = (await fixture.runtime.run(request())).error
|
||||
expect(malformed?.kind).toBe('worker-exit')
|
||||
expect(malformed?.message).toContain('bridge failed')
|
||||
expect((await fixture.runtime.run(request())).error?.message).toContain('non-ASCII')
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime stdout failed: stdout broke' })
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('contains stdin errors, process exits, spawn failures, and missing pipes', async () => {
|
||||
const writeError = new FakeHandle(() => {}, { writeError: new Error('write callback broke') })
|
||||
const stdinError = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.stdin?.emit('error', new Error('stdin broke'))
|
||||
})
|
||||
const earlyExit = new FakeHandle(() => {}, { stderr: 'remote diagnostic' })
|
||||
const quietExit = new FakeHandle()
|
||||
const emptyStderrExit = new FakeHandle(() => {}, { stderr: '' })
|
||||
const spawnFailure = new FakeHandle()
|
||||
const missingStdin = new FakeHandle(() => {}, { stdin: false })
|
||||
const missingStdout = new FakeHandle(() => {}, { stdout: false, waitError: new Error('missing-stream process query failed') })
|
||||
const truncated = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') {
|
||||
current.emitRaw('YQ==')
|
||||
setImmediate(() => { current.exit() })
|
||||
}
|
||||
})
|
||||
const cleanupFailure = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done' })
|
||||
}, { waitError: new Error('process query failed') })
|
||||
const fixture = await setup([
|
||||
writeError, stdinError, earlyExit, quietExit, emptyStderrExit,
|
||||
spawnFailure, missingStdin, missingStdout, truncated, cleanupFailure,
|
||||
])
|
||||
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime bridge write failed: write callback broke' })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime stdin failed: stdin broke' })
|
||||
setImmediate(() => { earlyExit.exit() })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing: remote diagnostic' })
|
||||
setImmediate(() => { quietExit.exit() })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing' })
|
||||
setImmediate(() => { emptyStderrExit.exit() })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing' })
|
||||
setImmediate(() => { spawnFailure.crash('spawn rejected') })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime spawn failed: spawn rejected' })
|
||||
expect((await fixture.runtime.run(request())).error?.message).toContain('dropped a piped runtime stream')
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime cleanup failed: missing-stream process query failed' })
|
||||
expect(missingStdin.terminated).toBe(1)
|
||||
expect(missingStdin.waitCalls).toBe(1)
|
||||
expect(missingStdout.terminated).toBe(1)
|
||||
expect(missingStdout.waitCalls).toBe(1)
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B frame stream ended mid-frame' })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime cleanup failed: process query failed' })
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reports wall timeout, abort, pre-abort, type-strip failure, and disposal', async () => {
|
||||
const timeout = new FakeHandle()
|
||||
const abort = new FakeHandle()
|
||||
const disposing = new FakeHandle()
|
||||
const fixture = await setup([timeout, abort, disposing], { maxWallMs: 20 })
|
||||
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'timeout', message: 'wall-clock ceiling reached (20ms)' })
|
||||
const controller = new AbortController()
|
||||
const aborting = fixture.runtime.run({ ...request(), signal: controller.signal })
|
||||
controller.abort('stop')
|
||||
expect((await aborting).error).toEqual({ kind: 'abort', message: 'stop' })
|
||||
expect((await fixture.runtime.run({ ...request(), signal: AbortSignal.abort('already') })).error)
|
||||
.toEqual({ kind: 'abort', message: 'already' })
|
||||
expect((await fixture.runtime.run(request('enum E { A }'))).error?.kind).toBe('exception')
|
||||
|
||||
const live = fixture.runtime.run(request())
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await fixture.fiber.dispose()
|
||||
expect((await live).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
await expect(fixture.runtime.run(request())).rejects.toThrow('after disposal')
|
||||
})
|
||||
|
||||
it('drops binding replies that settle after abort', async () => {
|
||||
const controller = new AbortController()
|
||||
const resolution = Promise.withResolvers<string>()
|
||||
const invoked = Promise.withResolvers<undefined>()
|
||||
const handle = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') {
|
||||
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'late', args: encodeWorkerJson(null) })
|
||||
}
|
||||
})
|
||||
const fixture = await setup([handle])
|
||||
const running = fixture.runtime.run({
|
||||
program: 'return await bridge.late(null)',
|
||||
bindings: [{
|
||||
global: 'bridge',
|
||||
functions: {
|
||||
late: async () => {
|
||||
invoked.resolve(undefined)
|
||||
return await resolution.promise
|
||||
},
|
||||
},
|
||||
}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
await invoked.promise
|
||||
controller.abort('stop')
|
||||
expect((await running).error).toEqual({ kind: 'abort', message: 'stop' })
|
||||
resolution.resolve('late')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(handle.writes).toHaveLength(1)
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('validates binding and runtime configuration before remote execution', async () => {
|
||||
const fixture = await setup([])
|
||||
const invalidRequests = [
|
||||
{ global: 'not-valid!', functions: {} },
|
||||
{ global: 'await', functions: {} },
|
||||
{ global: 'console', functions: {} },
|
||||
{ global: 'same', functions: {} },
|
||||
{ global: 'same', functions: {} },
|
||||
{ global: 'ok', functions: {}, errorClass: { name: 'not-valid!', memberNameProperty: 'member' } },
|
||||
{ global: 'ok', functions: {}, errorClass: { name: 'await', memberNameProperty: 'member' } },
|
||||
{ global: 'Clash', functions: {}, errorClass: { name: 'Clash', memberNameProperty: 'member' } },
|
||||
{ global: 'one', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'member' } },
|
||||
{ global: 'two', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'member' } },
|
||||
{ global: 'ok', functions: {}, errorClass: { name: 'Err', memberNameProperty: '' } },
|
||||
{ global: 'ok', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'message' } },
|
||||
]
|
||||
for (const bindings of [
|
||||
[invalidRequests[0]], [invalidRequests[1]], [invalidRequests[2]],
|
||||
invalidRequests.slice(3, 5), [invalidRequests[5]], [invalidRequests[6]],
|
||||
[invalidRequests[7]], invalidRequests.slice(8, 10), [invalidRequests[10]], [invalidRequests[11]],
|
||||
]) {
|
||||
await expect(fixture.runtime.run({ program: 'return 1', bindings: bindings as never })).rejects.toThrow()
|
||||
}
|
||||
await fixture.fiber.dispose()
|
||||
|
||||
for (const config of [
|
||||
{ computeMs: 0 }, { computeMs: 1.5 }, { maxOutputBytes: 3 },
|
||||
{ maxWallMs: 2_147_483_648 }, { maxFrameBytes: 10, maxOutputBytes: 20 },
|
||||
]) {
|
||||
const ctx = new Context()
|
||||
const subprocess = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
|
||||
ctx.provide('e2b', { getSandbox: async () => ({}) } as never)
|
||||
ctx.provide('subprocess', subprocess)
|
||||
await expect(ctx.plugin(E2BCodeRuntime, config)).rejects.toThrow()
|
||||
}
|
||||
|
||||
const wrong = new Context()
|
||||
wrong.provide('e2b', { getSandbox: async () => ({}) } as never)
|
||||
wrong.provide('subprocess', {} as never)
|
||||
await expect(wrong.plugin(E2BCodeRuntime, {})).rejects.toThrow('dsh-subprocess-e2b')
|
||||
})
|
||||
|
||||
it('turns asynchronous runtime preparation failure into a run result', async () => {
|
||||
const sandbox = {
|
||||
files: { write: vi.fn().mockRejectedValue(new Error('upload failed')) },
|
||||
commands: { run: vi.fn() },
|
||||
} as unknown as Sandbox
|
||||
const fixture = await setup([], {}, sandbox)
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({
|
||||
kind: 'worker-exit',
|
||||
message: 'E2B runtime setup failed: upload failed',
|
||||
})
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns disposal when remote preparation completes after teardown', async () => {
|
||||
const gate = Promise.withResolvers<Sandbox>()
|
||||
const fixture = await setup([], {}, {}, () => gate.promise)
|
||||
const running = fixture.runtime.run(request())
|
||||
await (fixture.runtime as unknown as { teardown(): Promise<void> }).teardown()
|
||||
gate.resolve(fixture.sandbox)
|
||||
expect((await running).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('registers the package-owned invariant companion', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = await ctx.plugin(E2BCodeRuntimeInvariant).await()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../code-runtime" },
|
||||
{ "path": "../code-runtime-worker" },
|
||||
{ "path": "../../e2b/e2b" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../subprocess/subprocess-e2b" },
|
||||
{ "path": "../../util/timeout" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user