mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Add E2B PTY, LSP, and code runtime providers
This commit is contained in:
@@ -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/code-runtime/README.md
|
||||
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
|
||||
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819
|
||||
README.md: f59fc3b15331b4799cbc7a3fbe27ce8cd9a12e51
|
||||
README.zh.md: 02ade97887fab8a8a0c22efcf73bb2dd1f2cf33c
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution seam and shared vocabulary | `ctx.codeRuntime` |
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend | registers `ctx.codeRuntime` |
|
||||
| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` |
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend: fresh worker per run, TypeScript via host-side type-strip (annotations advisory, never type-checked), port-bridged bindings, budget/heap containment | registers `ctx.codeRuntime` |
|
||||
| [`code-runtime-e2b/`](code-runtime-e2b/README.md) | E2B backend: host type-strip and bindings, fresh remote runner/worker, framed bridge, remote process-group cleanup | registers `ctx.codeRuntime` |
|
||||
|
||||
Backends register the seam without changing its consumer. The child READMEs own language, isolation, and execution-budget details.
|
||||
Backends differ by execution substrate and source language—both readonly descriptors on the service—and register `ctx.codeRuntime` without touching the interface or its consumer. The E2B ownership split is recorded in the [remote extension note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md).
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# code-runtime/ — 代码执行能力家族
|
||||
# code-runtime/:代码执行能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
|
||||
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品** 包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行 seam 与共享词汇 | `ctx.codeRuntime` |
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker 线程后端 | 注册 `ctx.codeRuntime` |
|
||||
| `code-runtime/` | 抽象代码执行 seam(接口 + 词汇) | `ctx.codeRuntime` |
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | worker 线程后端:每次运行使用全新 worker,由宿主侧剥离 TypeScript 类型(类型注解仅供参考,绝不执行类型检查)、端口桥接绑定、预算/堆隔离 | 注册 `ctx.codeRuntime` |
|
||||
| [`code-runtime-e2b/`](code-runtime-e2b/README.md) | E2B 后端:宿主侧类型剥离与绑定、全新远程 runner/worker、分帧桥、远程进程组清理 | 注册 `ctx.codeRuntime` |
|
||||
|
||||
后端在不改变消费方的情况下注册该 seam。子 README 负责语言、隔离和执行预算细节。
|
||||
不同后端的执行基底和源语言各异,二者都是服务上的只读描述符;后端注册 `ctx.codeRuntime`,无需修改接口或消费方。E2B 所有权拆分记录在[远程扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) 中。
|
||||
|
||||
6
packages/code-runtime/code-runtime-e2b/README.i18n.yaml
Normal file
6
packages/code-runtime/code-runtime-e2b/README.i18n.yaml
Normal file
@@ -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
|
||||
44
packages/code-runtime/code-runtime-e2b/README.md
Normal file
44
packages/code-runtime/code-runtime-e2b/README.md
Normal file
@@ -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.
|
||||
44
packages/code-runtime/code-runtime-e2b/README.zh.md
Normal file
44
packages/code-runtime/code-runtime-e2b/README.zh.md
Normal file
@@ -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。
|
||||
- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。
|
||||
52
packages/code-runtime/code-runtime-e2b/package.json
Normal file
52
packages/code-runtime/code-runtime-e2b/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
441
packages/code-runtime/code-runtime-e2b/src/index.ts
Normal file
441
packages/code-runtime/code-runtime-e2b/src/index.ts
Normal file
@@ -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
|
||||
20
packages/code-runtime/code-runtime-e2b/src/invariant.ts
Normal file
20
packages/code-runtime/code-runtime-e2b/src/invariant.ts
Normal file
@@ -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 */
|
||||
460
packages/code-runtime/code-runtime-e2b/src/runner-source.ts
Normal file
460
packages/code-runtime/code-runtime-e2b/src/runner-source.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
460
packages/code-runtime/code-runtime-e2b/tests/runtime.spec.ts
Normal file
460
packages/code-runtime/code-runtime-e2b/tests/runtime.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
20
packages/code-runtime/code-runtime-e2b/tsconfig.json
Normal file
20
packages/code-runtime/code-runtime-e2b/tsconfig.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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/code-runtime/code-runtime-worker/README.md
|
||||
README.md: 590b79dcd1bc322350060b55767b09c6305edacc
|
||||
README.zh.md: 12c25f892bd20892cb47e593f16b6aadd9ffa84c
|
||||
README.md: 4b14c6fad5d1e491faeb54c9cb0e4403c8e2d8dd
|
||||
README.zh.md: c522917835b562cf7648d8bc78f0315b7c52d2d2
|
||||
|
||||
@@ -32,7 +32,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
|
||||
## The worker entry, unbuilt and built
|
||||
|
||||
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local and session-owned JSON boundaries both flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. The repository-wide requirement to exercise this published entry path belongs to the [testing policy](../../../docs/testing.md).
|
||||
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local JSON snapshotter is parity-tested against the session-owned canonical boundary; both sides flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
|
||||
|
||||
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
|
||||
|
||||
@@ -46,8 +46,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists.
|
||||
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — amaro or sucrase are the named drop-in replacements if the relied-on behavior shifts.
|
||||
- **OS processes a program spawns survive this backend's termination** — `worker.terminate()` ends only the thread; deployments needing remote process-group cleanup can select the E2B backend, whose separate limitations still apply.
|
||||
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts.
|
||||
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
|
||||
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface.
|
||||
- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output.
|
||||
|
||||
@@ -23,18 +23,18 @@
|
||||
- **每次运行使用一个全新 worker,不设池化**:程序所在的世界会随 worker 一同终止,不会留下需要记录的跨运行状态,也无法发生状态泄漏;仅凭会话日志即可重建运行。
|
||||
- **在执行上下文中,由宿主侧剥离类型**:程序会包裹在异步函数外壳中,通过 `node:module` 的 `stripTypeScriptTypes` 剥离类型(只支持可擦除语法;`enum`/namespace 会作为程序 `exception` 被拒绝,且不会启动 worker),再按字节位置切回原内容。之后程序作为 `AsyncFunction` 的函数体执行,因此顶层 `await`/`return` 可用。
|
||||
- **端口把对端视为不可信**:模型代码能够访问 `parentPort` 并伪造通信,因此任何代码读取入站消息前,系统都会验证其形状并重新构建(`null`、原始值、无效类型和格式错误的载荷会被静默丢弃;伪造的额外字段绝不会被带入);宿主对每个调用 id 最多响应一次,只将绑定名称解析为自有属性(伪造的 `constructor` 无法沿原型链访问),丢弃结算后的回复,并验证每个绑定 resolve 值与完成值是否为无损 JSON。伪造的 `log`/`done` 消息无法绕过外层上限:宿主会再次验证,并统计每条获准日志以及完成值或诊断。worker 侧命名空间使用 null-prototype 和 `defineProperty`,因此形似 `__proto__` 的绑定名称只是普通键。
|
||||
- **绑定调用被拒绝时使用的异常类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收调用失败的成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools` 或 `ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误 intrinsic 与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。
|
||||
- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟限制为 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。
|
||||
- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为自身嵌套深度有界的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。
|
||||
- **日志主动流入一个外层账本**:console/stdout/stderr 文本按产生顺序经端口传输,因此超时或被终止的程序仍会显示已经打印的内容。worker 会精确统计 JSON 字符串的字节数,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。
|
||||
- **空环境**:worker 使用 `env: {}` 和 `execArgv: []`,既不会获得环境变量中的凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。
|
||||
- **dispose(资源释放)时等待完全停稳**:清理会使进行中的运行以 `abort` 失败,并会等待每个 worker 退出后再完成。
|
||||
- **绑定 reject 类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收失败成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools` 或 `ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。
|
||||
- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟夹到 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。
|
||||
- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为有深度上限的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。
|
||||
- **日志主动流入一个外层账本**:console/stdout/stderr 文本按发送顺序穿过端口,因此超时或被终止的程序仍会显示已经打印的内容。worker 会按 JSON 字符串精确计费,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。
|
||||
- **空环境**:worker 使用 `env: {}` 和 `execArgv: []`,既没有环境凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。
|
||||
- **释放资源时等待完全停稳**:清理会把进行中的运行标记为 `abort`,并在 resolve 前等待每个 worker 退出。
|
||||
|
||||
## 未构建与已构建的 worker 入口
|
||||
|
||||
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地和会话自有的 JSON 边界都会在消息端口周围展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFS)Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。演练这个已发布入口路径的仓库级要求由[测试策略](../../../docs/testing.md)规定。
|
||||
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的 VFS Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。
|
||||
|
||||
SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Config`。运行所用的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。
|
||||
SDK 接口是默认/具名 `WorkerCodeRuntime` 类与 `Config`。可操作的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -42,13 +42,13 @@ SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Confi
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;由上述消费方负责请求前缀变更。
|
||||
不会直接失效;由具名消费方负责请求前缀变更。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **程序派生的 OS 进程在程序终止后仍会存活**:`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿进程清理属于部署职责。
|
||||
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:如依赖的行为发生变化,amaro 或 sucrase 是已经点名的直接替代品。
|
||||
- **程序 spawn 的 OS 进程在该后端终止后仍会存活**:`worker.terminate()` 只结束线程;需要清理远程进程组的部署可以选择 E2B 后端,但该后端自身的限制仍然适用。
|
||||
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amaro/sucrase 是已经点名的直接替代品。
|
||||
- **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。
|
||||
- **程序获得一个含 5 个方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。
|
||||
- **程序获得一个含 5 种方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。
|
||||
- **中间绑定值没有字节上限**:程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。
|
||||
- **默认 64 MiB 是拒绝边界,不是可恢复存储**:外层落盘只能保存发生 `output-limit` 后返回的有界日志和诊断;在运行时上限之外被拒绝的字节永远不会到达落盘层。
|
||||
|
||||
@@ -21,6 +21,10 @@ import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from
|
||||
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
export { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
export { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
|
||||
export type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -165,14 +169,19 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
}
|
||||
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
class OutputLedger {
|
||||
/** Shared outer-output accounting for one isolated run; binding values never enter it. */
|
||||
export class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
||||
/**
|
||||
* Admit one exact log entry, or report that the hard cap was crossed.
|
||||
* @param text - Candidate log entry.
|
||||
* @param sink - Accepted log entries for the current run.
|
||||
* @returns Whether the complete entry fits the remaining outer-output budget.
|
||||
*/
|
||||
admit(text: string, sink: string[]): boolean {
|
||||
const separatorBytes = this.entries > 0 ? 1 : 0
|
||||
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
|
||||
@@ -183,19 +192,33 @@ class OutputLedger {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Finalize a successful absent-or-JSON completion against the combined cap. */
|
||||
/**
|
||||
* Finalize a successful absent-or-JSON completion against the combined cap.
|
||||
* @param logs - Already accepted log entries.
|
||||
* @param value - Optional lossless-JSON completion value.
|
||||
* @returns A success result or an output-limit failure.
|
||||
*/
|
||||
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
||||
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, ...value !== undefined ? { value } : {} }
|
||||
}
|
||||
|
||||
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
/**
|
||||
* Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap.
|
||||
* @param logs - Already accepted log entries.
|
||||
* @param error - Candidate failure diagnostic.
|
||||
* @returns The diagnostic result or an output-limit failure.
|
||||
*/
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||
/**
|
||||
* Build the explicit output-limit failure while retaining a fitting prefix of the final log.
|
||||
* @param logs - Candidate log entries in original order.
|
||||
* @returns A capped output-limit result.
|
||||
*/
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||
|
||||
@@ -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/code-runtime/code-runtime/README.md
|
||||
README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
|
||||
README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2
|
||||
README.md: c7690412d0f1bc8556fc758da4e94c6ce5a08d8f
|
||||
README.zh.md: ecfa48a97c46113c52f13c7a6edfc5d6fbbc2285
|
||||
|
||||
@@ -34,5 +34,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
|
||||
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
|
||||
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
|
||||
- **Isolation is backend-specific** — the worker backend is process-local, while the E2B backend reports `container` and keeps orchestration and bindings on the host; the descriptor remains informational rather than a security claim.
|
||||
- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound.
|
||||
|
||||
@@ -4,23 +4,21 @@
|
||||
|
||||
这是**代码执行 seam**:抽象的 `CodeRuntime` 服务(`ctx.codeRuntime`)只定义代码运行时做什么,即针对宿主提供的一组异步绑定运行一段模型编写的程序,并报告 `{ value, logs, error? }`,而不规定如何实现。
|
||||
|
||||
此包承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。这两项职责均由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有与工具有关的内容都留在消费方。
|
||||
此包是该能力的接口层(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。两者都由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有工具形状的内容都留在消费方。
|
||||
|
||||
## 服务 API(`ctx.codeRuntime`)
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose(资源释放)后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `run(request)` | 针对请求的绑定执行一段程序。**每一种程序结果都通过 error 字段完成 resolve**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底死亡(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如资源释放后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 |
|
||||
| `language` | 只读描述符:`run` 期望的源语言(已知值为 `'typescript'`)。仅供参考,不作门禁;生成语言专用呈现的消费方会对该值执行分支,遇到无法呈现的语言时明确失败。 |
|
||||
| `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 |
|
||||
|
||||
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。
|
||||
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为不可信对等方(任意绑定名称都是自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;资源释放会终止进行中的运行,并且在完成前等待其退出。
|
||||
|
||||
## 词汇
|
||||
|
||||
`CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。
|
||||
|
||||
binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`(ECMAScript ∪ Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global)、`RESERVED_ERROR_MEMBERS` 与 `DUNDER_MEMBER`(error-member 排除)——因此 `$tools`、`lambda`、`__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject,而非只在某些后端。确切集合与理由见 `src/index.ts`。
|
||||
`CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收 reject 成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -28,11 +26,11 @@ binding-global 与 error-class 名称是**语言可移植**的:必须匹配标
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;由上述消费方负责请求前缀变更。
|
||||
不会直接失效;由具名消费方负责请求前缀变更。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **`run()` 是一次性的**:`logs` 只有在 `CodeRunResult` resolve 后才能获得;seam 不提供正在运行的程序所产生输出的流式日志或进度接口。
|
||||
- **`run()` 是一次性的**:`logs` 只有在 `CodeRunResult` resolve 后才能获得;seam 不提供活跃程序输出的流式日志或进度接口。
|
||||
- **持久 REPL 风格内核已记录为未来工作**:在持久内核后端带来自己的日志方案前,运行之间不保留状态的契约继续有效(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md))。
|
||||
- **目前只提供 worker 线程后端**:`'process'`/`'container'` 是已经声明但没有实现的已知 `isolation` 值;强安全边界需要等待容器后端。
|
||||
- **隔离方式由后端决定**:worker 后端位于宿主进程内,而 E2B 后端报告 `container`,并把编排与绑定留在宿主;该描述符仍只提供信息,不构成安全声明。
|
||||
- **中间绑定值没有字节上限**:实现仍受 structured-clone 成本与进程内存约束,而提供方或执行器可能已经应用自己的获取上限。
|
||||
|
||||
Reference in New Issue
Block a user