refactor(e2b): group remote providers

This commit is contained in:
Tianyi Cui
2026-07-28 14:52:37 +08:00
parent 6667102890
commit e64d40837c
81 changed files with 171 additions and 249 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/README.md
README.md: 9e7f1a98ada9f56f3d1e3f74d2a906f8a1c7bc15
README.zh.md: 53fdf30eaf405ab5313274a7fadde0b044b7fba1
README.md: b25f00fb5f32643008127f0cee4f3d758018404a
README.zh.md: fc3d6901f3cdf6c645cfde49dc46108759959aca

View File

@@ -7,10 +7,10 @@ An experimental provider-composition POC that places the mutable coding world in
| Package | ctx key | Role |
|---|---|---|
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create or reconnect one sandbox, create its working/runtime directories, expose the shared SDK handle, and apply the configured kill/pause/leave disposition |
| [`fs-e2b`](../fs/fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement managed process groups, stdio projection, and remote spill files over E2B Commands |
| [`pty-e2b`](../pty/pty-e2b/README.md) (`@deepseek-ai/dsh-pty-e2b`) | `ctx.pty` backend | Run persistent interactive shells through E2B's byte PTY API |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md) (`@deepseek-ai/dsh-lsp-e2b`) | `ctx.lsp` provider | Run configured language servers and read query sources inside E2B |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md) (`@deepseek-ai/dsh-code-runtime-e2b`) | `ctx.codeRuntime` | Run model-written programs remotely while bridging bindings to the host |
| [`fs-e2b`](fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
| [`subprocess-e2b`](subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement managed process groups, stdio projection, and remote spill files over E2B Commands |
| [`pty-e2b`](pty-e2b/README.md) (`@deepseek-ai/dsh-pty-e2b`) | `ctx.pty` backend | Run persistent interactive shells through E2B's byte PTY API |
| [`lsp-e2b`](lsp-e2b/README.md) (`@deepseek-ai/dsh-lsp-e2b`) | `ctx.lsp` provider | Run configured language servers and read query sources inside E2B |
| [`code-runtime-e2b`](code-runtime-e2b/README.md) (`@deepseek-ai/dsh-code-runtime-e2b`) | `ctx.codeRuntime` | Run model-written programs remotely while bridging bindings to the host |
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, protocol state, or E2B SDK buffers. The [base decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) and [runtime-extension decision](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) own the POC boundary.
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, protocol state, or E2B SDK buffers. The [shared-runtime decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary.

View File

@@ -7,10 +7,10 @@
| 包package | ctx 键 | 职责 |
|---|---|---|
| [`e2b`](e2b/README.md)`@deepseek-ai/dsh-e2b` | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
| [`fs-e2b`](../fs/fs-e2b/README.md)`@deepseek-ai/dsh-fs-e2b` | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md)`@deepseek-ai/dsh-subprocess-e2b` | `ctx.subprocess` | 通过 E2B Commands 实现受管进程组、stdio 投影与远程 spill 文件 |
| [`pty-e2b`](../pty/pty-e2b/README.md)`@deepseek-ai/dsh-pty-e2b` | `ctx.pty` 后端 | 通过 E2B 的字节 PTY API 运行持久交互式 shell |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md)`@deepseek-ai/dsh-lsp-e2b` | `ctx.lsp` 提供方 | 在 E2B 内运行已配置的语言服务器并读取查询源代码 |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md)`@deepseek-ai/dsh-code-runtime-e2b` | `ctx.codeRuntime` | 远程运行模型编写的程序,同时把绑定桥接到宿主 |
| [`fs-e2b`](fs-e2b/README.md)`@deepseek-ai/dsh-fs-e2b` | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
| [`subprocess-e2b`](subprocess-e2b/README.md)`@deepseek-ai/dsh-subprocess-e2b` | `ctx.subprocess` | 通过 E2B Commands 实现受管进程组、stdio 投影与远程 spill 文件 |
| [`pty-e2b`](pty-e2b/README.md)`@deepseek-ai/dsh-pty-e2b` | `ctx.pty` 后端 | 通过 E2B 的字节 PTY API 运行持久交互式 shell |
| [`lsp-e2b`](lsp-e2b/README.md)`@deepseek-ai/dsh-lsp-e2b` | `ctx.lsp` 提供方 | 在 E2B 内运行已配置的语言服务器并读取查询源代码 |
| [`code-runtime-e2b`](code-runtime-e2b/README.md)`@deepseek-ai/dsh-code-runtime-e2b` | `ctx.codeRuntime` | 远程运行模型编写的程序,同时把绑定桥接到宿主 |
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork它把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent智能体会话状态、会话持久化、skill技能、协议状态或 E2B SDK 缓冲。[基础决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)与[运行时扩展决策](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)共同界定 POC 边界。
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork它把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent智能体会话状态、会话持久化、skill技能、协议状态或 E2B SDK 缓冲。[共享运行时决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)界定 POC 边界。

View 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/e2b/code-runtime-e2b/README.md
README.md: 1007800c59725d116197a52f6d087ea5e2860686
README.zh.md: a56026bce760879079627dd63a80dfe95c4d2049

View File

@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-code-runtime-e2b
English | [中文](README.zh.md)
E2B implementation of [`ctx.codeRuntime`](../../code-runtime/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.

View File

@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-code-runtime-e2b
[English](README.md) | 中文
[`ctx.codeRuntime`](../../code-runtime/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。
- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。

View 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"
}
}

View 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

View 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 */

View 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
}
}
`

View 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()
})
})

View 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/code-runtime" },
{ "path": "../../code-runtime/code-runtime-worker" },
{ "path": "../e2b" },
{ "path": "../../core/session" },
{ "path": "../subprocess-e2b" },
{ "path": "../../util/timeout" },
{ "path": "../../support/invariants" }
]
}

View 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/e2b/fs-e2b/README.md
README.md: d8f3915fa281e8b9c062e3412c92bc6358616284
README.zh.md: 93d27bcdd514ccbf87e07d0eff5958edf3c726c1

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-fs-e2b
English | [中文](README.zh.md)
E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provider seam. It has no config: load [`@deepseek-ai/dsh-e2b`](../e2b/README.md) first, then this service in place of `dsh-fs-local`. The provider uses the owner's remote cwd and SDK handle, so file tools observe the same world as E2B-backed Bash processes.
## Behavior
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; `realpath -m` supplies canonical target identity without requiring the final file to exist. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
- **Atomic mutations** — writes upload a mode-`0600` temporary sibling, preserve an existing file's POSIX mode, and publish through same-directory Linux `mv -f`. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at SDK request boundaries; a successful rename is the commit point.
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
## Model Experience
Indirectly, through [`dsh-tool-fs`](../../fs/tool-fs/README.md), which renders remote UTF-8 content, directory results, mutation acknowledgements, and provider errors while E2B identity and transport remain internal.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, template, or external process populates it; local files are neither uploaded nor reflected back.
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
- **Custom templates must support the used Linux and envd features** — `realpath`, `chmod`, `mv`, same-filesystem POSIX rename, streaming reads, and file metadata extended attributes are required; unsupported templates fail rather than degrade silently.

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-fs-e2b
[English](README.md) | 中文
[`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) 提供方 seam 的 E2B 实现。它没有配置:先加载 [`@deepseek-ai/dsh-e2b`](../e2b/README.md),再用本服务取代 `dsh-fs-local`。该提供方使用所有者的远程 cwd 和 SDK 句柄,因此文件工具观察到的环境与 E2B 后端 Bash 进程相同。
## 行为
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;`realpath -m` 提供规范化目标身份,且不要求最终文件存在。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
- **原子变更**:写入会上传 mode 为 `0600` 的同级临时文件,保留现有文件的 POSIX mode并通过同目录 Linux `mv -f` 发布。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF存储时恢复占主导的 CRLF并在宿主进程内按规范化目标串行执行变更。可选的创建版本防护会保留基础 seam 的已观察状态语义。
- **失败与取消**E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在 SDK 请求边界上采用尽力而为语义;成功 rename 是提交点。
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
## 模型体验
通过 [`dsh-tool-fs`](../../fs/tool-fs/README.md) 间接影响模型;该工具会渲染远程 UTF-8 内容、目录结果、变更确认和提供方错误,而 E2B 身份及传输保持内部实现。
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与延后工作
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令、模板或外部进程填充它;本地文件既不会上传,也不会同步回本地。
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
- **自定义模板必须支持所用的 Linux 与 envd 功能**:必须支持 `realpath``chmod``mv`、同一文件系统内的 POSIX rename、流式读取和文件元数据扩展属性不支持的模板会失败而不会静默降级。

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-fs-e2b",
"description": "E2B filesystem 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-e2b": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,423 @@
/**
* E2B implementation of the filesystem provider seam. Paths, contents, and
* atomic staging files remain inside the shared remote sandbox.
* @module @deepseek-ai/dsh-fs-e2b
*/
import { createHash, randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
FsPathInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import {
CommandExitError,
FileNotFoundError,
FileType,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b'
const VERSION_METADATA_KEY = 'dsh-version'
const BINARY_SAMPLE_BYTES = 8192
function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
}
function normalizeLineEndings(value: string): string {
return value.replaceAll('\r\n', '\n')
}
function detectsCrlf(value: string): boolean {
const sample = value.slice(0, 4096)
const crlf = sample.split('\r\n').length - 1
const lf = sample.split('\n').length - 1 - crlf
return crlf > lf
}
function restoreLineEndings(value: string, crlf: boolean): string {
return crlf ? normalizeLineEndings(value).replaceAll('\n', '\r\n') : value
}
function decodeText(bytes: Uint8Array, displayPath: string, binarySampleBytes: number): string {
if (bytes.subarray(0, binarySampleBytes).includes(0)) {
throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
function entryType(entry: EntryInfo): FsInfo['type'] {
switch (entry.type) {
case FileType.FILE:
return 'file'
case FileType.DIR:
return 'directory'
default:
return 'other'
}
}
function entryVersion(entry: EntryInfo): ReturnType<typeof FsVersion> {
const facts = JSON.stringify([
entry.metadata?.[VERSION_METADATA_KEY],
entry.path,
entry.type,
entry.size,
entry.mode,
entry.modifiedTime?.toISOString(),
entry.symlinkTarget,
])
return FsVersion(`e2b:${createHash('sha256').update(facts).digest('hex')}`)
}
function mapError(error: unknown, operation: string, displayPath: string, signal?: AbortSignal): FsError {
if (error instanceof FsError) return error
if (signal?.aborted === true || (error instanceof DOMException && error.name === 'AbortError')) {
return new FsError(`${operation} aborted`, 'FS_ABORTED', { cause: error })
}
if (error instanceof FileNotFoundError) {
return new FsError(`cannot ${operation} "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
}
if (/permission denied|operation not permitted/i.test(String(error))) {
return new FsError(`cannot ${operation} "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
}
return new FsError(`cannot ${operation} "${displayPath}": ${String(error)}`, 'FS_IO_ERROR', { cause: error })
}
function literalEdit(content: string, request: FsEditRequest, displayPath: string): string {
const oldString = normalizeLineEndings(request.oldString)
const newString = normalizeLineEndings(request.newString)
if (oldString.length === 0) {
throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, 'FS_EDIT_NOT_FOUND')
}
let matches = 0
let offset = 0
while (true) {
const found = content.indexOf(oldString, offset)
if (found < 0) break
matches += 1
offset = found + oldString.length
}
if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, 'FS_EDIT_NOT_FOUND')
if (!request.replaceAll && matches !== 1) {
throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, 'FS_AMBIGUOUS_EDIT')
}
return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
}
/** Remote filesystem backend sharing the sandbox owned by `ctx.e2b`. */
export class E2BFileSystem extends FileSystem {
static inject = ['e2b']
private readonly locks = new Map<string, Promise<unknown>>()
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
assertNotAborted(opts?.signal, 'resolve')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
try {
const sandbox = await this.ctx.e2b.getSandbox()
const targetKey = await this.canonicalPath(sandbox, displayPath, opts?.signal)
assertNotAborted(opts?.signal, 'resolve')
return { targetKey: FsTargetKey(targetKey), displayPath }
} catch (error: unknown) {
throw mapError(error, 'resolve', displayPath, opts?.signal)
}
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
assertNotAborted(signal, 'stat')
const entry = await this.probe(String(target.targetKey), target.displayPath, signal)
if (entry === undefined) return undefined
return {
version: entryVersion(entry),
type: entryType(entry),
...(entry.type === FileType.FILE ? { size: entry.size } : {}),
}
}
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
assertNotAborted(signal, 'lstat')
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
const entry = await this.probe(displayPath, displayPath, signal)
if (entry === undefined) return undefined
const type = entry.symlinkTarget !== undefined
? 'symlink' as const
: entry.type === FileType.FILE
? 'file' as const
: entry.type === FileType.DIR
? 'directory' as const
: 'other' as const
return {
version: entryVersion(entry),
type,
...(entry.type === FileType.FILE ? { size: entry.size } : {}),
}
}
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
try {
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'read')
return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES)
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
const sandbox = await this.ctx.e2b.getSandbox()
await this.requireRegular(target, signal)
let stream: ReadableStream<Uint8Array>
try {
stream = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) })
} catch (error: unknown) {
throw mapError(error, 'read', target.displayPath, signal)
}
const displayPath = target.displayPath
return {
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
try {
while (true) {
assertNotAborted(signal, 'read')
const next = await reader.read()
if (next.done) break
if (sampledBytes < BINARY_SAMPLE_BYTES) {
const sample = next.value.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes)
if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
sampledBytes += sample.length
}
let text: string
try {
text = decoder.decode(next.value, { stream: true })
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
if (text.length > 0) yield text
}
try {
decoder.decode()
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
} catch (error: unknown) {
throw mapError(error, 'read', displayPath, signal)
} finally {
reader.releaseLock()
}
},
}
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
try {
const sandbox = await this.ctx.e2b.getSandbox()
const listed = await sandbox.files.list(String(target.targetKey), { depth: 1, ...signalOpts(signal) })
const entries = await Promise.all(listed.map(async (entry): Promise<FsDirEntry> => {
const displayPath = posix.join(target.displayPath, entry.name)
const canonical = await this.canonicalPath(sandbox, entry.path, signal)
const resolved = await this.probe(canonical, displayPath, signal)
return {
name: entry.name,
type: resolved === undefined ? 'other' : entryType(resolved),
target: { targetKey: FsTargetKey(canonical), displayPath },
...(resolved !== undefined ? { version: entryVersion(resolved) } : {}),
...(resolved?.type === FileType.FILE ? { size: resolved.size } : {}),
}
}))
return entries.sort((left, right) => left.name.localeCompare(right.name))
} catch (error: unknown) {
throw mapError(error, 'list', target.displayPath, signal)
}
}
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
): Promise<FsWriteOutcome> {
return this.withLock(String(target.targetKey), async () => {
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
if (existing !== undefined && entryType(existing) !== 'file') {
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
this.checkWriteIntent(existing, expected, target)
const before = existing === undefined ? null : await this.readForDiff(target, signal)
const version = await this.writeAtomic(target, content, existing, signal)
return {
operation: existing === undefined ? 'create' : 'update',
version,
before,
after: normalizeLineEndings(content),
}
})
}
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: ReturnType<typeof FsVersion> },
signal?: AbortSignal,
): Promise<FsEditOutcome> {
return this.withLock(String(target.targetKey), async () => {
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
if (existing === undefined) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
if (entryType(existing) !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
if (expected !== undefined && entryVersion(existing) !== expected.version) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
const raw = await this.readForEdit(target, signal)
const before = normalizeLineEndings(raw)
const after = literalEdit(before, edit, target.displayPath)
const storage = restoreLineEndings(after, detectsCrlf(raw))
const version = await this.writeAtomic(target, storage, existing, signal)
return { version, before, after }
})
}
private async withLock<T>(targetKey: string, operation: () => Promise<T>): Promise<T> {
const prior = this.locks.get(targetKey) ?? Promise.resolve()
const run = prior.then(operation, operation)
const tail = run.then(() => undefined, () => undefined)
this.locks.set(targetKey, tail)
try {
return await run
} finally {
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey)
}
}
private async canonicalPath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
try {
const result = await sandbox.commands.run(`realpath -m -- ${quoteE2BShellArg(path)}`, signalOpts(signal))
return result.stdout.replace(/\n$/, '')
} catch (error: unknown) {
if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error })
throw error
}
}
private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<EntryInfo | undefined> {
assertNotAborted(signal, 'stat')
try {
const sandbox = await this.ctx.e2b.getSandbox()
const entry = await sandbox.files.getInfo(path, signalOpts(signal))
assertNotAborted(signal, 'stat')
return entry
} catch (error: unknown) {
if (error instanceof FileNotFoundError) return undefined
throw mapError(error, 'stat', displayPath, signal)
}
}
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<void> {
const info = await this.stat(target, signal)
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
if (expected?.kind === 'createIfAbsent' && existing !== undefined) {
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
if (expected?.kind === 'replaceIfVersion') {
if (existing === undefined || entryVersion(existing) !== expected.version) {
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
}
}
private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
try {
const sandbox = await this.ctx.e2b.getSandbox()
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'read')
return normalizeLineEndings(decodeText(bytes, target.displayPath, bytes.length))
} catch (error: unknown) {
if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
throw mapError(error, 'read', target.displayPath, signal)
}
}
private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
try {
const sandbox = await this.ctx.e2b.getSandbox()
const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
assertNotAborted(signal, 'edit')
return decodeText(bytes, target.displayPath, bytes.length)
} catch (error: unknown) {
throw mapError(error, 'edit', target.displayPath, signal)
}
}
private async writeAtomic(
target: FsTarget,
content: string,
existing: EntryInfo | undefined,
signal?: AbortSignal,
): Promise<ReturnType<typeof FsVersion>> {
assertNotAborted(signal, 'write')
const sandbox = await this.ctx.e2b.getSandbox()
const targetPath = String(target.targetKey)
const versionId = randomUUID()
const temporary = posix.join(posix.dirname(targetPath), `.${posix.basename(targetPath)}.dsh-${randomUUID()}.tmp`)
try {
await sandbox.files.write(temporary, content, {
metadata: { [VERSION_METADATA_KEY]: versionId },
...signalOpts(signal),
})
assertNotAborted(signal, 'write')
const mode = existing === undefined ? 0o600 : existing.mode & 0o777
await sandbox.commands.run(
`chmod ${mode.toString(8)} -- ${quoteE2BShellArg(temporary)}`,
signalOpts(signal),
)
assertNotAborted(signal, 'write')
await sandbox.commands.run(
`mv -f -- ${quoteE2BShellArg(temporary)} ${quoteE2BShellArg(targetPath)}`,
signalOpts(signal),
)
const committed = await sandbox.files.getInfo(targetPath)
return entryVersion(committed)
} catch (error: unknown) {
try {
await sandbox.files.remove(temporary)
} catch (_temporaryAlreadyAbsent) {
// Only the private staging path is swallowed; the original failure owns the operation.
}
throw mapError(error, 'write', target.displayPath, signal)
}
}
}
export default E2BFileSystem

View File

