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