@@ -0,0 +1,27 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-fs-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-fs-e2b'
/** Cordis companion plugin name. */
export const name = 'fs-e2b-invariant'
/** Service required before reserving package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: each operation returns the E2B controller's committed
* result directly, with no independent event or cache to cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,537 @@
import { dirname, posix } from 'node:path'
import { Context } from 'cordis'
import {
CommandExitError,
FileNotFoundError,
FileType,
type EntryInfo,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { FsVersion } from '@deepseek-ai/dsh-fs'
import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
import * as E2BFsInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
interface RemoteNode {
type: FileType
data: Uint8Array
mode: number
modified: number
metadata?: Record<string, string>
symlinkTarget?: string
}
function bytes(value: string | readonly number[]): Uint8Array {
return typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value)
}
function commandError(exitCode: number, stderr = ''): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr, error: stderr })
}
class FakeRemote {
readonly nodes = new Map<string, RemoteNode>()
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
readonly renames: Array<{ from: string; to: string }> = []
readonly removals: string[] = []
readonly commands: string[] = []
streamChunks: Uint8Array[] | undefined
nextCommandError: unknown
nextInfoError: unknown
nextListError: unknown
nextReadError: unknown
nextRenameError: unknown
nextRemoveError: unknown
abortAfterRename: AbortController | undefined
disappearOnInfo = new Set<string>()
private clock = 1
constructor() {
this.dir('/')
this.dir('/workspace')
}
dir(path: string): void {
this.nodes.set(path, { type: FileType.DIR, data: bytes(''), mode: 0o755, modified: this.clock++ })
}
file(path: string, data: string | readonly number[], mode = 0o644): void {
this.nodes.set(path, { type: FileType.FILE, data: bytes(data), mode, modified: this.clock++ })
}
other(path: string): void {
this.nodes.set(path, { type: 'other' as FileType, data: bytes(''), mode: 0o600, modified: this.clock++ })
}
symlink(path: string, target: string): void {
this.nodes.set(path, {
type: FileType.FILE,
data: bytes(''),
mode: 0o777,
modified: this.clock++,
symlinkTarget: target,
})
}
mutate(path: string, data: string): void {
const node = this.required(path)
node.data = bytes(data)
node.modified = this.clock++
}
private required(path: string): RemoteNode {
const node = this.nodes.get(path)
if (node === undefined) throw new FileNotFoundError(`missing: ${path}`)
return node
}
private followed(path: string): { path: string; node: RemoteNode; link?: RemoteNode } {
const node = this.required(path)
if (node.symlinkTarget === undefined) return { path, node }
return { path: node.symlinkTarget, node: this.required(node.symlinkTarget), link: node }
}
private info(path: string): EntryInfo {
if (this.disappearOnInfo.delete(path)) throw new FileNotFoundError(`missing: ${path}`)
return this.rawInfo(path)
}
private rawInfo(path: string): EntryInfo {
const followed = this.followed(path)
const node = followed.node
return {
name: posix.basename(path),
path,
type: node.type,
size: node.data.byteLength,
mode: node.mode,
permissions: 'rw-------',
owner: 'user',
group: 'user',
modifiedTime: new Date(node.modified),
...(node.metadata !== undefined ? { metadata: { ...node.metadata } } : {}),
...(followed.link?.symlinkTarget !== undefined ? { symlinkTarget: followed.link.symlinkTarget } : {}),
}
}
private checkAbort(options: { signal?: AbortSignal } | undefined): void {
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
}
readonly sandbox = {
sandboxId: 'fake',
files: {
makeDir: async (path: string): Promise<boolean> => {
if (this.nodes.has(path)) return false
this.dir(path)
return true
},
getInfo: async (path: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
this.checkAbort(options)
if (this.nextInfoError !== undefined) {
const error = this.nextInfoError
this.nextInfoError = undefined
throw error
}
return this.info(path)
},
read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array>> => {
this.checkAbort(options)
if (this.nextReadError !== undefined) {
const error = this.nextReadError
this.nextReadError = undefined
throw error
}
const data = this.followed(path).node.data
if (options.format === 'bytes') return data.slice()
const chunks = this.streamChunks ?? [data.slice()]
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
},
})
},
list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
this.checkAbort(options)
if (this.nextListError !== undefined) {
const error = this.nextListError
this.nextListError = undefined
throw error
}
this.required(path)
return [...this.nodes.keys()]
.filter(candidate => candidate !== path && dirname(candidate) === path)
.map(candidate => this.rawInfo(candidate))
},
write: async (path: string, data: string, options?: { metadata?: Record<string, string>; signal?: AbortSignal }): Promise<object> => {
this.checkAbort(options)
const parent = dirname(path)
if (!this.nodes.has(parent)) this.dir(parent)
this.nodes.set(path, {
type: FileType.FILE,
data: bytes(data),
mode: 0o644,
modified: this.clock++,
...(options?.metadata !== undefined ? { metadata: { ...options.metadata } } : {}),
})
this.writes.push({ path, data, ...(options?.metadata !== undefined ? { metadata: options.metadata } : {}) })
return {}
},
rename: async (from: string, to: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
this.checkAbort(options)
if (this.nextRenameError !== undefined) {
const error = this.nextRenameError
this.nextRenameError = undefined
throw error
}
const node = this.required(from)
this.nodes.delete(from)
this.nodes.set(to, node)
this.renames.push({ from, to })
this.abortAfterRename?.abort('after commit')
return this.info(to)
},
remove: async (path: string): Promise<void> => {
this.removals.push(path)
if (this.nextRemoveError !== undefined) {
const error = this.nextRemoveError
this.nextRemoveError = undefined
throw error
}
this.nodes.delete(path)
},
},
commands: {
run: async (command: string, options?: { signal?: AbortSignal }): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
this.checkAbort(options)
this.commands.push(command)
if (this.nextCommandError !== undefined) {
const error = this.nextCommandError
this.nextCommandError = undefined
throw error
}
if (command.startsWith('realpath -m -- ')) {
const input = command.slice('realpath -m -- '.length).slice(1, -1)
const node = this.nodes.get(input)
return { exitCode: 0, stdout: `${node?.symlinkTarget ?? input}\n`, stderr: '' }
}
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
if (move !== null) {
if (this.nextRenameError !== undefined) {
const error = this.nextRenameError
this.nextRenameError = undefined
throw error
}
const node = this.required(move[1]!)
this.nodes.delete(move[1]!)
this.nodes.set(move[2]!, node)
this.renames.push({ from: move[1]!, to: move[2]! })
this.abortAfterRename?.abort('after commit')
}
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
async function setup(remote = new FakeRemote()): Promise<{ ctx: Context; fs: E2BFileSystem; remote: FakeRemote }> {
const ctx = new Context()
const runtime = {
cwd: '/workspace',
runtimeRoot: '/workspace/.dsh-e2b',
disposeMode: 'kill',
getSandbox: async () => remote.sandbox,
} as unknown as E2BSandboxService
ctx.provide('e2b', runtime)
await ctx.plugin(E2BFileSystem)
return { ctx, fs: ctx.fs as E2BFileSystem, remote }
}
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
await expect(promise).rejects.toMatchObject({ code })
}
describe('E2BFileSystem identity, metadata, and reads', () => {
it('resolves remote paths, reports symlinks, and lists direct children in stable order', async () => {
const remote = new FakeRemote()
remote.file('/workspace/z.txt', 'z')
remote.file('/workspace/a.txt', 'a')
remote.dir('/workspace/dir')
remote.other('/workspace/special')
remote.file('/workspace/dir/nested.txt', 'nested')
remote.symlink('/workspace/link.txt', '/workspace/a.txt')
const { fs } = await setup(remote)
const link = await fs.resolve('link.txt')
expect(link).toEqual({ targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' })
await expect(fs.lstat('link.txt')).resolves.toMatchObject({ type: 'symlink', size: 1 })
await expect(fs.lstat('a.txt')).resolves.toMatchObject({ type: 'file', size: 1 })
await expect(fs.lstat('dir')).resolves.toEqual(expect.objectContaining({ type: 'directory' }))
await expect(fs.lstat('special')).resolves.toEqual(expect.objectContaining({ type: 'other' }))
await expect(fs.lstat('missing')).resolves.toBeUndefined()
await expect(fs.stat(link)).resolves.toMatchObject({ type: 'file', size: 1 })
const directory = await fs.resolve('.')
const listed = await fs.listDir(directory)
expect(listed.map(entry => entry.name)).toEqual(['a.txt', 'dir', 'link.txt', 'special', 'z.txt'])
expect(listed.find(entry => entry.name === 'dir')).toMatchObject({ type: 'directory' })
expect(listed.find(entry => entry.name === 'link.txt')).toMatchObject({
type: 'file',
target: { targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' },
})
expect(listed.some(entry => entry.name === 'nested.txt')).toBe(false)
})
it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
const remote = new FakeRemote()
remote.file('/workspace/text.txt', 'A€B')
remote.streamChunks = [bytes([65, 0xe2]), bytes([0x82, 0xac, 66])]
const { fs } = await setup(remote)
const target = await fs.resolve('text.txt')
await expect(fs.readText(target)).resolves.toBe('A€B')
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe('A€B')
remote.streamChunks = [bytes([0xe2]), bytes([0x82, 0xac])]
let initiallyBuffered = ''
for await (const chunk of await fs.streamText(target)) initiallyBuffered += chunk
expect(initiallyBuffered).toBe('€')
})
it('matches local binary sampling while edits still reject any NUL byte', async () => {
const remote = new FakeRemote()
remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)
const { fs } = await setup(remote)
const target = await fs.resolve('late-nul.txt')
await expect(fs.readText(target)).resolves.toContain('\0tail')
remote.streamChunks = [bytes('a'.repeat(8192)), bytes([0, 116])]
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe(`${'a'.repeat(8192)}\0t`)
await expectCode(fs.editText(target, { oldString: 'tail', newString: 'end', replaceAll: false }), 'FS_NOT_TEXT')
})
it('maps binary, invalid UTF-8, missing, and non-regular read failures', async () => {
const remote = new FakeRemote()
remote.file('/workspace/binary', [0, 1])
remote.file('/workspace/invalid', [0xff])
remote.dir('/workspace/directory')
const { fs } = await setup(remote)
await expectCode(fs.readText(await fs.resolve('binary')), 'FS_NOT_TEXT')
await expectCode(fs.readText(await fs.resolve('invalid')), 'FS_NOT_TEXT')
await expectCode(fs.readText(await fs.resolve('missing')), 'FS_NOT_FOUND')
await expectCode(fs.readText(await fs.resolve('directory')), 'FS_NOT_REGULAR_FILE')
remote.streamChunks = [bytes([0xff])]
const invalid = await fs.streamText(await fs.resolve('invalid'))
await expect((async () => { for await (const _chunk of invalid) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
remote.streamChunks = [bytes([0])]
const binary = await fs.streamText(await fs.resolve('binary'))
await expect((async () => { for await (const _chunk of binary) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
remote.streamChunks = [bytes([0xe2])]
const incomplete = await fs.streamText(await fs.resolve('invalid'))
await expect((async () => { for await (const _chunk of incomplete) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
const raced = await fs.resolve('invalid')
remote.nextReadError = new FileNotFoundError('gone after stat')
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
})
it('honors aborts before and during remote reads', async () => {
const remote = new FakeRemote()
remote.file('/workspace/a', 'a')
const { fs } = await setup(remote)
await expectCode(fs.resolve('a', { signal: AbortSignal.abort() }), 'FS_ABORTED')
await expectCode(fs.lstat('a', undefined, AbortSignal.abort()), 'FS_ABORTED')
await expectCode(fs.stat(await fs.resolve('a'), AbortSignal.abort()), 'FS_ABORTED')
remote.nextReadError = new DOMException('aborted', 'AbortError')
await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
})
it('rejects empty paths and directory-listing type errors', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file', 'x')
const { fs } = await setup(remote)
await expectCode(fs.resolve(' '), 'FS_NOT_FOUND')
await expectCode(fs.lstat(''), 'FS_NOT_FOUND')
await expectCode(fs.listDir(await fs.resolve('missing')), 'FS_NOT_FOUND')
await expectCode(fs.listDir(await fs.resolve('/workspace/file')), 'FS_NOT_DIRECTORY')
remote.nextListError = new Error('listing transport failed')
await expectCode(fs.listDir(await fs.resolve('/workspace')), 'FS_IO_ERROR')
})
})
describe('E2BFileSystem atomic writes and edits', () => {
it('creates owner-only files and returns metadata after the committed move', async () => {
const { fs, remote } = await setup()
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'one\r\ntwo\rthree', { kind: 'createIfAbsent' })
expect(outcome).toMatchObject({ operation: 'create', before: null, after: 'one\ntwo\rthree' })
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
await expect(fs.stat(target)).resolves.toMatchObject({ version: outcome.version, size: 14 })
})
it('preserves replacement mode, normalizes only CRLF for diffs, and changes version on external writes', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'old\r\nline\rlone', 0o640)
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const before = (await fs.stat(target))!.version
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: before })
expect(outcome).toMatchObject({ operation: 'update', before: 'old\nline\rlone', after: 'new' })
expect(remote.nodes.get('/workspace/file.txt')?.mode).toBe(0o640)
const committed = outcome.version
remote.mutate('/workspace/file.txt', 'external')
expect((await fs.stat(target))!.version).not.toBe(committed)
})
it('returns null as the overwrite diff basis for binary or invalid prior content', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', [0xff])
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
await expect(fs.writeText(target, 'valid')).resolves.toMatchObject({ before: null, after: 'valid' })
})
it('fails an overwrite when reading its text diff basis fails for another reason', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'prior')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
remote.nextReadError = new Error('read transport failed')
await expectCode(fs.writeText(target, 'replacement'), 'FS_IO_ERROR')
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('prior')
})
it('enforces create and version intents before publication', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'v1')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
await expectCode(fs.writeText(target, 'blind', { kind: 'createIfAbsent' }), 'FS_NOT_OBSERVED')
remote.mutate('/workspace/file.txt', 'v2')
await expectCode(fs.writeText(target, 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
await expectCode(fs.writeText(await fs.resolve('missing'), 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
remote.dir('/workspace/dir')
await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
})
it('does not turn an abort observed after a successful move into a failed write', async () => {
const remote = new FakeRemote()
const controller = new AbortController()
remote.abortAfterRename = controller
const { fs } = await setup(remote)
await expect(fs.writeText(await fs.resolve('committed'), 'yes', undefined, controller.signal))
.resolves.toMatchObject({ operation: 'create' })
expect(controller.signal.aborted).toBe(true)
})
it('cleans staging files and maps command, permission, and abort failures', async () => {
const remote = new FakeRemote()
const { fs } = await setup(remote)
const commandTarget = await fs.resolve('command')
remote.nextCommandError = commandError(1, 'chmod failed')
await expectCode(fs.writeText(commandTarget, 'x'), 'FS_IO_ERROR')
expect(remote.removals).toHaveLength(1)
remote.nextRenameError = new Error('permission denied')
await expectCode(fs.writeText(await fs.resolve('permission'), 'x'), 'FS_PERMISSION_DENIED')
remote.nextRemoveError = new Error('cleanup also failed')
remote.nextRenameError = new DOMException('aborted', 'AbortError')
await expectCode(fs.writeText(await fs.resolve('abort'), 'x'), 'FS_ABORTED')
})
it('applies literal edits atomically and restores the detected CRLF style', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'one\r\ntwo\r\nthree\n')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
const outcome = await fs.editText(
target,
{ oldString: 'two\r\n', newString: 'TWO\r\n', replaceAll: false },
{ version },
)
expect(outcome).toMatchObject({ before: 'one\ntwo\nthree\n', after: 'one\nTWO\nthree\n' })
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('one\r\nTWO\r\nthree\r\n')
})
it('reports stale and literal-match failures with stable codes', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'a a')
remote.dir('/workspace/dir')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
await expectCode(fs.editText(target, { oldString: '', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
await expectCode(fs.editText(target, { oldString: 'z', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
await expectCode(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: false }), 'FS_AMBIGUOUS_EDIT')
await expect(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: true }))
.resolves.toMatchObject({ after: 'x x' })
await expectCode(fs.editText(target, { oldString: 'x', newString: 'y', replaceAll: false }, { version: FsVersion('stale') }), 'FS_STALE_VERSION')
await expectCode(fs.editText(await fs.resolve('missing'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_STALE_VERSION')
await expectCode(fs.editText(await fs.resolve('dir'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_NOT_REGULAR_FILE')
})
it('serializes guarded mutations so only one stale version can win', async () => {
const remote = new FakeRemote()
remote.file('/workspace/file.txt', 'base')
const { fs } = await setup(remote)
const target = await fs.resolve('file.txt')
const version = (await fs.stat(target))!.version
const results = await Promise.allSettled([
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
])
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
})
})
describe('E2B filesystem adapter integration edges', () => {
it('maps canonicalization, permission, and generic provider failures', async () => {
const remote = new FakeRemote()
const { fs } = await setup(remote)
remote.nextCommandError = commandError(1, 'not a directory')
await expectCode(fs.resolve('bad'), 'FS_IO_ERROR')
remote.nextCommandError = commandError(1)
await expectCode(fs.resolve('bad-again'), 'FS_IO_ERROR')
remote.nextCommandError = new Error('canonical transport failed')
await expectCode(fs.resolve('bad-transport'), 'FS_IO_ERROR')
remote.file('/workspace/a', 'a')
const target = await fs.resolve('a')
remote.nextInfoError = new Error('metadata transport failed')
await expectCode(fs.stat(target), 'FS_IO_ERROR')
remote.nextReadError = new Error('operation not permitted')
await expectCode(fs.readText(target), 'FS_PERMISSION_DENIED')
remote.nextReadError = 'transport vanished'
await expectCode(fs.readText(target), 'FS_IO_ERROR')
})
it('keeps a listed child whose metadata disappears as an other entry', async () => {
const remote = new FakeRemote()
remote.file('/workspace/a', 'a')
remote.disappearOnInfo.add('/workspace/a')
const { fs } = await setup(remote)
const listed = await fs.listDir(await fs.resolve('/workspace'))
expect(listed).toEqual([{
name: 'a',
type: 'other',
target: { targetKey: '/workspace/a', displayPath: '/workspace/a' },
}])
})
it('registers the package-owned empty invariant installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BFsInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../e2b"
},
{
"path": "../../fs/fs"
},
{
"path": "../../support/invariants"
}
]
}

View 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/e2b/lsp-e2b/README.md
README.md: ded928ec073e6c0943d574d86359eeec586bb862
README.zh.md: 91eb7cecb49adcf73f4be948e9a5da400981e6f1

View File

@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-lsp-e2b
English | [中文](README.zh.md)
Generic E2B language-server backend for [`ctx.lsp`](../../lsp/lsp/README.md). It runs configured stdio servers and reads their source documents inside the shared `ctx.e2b` sandbox; the provider registry, normalized query results, queues, and protocol connection state remain on the host.
## Plugin and configuration
The `lsp-e2b` plugin injects `e2b`, `lsp`, and the concrete `dsh-subprocess-e2b` service. `servers` is a non-empty provider-id table:
| Server key | Default | Meaning |
|---|---|---|
| `command` | required | Remote executable, absolute or resolved on the sandbox PATH at load. |
| `args` | `[]` | Remote server arguments. |
| `env` | `{}` | Explicit environment entries passed through the subprocess adapter. |
| `extensionToLanguage` | required | Lowercase leading-dot extension to LSP language id. |
| `initializationOptions` / `configuration` | `null` / `null` | Static initialize options and `workspace/configuration` answer. |
| `maxMessageBytes` | `16000000` | Largest LSP message accepted from the server. |
| `maxStderrBytes` | `1000000` | Retained raw server stderr tail. |
| `maxDocumentBytes` | `4000000` | Largest remote source opened for one query. |
| `shutdownTimeoutMs` | `5000` | Graceful protocol-shutdown budget. |
| `killGraceMs` | `2000` | Request-cancel and TERM-to-KILL grace. |
Provider ids and commands are non-empty; numeric bounds are positive safe integers, and timer values cannot exceed Node's maximum timer delay. Setup uploads one owner-private proxy under `ctx.e2b.runtimeRoot`, resolves Node and every configured server executable remotely, then registers all providers atomically.
## Remote protocol and filesystem
E2B command callbacks are text, while LSP is byte-framed. The installed proxy therefore base64-frames raw server stdout, stderr, and stdin as newline-delimited ASCII JSON; the host validates and decodes every frame before handing bytes to the shared `LspInstance` protocol engine. `initialize.processId` is `null` because host and server do not share a process namespace.
One language-server process is pooled per provider and canonical remote workspace. Queries serialize per workspace but different workspaces run concurrently. Each query canonicalizes the remote workspace and source with `realpath`, rejects paths outside that workspace, requires a regular file, enforces the size bound before and after reading, decodes strict UTF-8, and uses the ordinary transient `didOpen` / request / `didClose` lifecycle. A transport failure disposes the instance and retries the read-only query once on a fresh remote process.
The subprocess adapter owns process groups and escalation, so cancellation and disposal await remote server quiescence. The host owns LSP request ids, pending requests, provider queues, and normalized results.
## Model Experience
Indirectly, through `@deepseek-ai/dsh-tool-lsp`, which exposes normalized semantic navigation and hover results without changing its model-facing schema.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **Configured servers only** — this package does not install language servers, select presets, or synchronize a host workspace into E2B.
- **Host protocol state is not reconnectable** — retaining a sandbox does not restore provider queues, JSON-RPC requests, subprocess handles, or document lifecycle state.
- **SDK output retention remains** — ASCII framing preserves protocol bytes, but E2B and the subprocess adapter still retain callback output in host memory.
- **Sandbox policy is template-owned** — this provider adds no volume, snapshot, credential, or network-policy layer.

View File

@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-lsp-e2b
[English](README.md) | 中文
用于 [`ctx.lsp`](../../lsp/lsp/README.md) 的通用 E2B 语言服务器后端。它在共享的 `ctx.e2b` 沙箱内运行已配置的 stdio 服务器并读取其源文档;提供方注册表、规范化查询结果、队列和协议连接状态仍保留在宿主侧。
## 插件与配置
`lsp-e2b` 插件注入 `e2b``lsp` 和具体的 `dsh-subprocess-e2b` 服务。`servers` 是一张非空的提供方 id 表:
| 服务器键 | 默认值 | 含义 |
|---|---|---|
| `command` | 必填 | 远程可执行文件:绝对路径,或在加载时通过沙箱 PATH 解析。 |
| `args` | `[]` | 远程服务器参数。 |
| `env` | `{}` | 经由进程管理适配器传入的显式环境条目。 |
| `extensionToLanguage` | 必填 | 小写、以点开头的扩展名到 LSP language id 的映射。 |
| `initializationOptions` / `configuration` | `null` / `null` | 静态初始化选项和 `workspace/configuration` 应答。 |
| `maxMessageBytes` | `16000000` | 从服务器接受的 LSP 消息大小上限。 |
| `maxStderrBytes` | `1000000` | 保留的服务器原始 stderr 尾部上限。 |
| `maxDocumentBytes` | `4000000` | 单次查询可打开的最大远程源文件。 |
| `shutdownTimeoutMs` | `5000` | 协议优雅关闭预算。 |
| `killGraceMs` | `2000` | 请求取消与 TERM 到 KILL 升级的宽限期。 |
提供方 id 与命令必须非空;数值上限必须是正的安全整数,定时器取值不得超过 Node 的最大定时器延迟。设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个仅供所有者使用的私有代理,在远程解析 Node 和每个已配置服务器的可执行文件,再以原子方式注册所有提供方。
## 远程协议与文件系统
E2B 命令回调交付的是文本,而 LSP 按字节分帧。因此,已安装的代理会把服务器 stdout、stderr 和 stdin 的原始字节进行 base64 编码,封装为以换行分隔的 ASCII JSON 帧;宿主会验证并解码每一帧,再把字节交给共享的 `LspInstance` 协议引擎。`initialize.processId``null`,因为宿主与服务器不共享进程命名空间。
每个提供方与规范化远程工作区的组合共享一个池化语言服务器进程。同一工作区的查询串行执行,不同工作区的查询并发运行。每项查询都会使用 `realpath` 规范化远程工作区与源文件,拒绝工作区外的路径,要求源文件为普通文件,在读取前后都检查大小上限,使用严格的 UTF-8 解码,并采用常规的临时 `didOpen`/请求/`didClose` 生命周期。传输失败会 dispose资源释放该实例并在全新的远程进程上重试一次只读查询。
进程管理适配器负责进程组和终止升级,因此取消与资源释放都会等待远程服务器完全停稳。宿主负责 LSP 请求 id、待完成请求、提供方队列和规范化结果。
## 模型体验
通过 `@deepseek-ai/dsh-tool-lsp` 间接影响模型;该包会公开规范化的语义导航与悬停结果,而不改变其面向模型的 schema。
#### KV Cache 影响
不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。
## 已知限制与暂缓工作
- **仅支持已配置的服务器**:本包不会安装语言服务器、选择 preset或把宿主工作区同步到 E2B。
- **宿主协议状态不可重连**保留沙箱并不会恢复提供方队列、JSON-RPC 请求、进程管理句柄或文档生命周期状态。
- **SDK 仍会保留输出**ASCII 分帧能保留协议字节,但 E2B 和进程管理适配器仍会在宿主内存中保留回调输出。
- **沙箱策略归模板负责**:本提供方不会额外增加卷、快照、凭据或网络策略层。

View File

@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-lsp-e2b",
"description": "E2B language-server provider 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-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-lsp-local": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^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-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-local": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,358 @@
/** E2B filesystem and process backend for the harness LSP capability seam. */
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
FileType,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
import type { LspProvider, LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { LSP_PROXY_SOURCE } from './proxy-source.ts'
import { E2BLspTransport } from './transport.ts'
export { E2BLspTransport } from './transport.ts'
/** Cordis plugin name. */
export const name = 'lsp-e2b'
/** Services required by the remote provider. */
export const inject = ['e2b', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const DEFAULT_KILL_GRACE_MS = 2_000
/* jscpd:ignore-start -- Loader requires each backend to expose its own statically walkable server schema. */
/** One configured language server inside the shared E2B sandbox. */
export interface LspE2BServerConfig {
/** Remote executable, absolute or resolved on the sandbox PATH. */
command: string
/** Lowercase leading-dot extension to LSP language id. */
extensionToLanguage: Record<string, string>
/** Remote executable arguments. */
args?: string[]
/** Explicit remote environment overrides. */
env?: Record<string, string>
/** Static `initialize` options. */
initializationOptions?: unknown
/** Static answer to every `workspace/configuration` item. */
configuration?: unknown
/** Largest LSP message accepted from the server. */
maxMessageBytes?: number
/** Largest remote stderr tail retained for diagnostics. */
maxStderrBytes?: number
/** Largest remote source opened for one query. */
maxDocumentBytes?: number
/** Graceful LSP shutdown budget. */
shutdownTimeoutMs?: number
/** Request-cancel and TERM-to-KILL grace. */
killGraceMs?: number
}
/** Plugin configuration. */
export interface Config {
/** Non-empty provider-id to remote-server table. */
servers: Record<string, LspE2BServerConfig>
}
type ResolvedServerConfig = Required<LspE2BServerConfig>
const ServerConfig: z<LspE2BServerConfig> = z.object({
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
extensionToLanguage: z.dict(String).required(),
initializationOptions: z.any().default(null),
configuration: z.any().default(null),
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
})
/** Schemastery plugin configuration. */
export const Config: z<Config> = z.object({
servers: z.dict(ServerConfig).required(),
})
/* jscpd:ignore-end */
interface RemoteSource {
canonicalPath: string
text: string
}
function abortReason(signal: AbortSignal): unknown {
try {
signal.throwIfAborted()
} catch (error: unknown) {
return error
}
return new DOMException('The operation was aborted', 'AbortError')
}
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return promise
// AbortSignal permits opaque reasons, and callers observe the exact reason.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
cleanup()
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the signal's exact opaque reason.
reject(abortReason(signal))
}
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => { cleanup(); resolve(value) },
(error: unknown) => { cleanup(); reject(error instanceof Error ? error : new Error(String(error))) },
)
})
}
function validateServerConfig(providerId: string, config: ResolvedServerConfig): void {
if (config.command.length === 0) throw new Error(`lsp-e2b: servers.${providerId}.command must be non-empty`)
for (const name of ['maxMessageBytes', 'maxStderrBytes', 'maxDocumentBytes', 'shutdownTimeoutMs', 'killGraceMs'] as const) {
const value = config[name]
if (!Number.isSafeInteger(value) || value <= 0 || (name.endsWith('Ms') && value > MAX_TIMER_DELAY_MS)) {
throw new Error(`lsp-e2b: servers.${providerId}.${name} must be a positive safe integer${name.endsWith('Ms') ? ` no greater than ${MAX_TIMER_DELAY_MS}` : ''}`)
}
}
}
async function canonicalRemotePath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted()
const result = await sandbox.commands.run(`realpath -e -- ${quoteE2BShellArg(path)}`, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
const canonical = result.stdout.trim()
if (!posix.isAbsolute(canonical) || canonical.includes('\n')) throw new Error(`remote path ${JSON.stringify(path)} did not resolve canonically`)
return canonical
}
/**
* Canonicalize and validate one workspace inside E2B.
* @param sandbox - Shared sandbox that owns the workspace.
* @param workspaceRoot - Remote workspace path supplied by the query.
* @param signal - Optional query cancellation signal.
* @returns The canonical remote directory path.
*/
export async function canonicalizeE2BWorkspace(
sandbox: Sandbox,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<string> {
const canonical = await canonicalRemotePath(sandbox, workspaceRoot, signal)
const info = await sandbox.files.getInfo(canonical, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
if (info.type !== FileType.DIR) throw new Error(`workspace root ${JSON.stringify(workspaceRoot)} is not a directory`)
return canonical
}
/**
* Resolve, contain, and read one UTF-8 query source inside E2B.
* @param sandbox - Shared sandbox that owns the source.
* @param filePath - Absolute path or path relative to the canonical workspace.
* @param workspace - Canonical remote workspace directory.
* @param maxDocumentBytes - Maximum source size before and after reading.
* @param signal - Optional query cancellation signal.
* @returns The canonical source path and decoded text.
*/
export async function readE2BSource(
sandbox: Sandbox,
filePath: string,
workspace: string,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<RemoteSource> {
const requested = posix.isAbsolute(filePath) ? filePath : posix.resolve(workspace, filePath)
const canonicalPath = await canonicalRemotePath(sandbox, requested, signal)
const relative = posix.relative(workspace, canonicalPath)
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) {
throw new Error(`source ${JSON.stringify(filePath)} resolves outside the workspace`)
}
const info = await sandbox.files.getInfo(canonicalPath, signal === undefined ? {} : { signal })
if (info.type !== FileType.FILE) throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
if (info.size > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
}
const bytes = await sandbox.files.read(canonicalPath, { format: 'bytes', ...signal === undefined ? {} : { signal } })
signal?.throwIfAborted()
if (bytes.length > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} grew past the ${maxDocumentBytes}-byte limit while reading`)
}
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new Error(`source ${JSON.stringify(filePath)} is not valid UTF-8 text`, { cause: error })
}
return { canonicalPath, text }
}
/* jscpd:ignore-start -- Provider identity mirrors the seam while remote source and process ownership stay local. */
/** One pooled remote provider with an isolated server per canonical workspace. */
export class E2BLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
private readonly instances = new Map<string, LspInstance>()
private readonly queues = new Map<string, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly sandbox: Sandbox,
private readonly subprocess: E2BSubprocessService,
private readonly config: ResolvedServerConfig,
private readonly executable: string,
private readonly nodeExecutable: string,
private readonly proxyPath: string,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}
/* jscpd:ignore-end */
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
this.assertActive(signal)
const workspace = await canonicalizeE2BWorkspace(this.sandbox, request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
const source = await readE2BSource(this.sandbox, request.filePath, workspace, this.config.maxDocumentBytes, signal)
this.assertActive(signal)
let instance = this.instanceFor(workspace)
try {
return await instance.query(request, source, signal)
} catch (error: unknown) {
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evict(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
if (instance.dead) {
await instance.dispose()
this.evict(workspace, instance)
}
}
})
}
/* jscpd:ignore-start -- Queue and pooling semantics are shared; transport failure and disposal identities differ. */
/** Stop accepting work and await every remote server and queued query. */
async disposeAll(): Promise<void> {
this.disposed = true
const instances = [...this.instances.values()]
const queues = [...this.queues.values()]
this.instances.clear()
await Promise.all([...instances.map(instance => instance.dispose()), ...queues])
this.queues.clear()
}
private assertActive(signal?: AbortSignal): void {
if (this.disposed) throw new LspError('lsp-e2b provider is disposed', 'LSP_DISPOSED')
signal?.throwIfAborted()
}
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
const tail = previous.then(() => result).then(() => undefined, () => undefined)
this.queues.set(workspace, tail)
void tail.then(() => { if (this.queues.get(workspace) === tail) this.queues.delete(workspace) })
return result
}
private instanceFor(workspace: string): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
return created
}
/* jscpd:ignore-end */
private createInstance(workspace: string): LspInstance {
return new LspInstance({
command: this.executable,
args: this.config.args,
cwd: workspace,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
clientProcessId: null,
}, (spec: SubprocessSpawnSpec) => {
const originalArgv = Buffer.from(JSON.stringify(spec.argv)).toString('base64')
const inner = this.subprocess.spawn({
...spec,
argv: [this.nodeExecutable, this.proxyPath, originalArgv],
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: this.config.maxStderrBytes },
},
})
const rawBound = Math.max(this.config.maxMessageBytes, this.config.maxStderrBytes)
return new E2BLspTransport(inner, rawBound * 2 + 1024, this.config.maxStderrBytes)
})
}
private evict(workspace: string, instance: LspInstance): void {
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
}
/** Install the proxy, resolve remote commands, and atomically register providers. */
export async function apply(ctx: Context, config: Config): Promise<void> {
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
throw new Error('lsp-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
}
const subprocess = ctx.subprocess
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-e2b: servers must contain at least one server')
const sandbox = await ctx.e2b.getSandbox()
const proxyPath = posix.join(ctx.e2b.runtimeRoot, 'lsp-stdio-proxy.mjs')
await sandbox.files.write([{ path: proxyPath, data: LSP_PROXY_SOURCE }])
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(proxyPath)}`)
const nodeExecutable = await resolveE2BExecutable(sandbox, 'node')
const providers = await Promise.all(entries.map(async ([providerId, raw]) => {
if (providerId.trim() === '') throw new Error('lsp-e2b: server ids must be non-empty strings')
const resolved = raw as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const executable = await resolveE2BExecutable(sandbox, resolved.command)
return new E2BLspProvider(providerId, sandbox, subprocess, resolved, executable, nodeExecutable, proxyPath)
}))
/* jscpd:ignore-start -- Every provider table publishes atomically through the same registry contract. */
ctx.effect(() => {
const disposers: Array<() => void> = []
try {
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
} catch (error: unknown) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return async () => {
for (const dispose of disposers.reverse()) dispose()
await Promise.all(providers.map(provider => provider.disposeAll()))
}
}, 'lsp-e2b.registerProviders')
/* jscpd:ignore-end */
}

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-lsp-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-e2b'
/** Cordis companion plugin name. */
export const name = 'lsp-e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the LSP registry owns provider publication. */
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 */

View File

@@ -0,0 +1,55 @@
/** Dependency-free remote stdio proxy installed inside the E2B sandbox. */
/**
* Node program that base64-frames raw child stdio so E2B's text callbacks
* never decode the language server's byte stream.
*/
export const LSP_PROXY_SOURCE = String.raw`import { Buffer } from 'node:buffer'
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
const emit = (message) => {
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
}
let argv
try {
argv = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'))
if (!Array.isArray(argv) || argv.length === 0 || argv.some(value => typeof value !== 'string')) throw new Error('invalid argv')
} catch (error) {
process.stderr.write('lsp-e2b proxy argv error: ' + String(error) + '\n')
process.exitCode = 125
process.stdin.destroy()
}
if (argv) {
const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], env: process.env })
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
child.stdout.on('data', data => { emit({ type: 'stdout', data: data.toString('base64') }) })
child.stderr.on('data', data => { emit({ type: 'stderr', data: data.toString('base64') }) })
child.on('error', error => {
emit({ type: 'stderr', data: Buffer.from('language server spawn failed: ' + error.message).toString('base64') })
})
child.on('close', (code, signal) => {
emit({ type: 'exit', code, signal })
input.close()
process.stdin.destroy()
process.exitCode = code === null ? 1 : code
})
input.on('line', line => {
input.pause()
try {
const message = JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
if (!message || message.type !== 'stdin' || typeof message.data !== 'string') throw new Error('invalid stdin frame')
const data = Buffer.from(message.data, 'base64')
if (data.toString('base64') !== message.data) throw new Error('invalid stdin base64')
if (child.stdin.write(data)) input.resume()
else child.stdin.once('drain', () => { input.resume() })
} catch (error) {
process.stderr.write('lsp-e2b proxy stdin error: ' + String(error) + '\n')
child.kill('SIGTERM')
}
})
input.on('close', () => { child.stdin.end() })
}
`

View File

@@ -0,0 +1,183 @@
/** Byte-faithful stdio transport over an E2B subprocess and ASCII/base64 frames. */
import { Buffer } from 'node:buffer'
import { PassThrough, Writable } from 'node:stream'
import { E2BFrameDecoder, encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputRead,
SubprocessOutputReader,
} from '@deepseek-ai/dsh-subprocess'
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
class ByteTailReader implements SubprocessOutputReader {
private chunks: Buffer[] = []
private totalBytes = 0
private retainedBytes = 0
private dropped = false
constructor(private readonly maxBytes: number) {}
append(data: Buffer): void {
if (data.length === 0) return
this.chunks.push(data)
this.totalBytes += data.length
this.retainedBytes += data.length
while (this.retainedBytes > this.maxBytes && this.chunks.length > 0) {
const first = this.chunks[0] as Buffer
const excess = this.retainedBytes - this.maxBytes
if (first.length <= excess) {
this.chunks.shift()
this.retainedBytes -= first.length
} else {
this.chunks[0] = first.subarray(excess)
this.retainedBytes -= excess
}
this.dropped = true
}
}
readFrom(fromByte: number): SubprocessOutputRead {
if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
throw new Error('subprocess output offset must be a non-negative safe integer')
}
const retainedStart = this.totalBytes - this.retainedBytes
const lossy = fromByte < retainedStart
const start = lossy ? 0 : Math.min(this.retainedBytes, fromByte - retainedStart)
const bytes = Buffer.concat(this.chunks).subarray(start)
return { text: bytes.toString('utf8'), nextOffset: this.totalBytes, lossy: lossy || this.dropped && fromByte === 0 }
}
}
class FramedInput extends Writable {
constructor(private readonly target: Writable) {
super()
target.on('error', (error: Error) => { this.destroy(error) })
}
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.target.write(encodeE2BFrame({ type: 'stdin', data: chunk.toString('base64') }), callback)
}
override _final(callback: (error?: Error | null) => void): void {
this.target.end(callback)
}
}
/** Subprocess handle that decodes a remote proxy's stdout/stderr byte frames. */
export class E2BLspTransport implements SubprocessHandle {
readonly stdin: Writable
readonly stdout = new PassThrough()
readonly stderr = undefined
readonly collected: SubprocessHandle['collected']
readonly done: Promise<SubprocessOutcome>
private readonly stderrTail: ByteTailReader
private readonly decoder: E2BFrameDecoder
private failed: Error | undefined
/**
* @param inner - E2B subprocess running the installed proxy.
* @param maxFrameBytes - Maximum decoded proxy frame size.
* @param maxStderrBytes - Retained raw language-server stderr tail.
*/
constructor(
private readonly inner: SubprocessHandle,
maxFrameBytes: number,
maxStderrBytes: number,
) {
if (inner.stdin === undefined || inner.stdout === undefined) {
inner.terminate()
throw new Error('lsp-e2b: proxy subprocess dropped a piped stream')
}
this.stdin = new FramedInput(inner.stdin)
this.stderrTail = new ByteTailReader(maxStderrBytes)
this.collected = { stderr: this.stderrTail }
this.decoder = new E2BFrameDecoder(maxFrameBytes)
inner.stdout.on('data', (chunk: Buffer) => { this.onProxyData(chunk) })
inner.stdout.on('error', (error: Error) => { this.fail(error) })
this.done = inner.done.then(
(outcome) => {
this.finishFrames()
this.captureProxyStderr()
this.stdout.end()
if (this.failed !== undefined) throw this.failed
return outcome
},
(error: unknown) => {
this.captureProxyStderr()
this.stdout.end()
throw error
},
)
void this.done.catch(() => {})
}
get pid(): number {
return this.inner.pid
}
terminate(): void {
this.inner.terminate()
}
async waitForExit(signal?: AbortSignal): Promise<boolean> {
return await this.inner.waitForExit(signal)
}
private onProxyData(chunk: Buffer): void {
if (this.failed !== undefined) return
let frames: unknown[]
try {
frames = this.decoder.push(chunk.toString('utf8'))
} catch (error: unknown) {
this.fail(asError(error))
return
}
for (const frame of frames) this.dispatch(frame)
}
private dispatch(frame: unknown): void {
if (typeof frame !== 'object' || frame === null) {
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
return
}
const record = frame as Record<string, unknown>
if (record.type === 'exit' && (record.code === null || typeof record.code === 'number') && (record.signal === null || typeof record.signal === 'string')) return
if ((record.type !== 'stdout' && record.type !== 'stderr') || typeof record.data !== 'string') {
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
return
}
const data = Buffer.from(record.data, 'base64')
if (data.toString('base64') !== record.data) {
this.fail(new Error('lsp-e2b: proxy emitted invalid base64'))
return
}
if (record.type === 'stdout') this.stdout.write(data)
else this.stderrTail.append(data)
}
private finishFrames(): void {
if (this.failed !== undefined) return
try {
this.decoder.finish()
} catch (error: unknown) {
this.fail(asError(error))
}
}
private captureProxyStderr(): void {
const diagnostic = this.inner.collected.stderr?.readFrom(0).text
if (diagnostic !== undefined && diagnostic.length > 0) this.stderrTail.append(Buffer.from(diagnostic))
}
private fail(error: Error): void {
if (this.failed !== undefined) return
this.failed = error
this.inner.terminate()
this.stdout.end()
}
}

View File

@@ -0,0 +1,397 @@
import { PassThrough } from 'node:stream'
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
FileType,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
const mockedLsp = vi.hoisted(() => {
interface Plan {
query?: (...args: unknown[]) => unknown
transportFailure?: unknown
dead?: boolean
deadAfterQuery?: boolean
disposeError?: unknown
}
class FakeLspInstance {
static readonly instances: FakeLspInstance[] = []
static readonly plans: Plan[] = []
readonly plan: Plan
readonly transport: unknown
readonly queries: unknown[][] = []
dead: boolean
disposals = 0
constructor(
readonly spec: Record<string, unknown>,
spawner: (spec: SubprocessSpawnSpec) => unknown,
) {
this.plan = FakeLspInstance.plans.shift() ?? {}
this.dead = this.plan.dead === true
this.transport = spawner({
argv: [String(spec.command), ...(spec.args as string[])],
cwd: String(spec.cwd),
env: spec.env as Record<string, string>,
graceMs: Number(spec.killGraceMs),
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: Number(spec.maxStderrBytes) } },
})
FakeLspInstance.instances.push(this)
}
async query(...args: unknown[]): Promise<unknown> {
this.queries.push(args)
const result = await Promise.resolve(this.plan.query?.(...args) ?? { kind: 'hover', hover: null })
if (this.plan.deadAfterQuery === true) this.dead = true
return result
}
isTransportFailure(error: unknown): boolean {
return error === this.plan.transportFailure
}
async dispose(): Promise<void> {
this.disposals += 1
this.dead = true
if (this.plan.disposeError !== undefined) throw this.plan.disposeError
}
}
return { FakeLspInstance }
})
vi.mock('@deepseek-ai/dsh-lsp-local', () => ({ LspInstance: mockedLsp.FakeLspInstance }))
import {
E2BLspProvider,
apply,
canonicalizeE2BWorkspace,
readE2BSource,
} from '@deepseek-ai/dsh-lsp-e2b'
import type { LspE2BServerConfig } from '@deepseek-ai/dsh-lsp-e2b'
import * as E2BLspInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
class FakeInnerHandle implements SubprocessHandle {
readonly pid = 777
readonly stdin = new PassThrough()
readonly stdout = new PassThrough()
readonly stderr = undefined
readonly collected = { stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }
readonly done = Promise.resolve({ exitCode: 0, signal: null })
terminate(): void {}
async waitForExit(): Promise<boolean> { return true }
}
class FakeRemote {
readonly writes: Array<Array<{ path: string; data: string }>> = []
readonly commands: string[] = []
readonly infos = new Map<string, { type: FileType; size: number }>()
readonly contents = new Map<string, Uint8Array>()
readonly realpaths = new Map<string, string>()
forcedRealpath: string | undefined
constructor() {
this.infos.set('/workspace', { type: FileType.DIR, size: 0 })
this.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 12 })
this.contents.set('/workspace/file.ts', Buffer.from('const x = 1'))
}
readonly sandbox = {
commands: {
run: async (command: string) => {
this.commands.push(command)
if (command.startsWith('realpath ')) {
const match = /'([^']*)'$/.exec(command)
const requested = match?.[1] ?? ''
return { exitCode: 0, stdout: `${this.forcedRealpath ?? this.realpaths.get(requested) ?? requested}\n`, stderr: '' }
}
if (command.startsWith('command -v')) return { exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' }
return { exitCode: 0, stdout: '', stderr: '' }
},
},
files: {
write: async (files: Array<{ path: string; data: string }>) => {
this.writes.push(files)
return files.map(() => ({}))
},
getInfo: async (path: string) => {
const info = this.infos.get(path)
if (info === undefined) throw new Error(`missing info for ${path}`)
return info
},
read: async (path: string) => this.contents.get(path) ?? new Uint8Array(),
},
} as unknown as Sandbox
}
function subprocess(spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())): E2BSubprocessService {
const service = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
Object.defineProperty(service, 'spawn', { value: spawn })
return service
}
function server(overrides: Partial<LspE2BServerConfig> = {}): Required<LspE2BServerConfig> {
return {
command: '/usr/bin/server', args: ['--stdio'], env: {},
extensionToLanguage: { '.ts': 'typescript' },
initializationOptions: null, configuration: null,
maxMessageBytes: 1_024, maxStderrBytes: 128, maxDocumentBytes: 1_024,
shutdownTimeoutMs: 100, killGraceMs: 50,
...overrides,
}
}
function provider(remote = new FakeRemote(), service = subprocess()): E2BLspProvider {
return new E2BLspProvider(
'fixture', remote.sandbox, service, server(),
'/usr/bin/server', '/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs',
)
}
function query(workspaceRoot = '/workspace') {
return {
operation: 'hover' as const,
filePath: 'file.ts',
position: { line: 0, character: 1 },
workspaceRoot,
languageId: 'typescript',
}
}
beforeEach(() => {
mockedLsp.FakeLspInstance.instances.length = 0
mockedLsp.FakeLspInstance.plans.length = 0
})
describe('E2B LSP filesystem boundary', () => {
it('canonicalizes a directory and reads a contained UTF-8 source', async () => {
const remote = new FakeRemote()
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace')).resolves.toBe('/workspace')
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024)).resolves.toEqual({
canonicalPath: '/workspace/file.ts',
text: 'const x = 1',
})
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024)).resolves.toMatchObject({
canonicalPath: '/workspace/file.ts',
})
const signal = new AbortController().signal
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace', signal)).resolves.toBe('/workspace')
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, signal)).resolves.toMatchObject({
canonicalPath: '/workspace/file.ts',
})
})
it('rejects malformed workspaces and source containment/type/size/encoding failures', async () => {
const malformed = new FakeRemote()
malformed.forcedRealpath = 'relative'
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
malformed.forcedRealpath = '/workspace\nother'
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
const notDirectory = new FakeRemote()
notDirectory.infos.set('/workspace', { type: FileType.FILE, size: 0 })
await expect(canonicalizeE2BWorkspace(notDirectory.sandbox, '/workspace')).rejects.toThrow('not a directory')
const outside = new FakeRemote()
outside.realpaths.set('/workspace/file.ts', '/outside/file.ts')
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('outside the workspace')
const notFile = new FakeRemote()
notFile.infos.set('/workspace/file.ts', { type: FileType.DIR, size: 0 })
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not a regular file')
const tooLarge = new FakeRemote()
tooLarge.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 21 })
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('over the 20-byte limit')
const grew = new FakeRemote()
grew.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
grew.contents.set('/workspace/file.ts', Buffer.alloc(21))
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('grew past')
const invalid = new FakeRemote()
invalid.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
invalid.contents.set('/workspace/file.ts', Uint8Array.from([0xff]))
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not valid UTF-8')
await expect(canonicalizeE2BWorkspace(new FakeRemote().sandbox, '/workspace', AbortSignal.abort('stop')))
.rejects.toBe('stop')
})
})
describe('E2BLspProvider pooling and lifecycle', () => {
it('reuses one canonical-workspace instance and constructs the remote proxy transport', async () => {
const spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())
const remote = new FakeRemote()
mockedLsp.FakeLspInstance.plans.push({ query: async () => ({ kind: 'hover', hover: { contents: 'ok' } }) })
const current = provider(remote, subprocess(spawn))
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(1)
expect(mockedLsp.FakeLspInstance.instances[0]?.spec).toMatchObject({ clientProcessId: null, cwd: '/workspace' })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs', expect.any(String)],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 128 } },
}))
expect(current.id).toBe('fixture')
expect(current.extensionToLanguage).toEqual({ '.ts': 'typescript' })
await current.disposeAll()
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
})
it('replaces one transport failure, but preserves ordinary query errors', async () => {
const transportFailure = new Error('transport failed')
mockedLsp.FakeLspInstance.plans.push(
{ transportFailure, query: async () => { throw transportFailure } },
{ query: async () => ({ kind: 'hover', hover: { contents: 'retried' } }) },
)
const retried = provider()
await expect(retried.query(query())).resolves.toMatchObject({ hover: { contents: 'retried' } })
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
const ordinary = new Error('ordinary failure')
mockedLsp.FakeLspInstance.plans.push(
{ query: async () => { throw ordinary }, dead: true },
{ query: async () => ({ kind: 'hover', hover: null }) },
)
const failed = provider()
await expect(failed.query(query())).rejects.toBe(ordinary)
await expect(failed.query(query())).resolves.toMatchObject({ kind: 'hover' })
})
it('evicts a server that dies after a successful query', async () => {
mockedLsp.FakeLspInstance.plans.push(
{ deadAfterQuery: true, query: async () => ({ kind: 'hover', hover: null }) },
{ query: async () => ({ kind: 'hover', hover: null }) },
)
const current = provider()
await current.query(query())
await current.query(query())
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
})
it('serializes a workspace queue, observes queued abort, and awaits work on disposal', async () => {
const first = Promise.withResolvers<unknown>()
mockedLsp.FakeLspInstance.plans.push({ query: () => first.promise })
const current = provider()
const running = current.query(query())
const controller = new AbortController()
const queued = current.query(query(), controller.signal)
await new Promise(resolve => setImmediate(resolve))
controller.abort('queued stop')
await expect(queued).rejects.toBe('queued stop')
const disposing = current.disposeAll()
first.resolve({ kind: 'hover', hover: null })
await expect(running).resolves.toMatchObject({ kind: 'hover' })
await disposing
await expect(current.query(query())).rejects.toMatchObject({ code: 'LSP_DISPOSED' })
})
it('covers pre-abort, synthetic abort, resolve, and rejection in the queue race', async () => {
const current = provider()
const internal = current as unknown as {
queues: Map<string, Promise<void>>
enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>
}
await expect(internal.enqueue('pre', AbortSignal.abort('pre-stop'), async () => 'unused')).rejects.toBe('pre-stop')
const signal = new AbortController().signal
await expect(internal.enqueue('resolve', signal, async () => 'ok')).resolves.toBe('ok')
const failure = new Error('queue failed')
const rejected = Promise.reject<undefined>(failure)
void rejected.catch(() => {})
internal.queues.set('reject', rejected)
await expect(internal.enqueue('reject', signal, async () => 'unused')).rejects.toBe(failure)
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Exercise normalization at the promise boundary.
const opaque = Promise.reject<undefined>('opaque queue failure')
void opaque.catch(() => {})
internal.queues.set('opaque', opaque)
await expect(internal.enqueue('opaque', signal, async () => 'unused')).rejects.toEqual(new Error('opaque queue failure'))
const synthetic = {
aborted: false,
reason: undefined,
throwIfAborted() {},
addEventListener(_type: string, listener: () => void) { listener() },
removeEventListener() {},
} as unknown as AbortSignal
await expect(internal.enqueue('synthetic', synthetic, async () => 'unused')).rejects.toMatchObject({ name: 'AbortError' })
await current.disposeAll()
})
})
describe('lsp-e2b plugin composition', () => {
function pluginContext(
remote: FakeRemote,
service: E2BSubprocessService,
registerProvider = vi.fn(() => vi.fn()),
) {
const effects: Array<() => void | Promise<void>> = []
const ctx = {
subprocess: service,
e2b: {
runtimeRoot: '/workspace/.dsh-e2b',
getSandbox: async () => remote.sandbox,
},
lsp: { registerProvider },
effect: (callback: () => (() => void | Promise<void>)) => { effects.push(callback()) },
} as unknown as Context
return { ctx, effects, registerProvider }
}
it('installs one proxy, resolves commands, registers providers, and disposes them', async () => {
const remote = new FakeRemote()
const fixture = pluginContext(remote, subprocess())
await apply(fixture.ctx, { servers: { one: server(), two: server({ command: 'server-two' }) } })
expect(remote.writes).toHaveLength(1)
expect(remote.writes[0]?.[0]?.path).toBe('/workspace/.dsh-e2b/lsp-stdio-proxy.mjs')
expect(remote.commands).toContain("chmod 600 -- '/workspace/.dsh-e2b/lsp-stdio-proxy.mjs'")
expect(fixture.registerProvider).toHaveBeenCalledTimes(2)
await fixture.effects[0]?.()
})
it('rolls back partial registration and rejects invalid composition/configuration', async () => {
const remote = new FakeRemote()
const firstDispose = vi.fn()
const register = vi.fn()
.mockReturnValueOnce(firstDispose)
.mockImplementationOnce(() => { throw new Error('duplicate provider') })
const rollback = pluginContext(remote, subprocess(), register)
await expect(apply(rollback.ctx, { servers: { one: server(), two: server() } })).rejects.toThrow('duplicate provider')
expect(firstDispose).toHaveBeenCalledOnce()
const wrong = pluginContext(remote, {} as E2BSubprocessService)
await expect(apply(wrong.ctx, { servers: { one: server() } })).rejects.toThrow('dsh-subprocess-e2b')
const empty = pluginContext(remote, subprocess())
await expect(apply(empty.ctx, { servers: {} })).rejects.toThrow('at least one server')
for (const [id, config] of [
['', server()],
['one', server({ command: '' })],
['one', server({ maxMessageBytes: 0 })],
['one', server({ maxStderrBytes: 1.5 })],
['one', server({ shutdownTimeoutMs: 2_147_483_648 })],
] as const) {
const fixture = pluginContext(new FakeRemote(), subprocess())
await expect(apply(fixture.ctx, { servers: { [id]: config } })).rejects.toThrow()
}
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BLspInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,181 @@
import { once } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import { E2BLspTransport } from '@deepseek-ai/dsh-lsp-e2b'
class FakeHandle implements SubprocessHandle {
readonly pid = 321
readonly stdin: PassThrough | undefined
readonly stdout: PassThrough | undefined
readonly stderr = undefined
readonly collected: SubprocessHandle['collected']
readonly result = Promise.withResolvers<SubprocessOutcome>()
readonly done = this.result.promise
terminated = 0
waitResult = true
constructor(options: { stdin?: boolean; stdout?: boolean; diagnostic?: string } = {}) {
this.stdin = options.stdin === false ? undefined : new PassThrough()
this.stdout = options.stdout === false ? undefined : new PassThrough()
this.collected = options.diagnostic === undefined
? {}
: { stderr: { readFrom: () => ({ text: options.diagnostic as string, nextOffset: 0, lossy: false }) } }
}
terminate(): void {
this.terminated += 1
}
async waitForExit(): Promise<boolean> {
return this.waitResult
}
resolve(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
this.stdout?.end()
this.result.resolve(outcome)
}
reject(error: unknown): void {
this.stdout?.end()
this.result.reject(error)
}
}
function proxyFrame(type: 'stdout' | 'stderr', data: string | Buffer): string {
return encodeE2BFrame({ type, data: Buffer.from(data).toString('base64') })
}
describe('E2BLspTransport', () => {
it('frames stdin, decodes split byte output, and exposes handle lifecycle', async () => {
const inner = new FakeHandle({ diagnostic: 'proxy diagnostic' })
const transport = new E2BLspTransport(inner, 1_024, 64)
let stdin = ''
inner.stdin?.on('data', (chunk) => { stdin += String(chunk) })
let stdout = Buffer.alloc(0)
transport.stdout.on('data', (chunk) => { stdout = Buffer.concat([stdout, chunk]) })
transport.stdin.write(Buffer.from([0, 0xff]))
await new Promise(resolve => setImmediate(resolve))
const encodedInput = stdin.trim()
const input = JSON.parse(Buffer.from(encodedInput, 'base64').toString('utf8')) as Record<string, string>
expect(input).toEqual({ type: 'stdin', data: 'AP8=' })
const frames = proxyFrame('stdout', Buffer.from([0, 0xff]))
+ proxyFrame('stderr', 'server diagnostic')
+ encodeE2BFrame({ type: 'exit', code: 0, signal: null })
inner.stdout?.write(frames.slice(0, 7))
inner.stdout?.write(frames.slice(7))
inner.stdout?.write(proxyFrame('stderr', ''))
inner.stdout?.write(encodeE2BFrame({ type: 'exit', code: null, signal: 'SIGTERM' }))
inner.resolve()
await expect(transport.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(stdout).toEqual(Buffer.from([0, 0xff]))
expect(transport.collected.stderr?.readFrom(0).text).toBe('server diagnosticproxy diagnostic')
expect(transport.pid).toBe(321)
inner.waitResult = false
await expect(transport.waitForExit()).resolves.toBe(false)
transport.terminate()
expect(inner.terminated).toBe(1)
})
it('ends the inner stdin and retains a bounded byte tail with independent offsets', async () => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 4)
const finished = once(inner.stdin!, 'finish')
transport.stdin.end()
await finished
inner.stdout?.write(proxyFrame('stderr', 'ab'))
inner.stdout?.write(proxyFrame('stderr', 'cdef'))
const reader = transport.collected.stderr!
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
expect(() => reader.readFrom(-1)).toThrow('non-negative safe integer')
expect(() => reader.readFrom(1.5)).toThrow('non-negative safe integer')
inner.resolve()
await transport.done
const partialInner = new FakeHandle()
const partial = new E2BLspTransport(partialInner, 1_024, 4)
partialInner.stdout?.write(proxyFrame('stderr', 'abcdef'))
expect(partial.collected.stderr?.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
partialInner.resolve()
await partial.done
})
it.each([
['non-object', encodeE2BFrame(null), 'malformed frame'],
['wrong type', encodeE2BFrame({ type: 'other', data: '' }), 'malformed frame'],
['invalid exit', encodeE2BFrame({ type: 'exit', code: 'zero', signal: null }), 'malformed frame'],
['missing data', encodeE2BFrame({ type: 'stdout' }), 'malformed frame'],
['invalid base64', encodeE2BFrame({ type: 'stdout', data: 'abc' }), 'invalid base64'],
['invalid outer frame', 'not-base64\n', 'invalid base64'],
['non-ASCII outer frame', 'é', 'non-ASCII'],
])('fails %s proxy traffic and terminates the group', async (_name, frame, message) => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 32)
inner.stdout?.write(frame)
inner.stdout?.write(proxyFrame('stdout', 'ignored'))
inner.stdout?.emit('error', new Error('second failure'))
inner.resolve()
await expect(transport.done).rejects.toThrow(message)
expect(inner.terminated).toBe(1)
})
it('fails truncated frames and preserves inner spawn rejection', async () => {
const truncatedInner = new FakeHandle()
const truncated = new E2BLspTransport(truncatedInner, 1_024, 32)
truncatedInner.stdout?.write('YQ==')
truncatedInner.resolve()
await expect(truncated.done).rejects.toThrow('mid-frame')
const rejectedInner = new FakeHandle({ diagnostic: 'tail' })
const rejected = new E2BLspTransport(rejectedInner, 1_024, 32)
rejectedInner.reject(new Error('spawn failed'))
await expect(rejected.done).rejects.toThrow('spawn failed')
expect(rejected.collected.stderr?.readFrom(0).text).toBe('tail')
})
it('forwards output and input stream errors without an unhandled inner error', async () => {
const outputInner = new FakeHandle()
const output = new E2BLspTransport(outputInner, 1_024, 32)
outputInner.stdout?.emit('error', new Error('proxy stdout failed'))
outputInner.resolve()
await expect(output.done).rejects.toThrow('proxy stdout failed')
const inputInner = new FakeHandle()
const input = new E2BLspTransport(inputInner, 1_024, 32)
const outerError = once(input.stdin, 'error')
inputInner.stdin?.emit('error', new Error('proxy stdin failed'))
await expect(outerError).resolves.toMatchObject([{ message: 'proxy stdin failed' }])
inputInner.resolve()
await input.done
})
it('normalizes a non-Error decoder throw', async () => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 32)
const internal = transport as unknown as {
decoder: { push(chunk: string): unknown[] }
onProxyData(chunk: Buffer): void
}
internal.decoder = { push: () => { throw 'raw decoder failure' } }
internal.onProxyData(Buffer.from('x'))
inner.resolve()
await expect(transport.done).rejects.toThrow('raw decoder failure')
})
it('rejects a subprocess that drops either required pipe', () => {
const missingStdin = new FakeHandle({ stdin: false })
const missingStdout = new FakeHandle({ stdout: false })
expect(() => new E2BLspTransport(missingStdin, 10, 10)).toThrow('dropped a piped stream')
expect(() => new E2BLspTransport(missingStdout, 10, 10)).toThrow('dropped a piped stream')
expect(missingStdin.terminated).toBe(1)
expect(missingStdout.terminated).toBe(1)
})
})

View 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": "../e2b" },
{ "path": "../../lsp/lsp" },
{ "path": "../../lsp/lsp-local" },
{ "path": "../../subprocess/subprocess" },
{ "path": "../subprocess-e2b" },
{ "path": "../../util/timeout" },
{ "path": "../../support/invariants" }
]
}

View 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/e2b/pty-e2b/README.md
README.md: 3d363309f6bd1c4def5fbff668735958ca2de52d
README.zh.md: 78d61cc654bf6eb46a3bec7124b3cc06cc7e34e9

View File

@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-pty-e2b
English | [中文](README.zh.md)
E2B byte-PTY backend for [`ctx.pty`](../../pty/pty/README.md). It creates persistent interactive shells inside the shared `ctx.e2b` sandbox while the PTY registry keeps session identity, exact-Agent ownership, and cleanup policy on the host.
## Plugin and configuration
The `pty-e2b` plugin injects `e2b` and `pty`, then registers one backend under `backendType`.
| Key | Default | Meaning |
|---|---|---|
| `backendType` | `shell` | Registry type selected by `terminal_open`. |
| `rows` / `cols` | `40` / `160` | Initial remote PTY size. |
| `scrollbackLines` | `10000` | Maximum retained logical lines. |
| `scrollbackMaxBytes` | `4194304` | Maximum retained UTF-8 scrollback bytes. |
| `maxReadBytes` | `262144` | Maximum bytes returned by one read or settled send. |
| `pollIntervalMs` | `50` | Host readiness-poll interval. |
| `idleSilenceMs` | `3000` | Output silence that yields `inferred_idle`. |
| `timeoutMs` | `30000` | Absolute startup and send wait bound. |
| `disposeGraceMs` | `3000` | TERM-to-KILL cleanup grace. |
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute.
## Runtime contract
The backend uses E2B's byte-oriented PTY callback with a streaming fatal UTF-8 decoder, then the backend-neutral line sanitizer and bounded buffers from `dsh-pty`. It installs a controlled Bash prompt marker and waits for printable prompt text; when that marker is unavailable, observed output plus the configured silence bound yields `inferred_idle`. Startup with no output reaches the absolute timeout and fails instead of publishing an empty session.
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. Close sends `SIGTERM` to the PTY process group, waits, escalates through E2B's PTY kill, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
The remote PTY process and its child processes live in E2B. Prompt/readiness state, scrollback, operation handles, owner authority, and SDK event delivery remain in host memory.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, signal results, and cleanup failures.
#### Token effect
None until a consumer returns bounded backend output. Retained host PTY scrollback is not placed in model history by this package.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
## Known Limitations and Deferred Work
- **Line-oriented terminal model** — CSI/OSC control sequences are removed; alternate-screen and full terminal emulation remain unsupported.
- **Readiness is marker-or-silence based** — E2B exposes foreground process groups but not the local backend's Linux syscall inspection, so `inferred_idle` is deliberately possible.
- **UTF-8 only** — invalid byte sequences fail the session instead of returning lossy text.
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.

View File

@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-pty-e2b
[English](README.md) | 中文
用于 [`ctx.pty`](../../pty/pty/README.md) 的 E2B 字节 PTY 后端。它在共享的 `ctx.e2b` 沙箱内创建持久交互式 shellPTY 注册表则在宿主侧维护会话身份、精确的 Agent 所有权和清理策略。
## 插件与配置
`pty-e2b` 插件注入 `e2b``pty`,然后以 `backendType` 注册一个后端。
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `backendType` | `shell` | `terminal_open` 选择的注册表类型。 |
| `rows` / `cols` | `40` / `160` | 远程 PTY 的初始尺寸。 |
| `scrollbackLines` | `10000` | 保留的逻辑行数上限。 |
| `scrollbackMaxBytes` | `4194304` | 保留的 UTF-8 scrollback 字节数上限。 |
| `maxReadBytes` | `262144` | 单次读取或发送结算时返回的字节数上限。 |
| `pollIntervalMs` | `50` | 宿主就绪轮询间隔。 |
| `idleSilenceMs` | `3000` | 触发 `inferred_idle` 的输出静默时长。 |
| `timeoutMs` | `30000` | 启动与发送等待的绝对上限。 |
| `disposeGraceMs` | `3000` | TERM 到 KILL 的清理宽限期。 |
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。
## 运行时契约
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。关闭操作向 PTY 进程组发送 `SIGTERM`,等待后通过 E2B 的 PTY kill 操作升级,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY若清理同时失败`PtyBackendCleanupError` 会保留这项失败。
远程 PTY 进程及其子进程位于 E2B。提示符就绪状态、scrollback、操作句柄、所有者权限和 SDK 事件交付仍保留在宿主内存中。
## 模型体验
### 间接消费方
#### 模型看到的内容
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因、信号结果和清理失败。
#### Token 影响
消费方返回有界的后端输出前没有影响。本包不会把宿主保留的 PTY scrollback 放入模型历史。
#### KV Cache 影响
不会直接失效提示词、schema 和追加结果由消费方负责。
## 已知限制与暂缓工作
- **面向行的终端模型**CSIOSC 控制序列会被移除;备用屏幕与完整终端仿真仍不受支持。
- **就绪判断基于标记或静默**E2B 会公开前台进程组,但不提供本地后端使用的 Linux syscall 检查,因此系统有意保留返回 `inferred_idle` 的可能性。
- **仅支持 UTF-8**:无效字节序列会使会话失败,而不是返回有损文本。
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-pty-e2b",
"description": "E2B PTY provider 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-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,64 @@
/** Validated configuration for the E2B PTY backend. */
import z from 'schemastery'
/** Public plugin configuration. */
export interface Config {
/** Backend registry type. */
backendType?: string
/** Initial terminal rows. */
rows?: number
/** Initial terminal columns. */
cols?: number
/** Maximum retained logical lines. */
scrollbackLines?: number
/** Maximum retained UTF-8 bytes. */
scrollbackMaxBytes?: number
/** Maximum bytes returned by one read or settled viewport. */
maxReadBytes?: number
/** Readiness polling interval. */
pollIntervalMs?: number
/** Output silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/** Absolute send and startup wait bound. */
timeoutMs?: number
/** Grace before PTY teardown escalates from TERM to KILL. */
disposeGraceMs?: number
}
/** Configuration after Schemastery defaults. */
export type ResolvedConfig = Required<Config>
/* jscpd:ignore-start -- Loader requires a backend-local schema and load-time diagnostics. */
/** Schemastery config exposed by the plugin. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
rows: z.number().default(40),
cols: z.number().default(160),
scrollbackLines: z.number().default(10_000),
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
maxReadBytes: z.number().default(256 * 1024),
pollIntervalMs: z.number().default(50),
idleSilenceMs: z.number().default(3_000),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
/**
* Validate the resolved configuration before publishing the backend.
* @param config - Schemastery-resolved plugin configuration.
* @returns Nothing; success narrows every optional field to its resolved value.
*/
export function validateConfig(config: Config): asserts config is ResolvedConfig {
const resolved = config as ResolvedConfig
if (resolved.backendType.length === 0) throw new Error('pty-e2b: backendType must be non-empty')
for (const [name, value] of Object.entries(resolved)) {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`pty-e2b: ${name} must be a positive safe integer`)
}
}
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('pty-e2b: maxReadBytes must not exceed scrollbackMaxBytes')
}
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,93 @@
/** E2B byte-PTY backend for persistent interactive terminal sessions. */
import { posix } from 'node:path'
import type { Context } from 'cordis'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { E2BPtySession } from './session.ts'
export { Config } from './config.ts'
export type { Config as PtyE2BConfig } from './config.ts'
export { E2BPtySession } from './session.ts'
/** Cordis plugin name. */
export const name = 'pty-e2b'
/** Required shared sandbox owner and PTY registry. */
export const inject = ['e2b', 'pty']
function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
return {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,
DSH_PTY_SESSION_ID: spec.sessionId,
}
}
/** E2B backend registered under the configured terminal type. */
export class E2BPtyBackend implements PtyBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly createPty: (
sandbox: Sandbox,
options: Parameters<Sandbox['pty']['create']>[0],
) => Promise<CommandHandle> = (sandbox, options) => sandbox.pty.create(options),
) {
this.type = config.backendType
}
/** Create, initialize, and publish one remote PTY session. */
async spawn(spec: PtyBackendSpawnSpec): Promise<E2BPtySession> {
spec.signal?.throwIfAborted()
const sandbox = await this.ctx.e2b.getSandbox()
spec.signal?.throwIfAborted()
const pending: Uint8Array[] = []
const created: { session?: E2BPtySession } = {}
const handle = await this.createPty(sandbox, {
rows: this.config.rows,
cols: this.config.cols,
cwd: posix.resolve(this.ctx.e2b.cwd, spec.cwd ?? this.ctx.e2b.cwd),
envs: terminalEnvironment(spec),
timeoutMs: 0,
...spec.signal === undefined ? {} : { signal: spec.signal },
onData: (data) => {
if (created.session === undefined) pending.push(Uint8Array.from(data))
else created.session.onData(data)
},
})
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
await handle.kill().catch(() => false)
throw new Error(`pty-e2b: E2B returned invalid PTY pid ${handle.pid}`)
}
const session = new E2BPtySession(sandbox, handle, this.config)
created.session = session
for (const data of pending) session.onData(data)
try {
await session.initialize(spec.signal)
return session
} catch (error: unknown) {
try {
await session.close('E2B PTY startup failed')
} catch (cleanupError: unknown) {
throw new PtyBackendCleanupError(error, cleanupError)
}
throw error
}
}
}
/** Register the E2B PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
ctx.pty.registerBackend(new E2BPtyBackend(ctx, config))
}

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-pty-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-pty-e2b'
/** Cordis companion plugin name. */
export const name = 'pty-e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the PTY registry owns publication and cleanup. */
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 */

View File

@@ -0,0 +1,367 @@
/** One byte-oriented E2B PTY session projected onto the harness PTY seam. */
import { Buffer } from 'node:buffer'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import { CommandExitError } from '@deepseek-ai/dsh-e2b'
import {
PtyTerminalSanitizer,
PtyTextBuffer,
ptySignalName,
ptyUtf8Tail,
} from '@deepseek-ai/dsh-pty'
import type {
PtyBackendSession,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRead,
PtySendRequest,
PtySendResult,
PtySessionStatus,
PtySignal,
PtySignalResult,
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/* jscpd:ignore-start -- Operation state stays backend-local because process readiness and cleanup identities diverge. */
class E2BSendOperation implements PtySendOperation {
private readonly output: PtyTextBuffer
private readonly result = Promise.withResolvers<PtySendResult>()
private finished = false
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new PtyTextBuffer(maxBytes)
}
get done(): Promise<PtySendResult> {
return this.result.promise
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
if (this.finished) return
this.finished = true
const read = this.output.snapshot()
this.result.resolve({
viewport: read.text,
waitReason,
sessionStatus,
truncated: read.truncated || inheritedTruncation,
})
}
fail(error: unknown): void {
if (this.finished) return
this.finished = true
this.result.reject(error)
}
readOutput(): PtySendRead {
return this.output.consume()
}
cancel(): boolean {
if (this.finished) return false
this.onCancel()
return true
}
}
/* jscpd:ignore-end */
/** Live session around one E2B SDK PTY handle. */
export class E2BPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
private readonly sanitizer: PtyTerminalSanitizer
private readonly scrollback: PtyTextBuffer
private readonly exited = Promise.withResolvers<void>()
private statusValue: PtySessionStatus = { kind: 'running' }
private active: E2BSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private promptTextSeen = false
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private closeSignal: NodeJS.Signals | null = null
private transportFailure: Error | undefined
private remoteExited = false
constructor(
private readonly sandbox: Sandbox,
private readonly handle: CommandHandle,
private readonly config: ResolvedConfig,
) {
this.pid = handle.pid
this.sanitizer = new PtyTerminalSanitizer(config.maxReadBytes)
this.scrollback = new PtyTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
const completion = handle.wait()
void completion.then(
(result) => { this.onExit(result.exitCode) },
(error: unknown) => {
if (error instanceof CommandExitError) this.onExit(error.exitCode)
else this.onTransportFailure(error)
},
)
}
/**
* Consume bytes received by the SDK's PTY callback.
* @param data - Exact callback bytes in delivery order.
*/
onData(data: Uint8Array): void {
let decoded: string
try {
decoded = this.decoder.decode(data, { stream: true })
} catch (error: unknown) {
this.onTransportFailure(new Error('pty-e2b: PTY emitted invalid UTF-8', { cause: error }))
return
}
const sanitized = this.sanitizer.push(decoded)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
}
/**
* Await the first prompt or bounded startup fallback.
* @param signal - Optional startup cancellation signal.
*/
async initialize(signal?: AbortSignal): Promise<void> {
this.initializing = true
try {
const operation = this.startSend({ text: '', submit: false, ...signal === undefined ? {} : { signal } })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('E2B PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('E2B PTY shell did not reach readiness before startup timeout')
this.motd = result.viewport
} catch (error: unknown) {
signal?.throwIfAborted()
throw error
} finally {
this.initializing = false
}
}
/* jscpd:ignore-start -- PTY backends share request admission while owning distinct input and readiness transports. */
startSend(request: PtySendRequest): PtySendOperation {
if (this.closing) throw new Error('E2B PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('E2B PTY session has exited')
if (this.active !== undefined) throw new Error('E2B PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('E2B PTY send aborted before write')
const operation = new E2BSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0) {
void this.sandbox.pty.sendInput(this.pid, Buffer.from(input)).catch((error: unknown) => {
if (this.active === operation) this.failActive(error)
})
}
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
return operation
}
/* jscpd:ignore-end */
/* jscpd:ignore-start -- The seam requires identical bounded-read coordinates across backend buffers. */
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
const offset = request.offset ?? 0
const count = request.count ?? 500
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
if (offset >= totalLines) {
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
}
const end = totalLines - offset
const start = Math.max(0, end - count)
const bounded = ptyUtf8Tail(lines.slice(start, end).join('\n'), this.config.maxReadBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text,
totalLines,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: snapshot.truncated || bounded.truncated,
}
}
/* jscpd:ignore-end */
/* jscpd:ignore-start -- Signal, status, and close methods preserve the seam shape around remote identities. */
async signal(signal: PtySignal): Promise<PtySignalResult> {
const pgid = await this.foregroundPgid()
if (signal === 'SIGKILL' && pgid === this.pid) {
throw new Error('refusing to SIGKILL the E2B PTY shell; use terminal_close')
}
await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${pgid}`)
return { delivered: true, targetPgid: pgid }
}
status(): PtySessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
this.closing = true
if (this.closePromise !== undefined) return this.closePromise
const closing = this.closeOnce(reason).catch((error: unknown) => {
this.closePromise = undefined
this.failActive(error)
throw error
})
this.closePromise = closing
return closing
}
/* jscpd:ignore-end */
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private pollReadiness(operation: E2BSendOperation): void {
if (this.active !== operation) return
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const elapsed = Date.now() - operation.startedAt
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason): void {
const operation = this.active
if (operation === undefined) return
const inherited = this.scrollback.snapshot().truncated
this.clearActive()
operation.settle(waitReason, this.statusValue, inherited)
}
private clearActive(): void {
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.activeTimer = undefined
this.activeAbort?.()
this.activeAbort = undefined
this.active = undefined
}
private failActive(error: unknown): void {
const operation = this.active
if (operation === undefined) return
this.clearActive()
operation.fail(error)
}
private interrupt(operation: E2BSendOperation): void {
if (this.active !== operation) return
void this.signal('SIGINT').catch((error: unknown) => { this.failActive(error) })
}
private async foregroundPgid(): Promise<number> {
const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`)
const raw = result.stdout.trim()
const pgid = Number(raw)
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid)) {
throw new Error(`cannot resolve foreground process group for E2B PTY ${this.pid}`)
}
return pgid
}
private onExit(exitCode: number): void {
this.remoteExited = true
let tail = ''
try {
tail = this.decoder.decode()
} catch (error: unknown) {
this.transportFailure ??= new Error('pty-e2b: PTY ended with invalid UTF-8', { cause: error })
}
this.appendOutput(this.sanitizer.push(tail).text)
this.appendOutput(this.sanitizer.flush())
const inferredSignal = this.closeSignal ?? (exitCode > 128 ? ptySignalName(exitCode - 128) : null)
this.statusValue = {
kind: 'exited',
exitCode: inferredSignal === null ? exitCode : null,
signal: inferredSignal,
}
if (this.transportFailure === undefined) this.settleActive('session_exit')
else this.failActive(this.transportFailure)
this.exited.resolve()
}
private onTransportFailure(error: unknown): void {
const failure = error instanceof Error ? error : new Error(String(error))
this.transportFailure ??= failure
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
this.failActive(failure)
}
private async closeOnce(reason: string): Promise<void> {
if (!this.remoteExited) {
this.closeSignal = 'SIGTERM'
try {
await this.sandbox.commands.run(`kill -TERM -- -${this.pid}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
}
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
}
if (!this.remoteExited) {
this.closeSignal = 'SIGKILL'
await this.sandbox.pty.kill(this.pid)
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
}
if (!this.remoteExited) {
throw new Error(`E2B PTY cleanup failed (${reason}); surviving pid: ${this.pid}`)
}
this.settleActive('session_exit')
await this.handle.disconnect().catch(() => {})
if (this.transportFailure !== undefined) throw this.transportFailure
}
}

View File

@@ -0,0 +1,195 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
import { E2BPtyBackend, apply } from '@deepseek-ai/dsh-pty-e2b'
import { validateConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
import * as E2BPtyInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
function config() {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 1, idleSilenceMs: 2, timeoutMs: 5, disposeGraceMs: 1,
}
}
function owner(ctx: Context): Agent {
const id = SessionId('owner')
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('unused'), steer: () => AgentMessageId('unused'),
inject: () => AgentMessageId('unused'), send: () => AgentMessageId('unused'),
cancel() {}, whenIdle: () => Promise.resolve(),
}
}
function handle(pid = 123, kill = vi.fn().mockResolvedValue(true)): CommandHandle {
const result = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
return {
pid,
wait: () => result.promise,
kill,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
}
describe('E2BPtyBackend and plugin', () => {
it('creates a remote PTY with isolated environment and initializes the session', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', {
cwd: '/workspace',
getSandbox: async () => sandbox,
} as E2BSandboxService)
const created = handle()
let options: Parameters<Sandbox['pty']['create']>[0] | undefined
const backend = new E2BPtyBackend(ctx, config(), async (_sandbox, received) => {
options = received
void received.onData(Buffer.from('banner\n'))
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const pending = backend.spawn({
sessionId: PtySessionId('pty-1'), owner: owner(ctx), type: 'shell', cwd: 'project',
signal: new AbortController().signal,
})
await vi.advanceTimersByTimeAsync(2)
const session = await pending
expect(session.motd).toBe('dsh> ')
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
expect(options?.envs).toMatchObject({
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
})
vi.useRealTimers()
})
it('uses the SDK PTY create method and the shared cwd by default', async () => {
vi.useFakeTimers()
const ctx = new Context()
const created = handle()
const create = vi.fn(async (received: Parameters<Sandbox['pty']['create']>[0]) => {
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const sandbox = { pty: { create } } as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config())
const pending = backend.spawn({ sessionId: PtySessionId('default'), owner: owner(ctx), type: 'shell' })
await vi.advanceTimersByTimeAsync(2)
await pending
expect(create).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace' }))
vi.useRealTimers()
})
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const create = vi.fn().mockResolvedValue(handle(0))
const backend = new E2BPtyBackend(ctx, config(), create)
const aborted = AbortSignal.abort(new Error('stop'))
await expect(backend.spawn({ sessionId: PtySessionId('one'), owner: owner(ctx), type: 'shell', signal: aborted })).rejects.toThrow('stop')
expect(create).not.toHaveBeenCalled()
const malformedKill = vi.fn().mockResolvedValue(true)
const malformed = handle(0, malformedKill)
const invalid = new E2BPtyBackend(ctx, config(), async () => malformed)
await expect(invalid.spawn({ sessionId: PtySessionId('two'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
expect(malformedKill).toHaveBeenCalledOnce()
const killFailureKill = vi.fn().mockRejectedValue(new Error('already gone'))
const killFailure = handle(0, killFailureKill)
const raced = new E2BPtyBackend(ctx, config(), async () => killFailure)
await expect(raced.spawn({ sessionId: PtySessionId('three'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
})
it('cleans failed startup and aggregates a cleanup failure', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) },
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const failedHandle = handle()
const backend = new E2BPtyBackend(ctx, config(), async () => failedHandle)
const pending = backend.spawn({ sessionId: PtySessionId('failed'), owner: owner(ctx), type: 'shell' })
const rejected = expect(pending).rejects.toMatchObject({
name: 'PtyBackendCleanupError',
cleanupError: expect.objectContaining({ message: 'cleanup failed' }),
} satisfies Partial<PtyBackendCleanupError>)
await vi.advanceTimersByTimeAsync(6)
await vi.advanceTimersByTimeAsync(2)
await rejected
vi.useRealTimers()
})
it('preserves startup failure when cleanup succeeds', async () => {
vi.useFakeTimers()
const ctx = new Context()
const completion = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
const created = {
pid: 123,
wait: () => completion.promise,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
const sandbox = {
commands: {
run: vi.fn(async (command: string) => {
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
return { exitCode: 0, stdout: '', stderr: '' }
}),
},
pty: { kill: vi.fn().mockResolvedValue(true) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config(), async () => created)
const rejected = expect(backend.spawn({ sessionId: PtySessionId('failed-clean'), owner: owner(ctx), type: 'shell' }))
.rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(6)
await rejected
vi.useRealTimers()
})
it('validates configuration and registers the selected backend type', async () => {
const valid = config()
expect(() => { validateConfig(valid) }).not.toThrow()
for (const invalid of [
{ ...valid, backendType: '' },
{ ...valid, rows: 0 },
{ ...valid, rows: 1.5 },
{ ...valid, maxReadBytes: 129 },
]) {
expect(() => { validateConfig(invalid) }).toThrow()
}
const registerBackend = vi.fn()
apply({ pty: { registerBackend } } as unknown as Context, valid)
expect(registerBackend).toHaveBeenCalledWith(expect.objectContaining({ type: 'shell' }))
const ctx = new Context()
await ctx.plugin(PtyService)
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => ({}) } as never)
const fiber = await ctx.plugin({
inject: ['pty', 'e2b'],
apply: (pluginCtx: Context) => { apply(pluginCtx, valid) },
})
expect(ctx.pty.listBackends()).toEqual(['shell'])
await 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(E2BPtyInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,398 @@
import { Buffer } from 'node:buffer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CommandExitError,
type CommandHandle,
type CommandResult,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type { PtySendOperation, PtySessionStatus } from '@deepseek-ai/dsh-pty'
import { E2BPtySession } from '@deepseek-ai/dsh-pty-e2b'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
function commandError(exitCode: number): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
}
class FakePtyHandle {
pid = 123
readonly result = Promise.withResolvers<CommandResult>()
disconnects = 0
kills = 0
disconnectError: unknown
private settled = false
wait(): Promise<CommandResult> {
return this.result.promise
}
async disconnect(): Promise<void> {
this.disconnects += 1
if (this.disconnectError !== undefined) throw this.disconnectError
}
async kill(): Promise<boolean> {
this.kills += 1
return true
}
exit(exitCode = 0): void {
if (this.settled) return
this.settled = true
this.result.resolve({ exitCode, stdout: '', stderr: '' })
}
failExit(exitCode: number): void {
if (this.settled) return
this.settled = true
this.result.reject(commandError(exitCode))
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.result.reject(error)
}
asHandle(): CommandHandle {
return this as unknown as CommandHandle
}
}
class FakeSandbox {
readonly sent: Array<{ pid: number; data: Buffer }> = []
readonly commands: string[] = []
readonly killed: number[] = []
pgid = '456\n'
sendError: unknown
commandError: unknown
killError: unknown
onTerm: (() => void) | undefined
onKill: (() => void) | undefined
readonly sandbox = {
pty: {
sendInput: async (pid: number, data: Uint8Array): Promise<void> => {
this.sent.push({ pid, data: Buffer.from(data) })
if (this.sendError !== undefined) throw this.sendError
},
kill: async (pid: number): Promise<boolean> => {
this.killed.push(pid)
if (this.killError !== undefined) throw this.killError
this.onKill?.()
return true
},
},
commands: {
run: async (command: string): Promise<CommandResult> => {
this.commands.push(command)
if (this.commandError !== undefined) {
const error = this.commandError
this.commandError = undefined
throw error
}
if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
if (command.startsWith('kill -TERM')) this.onTerm?.()
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, idleSilenceMs: 40, timeoutMs: 100, disposeGraceMs: 20,
...overrides,
}
}
async function initialize(session: E2BPtySession): Promise<void> {
const pending = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await pending
}
afterEach(() => { vi.useRealTimers() })
describe('E2BPtySession readiness, output, and signals', () => {
it('initializes, sends UTF-8 input, settles at a prompt, and reads bounded scrollback', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
await initialize(session)
expect(session.motd).toBe('dsh> ')
const operation = session.startSend({ text: 'printf 你好', submit: true })
expect(fake.sent).toEqual([{ pid: 123, data: Buffer.from('printf 你好\r') }])
session.onData(Buffer.from('一\n二\n三\x1b]133;D;0\x07dsh> '))
const bounded = operation.readOutput()
expect(bounded.delta).toContain('三')
expect(bounded.truncated).toBe(true)
await vi.advanceTimersByTimeAsync(10)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
expect(session.read({ count: 2 }).text).toContain('dsh>')
expect(session.read({ offset: 99 })).toMatchObject({ text: '', lineBegin: 99, lineEnd: 99 })
expect(() => session.read({ offset: -1 })).toThrow('non-negative safe integer')
expect(() => session.read({ offset: 1.5 })).toThrow('non-negative safe integer')
expect(() => session.read({ count: 0 })).toThrow('positive safe integer')
expect(() => session.read({ count: 1.5 })).toThrow('positive safe integer')
await expect(session.signal('SIGTERM')).resolves.toEqual({ delivered: true, targetPgid: 456 })
expect(fake.commands).toContain('kill -TERM -- -456')
expect(session.status()).toEqual({ kind: 'running' })
})
it('distinguishes inferred idle, timeout, session exit, and no-output startup timeout', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const inferred = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(40)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: '', submit: false })
for (let index = 0; index < 3; index += 1) {
await vi.advanceTimersByTimeAsync(30)
session.onData(Buffer.from('.'))
}
await vi.advanceTimersByTimeAsync(10)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: '', submit: false })
handle.failExit(143)
expect(await exiting.done).toMatchObject({
waitReason: 'session_exit',
sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' },
})
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
const startupHandle = new FakePtyHandle()
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('handles split prompt text, stale operations, and explicit cancellation', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const initializing = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07'))
await vi.advanceTimersByTimeAsync(20)
session.onData(Buffer.from('dsh> '))
await vi.advanceTimersByTimeAsync(10)
await initializing
const operation = session.startSend({ text: 'sleep', submit: true })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
interrupt(operation: PtySendOperation): void
settleActive(reason: 'timeout'): void
failActive(error: unknown): void
appendOutput(text: string): void
statusValue: PtySessionStatus
}
internal.pollReadiness({} as PtySendOperation)
internal.interrupt({} as PtySendOperation)
internal.appendOutput('')
fake.pgid = '789\n'
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(fake.commands).toContain('kill -INT -- -789')
session.onData(Buffer.from('\x1b]133;D;130\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await operation.done
internal.settleActive('timeout')
internal.failActive(new Error('ignored'))
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
fail(error: unknown): void
}
operationInternal.append('ignored')
operationInternal.settle('timeout', { kind: 'running' }, false)
operationInternal.fail(new Error('ignored'))
})
it('observes AbortSignal and contains send or foreground lookup failures', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const controller = new AbortController()
const aborting = session.startSend({ text: '', submit: false, signal: controller.signal })
expect(() => session.startSend({ text: '', submit: false })).toThrow('active send')
fake.pgid = 'not-a-pgid\n'
controller.abort()
await expect(aborting.done).rejects.toThrow('cannot resolve foreground process group')
const already = new AbortController()
already.abort()
expect(() => session.startSend({ text: '', submit: false, signal: already.signal })).toThrow('aborted before write')
fake.sendError = new Error('send failed')
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('send failed')
fake.pgid = '123\n'
await expect(session.signal('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
fake.pgid = '0\n'
await expect(session.signal('SIGINT')).rejects.toThrow('cannot resolve')
const deferred = Promise.withResolvers<undefined>()
fake.sendError = undefined
const sendInput = vi.spyOn(fake.sandbox.pty, 'sendInput').mockReturnValueOnce(deferred.promise)
const late = session.startSend({ text: 'late', submit: false })
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await late.done
deferred.reject(new Error('late failure'))
await vi.advanceTimersByTimeAsync(0)
expect(sendInput).toHaveBeenCalled()
})
it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => {
const fake = new FakeSandbox()
const abortHandle = new FakePtyHandle()
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config())
const controller = new AbortController()
const reason = new Error('startup cancelled')
const initializing = abortSession.initialize(controller.signal)
const rejected = expect(initializing).rejects.toBe(reason)
controller.abort(reason)
await rejected
const invalidHandle = new FakePtyHandle()
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config())
const pending = invalid.startSend({ text: '', submit: false })
invalid.onData(Uint8Array.from([0xff]))
await expect(pending.done).rejects.toThrow('invalid UTF-8')
expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
const crashHandle = new FakePtyHandle()
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config())
const active = crashed.startSend({ text: '', submit: false })
crashHandle.crash('transport gone')
await expect(active.done).rejects.toEqual(new Error('transport gone'))
const startupExitHandle = new FakePtyHandle()
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config())
const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
startupExitHandle.exit(7)
await exitedDuringStartup
})
it('covers empty bounded reads and polling an exited active session', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const tinyHandle = new FakePtyHandle()
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 }))
tiny.onData(Buffer.from('你'))
expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 })
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const operation = session.startSend({ text: '', submit: false })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
clearActive(): void
statusValue: PtySessionStatus
}
internal.statusValue = { kind: 'exited', exitCode: 7, signal: null }
internal.pollReadiness(operation)
expect((await operation.done).waitReason).toBe('session_exit')
internal.clearActive()
})
})
describe('E2BPtySession teardown', () => {
it('terminates the process group once, awaits exit, and disconnects', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.onTerm = () => { handle.failExit(143) }
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const first = session.close('done')
expect(session.close('again')).toBe(first)
await first
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGTERM' })
expect(handle.disconnects).toBe(1)
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => {
vi.useFakeTimers()
const gone = new FakeSandbox()
const goneHandle = new FakePtyHandle()
gone.commandError = commandError(1)
gone.onKill = () => { goneHandle.failExit(137) }
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
const closingGone = goneSession.close('gone')
await vi.advanceTimersByTimeAsync(20)
await closingGone
expect(gone.killed).toEqual([123])
expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' })
const survivor = new FakeSandbox()
const survivorHandle = new FakePtyHandle()
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config())
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123')
await vi.advanceTimersByTimeAsync(40)
await failed
survivorHandle.exit()
await expect(survivorSession.close('retry')).resolves.toBeUndefined()
})
it('propagates cleanup transport failures and lets close retry', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.commandError = new Error('TERM transport failed')
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
handle.exit()
await expect(session.close('retry')).resolves.toBeUndefined()
const invalidTailHandle = new FakePtyHandle()
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config())
invalidTail.onData(Uint8Array.from([0xe2]))
invalidTailHandle.exit()
await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8')
const normalHandle = new FakePtyHandle()
normalHandle.disconnectError = new Error('disconnect raced')
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config())
normalHandle.exit(7)
await Promise.resolve()
expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null })
await expect(normal.close('already exited')).resolves.toBeUndefined()
})
it('kills a remotely live PTY after its host transport fails', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const active = session.startSend({ text: '', submit: false })
session.onData(Uint8Array.from([0xff]))
await expect(active.done).rejects.toThrow('invalid UTF-8')
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
fake.onTerm = () => { handle.failExit(143) }
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
expect(fake.commands).toContain('kill -TERM -- -123')
expect(handle.disconnects).toBe(1)
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../e2b" },
{ "path": "../../pty/pty" },
{ "path": "../../support/invariants" }
]
}

View 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/e2b/subprocess-e2b/README.md
README.md: ce61de1100791be6e5c4db74c73ff43566281ed9
README.zh.md: a4c619f0c002cc1d36310c5a9b4a3f7ae655ad1f

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-subprocess-e2b
English | [中文](README.zh.md)
E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) seam. It has no config: load [`@deepseek-ai/dsh-e2b`](../e2b/README.md) first, then this service in place of `dsh-subprocess-local`. Existing consumers such as [`dsh-bash-local`](../../bash/bash-local/README.md) then execute in the shared remote sandbox without an E2B-specific Bash adapter.
## Behavior
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the SDK returns the command PID; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
- **Linux process groups** — a quoted wrapper starts each argv under `setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of assuming the SDK command PID is the group id. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback. Service disposal terminates and joins every retained handle before the sandbox owner disposes.
- **Environment boundary** — the wrapper starts from the sandbox command environment, removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names, then restores every `spec.env` entry as an explicit caller opt-in. Host ambient variables never enter the sandbox implicitly.
- **Stdio projection** — pipe mode forwards E2B callbacks into host Node streams; inherit mode forwards them to the harness process streams; collect mode retains a bounded host tail with offset reads. Optional complete spill files are written remotely and advertised only while within their cap. Batch and streaming stdin use the SDK handle.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, and `kill`. A custom template must retain compatible commands.
## Model Experience
Indirectly, through consumer seams such as the Bash executor behind `dsh-tool-bash`, which render remote output, exit facts, background deltas, and spill paths.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate even when this adapter exposes bounded tails, so the subprocess seam's normal host-memory bound is not achieved.
- **Pipe output is not byte-faithful** — E2B delivers separately decoded strings rather than raw bytes, so split multibyte sequences and arbitrary binary protocols can be corrupted; LSP and other framed byte-stream consumers are unsupported.
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
- **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them.
- **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep.
- **Signal attribution is inferred** — when termination was requested and E2B reports a nonzero exit code, the adapter reports the last requested signal because the SDK result does not identify the terminating signal.
- **Linux utility and E2B transport semantics are assumed** — there is no PTY, Windows, arbitrary-template, or network-partition fidelity layer.

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-subprocess-e2b
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) seam 的 E2B 实现。它没有配置:先加载 [`@deepseek-ai/dsh-e2b`](../e2b/README.md),再用本服务取代 `dsh-subprocess-local`。随后,[`dsh-bash-local`](../../bash/bash-local/README.md) 等现有消费方会在共享远程沙箱中执行,无需 E2B 专用 Bash 适配器。
## 行为
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。SDK 返回命令 PID 之前,`pid``-1``done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
- **Linux 进程组**:带引号保护的包装层会在 `setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会假设 SDK 命令 PID 就是进程组 ID。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。服务 dispose资源释放会在沙箱所有者释放前终止并等待每个保留句柄退出。
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
- **stdio 投影**pipe 模式把 E2B 回调转发到宿主 Node 流inherit 模式把回调转发到 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``tr``env``chmod``tee``kill`。自定义模板必须保留兼容的命令。
## 模型体验
通过消费方 seam 间接影响模型,例如 `dsh-tool-bash` 背后的 Bash 执行器;这些消费方会渲染远程输出、退出事实、后台增量和 spill 路径。
#### KV Cache 影响
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与延后工作
- **SDK 仍会在宿主内存中保留完整命令输出**即使本适配器公开的是有界尾部E2B `CommandHandle.stdout``.stderr` 仍会持续累积,因此无法达到进程管理 seam 通常提供的宿主内存边界。
- **Pipe 输出并非字节保真**E2B 交付的是分别解码后的字符串,而不是原始字节,因此拆分的多字节序列和任意二进制协议可能损坏;不支持 LSP 及其他带帧字节流消费方。
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
- **重新连接不会重建句柄**:保留沙箱后,远程 PID状态spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。
- **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。
- **信号归因依靠推断**:如果已经请求终止,而 E2B 报告非零退出码,适配器会报告最后请求的信号,因为 SDK 结果不标识终止信号。
- **依赖 Linux 工具与 E2B 传输语义**:没有 PTY、Windows、任意模板或网络分区的保真层。

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-subprocess-e2b",
"description": "E2B subprocess 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-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,58 @@
/**
* E2B implementation of the subprocess seam. Each handle starts through the
* shared sandbox and retains command output/status paths in that remote world.
* @module @deepseek-ai/dsh-subprocess-e2b
*/
import { randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { Context } from 'cordis'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { E2BSubprocessHandle } from './process.ts'
/** E2B command manager registered as `ctx.subprocess`. */
export class E2BSubprocessService extends SubprocessService {
static inject = ['e2b']
private readonly live = new Set<E2BSubprocessHandle>()
/** Create the E2B subprocess service and bind its disposal policy. */
constructor(ctx: Context) {
super(ctx)
ctx.effect(() => async () => {
const handles = [...this.live]
for (const handle of handles) handle.terminate()
await Promise.all(handles.map(async (handle) => {
await handle.done.catch(() => {})
await handle.waitForExit()
}))
this.live.clear()
}, 'e2b subprocess teardown')
}
/** @inheritdoc */
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const program = spec.argv[0]
if (program === undefined || program.length === 0) {
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
}
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0) {
throw new Error('subprocess-e2b: graceMs must be a positive finite number')
}
if (spec.signal?.aborted === true) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir)
this.live.add(handle)
const release = async (): Promise<void> => {
await handle.waitForExit()
this.live.delete(handle)
}
void handle.done.then(release, release).catch(() => {})
return handle
}
}
export default E2BSubprocessService

View File

@@ -0,0 +1,27 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-e2b'
/** Cordis companion plugin name. */
export const name = 'subprocess-e2b-invariant'
/** Service required before reserving package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: live remote handles are private teardown ownership,
* and the E2B command event stream is the sole outcome authority.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,70 @@
/** Bounded host-side projection of a complete output file retained in E2B. */
import { Buffer } from 'node:buffer'
import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
/** Offset reader used for one collect-mode E2B stream. */
export class E2BOutputReader implements SubprocessOutputReader {
private chunks: Buffer[] = []
private retainedBytes = 0
private totalBytes = 0
/**
* Create a bounded reader over one remote spill path.
* @param maxBytes - In-memory tail cap.
* @param maxSpillBytes - Maximum complete remote file size the caller accepts.
* @param spillPath - Remote full-output path.
*/
constructor(
private readonly maxBytes: number,
private readonly maxSpillBytes: number | undefined,
private readonly spillPath: string,
) {}
/** Total bytes observed from the SDK stream. */
get size(): number {
return this.totalBytes
}
/**
* Append one decoded SDK output event.
* @param text - Event text delivered by E2B.
*/
push(text: string): void {
if (text.length === 0) return
const chunk = Buffer.from(text)
this.totalBytes += chunk.length
this.chunks.push(chunk)
this.retainedBytes += chunk.length
while (this.retainedBytes > this.maxBytes) {
const head = this.chunks[0] as Buffer
const excess = this.retainedBytes - this.maxBytes
if (head.length <= excess) {
this.chunks.shift()
this.retainedBytes -= head.length
} else {
this.chunks[0] = head.subarray(excess)
this.retainedBytes -= excess
}
}
}
/** @inheritdoc */
readFrom(fromByte: number): SubprocessOutputRead {
if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
throw new Error('subprocess output offset must be a non-negative safe integer')
}
const retained = Buffer.concat(this.chunks, this.retainedBytes)
const firstRetained = this.totalBytes - this.retainedBytes
const lossy = fromByte < firstRetained
const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - firstRetained))
return {
text: retained.subarray(start).toString('utf8'),
nextOffset: this.totalBytes,
lossy,
...(lossy && this.maxSpillBytes !== undefined && this.totalBytes <= this.maxSpillBytes
? { spillPath: this.spillPath }
: {}),
}
}
}

View File

@@ -0,0 +1,417 @@
/** One asynchronously-started E2B command projected onto the subprocess seam. */
import { Buffer } from 'node:buffer'
import { PassThrough, Writable } from 'node:stream'
import { posix } from 'node:path'
import {
CommandExitError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
import type {
SubprocessCollect,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { E2BOutputReader } from './output.ts'
const GROUP_POLL_MS = 20
function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
return mode !== 'pipe' && mode !== 'inherit'
}
function hasSpill(mode: SubprocessOutputMode): mode is SubprocessCollect & { spill: { maxBytes: number } } {
return isCollect(mode) && mode.spill !== undefined
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
class DeferredStdin extends Writable {
constructor(private readonly ready: Promise<CommandHandle>) {
super({ decodeStrings: false })
}
override _write(chunk: string | Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
void this.ready.then(handle => handle.sendStdin(chunk)).then(
() => { callback() },
(error: unknown) => { callback(asError(error)) },
)
}
override _final(callback: (error?: Error | null) => void): void {
void this.ready.then(handle => handle.closeStdin()).then(
() => { callback() },
(error: unknown) => { callback(asError(error)) },
)
}
}
interface RemotePaths {
pid: string
status: string
stdout: string
stderr: string
}
function explicitEnvironmentNames(env: Readonly<Record<string, string>> | undefined): string {
return Object.keys(env ?? {})
.map(quoteE2BShellArg)
.join(' ')
}
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
const stdoutRedirect = hasSpill(spec.stdio.stdout)
? `> >(tee -a -- ${quoteE2BShellArg(paths.stdout)})`
: ''
const stderrRedirect = hasSpill(spec.stdio.stderr)
? `2> >(tee -a -- ${quoteE2BShellArg(paths.stderr)} >&2)`
: ''
const environmentNames = explicitEnvironmentNames(spec.env)
const inner = [
'set +e',
'umask 077',
'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"',
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
'dsh_e2b_env=()',
`dsh_e2b_explicit=(${environmentNames})`,
'while IFS= read -r dsh_e2b_name; do',
' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac',
' dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}")',
'done < <(compgen -e)',
'for dsh_e2b_name in "${dsh_e2b_explicit[@]}"; do dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}"); done',
`env -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
'wait',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
'exit "$dsh_e2b_status"',
].join('\n')
const argv = spec.argv.map(quoteE2BShellArg).join(' ')
return `exec setsid --wait -- bash -c ${quoteE2BShellArg(inner)} dsh-e2b ${argv}`
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
function waitTick(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted === true) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve(true)
}, GROUP_POLL_MS)
const onAbort = (): void => {
clearTimeout(timer)
resolve(false)
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/** E2B-backed subprocess handle with deferred remote PID acquisition. */
export class E2BSubprocessHandle implements SubprocessHandle {
readonly stdin: Writable | undefined
readonly stdout: PassThrough | undefined
readonly stderr: PassThrough | undefined
readonly collected: SubprocessHandle['collected']
readonly done: Promise<SubprocessOutcome>
private readonly readyState = Promise.withResolvers<CommandHandle>()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private remotePid = -1
private settled = false
private terminationRequested = false
private terminationSignal: NodeJS.Signals | null = null
private termination: Promise<void> | undefined
/**
* Begin an E2B command without blocking the synchronous subprocess spawn seam.
* @param runtime - Shared E2B sandbox owner.
* @param spec - Fully resolved subprocess request.
* @param stateDir - Remote directory retaining process identity, status, and valid spills.
*/
constructor(
private readonly runtime: E2BSandboxService,
private readonly spec: SubprocessSpawnSpec,
readonly stateDir: string,
) {
this.paths = {
pid: posix.join(stateDir, 'pid'),
status: posix.join(stateDir, 'exit-code'),
stdout: posix.join(stateDir, 'stdout.log'),
stderr: posix.join(stateDir, 'stderr.log'),
}
const outMode = spec.stdio.stdout
const errMode = spec.stdio.stderr
this.stdout = outMode === 'pipe' ? new PassThrough() : undefined
this.stderr = errMode === 'pipe' ? new PassThrough() : undefined
this.stdoutReader = isCollect(outMode)
? new E2BOutputReader(outMode.maxBytes, outMode.spill?.maxBytes, this.paths.stdout)
: undefined
this.stderrReader = isCollect(errMode)
? new E2BOutputReader(errMode.maxBytes, errMode.spill?.maxBytes, this.paths.stderr)
: undefined
this.collected = {
...(this.stdoutReader !== undefined ? { stdout: this.stdoutReader } : {}),
...(this.stderrReader !== undefined ? { stderr: this.stderrReader } : {}),
}
this.stdin = spec.stdio.stdin === 'pipe' ? new DeferredStdin(this.readyState.promise) : undefined
void this.readyState.promise.catch(() => {})
spec.signal?.addEventListener('abort', this.onAbort, { once: true })
this.done = this.run()
void this.done.catch(() => {})
if (spec.signal?.aborted === true) this.terminate()
}
/** Remote process id after start; `-1` while E2B startup is pending or after it fails. */
get pid(): number {
return this.remotePid
}
/** @inheritdoc */
terminate(): void {
if (this.terminationRequested || this.settled) return
this.terminationRequested = true
this.termination = this.terminateRemote()
void this.termination.catch(() => {})
}
/** @inheritdoc */
async waitForExit(signal?: AbortSignal): Promise<boolean> {
let handle: CommandHandle | undefined
try {
handle = await this.readyForWait(signal)
} catch {
return true
}
if (handle === undefined) return false
let sandbox: Sandbox
try {
sandbox = await this.runtime.getSandbox()
} catch (error: unknown) {
if (isAborted(signal)) return false
throw error
}
while (await this.groupAlive(sandbox, this.remotePid, signal)) {
if (!await waitTick(signal)) return false
}
return !isAborted(signal)
}
private readyForWait(signal: AbortSignal | undefined): Promise<CommandHandle | undefined> {
if (signal === undefined) return this.readyState.promise
return new Promise<CommandHandle | undefined>((resolve, reject) => {
const onAbort = (): void => { cleanup(); resolve(undefined) }
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
void this.readyState.promise.then(
(handle) => { cleanup(); resolve(handle) },
(error: unknown) => { cleanup(); reject(asError(error)) },
)
})
}
private readonly onAbort = (): void => { this.terminate() }
private async run(): Promise<SubprocessOutcome> {
try {
const sandbox = await this.runtime.getSandbox()
await this.prepareState(sandbox)
const handle = await sandbox.commands.run(
commandText(this.spec, this.paths),
{
background: true,
cwd: this.spec.cwd,
stdin: this.spec.stdio.stdin !== 'ignore',
timeoutMs: 0,
...(this.spec.env !== undefined ? { envs: this.spec.env } : {}),
onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
},
)
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
throw new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
}
const completion = handle.wait()
void completion.catch(() => {})
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
this.readyState.resolve(handle)
await this.writeBatchStdin(handle)
const outcome = await this.waitForCommand(completion)
await this.finalizeSpills(sandbox)
return outcome
} catch (error: unknown) {
this.readyState.reject(error)
throw error
} finally {
this.settled = true
this.spec.signal?.removeEventListener('abort', this.onAbort)
this.stdout?.end()
this.stderr?.end()
}
}
private async prepareState(sandbox: Sandbox): Promise<void> {
await sandbox.files.makeDir(this.stateDir)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files)
await sandbox.commands.run([
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
].join('\n'))
}
private async writeBatchStdin(handle: CommandHandle): Promise<void> {
if (typeof this.spec.stdio.stdin !== 'object') return
try {
await handle.sendStdin(this.spec.stdio.stdin.data)
await handle.closeStdin()
} catch (_processClosedItsInput) {
// Like the local adapter, batch stdin is best-effort; exit and output remain authoritative.
}
}
private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise<void> {
try {
if (stream === 'stdout') {
this.stdoutReader?.push(data)
await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, data)
return
}
this.stderrReader?.push(data)
await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, data)
} catch (error: unknown) {
const target = stream === 'stdout' ? this.stdout : this.stderr
target?.destroy(asError(error))
}
}
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: string): Promise<void> {
const target = pipe ?? inherited
if (target === undefined || data.length === 0) return
if (target.destroyed) throw new Error('subprocess output stream is closed')
if (target.write(Buffer.from(data))) return
await new Promise<void>((resolve, reject) => {
const onDrain = (): void => { cleanup(); resolve() }
const onError = (error: Error): void => { cleanup(); reject(error) }
const cleanup = (): void => {
target.removeListener('drain', onDrain)
target.removeListener('error', onError)
}
target.once('drain', onDrain)
target.once('error', onError)
})
}
private async waitForProcessGroupId(sandbox: Sandbox, completion: Promise<CommandResult>): Promise<number> {
const commandSettled = completion.then(
() => true,
() => true,
)
while (true) {
const raw = await sandbox.files.read(this.paths.pid)
const value = raw.trim()
if (value.length > 0) {
const pid = Number(value)
if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(pid)) {
throw new Error(`subprocess-e2b: remote wrapper published invalid process-group id ${JSON.stringify(value)}`)
}
return pid
}
const settled = await Promise.race([commandSettled, waitTick().then(() => false)])
if (settled) throw new Error('subprocess-e2b: remote command exited before publishing its process-group id')
}
}
private async waitForCommand(completion: Promise<CommandResult>): Promise<SubprocessOutcome> {
try {
const result = await completion
return { exitCode: result.exitCode, signal: null }
} catch (error: unknown) {
if (error instanceof CommandExitError) {
return this.terminationSignal === null
? { exitCode: error.exitCode, signal: null }
: { exitCode: null, signal: this.terminationSignal }
}
throw error
}
}
private async terminateRemote(): Promise<void> {
let handle: CommandHandle
try {
handle = await this.readyState.promise
} catch {
return
}
const sandbox = await this.runtime.getSandbox()
this.terminationSignal = 'SIGTERM'
await this.signalGroup(sandbox, this.remotePid, 'TERM')
const deadline = Date.now() + this.spec.graceMs
while (Date.now() < deadline && await this.groupAlive(sandbox, this.remotePid)) {
await waitTick()
}
if (!await this.groupAlive(sandbox, this.remotePid)) return
this.terminationSignal = 'SIGKILL'
try {
await this.signalGroup(sandbox, this.remotePid, 'KILL')
} finally {
await handle.kill().catch(() => false)
}
}
private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise<void> {
try {
await sandbox.commands.run(`kill -${signal} -- -${pid}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
}
}
private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<boolean> {
try {
await sandbox.commands.run(`kill -0 -- -${pid}`, signalOpts(signal))
return true
} catch (error: unknown) {
if (signal?.aborted === true) return false
if (error instanceof CommandExitError) return false
throw error
}
}
private async finalizeSpills(sandbox: Sandbox): Promise<void> {
const removals: Promise<void>[] = []
const collect = (mode: SubprocessOutputMode, reader: E2BOutputReader | undefined, path: string): void => {
if (!hasSpill(mode)) return
// A spill mode is a collect mode, so construction always created its reader.
const size = (reader as E2BOutputReader).size
if (size <= mode.maxBytes || size > mode.spill.maxBytes) {
removals.push(sandbox.files.remove(path).catch(() => {}))
}
}
collect(this.spec.stdio.stdout, this.stdoutReader, this.paths.stdout)
collect(this.spec.stdio.stderr, this.stderrReader, this.paths.stderr)
await Promise.all(removals)
}
}

View File

@@ -0,0 +1,725 @@
import { once } from 'node:events'
import { Context } from 'cordis'
import {
CommandExitError,
type CommandHandle,
type CommandResult,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import * as E2BSubprocessInvariant from '../src/invariant.ts'
import { E2BOutputReader } from '../src/output.ts'
import { E2BSubprocessHandle } from '../src/process.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it, vi } from 'vitest'
function commandError(exitCode: number): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
}
interface StartOptions {
background: true
cwd: string
stdin: boolean
timeoutMs: number
signal?: AbortSignal
envs?: Record<string, string>
onStdout?: (data: string) => void | Promise<void>
onStderr?: (data: string) => void | Promise<void>
}
class FakeCommandHandle {
pid = 4242
readonly sent: Array<string | Uint8Array> = []
closes = 0
kills = 0
killError: unknown
private readonly result = Promise.withResolvers<CommandResult>()
private settled = false
wait(): Promise<CommandResult> {
return this.result.promise
}
async sendStdin(data: string | Uint8Array): Promise<void> {
this.sent.push(data)
}
async closeStdin(): Promise<void> {
this.closes += 1
}
async kill(): Promise<boolean> {
this.kills += 1
if (this.killError !== undefined) throw this.killError
return true
}
succeed(exitCode = 0): void {
if (this.settled) return
this.settled = true
this.result.resolve({ exitCode, stdout: '', stderr: '' })
}
fail(exitCode: number): void {
if (this.settled) return
this.settled = true
this.result.reject(commandError(exitCode))
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.result.reject(error)
}
}
class FakeSandbox {
readonly handle = new FakeCommandHandle()
readonly commandsSeen: string[] = []
readonly writtenFiles: string[][] = []
readonly removed: string[] = []
readonly directories: string[] = []
startOptions: StartOptions | undefined
backgroundError: unknown
nextRemoveError: unknown
probeError: unknown
signalError: unknown
trapsTerm = false
alive = true
processGroupId = '4242\n'
readonly processGroupReads: string[] = []
beforeProbe: (() => void) | undefined
afterProbe: (() => void) | undefined
private startGate: Promise<void> | undefined
private openStart: (() => void) | undefined
deferStart(): void {
const gate = Promise.withResolvers<undefined>()
this.startGate = gate.promise
this.openStart = () => { gate.resolve(undefined) }
}
releaseStart(): void {
this.openStart?.()
}
finish(exitCode = 0): void {
this.alive = false
if (exitCode === 0) this.handle.succeed(0)
else this.handle.fail(exitCode)
}
async stdout(data: string): Promise<void> {
await this.startOptions?.onStdout?.(data)
}
async stderr(data: string): Promise<void> {
await this.startOptions?.onStderr?.(data)
}
readonly sandbox = {
sandboxId: 'fake',
files: {
makeDir: async (path: string): Promise<boolean> => {
this.directories.push(path)
return true
},
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
this.writtenFiles.push(files.map(file => file.path))
return files.map(() => ({}))
},
read: async (): Promise<string> => this.processGroupReads.shift() ?? this.processGroupId,
remove: async (path: string): Promise<void> => {
this.removed.push(path)
if (this.nextRemoveError !== undefined) {
const error = this.nextRemoveError
this.nextRemoveError = undefined
throw error
}
},
},
commands: {
run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
this.commandsSeen.push(command)
if (command.startsWith('kill -0 ')) {
this.beforeProbe?.()
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
if (this.probeError !== undefined) {
const error = this.probeError
this.probeError = undefined
throw error
}
if (!this.alive) throw commandError(1)
this.afterProbe?.()
return { exitCode: 0, stdout: '', stderr: '' }
}
if (command.startsWith('kill -TERM ')) {
if (this.signalError !== undefined) {
const error = this.signalError
this.signalError = undefined
throw error
}
if (!this.trapsTerm) {
this.alive = false
this.handle.fail(143)
}
return { exitCode: 0, stdout: '', stderr: '' }
}
if (command.startsWith('kill -KILL ')) {
if (this.signalError !== undefined) {
const error = this.signalError
this.signalError = undefined
throw error
}
this.alive = false
this.handle.fail(137)
return { exitCode: 0, stdout: '', stderr: '' }
}
if ((options as StartOptions | undefined)?.background === true) {
this.startOptions = options as StartOptions
await this.startGate
if (this.backgroundError !== undefined) throw this.backgroundError
return this.handle as unknown as CommandHandle
}
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
return {
argv: ['bash', '-c', 'printf ok'],
cwd: '/workspace',
stdio: {
stdin: 'ignore',
stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
stderr: { maxBytes: 4 },
},
graceMs: 5,
...overrides,
}
}
function runtime(fake: FakeSandbox, getSandbox: () => Promise<Sandbox> = async () => fake.sandbox): E2BSandboxService {
return {
cwd: '/workspace',
runtimeRoot: '/workspace/.dsh-e2b',
disposeMode: 'kill',
getSandbox,
} as unknown as E2BSandboxService
}
async function flush(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('E2BOutputReader', () => {
it('keeps a byte-exact tail with independent whole-stream cursors', () => {
const reader = new E2BOutputReader(4, 10, '/remote/spill')
reader.push('')
reader.push('ab')
reader.push('cdef')
expect(reader.size).toBe(6)
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' })
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
})
it('drops whole head chunks and withholds absent or over-cap spills', () => {
const withoutSpill = new E2BOutputReader(2, undefined, '/unused')
withoutSpill.push('ab')
withoutSpill.push('cd')
expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
const overCap = new E2BOutputReader(2, 3, '/too-small')
overCap.push('abcd')
expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/)
expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/)
})
})
describe('E2BSubprocessHandle', () => {
it('starts asynchronously, keeps secrets out of the command, and supports deferred piped stdin/output', async () => {
const fake = new FakeSandbox()
fake.processGroupId = '4343\n'
fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
argv: ['tool', 'argument with spaces'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
env: { PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
}), '/workspace/.dsh-e2b/processes/one')
expect(handle.pid).toBe(-1)
handle.stdin!.write('hello')
handle.stdin!.end()
fake.releaseStart()
await flush()
expect(handle.pid).toBe(4343)
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
expect(fake.handle.closes).toBe(1)
expect(fake.startOptions?.envs).toEqual({ PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' })
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('exec setsid --wait -- bash -c')
expect(command).toContain('DEEPSEEK_API_KEY')
expect(command).toContain('DSH_MODE')
expect(command).not.toContain('explicit-secret')
expect(fake.writtenFiles[0]).toEqual([
'/workspace/.dsh-e2b/processes/one/pid',
'/workspace/.dsh-e2b/processes/one/exit-code',
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
let piped = ''
handle.stdout!.on('data', (chunk) => { piped += String(chunk) })
await fake.stdout('pipe-data')
await fake.stderr('err')
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(piped).toBe('pipe-data')
expect(handle.collected.stderr!.readFrom(0)).toMatchObject({ text: 'err', lossy: false })
expect(fake.removed).toContain('/workspace/.dsh-e2b/processes/one/stderr.log')
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
const writeFake = new FakeSandbox()
writeFake.deferStart()
vi.spyOn(writeFake.handle, 'sendStdin').mockRejectedValueOnce('stdin rejected')
const writeHandle = new E2BSubprocessHandle(runtime(writeFake), spec({
stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-write-error')
const writeError = once(writeHandle.stdin!, 'error')
writeHandle.stdin!.write('input')
writeFake.releaseStart()
await expect(writeError).resolves.toMatchObject([{ message: 'stdin rejected' }])
writeFake.finish()
await writeHandle.done
const closeFake = new FakeSandbox()
vi.spyOn(closeFake.handle, 'closeStdin').mockRejectedValueOnce(new Error('close rejected'))
const closeHandle = new E2BSubprocessHandle(runtime(closeFake), spec({
stdio: { stdin: 'pipe', stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-close-error')
await flush()
const closeError = once(closeHandle.stdin!, 'error')
closeHandle.stdin!.end()
await expect(closeError).resolves.toMatchObject([{ message: 'close rejected' }])
closeFake.finish()
await closeHandle.done
})
it('collects bounded tails, retains valid spills, and maps natural nonzero exits', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: {
stdin: { data: 'batch' },
stdout: { maxBytes: 4, spill: { maxBytes: 16 } },
stderr: { maxBytes: 3 },
},
}), '/runtime/two')
await flush()
await fake.stdout('abcdef')
await fake.stderr('12345')
fake.finish(7)
await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
expect(fake.handle.sent).toEqual(['batch'])
expect(fake.handle.closes).toBe(1)
expect(handle.collected.stdout!.readFrom(0)).toEqual({
text: 'cdef',
nextOffset: 6,
lossy: true,
spillPath: '/runtime/two/stdout.log',
})
expect(handle.collected.stderr!.readFrom(0)).toEqual({ text: '345', nextOffset: 5, lossy: true })
expect(fake.removed).not.toContain('/runtime/two/stdout.log')
})
it('removes a spill once the complete stream exceeds its cap', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: { maxBytes: 2, spill: { maxBytes: 3 } }, stderr: 'inherit' },
}), '/runtime/oversize')
await flush()
await fake.stdout('abcd')
await fake.stderr('')
fake.finish()
await handle.done
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(fake.removed).toContain('/runtime/oversize/stdout.log')
})
it('contains remote spill-removal failures and routes empty inherited output', async () => {
const fake = new FakeSandbox()
fake.nextRemoveError = new Error('already removed')
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4, spill: { maxBytes: 8 } } },
}), '/runtime/remove-error')
await flush()
await fake.stdout('')
await fake.stderr('')
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.removed).toContain('/runtime/remove-error/stderr.log')
})
it('terminates a process group with TERM and reports the signal outcome', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/term')
await flush()
handle.terminate()
handle.terminate()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
await expect(handle.waitForExit()).resolves.toBe(true)
expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
expect(fake.commandsSeen).not.toContain('kill -KILL -- -4242')
})
it('escalates a TERM-trapping process group to KILL and uses the SDK kill as fallback', async () => {
const fake = new FakeSandbox()
fake.trapsTerm = true
fake.handle.killError = new Error('already gone')
const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/kill')
await flush()
handle.terminate()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await expect(handle.waitForExit()).resolves.toBe(true)
expect(fake.commandsSeen).toContain('kill -KILL -- -4242')
expect(fake.handle.kills).toBe(1)
})
it('honors termination requested before asynchronous startup finishes', async () => {
const fake = new FakeSandbox()
fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/deferred-kill')
handle.terminate()
fake.releaseStart()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('honors an already-aborted signal when constructing the asynchronous handle directly', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: AbortSignal.abort('stop') }), '/runtime/pre-aborted')
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('reacts to a signal that aborts after the remote command has started', async () => {
const fake = new FakeSandbox()
const controller = new AbortController()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ signal: controller.signal }), '/runtime/live-abort')
await flush()
controller.abort('stop')
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('bounds waitForExit while startup or a live group is pending', async () => {
const fake = new FakeSandbox()
fake.deferStart()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/wait')
const beforeStart = new AbortController()
const pending = handle.waitForExit(beforeStart.signal)
beforeStart.abort()
await expect(pending).resolves.toBe(false)
await expect(handle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
fake.releaseStart()
await flush()
const live = new AbortController()
const liveWait = handle.waitForExit(live.signal)
live.abort()
await expect(liveWait).resolves.toBe(false)
fake.finish()
await handle.done
})
it('bounds both sides of the liveness-poll abort race', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-abort')
await flush()
const beforeTick = new AbortController()
fake.afterProbe = () => { beforeTick.abort(); fake.afterProbe = undefined }
await expect(handle.waitForExit(beforeTick.signal)).resolves.toBe(false)
const duringTick = new AbortController()
fake.afterProbe = () => {
fake.afterProbe = undefined
setTimeout(() => { duringTick.abort() }, 0)
}
await expect(handle.waitForExit(duringTick.signal)).resolves.toBe(false)
const duringProbe = new AbortController()
fake.beforeProbe = () => { duringProbe.abort(); fake.beforeProbe = undefined }
await expect(handle.waitForExit(duringProbe.signal)).resolves.toBe(false)
fake.finish()
await handle.done
})
it('observes a live group across one successful bounded poll', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/poll-success')
await flush()
setTimeout(() => { fake.finish() }, 1)
await expect(handle.waitForExit(new AbortController().signal)).resolves.toBe(true)
await handle.done
})
it('treats startup failure as no live tree and contains readiness rejection', async () => {
const fake = new FakeSandbox()
fake.backgroundError = new Error('start failed')
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail')
await expect(handle.done).rejects.toThrow('start failed')
expect(handle.pid).toBe(-1)
await expect(handle.waitForExit()).resolves.toBe(true)
handle.terminate()
})
it('bounds a readiness rejection with a still-live caller signal', async () => {
const fake = new FakeSandbox()
fake.deferStart()
fake.backgroundError = new Error('start failed')
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/fail-with-signal')
const waiting = handle.waitForExit(new AbortController().signal)
fake.releaseStart()
await expect(handle.done).rejects.toThrow('start failed')
await expect(waiting).resolves.toBe(true)
})
it('propagates an unavailable sandbox unless the caller aborts the wait', async () => {
const fake = new FakeSandbox()
let calls = 0
const unavailable = runtime(fake, async () => {
calls += 1
if (calls === 1) return fake.sandbox
throw new Error('connection unavailable')
})
const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/unavailable')
await flush()
await expect(handle.waitForExit()).rejects.toThrow('connection unavailable')
fake.finish()
await handle.done
})
it('returns false when the caller aborts while reconnecting for liveness', async () => {
const fake = new FakeSandbox()
const reconnect = Promise.withResolvers<Sandbox>()
let calls = 0
const unavailable = runtime(fake, async () => {
calls += 1
return calls === 1 ? fake.sandbox : await reconnect.promise
})
const handle = new E2BSubprocessHandle(unavailable, spec(), '/runtime/reconnect-abort')
await flush()
const controller = new AbortController()
const waiting = handle.waitForExit(controller.signal)
await flush()
controller.abort()
reconnect.reject(new Error('connection unavailable'))
await expect(waiting).resolves.toBe(false)
fake.finish()
await handle.done
})
it('returns false when a liveness request itself is aborted and surfaces other probe failures', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/probe')
await flush()
const controller = new AbortController()
controller.abort()
await expect(handle.waitForExit(controller.signal)).resolves.toBe(false)
fake.probeError = new Error('probe failed')
await expect(handle.waitForExit()).rejects.toThrow('probe failed')
fake.finish()
await handle.done
})
it('makes batch stdin close failures best-effort', async () => {
const fake = new FakeSandbox()
vi.spyOn(fake.handle, 'sendStdin').mockRejectedValueOnce(new Error('closed'))
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: { data: 'ignored' }, stdout: { maxBytes: 4 }, stderr: { maxBytes: 4 } },
}), '/runtime/stdin-closed')
await flush()
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
it('rejects malformed SDK process ids and non-command settlement failures', async () => {
const invalidPid = new FakeSandbox()
invalidPid.handle.pid = 0
const invalid = new E2BSubprocessHandle(runtime(invalidPid), spec(), '/runtime/invalid-pid')
await expect(invalid.done).rejects.toThrow(/invalid command pid 0/)
await expect(invalid.waitForExit()).resolves.toBe(true)
const crashedFake = new FakeSandbox()
const crashed = new E2BSubprocessHandle(runtime(crashedFake), spec(), '/runtime/crashed')
await flush()
crashedFake.alive = false
crashedFake.handle.crash(new Error('command transport failed'))
await expect(crashed.done).rejects.toThrow('command transport failed')
})
it('rejects invalid or absent process-group publication', async () => {
const invalidGroup = new FakeSandbox()
invalidGroup.processGroupId = 'not-a-pid\n'
const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
const absentGroup = new FakeSandbox()
absentGroup.processGroupId = ''
const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group')
await flush()
absentGroup.finish()
await expect(absent.done).rejects.toThrow(/exited before publishing/)
})
it('waits for delayed process-group publication', async () => {
const fake = new FakeSandbox()
fake.processGroupReads.push('', '4242\n')
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/delayed-group')
await vi.waitFor(() => { expect(handle.pid).toBe(4242) })
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
it('handles output backpressure and contains a stderr sink failure', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
}), '/runtime/backpressure')
await flush()
handle.stdout!.on('error', () => {})
const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
const stdoutPending = fake.stdout('blocked')
queueMicrotask(() => { handle.stdout!.emit('drain') })
await stdoutPending
stdoutWrite.mockRestore()
handle.stderr!.on('error', () => {})
const stderrWrite = vi.spyOn(handle.stderr!, 'write').mockReturnValueOnce(false)
const stderrPending = fake.stderr('broken')
queueMicrotask(() => { handle.stderr!.emit('error', new Error('sink failed')) })
await stderrPending
stderrWrite.mockRestore()
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
it('contains a pipe callback failure instead of rejecting command settlement', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/pipe-error')
await flush()
const emitted = once(handle.stdout!, 'error')
handle.stdout!.destroy(new Error('consumer failed'))
await emitted
await fake.stdout('late output')
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
it('contains an already-gone group signal and observes non-command signal failures', async () => {
const gone = new FakeSandbox()
gone.trapsTerm = true
gone.signalError = commandError(1)
const goneHandle = new E2BSubprocessHandle(runtime(gone), spec({ graceMs: 1 }), '/runtime/gone-signal')
await flush()
goneHandle.terminate()
await expect(goneHandle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
const failed = new FakeSandbox()
failed.signalError = new Error('signal transport failed')
const failedHandle = new E2BSubprocessHandle(runtime(failed), spec(), '/runtime/failed-signal')
await flush()
failedHandle.terminate()
await flush()
failed.finish()
await expect(failedHandle.done).resolves.toEqual({ exitCode: 0, signal: null })
})
})
describe('E2BSubprocessService', () => {
async function service(
fake = new FakeSandbox(),
providedRuntime: E2BSandboxService = runtime(fake),
): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
const ctx = new Context()
ctx.provide('e2b', providedRuntime)
const fiber = await ctx.plugin(E2BSubprocessService)
return { ctx, fiber }
}
it('registers handles and disposal terminates and joins live remote groups regardless of sandbox policy', async () => {
const fake = new FakeSandbox()
fake.trapsTerm = true
const { ctx, fiber } = await service(fake)
const handle = ctx.subprocess.spawn(spec({ graceMs: 1 }))
await flush()
await fiber.dispose()
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
expect(fake.alive).toBe(false)
})
it('releases naturally settled handles before later service disposal', async () => {
const fake = new FakeSandbox()
const { ctx, fiber } = await service(fake)
const handle = ctx.subprocess.spawn(spec())
await flush()
fake.finish()
await handle.done
await flush()
const signalsBefore = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
await fiber.dispose()
expect(fake.commandsSeen.filter(command => command.startsWith('kill -')).length).toBe(signalsBefore)
})
it('contains a release liveness failure and retries quiescence during disposal', async () => {
const fake = new FakeSandbox()
let calls = 0
const reconnecting = runtime(fake, async () => {
calls += 1
if (calls === 2) throw new Error('transient liveness failure')
return fake.sandbox
})
const { ctx, fiber } = await service(fake, reconnecting)
const handle = ctx.subprocess.spawn(spec())
await flush()
fake.finish()
await handle.done
await flush()
await fiber.dispose()
expect(calls).toBeGreaterThanOrEqual(3)
})
it('contains spawn rejection while disposal is joining the pending handle', async () => {
const fake = new FakeSandbox()
fake.deferStart()
fake.backgroundError = new Error('start failed during disposal')
const { ctx, fiber } = await service(fake)
const handle = ctx.subprocess.spawn(spec())
const disposing = fiber.dispose()
fake.releaseStart()
await expect(disposing).resolves.toBeUndefined()
await expect(handle.done).rejects.toThrow('start failed during disposal')
})
it('validates synchronous spawn preconditions', async () => {
const { ctx } = await service()
expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
expect(() => ctx.subprocess.spawn(spec({ graceMs: 0 }))).toThrow(/positive finite/)
expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
expect(() => ctx.subprocess.spawn(spec({ signal: { aborted: true, reason: undefined } as AbortSignal }))).toThrow(/aborted$/)
})
it('registers the package-owned empty invariant installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BSubprocessInvariant).await()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../e2b"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../support/invariants"
}
]
}