refactor(e2b): compose portable runtime consumers
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 .agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md
|
||||
2026-07-27-e2b-remote-runtime-poc.md: 4e6414b7d919d765712ac9314ce73a69c5c6344e
|
||||
2026-07-27-e2b-remote-runtime-poc.zh.md: 4d39ed7eb07cdd17c5bfa9155bc7efabaa798117
|
||||
2026-07-27-e2b-remote-runtime-poc.md: df0d6be97c502a9afe1a7aff3656a7567d302704
|
||||
2026-07-27-e2b-remote-runtime-poc.zh.md: 111d7aa63f8f7e51a82cfa0e42c489901f2b88e4
|
||||
@@ -12,27 +12,23 @@ Moving the complete harness into a remote VM would unify that state but also cou
|
||||
|
||||
## Decision
|
||||
|
||||
The E2B integration is an opt-in provider-composition POC. Its six E2B-specific packages live under `packages/e2b/` while retaining seam-specific npm names:
|
||||
The E2B integration is an opt-in provider-composition POC. Its three E2B-specific packages live together under `packages/e2b/`:
|
||||
|
||||
- `@deepseek-ai/dsh-e2b` creates or reconnects one secure E2B sandbox, creates its working and private runtime directories, and owns kill/pause/leave disposal.
|
||||
- `@deepseek-ai/dsh-fs-e2b` implements `ctx.fs` over that sandbox's Filesystem API.
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands and remote Linux process groups.
|
||||
- `@deepseek-ai/dsh-pty-e2b` registers an E2B byte-PTY backend on `ctx.pty` while the existing registry retains exact-Agent ownership.
|
||||
- `@deepseek-ai/dsh-lsp-e2b` registers configured remote language servers on `ctx.lsp`, reads source through a bounded no-follow helper in E2B, and runs servers through `dsh-subprocess-e2b`.
|
||||
- `@deepseek-ai/dsh-code-runtime-e2b` registers `ctx.codeRuntime`, runs each model program in a fresh remote worker, and dispatches binding functions in the host process.
|
||||
- The existing `@deepseek-ai/dsh-bash-local` remains the Bash implementation because it delegates all process mechanics to `ctx.subprocess`.
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands, byte PTYs, and remote Linux process groups.
|
||||
|
||||
The owner is the sole source of sandbox identity. Providers inject it and never create private sandboxes, so filesystem tools, Bash, interactive shells, language servers, and code workers share one remote cwd, process namespace, and adapter-private directory while preserving the existing capability interfaces and model-facing tools.
|
||||
The higher capabilities use provider-neutral implementations. `dsh-bash-local` delegates command mechanics to `ctx.subprocess`; `dsh-pty-local` delegates terminal allocation and signalling to `ctx.subprocess.spawnTerminal()`; `dsh-lsp-local` reads through `ctx.fs` and launches through `ctx.subprocess`; `dsh-code-runtime-subprocess` materializes its runner through `ctx.fs` and starts it through `ctx.subprocess`. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns those generic interfaces and consumers.
|
||||
|
||||
The providers reuse the PTY, LSP, Code Runtime, and subprocess seams without changing their model-facing consumers or the agent loop. Backend-neutral PTY text handling lives in `dsh-pty`; the LSP protocol engine accepts `processId: null` for a server in another process namespace; Code Runtime exports its output-ledger and lossless-JSON helpers for backend parity.
|
||||
The E2B owner is the sole source of sandbox identity. Its two adapters never create private sandboxes, so filesystem tools, Bash, interactive shells, language servers, and code workers share one remote cwd, process namespace, and adapter-private directory while preserving the existing capability interfaces, generic implementations, model-facing tools, and agent loop.
|
||||
|
||||
## POC boundary
|
||||
|
||||
E2B owns the mutable filesystem, command and Bash processes, PTY shell and terminal-session process groups, language-server processes and source reads, the Code Runtime launcher, controller, and worker, and adapter-private files under `.dsh-e2b`.
|
||||
E2B owns the mutable filesystem, managed command and Bash processes, terminal allocation and terminal-session process groups, language-server processes and source reads, the Code Runtime launcher, controller, and worker, and adapter-private files under `.dsh-e2b`.
|
||||
|
||||
The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority decisions, skills, subagent orchestration, PTY buffers and readiness state, LSP JSON-RPC ids/queues/protocol state, Code Runtime type stripping/output accounting/binding dispatch, and E2B SDK/network orchestration. The overlay does not upload, mount, or synchronize the host workspace; identical cwd strings name independent host and remote directories.
|
||||
|
||||
Byte-sensitive protocols use the narrowest adapter required by E2B's callback shapes. PTY consumes the SDK's byte callback directly and carries send identity across asynchronous foreground-group lookup. LSP installs a bounded remote source reader that walks no-follow directory descriptors beneath the canonical workspace. Code Runtime keeps framed stdout in a launcher process isolated from the controller and worker descriptors, and gives each controller a process group that is killed before its inherited pipes drain. Their dependency-free helpers encode protocol payloads as validated newline-delimited base64 JSON, keeping E2B's decoded command callbacks on an ASCII transport.
|
||||
The fundamental adapters carry the substrate-specific mechanics. `dsh-subprocess-e2b` consumes E2B's byte PTY callback directly, retains terminal send identity across asynchronous foreground-group lookup, and owns whole-session cleanup. `dsh-fs-e2b` performs bounded source reads through a dependency-free helper that walks no-follow directory descriptors beneath the canonical target. Generic Code Runtime keeps its controller/worker protocol on validated ASCII/base64 frames and kills the provider-owned process group before inherited pipes drain. Generic LSP uses UTF-8 JSON over command pipes; E2B's decoded callback transport is not an arbitrary binary channel.
|
||||
|
||||
Retaining a sandbox preserves remote files and unmanaged remote state only. Reconnect does not reconstruct host PTY sessions, buffers, process handles, LSP connections or requests, code workers, binding calls, timers, output cursors, or locks. Managed groups terminate and join when their provider disposes before the shared owner pauses, leaves, or kills the sandbox.
|
||||
|
||||
@@ -40,7 +36,7 @@ The POC has no session-persistence backend, template builder, volume, snapshot,
|
||||
|
||||
## Verification
|
||||
|
||||
Focused package suites pin owner lifecycle cleanup, filesystem semantics and commit metadata, subprocess process groups, configuration and verified publication rollback, byte framing and multibyte boundaries, PTY readiness/signal identity/default-environment scrubbing/terminal-session cleanup, descriptor-walked bounded LSP source reads, Code Runtime binding and descriptor isolation, worker and descendant-held pipe draining, hostile traffic, output limits, timeout/abort ordering, disposal to quiescence, and package-owned invariant registrations. Adjacent local-backend suites pin the shared PTY utilities and the LSP cross-namespace `processId` behavior.
|
||||
Focused package suites pin owner lifecycle cleanup, filesystem paths/containment/bounded descriptor reads and commit metadata, subprocess executable lookup/process groups/publication rollback, terminal byte I/O/signal identity/default-environment scrubbing/session cleanup, output limits, abort ordering, disposal to quiescence, and package-owned invariant registrations. The generic PTY, LSP, and subprocess Code Runtime suites pin their provider-neutral readiness, cross-namespace `processId`, binding bridge, descriptor isolation, hostile traffic, and worker/descendant cleanup behavior.
|
||||
|
||||
A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, process-publication rollback, bounded spill output, PTY default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports.
|
||||
|
||||
@@ -50,13 +46,13 @@ A credential-gated Loader composition creates real E2B sandboxes and exercises F
|
||||
|
||||
**Run the entire harness process inside E2B** — rejected because it changes deployment, credential flow, model transport, session durability, plugin loading, and supervision at once. Those questions are independent of proving the provider seams.
|
||||
|
||||
**Put every E2B capability in the shared owner package** — rejected because lifecycle identity is the owner's only concern. Filesystem, subprocess, PTY, LSP, and Code Runtime retain separate provider contracts, configuration, tests, and consumers; Bash continues to reuse its subprocess seam.
|
||||
**Put every E2B operation in the shared owner package** — rejected because lifecycle identity is the owner's only concern. Filesystem and subprocess retain separate provider contracts, tests, and consumers; the owner exposes one shared SDK handle without becoming a capability grab bag.
|
||||
|
||||
**Implement filesystem operations through shell commands only** — rejected because that bypasses `ctx.fs` identity, structured errors, version guards, streaming reads, and atomic mutation semantics already consumed by the file tools.
|
||||
|
||||
**Use the host PTY, LSP, and worker backends unchanged** — rejected because they use host process and filesystem APIs; sharing an absolute cwd string does not share state across machines.
|
||||
**Keep E2B-specific PTY, LSP, and Code Runtime packages** — rejected because their domain behavior does not vary with E2B. They were shallow adapters that duplicated existing consumers to replace filesystem and process operations; moving those operations behind the fundamental seams gives every provider one implementation of readiness, protocol, binding, and presentation behavior.
|
||||
|
||||
**Expose E2B Commands as one generic transport and bypass capability providers** — rejected because PTY needs byte callbacks and foreground signaling, LSP needs byte-faithful stdio plus remote source containment, and Code Runtime needs bidirectional host binding calls and hostile-peer validation. Bypassing their registries would also fork model-facing behavior.
|
||||
**Call E2B Filesystem, Commands, or PTY APIs directly from higher capabilities** — rejected because it bypasses the `ctx.fs` and `ctx.subprocess` contracts, duplicates execution-world policy in each consumer, and forks model-facing behavior. The subprocess seam includes the irreducible terminal primitive because ordinary pipes cannot supply foreground groups or whole-session cleanup.
|
||||
|
||||
**Add a generic distributed-runtime abstraction first** — rejected because the existing capability seams already carry the required contracts. A new cross-cutting interface would speculate about persistence, synchronization, and reconnect semantics beyond the POC.
|
||||
|
||||
@@ -64,6 +60,6 @@ A credential-gated Loader composition creates real E2B sandboxes and exercises F
|
||||
|
||||
## Consequences
|
||||
|
||||
The small composition demonstrates that existing capability seams can move an agent's mutable coding world off-host without changing the loop or model-facing tool packages. `sandboxId` plus pause/leave permits manual remote-file retention for experiments, while kill remains the demo's cleanup policy.
|
||||
The three-package composition demonstrates that filesystem and subprocess are the sufficient provider seams for moving an agent's mutable coding world off-host without changing the loop, higher capability implementations, or model-facing tool packages. Fixes to Bash, PTY, LSP, and Code Runtime remain provider-neutral. `sandboxId` plus pause/leave permits manual remote-file retention for experiments, while kill remains the demo's cleanup policy.
|
||||
|
||||
The providers are not interchangeable with local backends for every consumer: remote startup cannot synchronously expose a PID, E2B retains complete command output in SDK memory, ordinary command callbacks are not byte-faithful, signal attribution is partly inferred, and reconnect cannot restore handles or protocol state. PTY uses E2B's byte API; LSP and Code Runtime add validated ASCII framing where protocol bytes matter. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a managed process group or PTY session does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
|
||||
The adapters are not interchangeable with local backends for every consumer: remote startup cannot synchronously expose a PID, E2B retains complete command output in SDK memory, command callbacks are text-decoded rather than arbitrary binary streams, exact terminal stdin-wait inspection is unavailable, signal attribution is partly inferred, and reconnect cannot restore handles or protocol state. PTY uses E2B's byte API; Code Runtime uses validated ASCII/base64 framing; the exercised LSP path carries valid UTF-8 JSON. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a managed process group or terminal session does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
|
||||
@@ -12,27 +12,23 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
E2B 集成是一个选择性启用的提供方组合 POC。它的 6 个 E2B 专用包(package)位于 `packages/e2b/` 下,同时保留按 seam 区分的 npm 名称:
|
||||
E2B 集成是一个选择性启用的提供方组合 POC。它的 3 个 E2B 专用包(package)集中位于 `packages/e2b/` 下:
|
||||
|
||||
- `@deepseek-ai/dsh-e2b` 创建或重新连接一个安全的 E2B 沙箱,创建其工作目录与私有运行时目录,并拥有 kill/pause/leave 资源释放操作。
|
||||
- `@deepseek-ai/dsh-fs-e2b` 在该沙箱的 Filesystem API 之上实现 `ctx.fs`。
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` 在 E2B Commands 和远程 Linux 进程组之上实现 `ctx.subprocess`。
|
||||
- `@deepseek-ai/dsh-pty-e2b` 在 `ctx.pty` 上注册 E2B 字节 PTY 后端,并把精确的 Agent 所有权保留在现有注册表中。
|
||||
- `@deepseek-ai/dsh-lsp-e2b` 在 `ctx.lsp` 上注册已配置的远程语言服务器,通过 E2B 内有界且不跟随链接的辅助程序读取源代码,并通过 `dsh-subprocess-e2b` 运行服务器。
|
||||
- `@deepseek-ai/dsh-code-runtime-e2b` 注册 `ctx.codeRuntime`,在全新的远程 worker 中运行每个模型程序,并在宿主进程中分发绑定函数。
|
||||
- 现有的 `@deepseek-ai/dsh-bash-local` 继续作为 Bash 实现,因为它把所有进程机制委托给 `ctx.subprocess`。
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` 在 E2B Commands、字节 PTY 和远程 Linux 进程组之上实现 `ctx.subprocess`。
|
||||
|
||||
该所有者是沙箱身份的唯一真源。提供方会注入该所有者,绝不创建私有沙箱,因此文件系统工具、Bash、交互式 shell、语言服务器和代码 worker 会共享一个远程 cwd、进程命名空间和适配器私有目录,同时保留现有功能接口与面向模型的工具。
|
||||
上层功能使用提供方无关的实现。`dsh-bash-local` 把命令机制委托给 `ctx.subprocess`;`dsh-pty-local` 把终端分配与信号发送委托给 `ctx.subprocess.spawnTerminal()`;`dsh-lsp-local` 通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动;`dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化 runner,再通过 `ctx.subprocess` 启动它。这些通用接口与消费方由[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.md)负责定义。
|
||||
|
||||
这些提供方复用 PTY、LSP、Code Runtime 与进程管理 seam,不更改面向模型的消费方或 agent loop(智能体循环)。后端无关的 PTY 文本处理位于 `dsh-pty`;LSP 协议引擎允许位于另一个进程命名空间的服务器使用 `processId: null`;Code Runtime 导出输出账本与无损 JSON 辅助函数,以保持各后端一致。
|
||||
E2B 所有者是沙箱身份的唯一真源。其两个适配器绝不创建私有沙箱,因此文件系统工具、Bash、交互式 shell、语言服务器和代码 worker 会共享一个远程 cwd、进程命名空间和适配器私有目录,同时保留现有功能接口、通用实现、面向模型的工具与 agent loop(智能体循环)。
|
||||
|
||||
## POC 边界
|
||||
|
||||
E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与终端会话进程组、语言服务器进程及源码读取、Code Runtime launcher、controller 和 worker,以及 `.dsh-e2b` 下的适配器私有文件。
|
||||
E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终端会话进程组、语言服务器进程及源码读取、Code Runtime launcher、controller 和 worker,以及 `.dsh-e2b` 下的适配器私有文件。
|
||||
|
||||
宿主拥有 Cordis 与插件对象、agent loop、agent/会话/goal 状态、会话日志及持久化、LLM(大语言模型)调用、提示词与工具、权限决策、skill(技能)、subagent 编排、PTY 缓冲与就绪状态、LSP JSON-RPC id/队列/协议状态、Code Runtime 类型剥离/输出计量/绑定分发,以及 E2B SDK/网络编排。该 overlay 不会上传、挂载或同步宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。
|
||||
|
||||
对字节敏感的协议只使用适配 E2B 回调形状所需的最窄适配器。PTY 直接消费 SDK 的字节回调,并在异步查找前台进程组的过程中保留发送操作身份。LSP 会安装一个有界的远程源码读取器,通过不跟随符号链接打开的目录描述符在规范化工作区下逐级遍历。Code Runtime 则把分帧 stdout 保留在与 controller 和 worker 描述符隔离的 launcher 进程内,并为每个 controller 分配一个进程组,在 controller 继承的管道排空前终止该组。它们的无依赖辅助程序会把协议载荷编码为经过验证、以换行分隔的 base64 JSON,并通过 ASCII 传输承载 E2B 已解码的命令回调。
|
||||
基础适配器承载基底专用机制。`dsh-subprocess-e2b` 直接消费 E2B 的字节 PTY 回调,在异步查找前台进程组的过程中保留终端发送身份,并负责全会话清理。`dsh-fs-e2b` 通过无依赖辅助程序执行有界源码读取,该程序会在规范化目标下逐级遍历不跟随符号链接的目录描述符。通用 Code Runtime 通过经过验证的 ASCII/base64 帧承载 controller/worker 协议,并在继承的管道排空前终止提供方拥有的进程组。通用 LSP 通过命令管道使用 UTF-8 JSON;E2B 的已解码回调传输并非任意二进制通道。
|
||||
|
||||
保留沙箱只会保存远程文件与未受管的远程状态。重新连接不会重建宿主 PTY 会话、缓冲、进程句柄、LSP 连接或请求、代码 worker、绑定调用、定时器、输出游标或锁。受管进程组会在所属提供方 dispose(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。
|
||||
|
||||
@@ -40,7 +36,7 @@ E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与终端会话
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦包测试套件固定所有者生命周期清理、文件系统语义与提交元数据、进程管理的进程组、配置与经过验证的发布回滚、字节分帧与多字节边界、PTY 就绪状态/信号身份/默认环境清理/终端会话清理、基于描述符逐级遍历的有界 LSP 源码读取、Code Runtime 绑定与描述符隔离、worker 管道及后代进程所持管道的排空、恶意通信、输出上限、超时/中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。相邻本地后端测试套件固定共享 PTY 工具函数,以及 LSP 跨命名空间 `processId` 行为。
|
||||
聚焦包测试套件固定所有者生命周期清理、文件系统路径/containment/有界描述符读取与提交元数据、子进程可执行文件查找/进程组/发布回滚、终端字节 I/O/信号身份/默认环境清理/会话清理、输出上限、中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。通用 PTY、LSP 与子进程 Code Runtime 测试套件固定其提供方无关的就绪判定、跨命名空间 `processId`、绑定桥接、描述符隔离、恶意通信,以及 worker/后代进程清理行为。
|
||||
|
||||
凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、进程发布回滚、有界 spill 输出、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。
|
||||
|
||||
@@ -50,13 +46,13 @@ E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与终端会话
|
||||
|
||||
**在 E2B 内运行完整 harness 进程。** 不予采纳,因为这会同时改变部署、凭据流、模型传输、会话持久性、插件加载和监管方式。要证明提供方 seam,并不需要同时回答这些彼此独立的问题。
|
||||
|
||||
**把所有 E2B 功能放入共享所有者包。** 不予采纳,因为生命周期身份是该所有者唯一负责的事项。文件系统、进程管理、PTY、LSP 与 Code Runtime 各自保留独立的提供方契约、配置、测试和消费方;Bash 继续复用其进程管理 seam。
|
||||
**把所有 E2B 操作放入共享所有者包。** 不予采纳,因为生命周期身份是该所有者唯一负责的事项。文件系统与进程管理各自保留独立的提供方契约、测试和消费方;所有者只公开一个共享 SDK 句柄,不会因此包揽各类功能。
|
||||
|
||||
**仅通过 shell 命令实现文件系统操作。** 不予采纳,因为这会绕过文件工具已经使用的 `ctx.fs` 身份、结构化错误、版本防护、流式读取和原子变更语义。
|
||||
|
||||
**原样使用宿主 PTY、LSP 与 worker 后端。** 不予采纳,因为它们使用宿主的进程与文件系统 API;在不同机器上复用同一个绝对 cwd 字符串并不会共享状态。
|
||||
**保留 E2B 专用的 PTY、LSP 与 Code Runtime 包。** 不予采纳,因为它们的领域行为不会随 E2B 改变。这些浅层适配器为了替换文件系统与进程操作而重复现有消费方;把这些操作移到基础 seam 之后,可让所有提供方共享同一套就绪判定、协议、绑定与呈现行为实现。
|
||||
|
||||
**把 E2B Commands 公开为通用传输并绕过功能提供方。** 不予采纳,因为 PTY 需要字节回调和前台信号,LSP 需要字节保真的 stdio 与远程源码路径约束,Code Runtime 则需要双向宿主绑定调用与不可信对等方验证。绕过其注册表还会使面向模型的行为产生分叉。
|
||||
**从上层功能直接调用 E2B Filesystem、Commands 或 PTY API。** 不予采纳,因为这会绕过 `ctx.fs` 与 `ctx.subprocess` 契约,在每个消费方中重复执行环境策略,并使面向模型的行为产生分叉。进程管理 seam 纳入不可约简的终端原语,因为普通管道无法提供前台进程组或全会话清理。
|
||||
|
||||
**先添加通用分布式运行时抽象。** 不予采纳,因为现有功能 seam 已承载所需契约。新的跨领域接口会预先假定 POC 范围之外的持久化、同步与重连语义。
|
||||
|
||||
@@ -64,6 +60,6 @@ E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与终端会话
|
||||
|
||||
## 后果
|
||||
|
||||
这个小型组合证明,现有功能 seam 可以把 agent 的可变 coding 环境移出宿主,而无需改变循环或面向模型的工具包。`sandboxId` 与 `pause`/`leave` 允许实验手动保留远程文件,演示仍以 `kill` 作为清理策略。
|
||||
这个由 3 个包组成的组合证明,文件系统与进程管理这两个提供方 seam 足以把 agent 的可变 coding 环境移出宿主,而无需改变循环、上层功能实现或面向模型的工具包。Bash、PTY、LSP 与 Code Runtime 的修复仍与提供方无关。`sandboxId` 与 `pause`/`leave` 允许实验手动保留远程文件,演示仍以 `kill` 作为清理策略。
|
||||
|
||||
这些提供方并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,普通命令回调并非字节保真,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 API;LSP 与 Code Runtime 则在必须保真处理协议字节之处增加经过验证的 ASCII 分帧。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离受管理进程组或 PTY 会话的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
|
||||
这些适配器并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,命令回调传递的是已解码文本而非任意二进制流,无法精确检查终端 stdin 等待状态,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 API;Code Runtime 使用经过验证的 ASCII/base64 分帧;已演练的 LSP 路径承载有效的 UTF-8 JSON。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离受管理进程组或终端会话的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
|
||||
@@ -1,6 +1,6 @@
|
||||
# POC overlay: keep the advanced headless agent and model-facing tools, but
|
||||
# place its filesystem, processes, terminals, language servers, and Code Mode
|
||||
# execution in one short-lived E2B sandbox.
|
||||
# place its filesystem and process substrate in one short-lived E2B sandbox;
|
||||
# the generic Bash, PTY, LSP, and Code Runtime consumers compose above them.
|
||||
- id: base
|
||||
name: '@cordisjs/plugin-include'
|
||||
config:
|
||||
@@ -32,18 +32,25 @@
|
||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||
- id: fs-e2b
|
||||
name: '@deepseek-ai/dsh-fs-e2b'
|
||||
- id: code-runtime-e2b
|
||||
name: '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
- id: code-runtime-subprocess
|
||||
name: '@deepseek-ai/dsh-code-runtime-subprocess'
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.cwd()
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
- id: pty-e2b
|
||||
name: '@deepseek-ai/dsh-pty-e2b'
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
- id: tool-pty
|
||||
name: '@deepseek-ai/dsh-tool-pty'
|
||||
- id: lsp
|
||||
name: '@deepseek-ai/dsh-lsp'
|
||||
- id: lsp-e2b
|
||||
name: '@deepseek-ai/dsh-lsp-e2b'
|
||||
- id: lsp-local
|
||||
name: '@deepseek-ai/dsh-lsp-local'
|
||||
config:
|
||||
servers:
|
||||
typescript:
|
||||
|
||||
+9
-10
@@ -1,15 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { posix, resolve } from 'node:path'
|
||||
import { boot } from '@deepseek-ai/dsh-app-boot'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-code-runtime-subprocess'
|
||||
import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-fs-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-bash-local'
|
||||
import type {} from '@deepseek-ai/dsh-lsp-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-pty-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-lsp-local'
|
||||
import type {} from '@deepseek-ai/dsh-pty-local'
|
||||
|
||||
const configPath = process.argv[2]
|
||||
if (configPath === undefined) throw new Error('usage: bin.ts <cordis.yml>')
|
||||
@@ -24,10 +23,10 @@ const owner: Agent = {
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: ownerFiber.ctx,
|
||||
followup: () => AgentMessageId('unused'),
|
||||
steer: () => AgentMessageId('unused'),
|
||||
inject: () => AgentMessageId('unused'),
|
||||
send: () => AgentMessageId('unused'),
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
send() {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
@@ -159,7 +158,7 @@ try {
|
||||
const runRemoteCommand = remoteCommands.run.bind(sandbox.commands)
|
||||
let containmentFaultInjected = false
|
||||
remoteCommands.run = async (command, options) => {
|
||||
if (!containmentFaultInjected && command.includes('dsh-e2b-source-reader') && command.includes('swapped-parent/source.ts')) {
|
||||
if (!containmentFaultInjected && command.includes('dsh-e2b-bounded-reader') && command.includes('swapped-parent/source.ts')) {
|
||||
containmentFaultInjected = true
|
||||
await runRemoteCommand(
|
||||
`rm -rf -- ${quoteE2BShellArg(swappedParentPath)} && ln -s -- ${quoteE2BShellArg(swappedOutsidePath)} ${quoteE2BShellArg(swappedParentPath)}`,
|
||||
@@ -194,7 +193,7 @@ try {
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
lspDocumentBound = String(error).includes('over the 4000000-byte limit')
|
||||
lspDocumentBound = String(error).includes('exceeds the 4000000-byte limit')
|
||||
if (!lspDocumentBound) throw error
|
||||
}
|
||||
if (!lspDocumentBound) throw new Error('E2B LSP accepted an oversized remote source')
|
||||
|
||||
+17
-6
@@ -21,22 +21,33 @@
|
||||
- id: agents
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
- id: pty-e2b
|
||||
name: '@deepseek-ai/dsh-pty-e2b'
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
pollIntervalMs: 25
|
||||
exactProbeAfterMs: 150
|
||||
idleSilenceMs: 2000
|
||||
handoffGraceMs: 500
|
||||
timeoutMs: 5000
|
||||
disposeGraceMs: 1000
|
||||
|
||||
- id: lsp
|
||||
name: '@deepseek-ai/dsh-lsp'
|
||||
|
||||
- id: lsp-e2b
|
||||
name: '@deepseek-ai/dsh-lsp-e2b'
|
||||
- id: lsp-local
|
||||
name: '@deepseek-ai/dsh-lsp-local'
|
||||
config:
|
||||
servers:
|
||||
fixture:
|
||||
@@ -48,8 +59,8 @@
|
||||
shutdownTimeoutMs: 1000
|
||||
killGraceMs: 500
|
||||
|
||||
- id: code-runtime-e2b
|
||||
name: '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
- id: code-runtime-subprocess
|
||||
name: '@deepseek-ai/dsh-code-runtime-subprocess'
|
||||
config:
|
||||
computeMs: 500
|
||||
maxWallMs: 5000
|
||||
|
||||
@@ -43,12 +43,10 @@
|
||||
"@deepseek-ai/dsh-llm-replay": "workspace:*",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp-e2b": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-permission": "workspace:*",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:*",
|
||||
"@deepseek-ai/dsh-pty": "workspace:*",
|
||||
"@deepseek-ai/dsh-pty-e2b": "workspace:*",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:*",
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 7dc52943e488a14e0c055ac72be0b2ad6f752455
|
||||
README.zh.md: bc3dbf0b4533d1d6adf6d49b0376a3dcdd6bfdb5
|
||||
README.md: 1fd0c9c2385f3169791a28f3ef50bb42792760af
|
||||
README.zh.md: f9f7d5ae8839ea7033a349ac7a0083a97780a21c
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 层级结构
|
||||
|
||||
组在 `packages/<group>/<pkg>/` 容纳包;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 是规范的包/ctx 键映射。**
|
||||
包按组置于 `packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-worker/README.md
|
||||
README.md: 4b14c6fad5d1e491faeb54c9cb0e4403c8e2d8dd
|
||||
README.zh.md: c522917835b562cf7648d8bc78f0315b7c52d2d2
|
||||
README.md: 35196a0b4fba5cd0388246a70354308ece08b39f
|
||||
README.zh.md: 49871c38c540addd06f5d24793ae00b45e2f8bb0
|
||||
@@ -46,7 +46,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **OS processes a program spawns survive this backend's termination** — `worker.terminate()` ends only the thread; deployments needing remote process-group cleanup can select the E2B backend, whose separate limitations still apply.
|
||||
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only; deployments requiring process-tree cleanup select `dsh-code-runtime-subprocess`, whose mounted subprocess provider owns that cleanup.
|
||||
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts.
|
||||
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
|
||||
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface.
|
||||
|
||||
@@ -46,7 +46,7 @@ SDK 接口是默认/具名 `WorkerCodeRuntime` 类与 `Config`。可操作的
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **程序 spawn 的 OS 进程在该后端终止后仍会存活**:`worker.terminate()` 只结束线程;需要清理远程进程组的部署可以选择 E2B 后端,但该后端自身的限制仍然适用。
|
||||
- **程序 spawn 的 OS 进程在终止后仍会存活**:`worker.terminate()` 只结束线程;需要清理进程树的部署应选择 `dsh-code-runtime-subprocess`,由其挂载的子进程提供方负责该清理。
|
||||
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amaro/sucrase 是已经点名的直接替代品。
|
||||
- **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。
|
||||
- **程序获得一个含 5 种方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。
|
||||
|
||||
@@ -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: b25f00fb5f32643008127f0cee4f3d758018404a
|
||||
README.zh.md: fc3d6901f3cdf6c645cfde49dc46108759959aca
|
||||
README.md: ef2e5ef49056e6688630e785a61c01fa53febbb1
|
||||
README.zh.md: d76b05f9b60bc471c1793decd1def1d15615c852
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
An experimental provider-composition POC that places the mutable coding world in one E2B Linux sandbox. The shared owner is separate from capability adapters so every remote provider awaits the same sandbox identity and lifecycle.
|
||||
An experimental provider-composition POC that places one filesystem/process execution world in an E2B Linux sandbox. E2B supplies only sandbox lifecycle and the two fundamental OS adapters; provider-neutral consumers build higher capabilities above them.
|
||||
|
||||
| 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-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 |
|
||||
| [`subprocess-e2b`](subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement executable lookup, managed process groups and stdio, remote spill files, and terminal sessions over E2B Commands and PTY APIs |
|
||||
|
||||
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.
|
||||
The existing [`dsh-bash-local`](../bash/bash-local/README.md), [`dsh-pty-local`](../pty/pty-local/README.md), [`dsh-lsp-local`](../lsp/lsp-local/README.md), and [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) need no E2B-specific forks. They delegate every execution-world operation to `ctx.fs` and `ctx.subprocess`, so mounting the two E2B adapters places their mutable work in the same sandbox.
|
||||
|
||||
This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level 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; the [portable-consumer decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns the generic composition.
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是一个实验性提供方组合 POC,把可变的编码环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
|
||||
这是一个实验性提供方组合 POC,把一个文件系统/进程执行环境放进 E2B Linux 沙箱。E2B 只提供沙箱生命周期与两个基础 OS 适配器;提供方无关的消费方在其上构建更高层能力。
|
||||
|
||||
| 包(package) | ctx 键 | 职责 |
|
||||
|---|---|---|
|
||||
| [`e2b`](e2b/README.md)(`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
|
||||
| [`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` | 远程运行模型编写的程序,同时把绑定桥接到宿主 |
|
||||
| [`subprocess-e2b`](subprocess-e2b/README.md)(`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | 通过 E2B Commands 与 PTY API 实现可执行文件查找、受管进程组与 stdio、远程 spill 文件及终端会话 |
|
||||
|
||||
现有的 [`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 边界。
|
||||
现有的 [`dsh-bash-local`](../bash/bash-local/README.md)、[`dsh-pty-local`](../pty/pty-local/README.md)、[`dsh-lsp-local`](../lsp/lsp-local/README.md) 及 [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) 无需 E2B 专用 fork。它们把执行环境中的所有操作委托给 `ctx.fs` 和 `ctx.subprocess`,因此挂载这两个 E2B 适配器后,它们执行的可变操作都发生在同一个沙箱内。
|
||||
|
||||
该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、更高层协议状态或 E2B SDK 缓冲。[共享运行时决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)界定 POC 边界;[可移植消费方决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)界定通用组合。
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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: a8623f95d16b54b29e53bb9cf2c528b36f121283
|
||||
README.zh.md: 2b4a37864e3c6755a74f0d2ef6ce38aa39aae24b
|
||||
@@ -1,44 +0,0 @@
|
||||
# @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 keeps the framed host protocol in a launcher process, forks a controller process group whose stdout and stderr are bounded data pipes, and creates a fresh worker thread with an empty environment and heap limit. Model writes to native descriptors and inherited child output therefore cannot enter the frame stream; completion kills the controller group before draining its pipes and emitting the terminal frame. The worker measures active event-loop time and is destroyed after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in either managed 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
# @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 会把面向宿主的分帧协议保留在 launcher 进程内,派生一个以 stdout 和 stderr 作为有界数据管道的 controller 进程组,再创建一个具有空环境与堆上限的全新 worker 线程。因此,模型对原生描述符的写入和继承的子进程输出无法进入分帧流;运行结算会先终止 controller 进程组,再排空其管道并发出终结帧。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。
|
||||
- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
/** 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,
|
||||
encodeBoundedE2BFrame,
|
||||
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 type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
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>
|
||||
type PreparedRuntime = { node: string; runner: string }
|
||||
|
||||
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<PreparedRuntime>
|
||||
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: PreparedRuntime | undefined
|
||||
try {
|
||||
runtime = await this.awaitPreparation(request.signal)
|
||||
} catch (error: unknown) {
|
||||
// Disposal can race the awaited setup despite the synchronous precheck.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
|
||||
return this.failure({ kind: 'worker-exit', message: `E2B runtime setup failed: ${messageOf(error)}` })
|
||||
}
|
||||
if (runtime === undefined) {
|
||||
return this.failure({ kind: 'abort', message: String(request.signal?.reason) })
|
||||
}
|
||||
// Disposal can race the awaited remote setup after the pre-await check.
|
||||
/* v8 ignore start -- requires disposal between promise resolution and its awaiting continuation. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
|
||||
/* v8 ignore stop */
|
||||
return await this.execute(request, code, bindings, runtime)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
private async awaitPreparation(signal: AbortSignal | undefined): Promise<PreparedRuntime | undefined> {
|
||||
if (signal === undefined) return await this.ready
|
||||
const aborted = Promise.withResolvers<undefined>()
|
||||
const onAbort = (): void => { aborted.resolve(undefined) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return await Promise.race([this.ready, aborted.promise])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private assertPreparationActive(): void {
|
||||
if (this.disposed) throw new Error('code-runtime-e2b: runtime disposed during setup')
|
||||
}
|
||||
|
||||
private async prepare(): Promise<PreparedRuntime> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
this.assertPreparationActive()
|
||||
const runner = posix.join(this.ctx.e2b.runtimeRoot, 'code-runtime-runner.mjs')
|
||||
await sandbox.files.write([{ path: runner, data: CODE_RUNNER_SOURCE }])
|
||||
this.assertPreparationActive()
|
||||
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(runner)}`)
|
||||
this.assertPreparationActive()
|
||||
const node = await resolveE2BExecutable(sandbox, 'node')
|
||||
this.assertPreparationActive()
|
||||
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: PreparedRuntime,
|
||||
): Promise<CodeRunResult> {
|
||||
let handle: SubprocessHandle
|
||||
try {
|
||||
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: {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
|
||||
if (request.signal?.aborted === true) {
|
||||
return this.failure({ kind: 'abort', message: String(request.signal.reason) })
|
||||
}
|
||||
return this.failure({ kind: 'worker-exit', message: `E2B runtime spawn failed: ${messageOf(error)}` })
|
||||
}
|
||||
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)
|
||||
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
|
||||
this.live.delete(live)
|
||||
finishResolve()
|
||||
resolve(final)
|
||||
})
|
||||
}
|
||||
|
||||
const sendReply = (message: unknown): void => {
|
||||
if (settled) return
|
||||
let frame: string
|
||||
try {
|
||||
frame = encodeBoundedE2BFrame(message, this.config.maxFrameBytes)
|
||||
} catch (error: unknown) {
|
||||
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge failed: ${messageOf(error)}` }))
|
||||
return
|
||||
}
|
||||
stdin.write(frame, (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([
|
||||
this.ready.then(() => {}, () => {}),
|
||||
...runs.map(run => run.finished),
|
||||
])
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
|
||||
export default E2BCodeRuntime
|
||||
@@ -1,20 +0,0 @@
|
||||
/** 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 */
|
||||
@@ -1,649 +0,0 @@
|
||||
/** 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 { fork } from 'node:child_process'
|
||||
import { inspect } from 'node:util'
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const emitFrame = message => {
|
||||
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
|
||||
}
|
||||
|
||||
const parseFrame = line => JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
|
||||
|
||||
const waitForPipeDrain = stream => {
|
||||
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
|
||||
return new Promise(resolve => {
|
||||
const done = () => {
|
||||
stream.off('end', done)
|
||||
stream.off('close', done)
|
||||
stream.off('error', done)
|
||||
resolve()
|
||||
}
|
||||
stream.once('end', done)
|
||||
stream.once('close', done)
|
||||
stream.once('error', done)
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
const waitForChildExit = child => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise(resolve => { child.once('exit', resolve) })
|
||||
}
|
||||
|
||||
const killControllerGroup = child => {
|
||||
if (process.platform !== 'win32' && Number.isSafeInteger(child.pid)) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== 'object' || error.code !== 'ESRCH') throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
const jsonStringBytes = text => Buffer.byteLength(JSON.stringify(text))
|
||||
|
||||
const truncateLog = (text, available) => {
|
||||
if (available < 2) return ''
|
||||
let result = ''
|
||||
let bytes = 2
|
||||
for (const character of text) {
|
||||
const cost = jsonStringBytes(character) - 2
|
||||
if (bytes + cost > available) break
|
||||
bytes += cost
|
||||
result += character
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const runLauncher = () => {
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
let controller
|
||||
let maxOutputBytes = 0
|
||||
let logBytes = 2
|
||||
let logEntries = 0
|
||||
let settling = false
|
||||
let closed = false
|
||||
let terminal
|
||||
|
||||
const finish = message => {
|
||||
if (settling) {
|
||||
if (message.type === 'output-limit') terminal = message
|
||||
return
|
||||
}
|
||||
settling = true
|
||||
terminal = message
|
||||
const current = controller
|
||||
controller = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
const exited = waitForChildExit(current)
|
||||
killControllerGroup(current)
|
||||
await Promise.all([exited, stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
process.stderr.write('code-runtime-e2b controller cleanup error: ' + String(error) + '\n')
|
||||
}).then(() => {
|
||||
closed = true
|
||||
emitFrame(terminal)
|
||||
input.close()
|
||||
process.stdin.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
const forwardLog = text => {
|
||||
if (closed || terminal?.type === 'output-limit') return
|
||||
const separator = logEntries > 0 ? 1 : 0
|
||||
const available = maxOutputBytes - logBytes - separator
|
||||
const cost = jsonStringBytes(text)
|
||||
if (cost > available) {
|
||||
const prefix = truncateLog(text, available)
|
||||
if (prefix) {
|
||||
logBytes += jsonStringBytes(prefix) + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text: prefix })
|
||||
}
|
||||
finish({ type: 'output-limit' })
|
||||
return
|
||||
}
|
||||
logBytes += cost + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text })
|
||||
}
|
||||
|
||||
const startController = message => {
|
||||
maxOutputBytes = message.maxOutputBytes
|
||||
controller = fork(fileURLToPath(import.meta.url), [], {
|
||||
env: { DSH_CODE_RUNTIME_CONTROLLER: '1' },
|
||||
detached: process.platform !== 'win32',
|
||||
execArgv: [],
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
const current = controller
|
||||
current.stdout.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.stderr.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
forwardLog(raw.text)
|
||||
return
|
||||
}
|
||||
if (settling) return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'output-limit') {
|
||||
finish({ type: 'output-limit' })
|
||||
} else if (raw.type === 'done') {
|
||||
if (raw.error && typeof raw.error === 'object' && typeof raw.error.kind === 'string' && typeof raw.error.message === 'string') {
|
||||
finish({ type: 'done', error: { kind: raw.error.kind, message: raw.error.message } })
|
||||
} else if (raw.value === undefined || Array.isArray(raw.value)) {
|
||||
finish({ type: 'done', ...(raw.value === undefined ? {} : { value: raw.value }) })
|
||||
}
|
||||
}
|
||||
})
|
||||
current.on('error', error => {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller error: ' + error.message } })
|
||||
})
|
||||
current.on('exit', code => {
|
||||
if (!settling) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller exited with code ' + code + ' before completing' } })
|
||||
})
|
||||
current.send(message, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller boot failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
|
||||
input.on('line', line => {
|
||||
let message
|
||||
try {
|
||||
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 (!controller) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces) || !Number.isSafeInteger(message.maxOutputBytes) || message.maxOutputBytes < 4) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
startController(message)
|
||||
return
|
||||
}
|
||||
if (message && message.type === 'reply' && typeof message.id === 'number' && typeof message.ok === 'boolean') {
|
||||
controller.send(message.ok
|
||||
? { type: 'reply', id: message.id, ok: true, value: message.value }
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) }, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller reply failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (controller && !settling) killControllerGroup(controller) })
|
||||
}
|
||||
|
||||
const runController = () => {
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
const send = message => {
|
||||
if (process.send) process.send(message)
|
||||
}
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
await Promise.all([current.terminate(), stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
send({ type: 'log', text: 'code-runtime-e2b worker cleanup error: ' + String(error) + '\n' })
|
||||
}).then(() => {
|
||||
if (!process.send) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
process.send(message, () => { if (process.connected) process.disconnect() })
|
||||
})
|
||||
}
|
||||
process.on('message', message => {
|
||||
if (!worker) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces)) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: message,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
resourceLimits: { maxOldGenerationSizeMb: message.maxOldGenerationSizeMb },
|
||||
})
|
||||
worker.stdout.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stderr.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
send({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
send({ 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) })
|
||||
}
|
||||
})
|
||||
process.on('disconnect', () => { if (worker && !finished) void worker.terminate() })
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
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
|
||||
}
|
||||
} else if (process.env.DSH_CODE_RUNTIME_CONTROLLER === '1') {
|
||||
runController()
|
||||
} else {
|
||||
runLauncher()
|
||||
}
|
||||
`
|
||||
@@ -1,765 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
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 readonly waitResult: Promise<boolean> | undefined
|
||||
private settled = false
|
||||
|
||||
constructor(
|
||||
private readonly onMessage: (message: unknown, handle: FakeHandle) => void = () => {},
|
||||
options: {
|
||||
stdin?: boolean
|
||||
stdout?: boolean
|
||||
stderr?: string
|
||||
writeError?: Error
|
||||
waitError?: Error
|
||||
waitResult?: Promise<boolean>
|
||||
} = {},
|
||||
) {
|
||||
this.waitError = options.waitError
|
||||
this.waitResult = options.waitResult
|
||||
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
|
||||
if (this.waitResult !== undefined) return await this.waitResult
|
||||
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: [] }
|
||||
}
|
||||
|
||||
async function runInstalledRunner(
|
||||
code: string,
|
||||
maxOutputBytes = 2_000_000,
|
||||
): Promise<{ messages: unknown[]; stderr: string }> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-e2b-code-runner-'))
|
||||
const runner = join(directory, 'runner.mjs')
|
||||
await writeFile(runner, CODE_RUNNER_SOURCE)
|
||||
const child = spawn(process.execPath, [runner], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const decoder = new E2BFrameDecoder(4_000_000)
|
||||
const messages: unknown[] = []
|
||||
let stderr = ''
|
||||
let outputError: unknown
|
||||
child.stdout.setEncoding('ascii')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
try {
|
||||
messages.push(...decoder.push(chunk))
|
||||
} catch (error: unknown) {
|
||||
outputError = error
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
try {
|
||||
child.stdin.write(encodeE2BFrame({
|
||||
type: 'boot',
|
||||
code,
|
||||
namespaces: [],
|
||||
computeMs: 1_000,
|
||||
maxOutputBytes,
|
||||
maxOldGenerationSizeMb: 128,
|
||||
}))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('installed E2B code runner did not exit'))
|
||||
}, 5_000)
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (outputError !== undefined) throw outputError
|
||||
decoder.finish()
|
||||
return { messages, stderr }
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('E2BCodeRuntime', () => {
|
||||
it('keeps model-owned descriptors outside the host framing process', async () => {
|
||||
const forged = Buffer.from(JSON.stringify({ type: 'done' })).toString('base64') + '\\n'
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
const fs = await import('node:fs')
|
||||
const childProcess = await import('node:child_process')
|
||||
fs.writeSync(1, ${JSON.stringify(forged)})
|
||||
childProcess.spawnSync(process.execPath, ['-e', 'process.stdout.write("child-native")'], { stdio: 'inherit' })
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string; value?: unknown }>
|
||||
const terminal = records.filter(message => message.type === 'done')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminal).toEqual([{ type: 'done', value: [true] }])
|
||||
expect(records.at(-1)).toEqual(terminal[0])
|
||||
expect(records.filter(message => message.type === 'log').map(message => message.text).join(''))
|
||||
.toContain(forged + 'child-native')
|
||||
})
|
||||
|
||||
it('bounds native descriptor output before it reaches the host protocol', async () => {
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
"(await import('node:fs')).writeSync(1, 'x'.repeat(4096)); return true",
|
||||
64,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(records.at(-1)).toEqual({ type: 'output-limit' })
|
||||
expect(Buffer.byteLength(records.filter(message => message.type === 'log').map(message => message.text).join('')))
|
||||
.toBeLessThanOrEqual(62)
|
||||
})
|
||||
|
||||
it('drains native worker pipes before emitting the terminal frame', async () => {
|
||||
const expectedBytes = 1_048_576
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
let stdoutPrototype = Object.getPrototypeOf(process.stdout)
|
||||
while (stdoutPrototype && !Object.hasOwn(stdoutPrototype, 'write')) stdoutPrototype = Object.getPrototypeOf(stdoutPrototype)
|
||||
Reflect.apply(stdoutPrototype.write, process.stdout, ['x'.repeat(${expectedBytes})])
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
const terminalIndex = records.findIndex(message => message.type === 'done')
|
||||
const nativeOutput = records
|
||||
.slice(0, terminalIndex)
|
||||
.filter(message => message.type === 'log')
|
||||
.map(message => message.text ?? '')
|
||||
.join('')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminalIndex).toBe(records.length - 1)
|
||||
expect(Buffer.byteLength(nativeOutput)).toBe(expectedBytes)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('reaps descendant-held controller pipes before completion', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-e2b-code-descendant-'))
|
||||
const marker = join(directory, 'started')
|
||||
const release = join(directory, 'release')
|
||||
const childSource = `
|
||||
const fs = require('node:fs')
|
||||
fs.writeFileSync(${JSON.stringify(marker)}, 'started')
|
||||
const timer = setInterval(() => {
|
||||
if (fs.existsSync(${JSON.stringify(release)})) clearInterval(timer)
|
||||
}, 10)
|
||||
`
|
||||
let running: ReturnType<typeof runInstalledRunner> | undefined
|
||||
try {
|
||||
running = runInstalledRunner(`
|
||||
const fs = await import('node:fs')
|
||||
const childProcess = await import('node:child_process')
|
||||
childProcess.spawn(process.execPath, ['-e', ${JSON.stringify(childSource)}], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
})
|
||||
while (!fs.existsSync(${JSON.stringify(marker)})) await new Promise(resolve => setTimeout(resolve, 5))
|
||||
return true
|
||||
`)
|
||||
const deadline = Date.now() + 2_000
|
||||
for (;;) {
|
||||
try {
|
||||
await access(marker)
|
||||
break
|
||||
} catch (error: unknown) {
|
||||
if (Date.now() >= deadline) throw error
|
||||
await delay(10)
|
||||
}
|
||||
}
|
||||
const completed = await Promise.race([
|
||||
running.then(() => true),
|
||||
delay(500).then(() => false),
|
||||
])
|
||||
await writeFile(release, '')
|
||||
const { messages, stderr } = await running
|
||||
|
||||
expect(completed).toBe(true)
|
||||
expect(stderr).toBe('')
|
||||
expect(messages.at(-1)).toEqual({ type: 'done', value: [true] })
|
||||
} finally {
|
||||
await writeFile(release, '').catch(() => undefined)
|
||||
await running?.catch(() => undefined)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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('enforces the outbound frame bound on boot and binding replies', async () => {
|
||||
const oversizedBoot = new FakeHandle()
|
||||
const oversizedReply = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') {
|
||||
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'large', args: encodeWorkerJson(null) })
|
||||
}
|
||||
})
|
||||
const fixture = await setup([oversizedBoot, oversizedReply], { maxOutputBytes: 128, maxFrameBytes: 512 })
|
||||
|
||||
const bootResult = await fixture.runtime.run(request(`return ${JSON.stringify('x'.repeat(1_000))}`))
|
||||
expect(bootResult.error).toMatchObject({ kind: 'worker-exit' })
|
||||
expect(bootResult.error?.message).toContain('frame exceeded its byte limit')
|
||||
expect(oversizedBoot.writes).toHaveLength(0)
|
||||
|
||||
const replyResult = await fixture.runtime.run({
|
||||
program: 'return await bridge.large(null)',
|
||||
bindings: [{ global: 'bridge', functions: { large: async () => 'x'.repeat(1_000) } }],
|
||||
})
|
||||
expect(replyResult.error).toMatchObject({ kind: 'worker-exit' })
|
||||
expect(replyResult.error?.message).toContain('frame exceeded its byte limit')
|
||||
expect(oversizedReply.writes).toHaveLength(1)
|
||||
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())
|
||||
const disposing = fixture.fiber.dispose()
|
||||
let disposed = false
|
||||
void disposing.then(() => { disposed = true })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const disposedBeforeSetup = disposed
|
||||
gate.resolve(fixture.sandbox)
|
||||
await disposing
|
||||
expect(disposedBeforeSetup).toBe(false)
|
||||
expect((await running).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
expect(fixture.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('observes abort while runtime preparation is pending', async () => {
|
||||
const gate = Promise.withResolvers<Sandbox>()
|
||||
const fixture = await setup([], {}, {}, () => gate.promise)
|
||||
const controller = new AbortController()
|
||||
const running = fixture.runtime.run({ ...request(), signal: controller.signal })
|
||||
|
||||
controller.abort('stop during setup')
|
||||
const early = await Promise.race([
|
||||
running.then(result => ({ kind: 'result' as const, result })),
|
||||
new Promise<{ kind: 'pending' }>((resolve) => { setImmediate(() => { resolve({ kind: 'pending' }) }) }),
|
||||
])
|
||||
expect(fixture.spawn).not.toHaveBeenCalled()
|
||||
|
||||
gate.resolve(fixture.sandbox)
|
||||
expect(early).toMatchObject({ kind: 'result', result: { error: { kind: 'abort', message: 'stop during setup' } } })
|
||||
await running
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('classifies an abort that races synchronous subprocess spawn', async () => {
|
||||
const fixture = await setup()
|
||||
const controller = new AbortController()
|
||||
fixture.spawn.mockImplementationOnce(() => {
|
||||
controller.abort('stop at spawn')
|
||||
throw new Error('aborted before spawn')
|
||||
})
|
||||
|
||||
expect((await fixture.runtime.run({ ...request(), signal: controller.signal })).error)
|
||||
.toEqual({ kind: 'abort', message: 'stop at spawn' })
|
||||
|
||||
fixture.spawn.mockImplementationOnce(() => { throw new Error('synchronous spawn failure') })
|
||||
expect((await fixture.runtime.run(request())).error).toEqual({
|
||||
kind: 'worker-exit',
|
||||
message: 'E2B runtime spawn failed: synchronous spawn failure',
|
||||
})
|
||||
await fixture.fiber.dispose()
|
||||
|
||||
const disposingFixture = await setup()
|
||||
disposingFixture.spawn.mockImplementationOnce(() => {
|
||||
void (disposingFixture.runtime as unknown as { teardown(): Promise<void> }).teardown()
|
||||
throw new Error('spawn raced disposal')
|
||||
})
|
||||
expect((await disposingFixture.runtime.run(request())).error)
|
||||
.toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
await disposingFixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('closes both abort races around runtime readiness and live-run publication', async () => {
|
||||
let preparationAborted = false
|
||||
const preparationSignal = {
|
||||
get aborted() { return preparationAborted },
|
||||
reason: 'preparation race',
|
||||
addEventListener() { preparationAborted = true },
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
const liveHandle = new FakeHandle()
|
||||
const fixture = await setup([liveHandle])
|
||||
expect((await fixture.runtime.run({ ...request(), signal: preparationSignal })).error)
|
||||
.toEqual({ kind: 'abort', message: 'preparation race' })
|
||||
expect(fixture.spawn).not.toHaveBeenCalled()
|
||||
|
||||
let liveAborted = false
|
||||
let registrations = 0
|
||||
const liveSignal = {
|
||||
get aborted() { return liveAborted },
|
||||
reason: 'live publication race',
|
||||
addEventListener() {
|
||||
registrations += 1
|
||||
if (registrations === 2) liveAborted = true
|
||||
},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
expect((await fixture.runtime.run({ ...request(), signal: liveSignal })).error)
|
||||
.toEqual({ kind: 'abort', message: 'live publication race' })
|
||||
await fixture.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains a live run until remote cleanup reaches quiescence', async () => {
|
||||
const cleanup = Promise.withResolvers<boolean>()
|
||||
const handle = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done' })
|
||||
}, { waitResult: cleanup.promise })
|
||||
const fixture = await setup([handle])
|
||||
const running = fixture.runtime.run(request())
|
||||
await vi.waitFor(() => { expect(handle.waitCalls).toBe(1) })
|
||||
|
||||
const disposing = fixture.fiber.dispose()
|
||||
let disposed = false
|
||||
void disposing.then(() => { disposed = true })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const disposedBeforeCleanup = disposed
|
||||
|
||||
cleanup.resolve(true)
|
||||
await expect(running).resolves.toEqual({ logs: [] })
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
expect(disposedBeforeCleanup).toBe(false)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -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/e2b/README.md
|
||||
README.md: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
|
||||
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115
|
||||
README.md: 00264b8f0b03e4af8512025322fe3e457e7b6b9b
|
||||
README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared lifecycle owner for one E2B sandbox. Capability adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`; the [family map](../README.md) lists the opt-in adapters.
|
||||
Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`; the [family map](../README.md) lists the opt-in composition.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个 E2B 沙箱的共享生命周期所有者。功能适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`;可选适配器见[包族索引](../README.md)。
|
||||
一个 E2B 沙箱的共享生命周期所有者。文件系统与进程管理适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`;可选组合见[包族索引](../README.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/** ASCII/base64 JSON framing for byte-faithful protocols over E2B text callbacks. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
const BASE64_LINE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
/**
|
||||
* Encode one JSON-compatible value as a newline-delimited ASCII frame.
|
||||
* @param value - Value accepted by `JSON.stringify`.
|
||||
* @returns Base64-encoded UTF-8 JSON followed by one newline.
|
||||
*/
|
||||
export function encodeE2BFrame(value: unknown): string {
|
||||
return encodeFrame(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one JSON-compatible value while enforcing the decoded frame bound.
|
||||
* @param value - Value accepted by `JSON.stringify`.
|
||||
* @param maxFrameBytes - Maximum UTF-8 JSON bytes in the encoded frame.
|
||||
* @returns Base64-encoded UTF-8 JSON followed by one newline.
|
||||
*/
|
||||
export function encodeBoundedE2BFrame(value: unknown, maxFrameBytes: number): string {
|
||||
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
||||
throw new Error('E2B frame maxFrameBytes must be a positive safe integer')
|
||||
}
|
||||
return encodeFrame(value, maxFrameBytes)
|
||||
}
|
||||
|
||||
function encodeFrame(value: unknown, maxFrameBytes?: number): string {
|
||||
const json: unknown = JSON.stringify(value)
|
||||
if (typeof json !== 'string') throw new Error('E2B frame value is not JSON-serializable')
|
||||
const bytes = Buffer.from(json)
|
||||
if (maxFrameBytes !== undefined && bytes.length > maxFrameBytes) {
|
||||
throw new Error('E2B frame exceeded its byte limit')
|
||||
}
|
||||
return `${bytes.toString('base64')}\n`
|
||||
}
|
||||
|
||||
/** Incremental decoder for newline-delimited base64 JSON frames. */
|
||||
export class E2BFrameDecoder {
|
||||
private pending = ''
|
||||
private readonly maxEncodedChars: number
|
||||
|
||||
/** @param maxFrameBytes - Maximum decoded UTF-8 JSON bytes in one frame. */
|
||||
constructor(private readonly maxFrameBytes: number) {
|
||||
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
||||
throw new Error('E2B frame maxFrameBytes must be a positive safe integer')
|
||||
}
|
||||
this.maxEncodedChars = Math.ceil(maxFrameBytes / 3) * 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one E2B callback chunk.
|
||||
* @param chunk - ASCII text received from the remote helper.
|
||||
* @returns Every complete decoded JSON value, in order.
|
||||
*/
|
||||
push(chunk: string): unknown[] {
|
||||
if (/[^\x0a\x20-\x7e]/.test(chunk)) throw new Error('E2B frame stream contained non-ASCII data')
|
||||
this.pending += chunk
|
||||
const values: unknown[] = []
|
||||
for (;;) {
|
||||
const newline = this.pending.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
if (this.pending.length > this.maxEncodedChars) throw new Error('E2B frame exceeded its byte limit')
|
||||
return values
|
||||
}
|
||||
const line = this.pending.slice(0, newline)
|
||||
this.pending = this.pending.slice(newline + 1)
|
||||
values.push(this.decode(line))
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a truncated final frame. */
|
||||
finish(): void {
|
||||
if (this.pending.length !== 0) throw new Error('E2B frame stream ended mid-frame')
|
||||
}
|
||||
|
||||
private decode(line: string): unknown {
|
||||
if (line.length === 0 || line.length > this.maxEncodedChars || !BASE64_LINE.test(line)) {
|
||||
throw new Error('E2B frame contained invalid base64 or exceeded its byte limit')
|
||||
}
|
||||
const bytes = Buffer.from(line, 'base64')
|
||||
if (bytes.length > this.maxFrameBytes) throw new Error('E2B frame exceeded its byte limit')
|
||||
let json: string
|
||||
try {
|
||||
json = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
} catch (error: unknown) {
|
||||
throw new Error('E2B frame contained invalid UTF-8', { cause: error })
|
||||
}
|
||||
try {
|
||||
return JSON.parse(json) as unknown
|
||||
} catch (error: unknown) {
|
||||
throw new Error('E2B frame contained invalid JSON', { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import z from 'schemastery'
|
||||
import { Sandbox } from 'e2b'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
export { E2BFrameDecoder, encodeBoundedE2BFrame, encodeE2BFrame } from './frame.ts'
|
||||
|
||||
export {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
@@ -44,26 +42,6 @@ export function quoteE2BShellArg(value: string): string {
|
||||
return `'${value.replaceAll('\'', "'\"'\"'")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one executable inside an E2B sandbox and require an absolute result.
|
||||
* @param sandbox - Sandbox whose PATH and filesystem own the executable.
|
||||
* @param command - Absolute path or bare executable name.
|
||||
* @returns Verified absolute remote executable path.
|
||||
*/
|
||||
export async function resolveE2BExecutable(sandbox: Sandbox, command: string): Promise<string> {
|
||||
if (command.length === 0) throw new Error('E2B executable name must be non-empty')
|
||||
if (posix.isAbsolute(command)) {
|
||||
await sandbox.commands.run(`test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`)
|
||||
return command
|
||||
}
|
||||
const result = await sandbox.commands.run(`command -v -- ${quoteE2BShellArg(command)}`)
|
||||
const executable = result.stdout.trim()
|
||||
if (!posix.isAbsolute(executable) || executable.includes('\n')) {
|
||||
throw new Error(`E2B executable ${JSON.stringify(command)} did not resolve to one absolute path`)
|
||||
}
|
||||
return executable
|
||||
}
|
||||
|
||||
/** Action taken on the owned sandbox when the Cordis service is disposed. */
|
||||
export type E2BDisposeMode = 'kill' | 'pause' | 'leave'
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
|
||||
import { E2BPtyBackend } from '@deepseek-ai/dsh-pty-e2b'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
|
||||
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
|
||||
const binScript = join(fixtureRoot, 'bin.ts')
|
||||
@@ -29,7 +29,17 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
})
|
||||
try {
|
||||
const ctx = new Context()
|
||||
ctx.provide('e2b', { cwd: '/home/user', getSandbox: async () => sandbox } as never)
|
||||
ctx.provide('e2b', {
|
||||
cwd: '/home/user',
|
||||
runtimeRoot: '/home/user/.dsh-e2b',
|
||||
getSandbox: async () => sandbox,
|
||||
} as never)
|
||||
ctx.provide('sandboxPolicy', {
|
||||
defaultMode: 'danger-full-access',
|
||||
workspaceRoot: '/home/user',
|
||||
} as never)
|
||||
const ptyFiber = await ctx.plugin(PtyService)
|
||||
const subprocessFiber = await ctx.plugin(E2BSubprocessService)
|
||||
const ownerId = SessionId('e2b-pty-env-owner')
|
||||
const owner: Agent = {
|
||||
id: ownerId,
|
||||
@@ -38,17 +48,19 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx,
|
||||
followup: () => AgentMessageId('unused'),
|
||||
steer: () => AgentMessageId('unused'),
|
||||
inject: () => AgentMessageId('unused'),
|
||||
send: () => AgentMessageId('unused'),
|
||||
followup() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
send() {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const backend = new E2BPtyBackend(ctx, {
|
||||
backendType: 'shell', rows: 24, cols: 80,
|
||||
const backend = new LocalPtyBackend(ctx, {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
|
||||
rows: 24, cols: 80,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
|
||||
pollIntervalMs: 25, idleSilenceMs: 1_000, timeoutMs: 5_000, disposeGraceMs: 1_000,
|
||||
pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
|
||||
handoffGraceMs: 500, timeoutMs: 5_000, disposeGraceMs: 1_000,
|
||||
})
|
||||
const session = await backend.spawn({ sessionId: PtySessionId('env'), owner, type: 'shell' })
|
||||
const result = await session.startSend({
|
||||
@@ -59,6 +71,8 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
expect(result.viewport).not.toContain('sentinel-secret')
|
||||
expect(result.viewport).not.toContain('sentinel-stale')
|
||||
await session.close('environment test complete')
|
||||
await subprocessFiber.dispose()
|
||||
await ptyFiber.dispose()
|
||||
} finally {
|
||||
await sandbox.kill().catch(() => false)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Sandbox as SandboxType } from 'e2b'
|
||||
import E2BSandboxService, {
|
||||
E2BFrameDecoder,
|
||||
E2BSandboxId,
|
||||
encodeBoundedE2BFrame,
|
||||
encodeE2BFrame,
|
||||
quoteE2BShellArg,
|
||||
resolveE2BExecutable,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import * as E2BInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -229,57 +225,6 @@ describe('E2B helpers and invariant companion', () => {
|
||||
expect(quoteE2BShellArg("a'b $HOME")).toBe("'a'\"'\"'b $HOME'")
|
||||
})
|
||||
|
||||
it('resolves absolute and PATH executables inside the sandbox', async () => {
|
||||
const run = vi.fn()
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' })
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' })
|
||||
const sandbox = { commands: { run } } as unknown as SandboxType
|
||||
await expect(resolveE2BExecutable(sandbox, '/bin/bash')).resolves.toBe('/bin/bash')
|
||||
await expect(resolveE2BExecutable(sandbox, 'node')).resolves.toBe('/usr/bin/node')
|
||||
expect(run).toHaveBeenNthCalledWith(1, "test -f '/bin/bash' -a -x '/bin/bash'")
|
||||
expect(run).toHaveBeenNthCalledWith(2, "command -v -- 'node'")
|
||||
})
|
||||
|
||||
it('rejects empty or non-absolute executable resolutions', async () => {
|
||||
const sandbox = {
|
||||
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'relative\npath\n', stderr: '' }) },
|
||||
} as unknown as SandboxType
|
||||
await expect(resolveE2BExecutable(sandbox, '')).rejects.toThrow('non-empty')
|
||||
await expect(resolveE2BExecutable(sandbox, 'tool')).rejects.toThrow('did not resolve')
|
||||
})
|
||||
|
||||
it('round-trips split and adjacent ASCII/base64 JSON frames', () => {
|
||||
const decoder = new E2BFrameDecoder(128)
|
||||
const encoded = encodeE2BFrame({ text: '你好' }) + encodeE2BFrame([1, true])
|
||||
expect(decoder.push(encoded.slice(0, 5))).toEqual([])
|
||||
expect(decoder.push(encoded.slice(5))).toEqual([{ text: '你好' }, [1, true]])
|
||||
expect(() => { decoder.finish() }).not.toThrow()
|
||||
expect(() => encodeE2BFrame(undefined)).toThrow('not JSON-serializable')
|
||||
})
|
||||
|
||||
it('bounds outbound frames by decoded UTF-8 bytes', () => {
|
||||
const exact = encodeBoundedE2BFrame({ text: '你' }, 14)
|
||||
expect(new E2BFrameDecoder(14).push(exact)).toEqual([{ text: '你' }])
|
||||
expect(() => encodeBoundedE2BFrame({ text: '你' }, 13)).toThrow('byte limit')
|
||||
expect(() => encodeBoundedE2BFrame(null, 0)).toThrow('positive safe integer')
|
||||
expect(() => encodeBoundedE2BFrame(null, 1.5)).toThrow('positive safe integer')
|
||||
})
|
||||
|
||||
it('rejects malformed, oversized, and truncated frame streams', () => {
|
||||
expect(() => new E2BFrameDecoder(0)).toThrow('positive safe integer')
|
||||
expect(() => new E2BFrameDecoder(1.5)).toThrow('positive safe integer')
|
||||
expect(() => new E2BFrameDecoder(4).push('é')).toThrow('non-ASCII')
|
||||
expect(() => new E2BFrameDecoder(3).push('AAAAA')).toThrow('byte limit')
|
||||
expect(() => new E2BFrameDecoder(8).push('\n')).toThrow('invalid base64')
|
||||
expect(() => new E2BFrameDecoder(8).push('abc!\n')).toThrow('invalid base64')
|
||||
expect(() => new E2BFrameDecoder(2).push(`${Buffer.from('abc').toString('base64')}\n`)).toThrow('byte limit')
|
||||
expect(() => new E2BFrameDecoder(8).push('/w==\n')).toThrow('invalid UTF-8')
|
||||
expect(() => new E2BFrameDecoder(16).push(`${Buffer.from('not-json').toString('base64')}\n`)).toThrow('invalid JSON')
|
||||
const truncated = new E2BFrameDecoder(8)
|
||||
truncated.push('YQ==')
|
||||
expect(() => { truncated.finish() }).toThrow('mid-frame')
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
@@ -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/fs-e2b/README.md
|
||||
README.md: a505703fc764f8441fa54d2b1d922762eb3fafdf
|
||||
README.zh.md: 626fdb52979d29f12d1bc1b67b0ea13730003af7
|
||||
README.md: 86ad8720d3e4c7ce70ee0ac8c41713af03ed297a
|
||||
README.zh.md: 90dff57a5b9fe786aea3315633125536277ecc20
|
||||
@@ -7,7 +7,9 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
## 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.
|
||||
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
|
||||
- **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.
|
||||
- **Stable bounded reads** — a dependency-free Node helper walks directory descriptors with no-follow opens and reads one held regular-file descriptor through the byte cap. Generic LSP queries therefore reject parent swaps, non-files, invalid UTF-8, and growth past the configured document limit before server startup.
|
||||
- **Atomic mutations** — writes upload a mode-`0600` temporary sibling, preserve an existing file's POSIX mode, and publish through E2B's same-directory atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. 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.
|
||||
|
||||
@@ -26,4 +28,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **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.
|
||||
- **Custom templates must support the used Linux, Node, procfs, and envd features** — `realpath`, `chmod`, `mv`, same-filesystem POSIX rename, streaming reads, file metadata extended attributes, `/proc/self/fd`, and no-follow descriptor opens are required; unsupported templates fail rather than degrade silently.
|
||||
@@ -7,7 +7,9 @@
|
||||
## 行为
|
||||
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;`realpath -m` 提供规范化目标身份,且不要求最终文件存在。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **稳定的有界读取**:一个零依赖 Node 辅助程序会以不跟随链接的方式逐级打开目录描述符,并通过一个持续持有的常规文件描述符读取至字节上限。因此,通用 LSP 查询会在服务器启动前拒绝父目录交换、非文件、无效 UTF-8,以及增长后超出所配置文档上限的文件。
|
||||
- **原子变更**:写入会上传 mode 为 `0600` 的同级临时文件,保留现有文件的 POSIX mode,并通过 E2B 的同目录原子重命名发布。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在 SDK 请求边界上采用尽力而为语义;成功 rename 是提交点。
|
||||
|
||||
@@ -26,4 +28,4 @@
|
||||
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令、模板或外部进程填充它;本地文件既不会上传,也不会同步回本地。
|
||||
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
||||
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
|
||||
- **自定义模板必须支持所用的 Linux 与 envd 功能**:必须支持 `realpath`、`chmod`、`mv`、同一文件系统内的 POSIX rename、流式读取和文件元数据扩展属性;不支持的模板会失败,而不会静默降级。
|
||||
- **自定义模板必须支持所用的 Linux、Node、procfs 与 envd 功能**:必须支持 `realpath`、`chmod`、`mv`、同一文件系统内的 POSIX rename、流式读取、文件元数据扩展属性、`/proc/self/fd` 和不跟随链接的描述符打开操作;不支持的模板会失败,而不会静默降级。
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { posix } from 'node:path'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
@@ -24,10 +25,18 @@ import {
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { BOUNDED_READER_SOURCE } from './source-reader.ts'
|
||||
|
||||
const VERSION_METADATA_KEY = 'dsh-version'
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
type BoundedReadResponse =
|
||||
| { kind: 'ok'; data: string }
|
||||
| { kind: 'not-file' }
|
||||
| { kind: 'oversize'; size: number }
|
||||
| { kind: 'grew' }
|
||||
| { kind: 'open-error'; message: string }
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
|
||||
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
@@ -141,6 +150,21 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
override processPath(target: FsTarget): string {
|
||||
return String(target.targetKey)
|
||||
}
|
||||
|
||||
override fileUrl(target: FsTarget): string {
|
||||
const path = this.processPath(target)
|
||||
if (!posix.isAbsolute(path)) throw new Error(`fs-e2b: expected an absolute process path: ${JSON.stringify(path)}`)
|
||||
return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
|
||||
}
|
||||
|
||||
override contains(parent: FsTarget, child: FsTarget): boolean {
|
||||
const relative = posix.relative(this.processPath(parent), this.processPath(child))
|
||||
return relative === '' || (relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative))
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
assertNotAborted(signal, 'stat')
|
||||
const entry = await this.probe(String(target.targetKey), target.displayPath, signal)
|
||||
@@ -184,6 +208,69 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded read maxBytes must be a positive safe integer')
|
||||
}
|
||||
assertNotAborted(signal, 'read')
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
try {
|
||||
const node = await sandbox.commands.run('command -v -- node', signalOpts(signal))
|
||||
const executable = node.stdout.trim()
|
||||
if (!posix.isAbsolute(executable) || executable.includes('\n')) {
|
||||
throw new Error('fs-e2b: bounded reader requires one absolute Node executable')
|
||||
}
|
||||
const command = [
|
||||
quoteE2BShellArg(executable),
|
||||
'--input-type=commonjs',
|
||||
'-e',
|
||||
quoteE2BShellArg(BOUNDED_READER_SOURCE),
|
||||
quoteE2BShellArg(this.processPath(target)),
|
||||
String(maxBytes),
|
||||
].join(' ')
|
||||
const result = await sandbox.commands.run(command, signalOpts(signal))
|
||||
assertNotAborted(signal, 'read')
|
||||
const response = this.parseBoundedRead(result.stdout, target)
|
||||
if (response.kind === 'not-file') {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
if (response.kind === 'oversize' && Number.isSafeInteger(response.size)) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": ${response.size} bytes exceeds the ${maxBytes}-byte limit`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind === 'grew') {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind === 'open-error' && typeof response.message === 'string') {
|
||||
if (/ENOENT|no such file/i.test(response.message)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (/EACCES|EPERM|permission denied|operation not permitted/i.test(response.message)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED')
|
||||
}
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}" safely: ${response.message}`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind !== 'ok' || typeof response.data !== 'string') {
|
||||
throw new FsError(`cannot read "${target.displayPath}": bounded reader returned an invalid response`, 'FS_IO_ERROR')
|
||||
}
|
||||
const bytes = Buffer.from(response.data, 'base64')
|
||||
if (bytes.toString('base64') !== response.data || bytes.length > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": bounded reader returned invalid bytes`, 'FS_IO_ERROR')
|
||||
}
|
||||
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)
|
||||
@@ -336,6 +423,18 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
private parseBoundedRead(stdout: string, target: FsTarget): BoundedReadResponse {
|
||||
try {
|
||||
return JSON.parse(stdout) as BoundedReadResponse
|
||||
} catch (error: unknown) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": bounded reader returned invalid JSON`,
|
||||
'FS_IO_ERROR',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<EntryInfo | undefined> {
|
||||
assertNotAborted(signal, 'stat')
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Dependency-free stable-handle bounded reader installed inside E2B. */
|
||||
export const BOUNDED_READER_SOURCE = String.raw`
|
||||
/* dsh-e2b-bounded-reader */
|
||||
const fs = require('node:fs')
|
||||
const target = process.argv[1]
|
||||
const maxBytes = Number(process.argv[2])
|
||||
const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
||||
const fileFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
||||
let directory
|
||||
let descriptor
|
||||
let response
|
||||
|
||||
const openChild = (parent, component, flags) => fs.openSync('/proc/self/fd/' + parent + '/' + component, flags)
|
||||
const invalidComponent = component => component === '' || component === '.' || component === '..'
|
||||
|
||||
try {
|
||||
if (typeof target !== 'string' || !target.startsWith('/') || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded reader requires an absolute target and positive byte limit')
|
||||
}
|
||||
const components = target === '/' ? [] : target.slice(1).split('/')
|
||||
if (components.length === 0 || components.some(invalidComponent)) {
|
||||
throw new Error('bounded reader received a non-canonical file path')
|
||||
}
|
||||
|
||||
directory = fs.openSync('/', directoryFlags)
|
||||
for (const component of components.slice(0, -1)) {
|
||||
const child = openChild(directory, component, directoryFlags)
|
||||
fs.closeSync(directory)
|
||||
directory = child
|
||||
}
|
||||
descriptor = openChild(directory, components.at(-1), fileFlags)
|
||||
|
||||
const info = fs.fstatSync(descriptor)
|
||||
if (!info.isFile()) response = { kind: 'not-file' }
|
||||
else if (info.size > maxBytes) response = { kind: 'oversize', size: info.size }
|
||||
else {
|
||||
const chunks = []
|
||||
let total = 0
|
||||
while (total <= maxBytes) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(65536, maxBytes - total + 1))
|
||||
const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)
|
||||
if (bytesRead === 0) break
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
total += bytesRead
|
||||
}
|
||||
response = total > maxBytes
|
||||
? { kind: 'grew' }
|
||||
: { kind: 'ok', data: Buffer.concat(chunks, total).toString('base64') }
|
||||
}
|
||||
} catch (error) {
|
||||
response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
for (const openDescriptor of [descriptor, directory]) {
|
||||
if (openDescriptor === undefined) continue
|
||||
try {
|
||||
fs.closeSync(openDescriptor)
|
||||
} catch (error) {
|
||||
response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(response))
|
||||
`
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { dirname, posix } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
type Sandbox,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { FsTargetKey, 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'
|
||||
@@ -46,6 +47,10 @@ class FakeRemote {
|
||||
nextReadError: unknown
|
||||
nextRenameError: unknown
|
||||
nextRemoveError: unknown
|
||||
boundedOutput: string | undefined
|
||||
boundedError: unknown
|
||||
nodeExecutable = '/usr/bin/node\n'
|
||||
abortAfterBoundedCommand: AbortController | undefined
|
||||
abortAfterRename: AbortController | undefined
|
||||
disappearOnInfo = new Set<string>()
|
||||
private clock = 1
|
||||
@@ -221,6 +226,14 @@ class FakeRemote {
|
||||
const node = this.nodes.get(input)
|
||||
return { exitCode: 0, stdout: `${node?.symlinkTarget ?? input}\n`, stderr: '' }
|
||||
}
|
||||
if (command === 'command -v -- node') {
|
||||
return { exitCode: 0, stdout: this.nodeExecutable, stderr: '' }
|
||||
}
|
||||
if (command.includes('dsh-e2b-bounded-reader')) {
|
||||
if (this.boundedError !== undefined) throw this.boundedError
|
||||
this.abortAfterBoundedCommand?.abort('after bounded read')
|
||||
return { exitCode: 0, stdout: this.boundedOutput ?? '{"kind":"ok","data":""}', 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)
|
||||
@@ -289,6 +302,26 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
expect(listed.some(entry => entry.name === 'nested.txt')).toBe(false)
|
||||
})
|
||||
|
||||
it('projects canonical process paths, file URLs, and containment', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.dir('/workspace/nested')
|
||||
remote.file('/workspace/nested/multibyte # file.ts', 'text')
|
||||
remote.file('/outside.ts', 'outside')
|
||||
const { fs } = await setup(remote)
|
||||
const workspace = await fs.resolve('/workspace')
|
||||
const nested = await fs.resolve('/workspace/nested/multibyte # file.ts')
|
||||
const outside = await fs.resolve('/outside.ts')
|
||||
|
||||
expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts')
|
||||
expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts')
|
||||
expect(fs.contains(workspace, workspace)).toBe(true)
|
||||
expect(fs.contains(workspace, nested)).toBe(true)
|
||||
expect(fs.contains(nested, workspace)).toBe(false)
|
||||
expect(fs.contains(workspace, outside)).toBe(false)
|
||||
expect(() => fs.fileUrl({ targetKey: FsTargetKey('relative'), displayPath: 'relative' }))
|
||||
.toThrow('expected an absolute process path')
|
||||
})
|
||||
|
||||
it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/text.txt', 'A€B')
|
||||
@@ -373,6 +406,78 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('performs stable bounded reads through the remote no-follow reader', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('hello 你好').toString('base64') })
|
||||
await expect(fs.readTextBounded(target, 64)).resolves.toBe('hello 你好')
|
||||
expect(remote.commands.some(command => command.includes('dsh-e2b-bounded-reader'))).toBe(true)
|
||||
|
||||
await expect(fs.readTextBounded(target, 0)).rejects.toThrow('positive safe integer')
|
||||
await expect(fs.readTextBounded(target, 1.5)).rejects.toThrow('positive safe integer')
|
||||
await expect(fs.readTextBounded(target, 64, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('maps bounded-reader file, size, and open failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[{ kind: 'not-file' }, 'FS_NOT_REGULAR_FILE'],
|
||||
[{ kind: 'oversize', size: 65 }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'grew' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'open-error', message: 'ENOENT: no such file' }, 'FS_NOT_FOUND'],
|
||||
[{ kind: 'open-error', message: 'EACCES: permission denied' }, 'FS_PERMISSION_DENIED'],
|
||||
[{ kind: 'open-error', message: 'ELOOP: symbolic link' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'oversize', size: 'large' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'open-error', message: 7 }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'unknown' }, 'FS_IO_ERROR'],
|
||||
]
|
||||
for (const [response, code] of cases) {
|
||||
remote.boundedOutput = JSON.stringify(response)
|
||||
await expectCode(fs.readTextBounded(target, 64), code)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed bounded-reader transports and bytes', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
|
||||
remote.boundedOutput = 'not-json'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: '!!!!' })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('12345').toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0]).toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0xff]).toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT')
|
||||
|
||||
remote.nodeExecutable = 'node\n'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.nodeExecutable = '/usr/bin/node\n/other\n'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.nodeExecutable = '/usr/bin/node\n'
|
||||
remote.boundedError = new Error('reader transport failed')
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
})
|
||||
|
||||
it('does not turn a post-read abort into successful source text', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const controller = new AbortController()
|
||||
remote.abortAfterBoundedCommand = controller
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('text').toString('base64') })
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.readTextBounded(await fs.resolve('a'), 4, controller.signal), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('rejects empty paths and directory-listing type errors', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file', 'x')
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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: d1b1731cd7b65577a18814a90363acdc66d7d262
|
||||
README.zh.md: 4aa2cc8d661e61e80ef61693422a7b6f73c2cb67
|
||||
@@ -1,55 +0,0 @@
|
||||
# @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. Before launch, the backend enumerates sandbox-default environment names, blanks `DSH_*` and credential-shaped names, then overlays its controlled terminal values and explicit `spec.env` entries.
|
||||
|
||||
## 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; cancellation rechecks the originating send after lookup so a settled operation cannot signal or fail its successor, and `SIGKILL` refuses to target the shell itself. The backend records the terminal's POSIX session id at startup. Close sends `SIGTERM` to every process group still in that session, escalates survivors to `SIGKILL`, verifies that the session is empty, 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.
|
||||
- **Deliberate session escape is unmanaged** — a process that calls `setsid` leaves the terminal session and is outside this backend's cleanup identity.
|
||||
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.
|
||||
@@ -1,55 +0,0 @@
|
||||
# @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` 为基准解析;绝对远程路径保持不变。启动前,后端会枚举沙箱默认环境变量名,清空 `DSH_*` 和形似凭据的名称,再覆盖其受控终端值与显式 `spec.env` 条目。
|
||||
|
||||
## 运行时契约
|
||||
|
||||
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
|
||||
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;取消处理会在查找后重新检查原发送操作是否仍为当前操作,以免已结算的操作向后继操作发送信号或令其失败;发送 `SIGKILL` 时拒绝以 shell 本身为目标。后端会在启动时记录终端的 POSIX 会话 id。关闭操作会向该会话内仍存在的每个进程组发送 `SIGTERM`,对存活者升级为 `SIGKILL`,验证会话已经清空,并且直到 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**:无效字节序列会使会话失败,而不是返回有损文本。
|
||||
- **主动逃离会话的进程不受管理**:调用 `setsid` 的进程会离开终端会话,因而不属于本后端的清理身份。
|
||||
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/** 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 */
|
||||
@@ -1,130 +0,0 @@
|
||||
/** 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']
|
||||
|
||||
const SENSITIVE_ENV_NAME = /KEY|SECRET|TOKEN/i
|
||||
|
||||
async function terminalEnvironment(
|
||||
sandbox: Sandbox,
|
||||
spec: PtyBackendSpawnSpec,
|
||||
): Promise<Record<string, string>> {
|
||||
const discovered = await sandbox.commands.run(
|
||||
'env -0 | cut -z -d= -f1',
|
||||
spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
)
|
||||
spec.signal?.throwIfAborted()
|
||||
const scrubbed = Object.fromEntries(discovered.stdout.split('\0')
|
||||
.filter(name => name.startsWith('DSH_') || SENSITIVE_ENV_NAME.test(name))
|
||||
.map(name => [name, '']))
|
||||
return {
|
||||
...scrubbed,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTerminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<number> {
|
||||
const result = await sandbox.commands.run(
|
||||
`ps -o sid= -p ${pid}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const raw = result.stdout.trim()
|
||||
const sessionId = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(sessionId)) {
|
||||
throw new Error(`pty-e2b: cannot resolve process session for E2B PTY ${pid}`)
|
||||
}
|
||||
return 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: await terminalEnvironment(sandbox, 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}`)
|
||||
}
|
||||
let terminalSessionId: number
|
||||
try {
|
||||
terminalSessionId = await resolveTerminalSessionId(sandbox, handle.pid, spec.signal)
|
||||
} catch (error: unknown) {
|
||||
await handle.kill().catch(() => false)
|
||||
await Promise.allSettled([handle.wait()])
|
||||
throw error
|
||||
}
|
||||
const session = new E2BPtySession(sandbox, handle, terminalSessionId, this.config)
|
||||
created.session = session
|
||||
try {
|
||||
const initializing = session.initialize(spec.signal)
|
||||
for (const data of pending) session.onData(data)
|
||||
await initializing
|
||||
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))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/** 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 */
|
||||
@@ -1,415 +0,0 @@
|
||||
/** 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 terminalSessionId: number,
|
||||
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()
|
||||
return await this.deliverSignal(signal, pgid)
|
||||
}
|
||||
|
||||
private async deliverSignal(signal: PtySignal, pgid: number): Promise<PtySignalResult> {
|
||||
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.interruptActive(operation).catch((error: unknown) => {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async interruptActive(operation: E2BSendOperation): Promise<void> {
|
||||
const pgid = await this.foregroundPgid()
|
||||
if (this.active !== operation) return
|
||||
await this.deliverSignal('SIGINT', pgid)
|
||||
}
|
||||
|
||||
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 async sessionProcessGroups(): Promise<number[]> {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${this.terminalSessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const pgid = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid) || pgid <= 1) {
|
||||
throw new Error(`pty-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.terminalSessionId}`)
|
||||
}
|
||||
groups.add(pgid)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
private async signalProcessGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(pgid => `-${pgid}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitSessionEmpty(timeoutMs: number, signal?: 'KILL'): Promise<number[]> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
const groups = await this.sessionProcessGroups()
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (signal !== undefined) await this.signalProcessGroups(groups, signal)
|
||||
await delay(Math.min(this.config.pollIntervalMs, deadline - Date.now()))
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let survivingGroups = await this.sessionProcessGroups()
|
||||
if (survivingGroups.length > 0) {
|
||||
this.closeSignal = 'SIGTERM'
|
||||
await this.signalProcessGroups(survivingGroups, 'TERM')
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs)
|
||||
}
|
||||
if (survivingGroups.length > 0 || !this.remoteExited) {
|
||||
this.closeSignal = 'SIGKILL'
|
||||
if (!this.remoteExited) await this.sandbox.pty.kill(this.pid)
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs, 'KILL')
|
||||
if (!this.remoteExited) await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (survivingGroups.length > 0) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving process groups: ${survivingGroups.join(', ')}`)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
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 run = vi.fn(async (command: string) => command.startsWith('env -0')
|
||||
? { exitCode: 0, stdout: 'NPM_TOKEN\0DSH_STALE\0KEEP\0', stderr: '' }
|
||||
: { exitCode: 0, stdout: '123\n', stderr: '' })
|
||||
const sandbox = { commands: { run } } as unknown 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'))
|
||||
void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
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('banner\ndsh> ')
|
||||
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
|
||||
expect(options?.envs).toMatchObject({
|
||||
NPM_TOKEN: '', DSH_STALE: '',
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
})
|
||||
expect(options?.envs).not.toHaveProperty('KEEP')
|
||||
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 = {
|
||||
commands: { run: async (command: string) => ({ exitCode: 0, stdout: command.startsWith('env -0') ? '' : '123\n', stderr: '' }) },
|
||||
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 = {
|
||||
commands: { run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
||||
} as unknown 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')
|
||||
|
||||
const invalidSessionKill = vi.fn().mockRejectedValue(new Error('kill raced'))
|
||||
const invalidSessionHandle = {
|
||||
pid: 123,
|
||||
wait: vi.fn().mockRejectedValue(new Error('already exited')),
|
||||
kill: invalidSessionKill,
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CommandHandle
|
||||
const invalidSessionSandbox = {
|
||||
commands: {
|
||||
run: async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('env -0') ? '' : '9007199254740992\n',
|
||||
stderr: '',
|
||||
}),
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
const invalidSessionContext = new Context()
|
||||
invalidSessionContext.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => invalidSessionSandbox,
|
||||
} as E2BSandboxService)
|
||||
const invalidSession = new E2BPtyBackend(invalidSessionContext, config(), async () => invalidSessionHandle)
|
||||
await expect(invalidSession.spawn({
|
||||
sessionId: PtySessionId('four'), owner: owner(invalidSessionContext), type: 'shell',
|
||||
}))
|
||||
.rejects.toThrow('cannot resolve process session')
|
||||
expect(invalidSessionKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans failed startup and aggregates a cleanup failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const sandbox = {
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('ps -o sid=') || command.startsWith('ps -eo sid=') ? '123\n' : '',
|
||||
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
|
||||
let sessionRunning = true
|
||||
const sandbox = {
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => {
|
||||
if (command.startsWith('env -0')) return { exitCode: 0, stdout: '', stderr: '' }
|
||||
if (command.startsWith('ps -o sid=')) return { exitCode: 0, stdout: '123\n', stderr: '' }
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: sessionRunning ? '123\n' : '', stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) {
|
||||
sessionRunning = false
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,485 +0,0 @@
|
||||
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'
|
||||
sessionGroups = [123]
|
||||
sendError: unknown
|
||||
signalError: unknown
|
||||
killError: unknown
|
||||
foregroundLookup: Promise<CommandResult> | undefined
|
||||
onTerm: (() => void) | undefined
|
||||
onGroupKill: (() => 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 (command.startsWith('ps -o tpgid')) {
|
||||
return await (this.foregroundLookup ?? Promise.resolve({ exitCode: 0, stdout: this.pgid, stderr: '' }))
|
||||
}
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: this.sessionGroups.map(value => `${value}\n`).join(''), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -')) {
|
||||
if (this.signalError !== undefined) {
|
||||
const error = this.signalError
|
||||
this.signalError = undefined
|
||||
throw error
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
if (command.startsWith('kill -KILL')) this.onGroupKill?.()
|
||||
}
|
||||
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(), 123, 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(), 123, 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(), 123, 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(), 123, 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(), 123, 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('does not let a stale interrupt signal or fail a successor send', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await initialize(session)
|
||||
|
||||
const lookup = Promise.withResolvers<CommandResult>()
|
||||
fake.foregroundLookup = lookup.promise
|
||||
const stale = session.startSend({ text: 'old', submit: true })
|
||||
expect(stale.cancel()).toBe(true)
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await stale.done
|
||||
|
||||
const successor = session.startSend({ text: 'new', submit: true })
|
||||
fake.signalError = new Error('late interrupt failure')
|
||||
lookup.resolve({ exitCode: 0, stdout: '789\n', stderr: '' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fake.commands).not.toContain('kill -INT -- -789')
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await expect(successor.done).resolves.toMatchObject({ waitReason: 'stdin_read' })
|
||||
|
||||
const failedLookup = Promise.withResolvers<CommandResult>()
|
||||
fake.foregroundLookup = failedLookup.promise
|
||||
const staleFailure = session.startSend({ text: 'old failure', submit: true })
|
||||
expect(staleFailure.cancel()).toBe(true)
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await staleFailure.done
|
||||
const finalSuccessor = session.startSend({ text: 'new after failure', submit: true })
|
||||
failedLookup.reject(new Error('late lookup failure'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await expect(finalSuccessor.done).resolves.toMatchObject({ waitReason: 'stdin_read' })
|
||||
})
|
||||
|
||||
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(), 123, 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(), 123, 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(), 123, 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(), 123, 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(), 123, 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(), 123, 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 = () => { fake.sessionGroups = []; handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, 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('escalates every job-control group that survives shell exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
fake.sessionGroups = [123, 456]
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { fake.sessionGroups = [456]; handle.failExit(143) }
|
||||
fake.onGroupKill = () => { fake.sessionGroups = [] }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
|
||||
const closing = session.close('tree cleanup')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await closing
|
||||
|
||||
expect(fake.commands).toContain('kill -TERM -- -123 -456')
|
||||
expect(fake.commands).toContain('kill -KILL -- -456')
|
||||
})
|
||||
|
||||
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.signalError = commandError(1)
|
||||
gone.onGroupKill = () => { gone.sessionGroups = [] }
|
||||
gone.onKill = () => { goneHandle.failExit(137) }
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), 123, config())
|
||||
const closingGone = goneSession.close('gone')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
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(), 123, config())
|
||||
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving process groups: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
survivorHandle.exit()
|
||||
survivor.sessionGroups = []
|
||||
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.signalError = new Error('TERM transport failed')
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
|
||||
handle.exit()
|
||||
fake.sessionGroups = []
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
|
||||
const invalidTailHandle = new FakePtyHandle()
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), 123, 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(), 123, 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('rejects invalid session groups and a shell handle that survives SDK kill', async () => {
|
||||
const invalid = new FakeSandbox()
|
||||
invalid.sessionGroups = [1]
|
||||
const invalidSession = new E2BPtySession(invalid.sandbox, new FakePtyHandle().asHandle(), 123, config())
|
||||
await expect(invalidSession.close('invalid group')).rejects.toThrow('invalid process group')
|
||||
|
||||
vi.useFakeTimers()
|
||||
const survivor = new FakeSandbox()
|
||||
survivor.sessionGroups = []
|
||||
const survivorHandle = new FakePtyHandle()
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), 123, config())
|
||||
const failed = expect(survivorSession.close('shell survived')).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
expect(survivor.killed).toEqual([123])
|
||||
})
|
||||
|
||||
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(), 123, 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 = () => { fake.sessionGroups = []; 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)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -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/subprocess-e2b/README.md
|
||||
README.md: 066d35a099f560fe40fe629ede632c37535129f7
|
||||
README.zh.md: d3af14e8cc58c7d86331156358f78a0abbb24c39
|
||||
README.md: e0805345e708c67c6de721d95641e2366a7f990e
|
||||
README.zh.md: f83969c794e50f0658aa86218a0f5f3c89d53b56
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
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.
|
||||
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 Bash, PTY, LSP, and subprocess Code Runtime consumers then execute in the shared remote sandbox without E2B-specific capability packages.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
|
||||
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides.
|
||||
- **Linux process groups** — a quoted wrapper starts each argv under `exec 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 treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback. If publication fails, the SDK PID remains the provisional `exec setsid` group id; rollback kills and verifies that group before startup rejects. 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.
|
||||
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every group in the remote terminal session before settlement. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
|
||||
|
||||
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands.
|
||||
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands and E2B PTY support.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -24,9 +26,10 @@ 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.
|
||||
- **Command-pipe output is text-decoded by the SDK** — valid UTF-8 protocol traffic, including the exercised LSP composition and Code Runtime's ASCII/base64 frames, is supported; arbitrary binary protocols and invalid UTF-8 are not byte-faithful.
|
||||
- **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.
|
||||
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
|
||||
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, arbitrary-template, escaped-session recovery, or network-partition fidelity layer.
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
[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 适配器。
|
||||
[`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) seam 的 E2B 实现。它没有配置:先加载 [`@deepseek-ai/dsh-e2b`](../e2b/README.md),再用本服务取代 `dsh-subprocess-local`。现有的 Bash、PTY、LSP 以及使用 subprocess 的 Code Runtime 消费方随后会在共享远程沙箱中执行,无需 E2B 专用的功能包(package)。
|
||||
|
||||
## 行为
|
||||
|
||||
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;`done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
|
||||
- **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
|
||||
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。如果发布失败,SDK PID 仍为临时的 `exec setsid` 进程组 ID;回滚会终止并验证该进程组,随后启动操作才会以拒绝结束。服务 dispose(资源释放)会在沙箱所有者释放前终止并等待每个保留句柄退出。
|
||||
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
|
||||
- **stdio 投影**:pipe 模式把 E2B 回调转发到宿主 Node 流;inherit 模式把回调转发到 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。
|
||||
- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中的每个进程组。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
|
||||
|
||||
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash`、`setsid`、`ps`、`tr`、`env`、`chmod`、`tee`、`head` 和 `kill`。自定义模板必须保留兼容的命令。
|
||||
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`chmod`、`tee`、`head` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -24,9 +26,10 @@
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会持续累积,因此无法达到进程管理 seam 通常提供的宿主内存边界。
|
||||
- **Pipe 输出并非字节保真**:E2B 交付的是分别解码后的字符串,而不是原始字节,因此拆分的多字节序列和任意二进制协议可能损坏;不支持 LSP 及其他带帧字节流消费方。
|
||||
- **命令管道输出由 SDK 解码为文本**:支持有效的 UTF-8 协议流量,包括已经过测试的 LSP 组合与 Code Runtime 的 ASCII/base64 帧;任意二进制协议和无效 UTF-8 不具备字节保真。
|
||||
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
|
||||
- **重新连接不会重建句柄**:保留沙箱后,远程 PID/状态/spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。
|
||||
- **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。
|
||||
- **信号归因依靠推断**:如果已经请求终止,而 E2B 报告非零退出码,适配器会报告最后请求的信号,因为 SDK 结果不标识终止信号。
|
||||
- **依赖 Linux 工具与 E2B 传输语义**:没有 PTY、Windows、任意模板或网络分区的保真层。
|
||||
- **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
|
||||
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。
|
||||
@@ -8,29 +8,87 @@ 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 type {
|
||||
SubprocessHandle,
|
||||
SubprocessSpawnSpec,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
|
||||
import { E2BSubprocessHandle } from './process.ts'
|
||||
import { spawnE2BTerminal } from './terminal.ts'
|
||||
|
||||
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
||||
return signal === undefined ? {} : { signal }
|
||||
}
|
||||
|
||||
/** E2B command manager registered as `ctx.subprocess`. */
|
||||
export class E2BSubprocessService extends SubprocessService {
|
||||
static inject = ['e2b']
|
||||
|
||||
private readonly live = new Set<E2BSubprocessHandle>()
|
||||
private readonly terminals = new Set<SubprocessTerminalHandle>()
|
||||
|
||||
/** @inheritdoc */
|
||||
readonly cwd: string
|
||||
|
||||
/** @inheritdoc */
|
||||
readonly runtimeRoot: string
|
||||
|
||||
/** Create the E2B subprocess service and bind its disposal policy. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx)
|
||||
this.cwd = ctx.e2b.cwd
|
||||
this.runtimeRoot = ctx.e2b.runtimeRoot
|
||||
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()
|
||||
}))
|
||||
const terminals = [...this.terminals]
|
||||
const pending: Promise<unknown>[] = []
|
||||
for (const handle of handles) {
|
||||
handle.terminate()
|
||||
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
|
||||
}
|
||||
for (const terminal of terminals) {
|
||||
terminal.terminate()
|
||||
pending.push(terminal.waitForExit())
|
||||
}
|
||||
this.live.clear()
|
||||
this.terminals.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'e2b subprocess teardown')
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async resolveExecutable(
|
||||
command: string,
|
||||
env?: Readonly<Record<string, string>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (command.length === 0) throw new Error('subprocess-e2b: executable name must be non-empty')
|
||||
signal?.throwIfAborted()
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
if (posix.isAbsolute(command)) {
|
||||
await sandbox.commands.run(
|
||||
`test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`,
|
||||
signalOpts(signal),
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
return command
|
||||
}
|
||||
const path = env?.PATH
|
||||
const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} `
|
||||
const result = await sandbox.commands.run(
|
||||
`${prefix}command -v -- ${quoteE2BShellArg(command)}`,
|
||||
signalOpts(signal),
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const executable = result.stdout.trim()
|
||||
if (!posix.isAbsolute(executable) || executable.includes('\n')) {
|
||||
throw new Error(`subprocess-e2b: executable ${JSON.stringify(command)} did not resolve to one absolute path`)
|
||||
}
|
||||
return executable
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||
const program = spec.argv[0]
|
||||
@@ -53,6 +111,29 @@ export class E2BSubprocessService extends SubprocessService {
|
||||
void handle.done.then(release, release).catch(() => {})
|
||||
return handle
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
||||
const program = spec.argv[0]
|
||||
if (program === undefined || program.length === 0) {
|
||||
throw new Error('subprocess-e2b: terminal argv must contain a program')
|
||||
}
|
||||
for (const [name, value] of [['rows', spec.rows], ['cols', spec.cols], ['graceMs', spec.graceMs]] as const) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`subprocess-e2b: terminal ${name} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
spec.signal?.throwIfAborted()
|
||||
const stateDir = posix.join(this.runtimeRoot, 'terminals', randomUUID())
|
||||
const terminal = await spawnE2BTerminal(this.ctx.e2b, spec, stateDir)
|
||||
this.terminals.add(terminal)
|
||||
const release = async (): Promise<void> => {
|
||||
await terminal.waitForExit()
|
||||
this.terminals.delete(terminal)
|
||||
}
|
||||
void terminal.done.then(release, release).catch(() => {})
|
||||
return terminal
|
||||
}
|
||||
}
|
||||
|
||||
export default E2BSubprocessService
|
||||
@@ -0,0 +1,386 @@
|
||||
/** E2B PTY allocation and process-session ownership for the subprocess seam. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { constants } from 'node:os'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { posix } from 'node:path'
|
||||
import {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSignal,
|
||||
SubprocessTerminalSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
|
||||
const POLL_MS = 20
|
||||
|
||||
const TERMINAL_RUNNER_SOURCE = [
|
||||
'#!/bin/bash',
|
||||
'set -euo pipefail',
|
||||
'dsh_state=$1',
|
||||
'mapfile -d \'\' -t dsh_env < "$dsh_state/environment"',
|
||||
'mapfile -d \'\' -t dsh_argv < "$dsh_state/argv"',
|
||||
'rm -f -- "$dsh_state/environment" "$dsh_state/argv" "$dsh_state/runner.bash"',
|
||||
'if (( ${#dsh_argv[@]} == 0 )); then',
|
||||
" printf 'terminal runner received empty argv\\n' >&2",
|
||||
' exit 125',
|
||||
'fi',
|
||||
"printf 'ready\\n' > \"$dsh_state/ready\"",
|
||||
'exec env -i "${dsh_env[@]}" "${dsh_argv[@]}"',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
interface TerminalPaths {
|
||||
runner: string
|
||||
environment: string
|
||||
argv: string
|
||||
ready: string
|
||||
}
|
||||
|
||||
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
||||
return signal === undefined ? {} : { signal }
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function commandSignal(exitCode: number): NodeJS.Signals | null {
|
||||
const number = exitCode - 128
|
||||
if (number <= 0) return null
|
||||
for (const [name, value] of Object.entries(constants.signals)) {
|
||||
if (value === number) return name as NodeJS.Signals
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePositiveId(value: string, message: string): number {
|
||||
const raw = value.trim()
|
||||
const id = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(id)) throw new Error(message)
|
||||
return id
|
||||
}
|
||||
|
||||
function serializeValues(values: readonly string[], kind: string): string {
|
||||
for (const value of values) {
|
||||
if (value.includes('\0')) throw new Error(`subprocess-e2b: terminal ${kind} must not contain NUL bytes`)
|
||||
}
|
||||
return values.map(value => `${value}\0`).join('')
|
||||
}
|
||||
|
||||
function remoteEnvironment(raw: string, explicit: Readonly<Record<string, string>> | undefined): string {
|
||||
const environment = new Map<string, string>()
|
||||
for (const entry of raw.split('\0')) {
|
||||
if (entry.length === 0) continue
|
||||
const separator = entry.indexOf('=')
|
||||
if (separator <= 0) continue
|
||||
const name = entry.slice(0, separator)
|
||||
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
|
||||
environment.set(name, entry.slice(separator + 1))
|
||||
}
|
||||
for (const [name, value] of Object.entries(explicit ?? {})) {
|
||||
if (name.length === 0 || name.includes('=') || name.includes('\0') || value.includes('\0')) {
|
||||
throw new Error('subprocess-e2b: terminal environment entries require non-empty NUL-free names without = and NUL-free values')
|
||||
}
|
||||
environment.set(name, value)
|
||||
}
|
||||
return serializeValues([...environment].map(([name, value]) => `${name}=${value}`), 'environment')
|
||||
}
|
||||
|
||||
async function terminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<number> {
|
||||
const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, signalOpts(signal))
|
||||
signal?.throwIfAborted()
|
||||
return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
|
||||
}
|
||||
|
||||
async function waitUntilReady(
|
||||
sandbox: Sandbox,
|
||||
paths: TerminalPaths,
|
||||
completion: Promise<CommandResult>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const settled = completion.then(() => true, () => true)
|
||||
for (;;) {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
if ((await sandbox.files.read(paths.ready, signalOpts(signal))).trim() === 'ready') return
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof FileNotFoundError)) throw error
|
||||
}
|
||||
if (await Promise.race([settled, delay(POLL_MS).then(() => false)])) {
|
||||
throw new Error('subprocess-e2b: terminal exited before publishing readiness')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One E2B PTY and all process groups in its remote process session. */
|
||||
export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
readonly pid: number
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
|
||||
private topLevelExited = false
|
||||
private termination: Promise<void> | undefined
|
||||
private terminationSignal: NodeJS.Signals | null = null
|
||||
private removeAbort: (() => void) | undefined
|
||||
|
||||
constructor(
|
||||
private readonly sandbox: Sandbox,
|
||||
private readonly handle: CommandHandle,
|
||||
readonly output: PassThrough,
|
||||
private readonly completion: Promise<CommandResult>,
|
||||
private readonly sessionId: number,
|
||||
private readonly stateDir: string,
|
||||
private readonly graceMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
this.pid = handle.pid
|
||||
this.done = this.waitForCommand()
|
||||
void this.done.then(() => { this.terminate() }, () => { this.terminate() })
|
||||
if (signal !== undefined) {
|
||||
const onAbort = (): void => { this.terminate() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.removeAbort = () => { signal.removeEventListener('abort', onAbort) }
|
||||
if (signal.aborted) this.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
if (this.topLevelExited) throw new Error('terminal process has exited')
|
||||
await this.sandbox.pty.sendInput(this.pid, data)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
|
||||
try {
|
||||
const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`)
|
||||
return {
|
||||
processGroupId: parsePositiveId(
|
||||
result.stdout,
|
||||
`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`,
|
||||
),
|
||||
// E2B exposes process-table commands but not the /proc memory access
|
||||
// needed to prove a specific syscall is waiting on fd 0.
|
||||
inputWaiting: false,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CommandExitError && this.topLevelExited) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
|
||||
const foreground = await this.inspectForeground()
|
||||
if (foreground === undefined) {
|
||||
throw new Error(`subprocess-e2b: cannot resolve foreground process group for terminal ${this.pid}`)
|
||||
}
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${foreground.processGroupId}`)
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
terminate(): void {
|
||||
this.termination ??= this.closeOnce().catch((error: unknown) => {
|
||||
this.termination = undefined
|
||||
throw error
|
||||
})
|
||||
void this.termination.catch(() => {})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
const quiescence = this.termination ?? this.done.then(
|
||||
() => { this.terminate(); return this.termination },
|
||||
() => { this.terminate(); return this.termination },
|
||||
)
|
||||
if (signal === undefined) {
|
||||
await quiescence
|
||||
return true
|
||||
}
|
||||
if (signal.aborted) return false
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const onAbort = (): void => { cleanup(); resolve(false) }
|
||||
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void quiescence.then(
|
||||
() => { cleanup(); resolve(true) },
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForCommand(): Promise<SubprocessOutcome> {
|
||||
try {
|
||||
const result = await this.completion
|
||||
return { exitCode: result.exitCode, signal: null }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const signal = this.terminationSignal ?? commandSignal(error.exitCode)
|
||||
return signal === null ? { exitCode: error.exitCode, signal: null } : { exitCode: null, signal }
|
||||
}
|
||||
this.output.destroy(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
this.topLevelExited = true
|
||||
if (!this.output.destroyed) this.output.end()
|
||||
}
|
||||
}
|
||||
|
||||
private async sessionProcessGroups(): Promise<number[]> {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${this.sessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const group = parsePositiveId(
|
||||
raw,
|
||||
`subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.sessionId}`,
|
||||
)
|
||||
if (group <= 1) {
|
||||
throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${this.sessionId}`)
|
||||
}
|
||||
groups.add(group)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
private async signalGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
if (groups.length === 0) return
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitSessionEmpty(kill = false): Promise<number[]> {
|
||||
const deadline = Date.now() + this.graceMs
|
||||
for (;;) {
|
||||
const groups = await this.sessionProcessGroups()
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (kill) await this.signalGroups(groups, 'KILL')
|
||||
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(): Promise<void> {
|
||||
let groups = await this.sessionProcessGroups()
|
||||
if (groups.length > 0) {
|
||||
this.terminationSignal = 'SIGTERM'
|
||||
await this.signalGroups(groups, 'TERM')
|
||||
groups = await this.awaitSessionEmpty()
|
||||
}
|
||||
if (groups.length === 0 && !this.topLevelExited) {
|
||||
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
|
||||
}
|
||||
if (groups.length > 0 || !this.topLevelExited) {
|
||||
this.terminationSignal = 'SIGKILL'
|
||||
if (!this.topLevelExited) await this.sandbox.pty.kill(this.pid)
|
||||
groups = await this.awaitSessionEmpty(true)
|
||||
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
throw new Error(`subprocess-e2b: terminal cleanup failed; surviving process groups: ${groups.join(', ')}`)
|
||||
}
|
||||
if (!this.topLevelExited) {
|
||||
throw new Error(`subprocess-e2b: terminal cleanup failed; surviving pid: ${this.pid}`)
|
||||
}
|
||||
this.removeAbort?.()
|
||||
this.removeAbort = undefined
|
||||
await this.handle.disconnect()
|
||||
await this.sandbox.files.remove(this.stateDir).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate an E2B PTY, replace its bootstrap shell with the requested argv,
|
||||
* and return only after the private runner has published readiness.
|
||||
* @param runtime - Shared E2B sandbox owner.
|
||||
* @param spec - Fully specified terminal-process request.
|
||||
* @param stateDir - Private remote directory for one startup transaction.
|
||||
* @returns The live subprocess terminal handle.
|
||||
*/
|
||||
export async function spawnE2BTerminal(
|
||||
runtime: E2BSandboxService,
|
||||
spec: SubprocessTerminalSpawnSpec,
|
||||
stateDir: string,
|
||||
): Promise<E2BTerminalHandle> {
|
||||
const sandbox = await runtime.getSandbox()
|
||||
spec.signal?.throwIfAborted()
|
||||
const paths: TerminalPaths = {
|
||||
runner: posix.join(stateDir, 'runner.bash'),
|
||||
environment: posix.join(stateDir, 'environment'),
|
||||
argv: posix.join(stateDir, 'argv'),
|
||||
ready: posix.join(stateDir, 'ready'),
|
||||
}
|
||||
const ambient = await sandbox.commands.run('env -0', signalOpts(spec.signal))
|
||||
const environment = remoteEnvironment(ambient.stdout, spec.env)
|
||||
const argv = serializeValues(spec.argv, 'argv')
|
||||
await sandbox.files.makeDir(stateDir)
|
||||
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stateDir)}`, signalOpts(spec.signal))
|
||||
await sandbox.files.write([
|
||||
{ path: paths.runner, data: TERMINAL_RUNNER_SOURCE },
|
||||
{ path: paths.environment, data: environment },
|
||||
{ path: paths.argv, data: argv },
|
||||
], signalOpts(spec.signal))
|
||||
await sandbox.commands.run(
|
||||
`chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)}`,
|
||||
signalOpts(spec.signal),
|
||||
)
|
||||
|
||||
const output = new PassThrough()
|
||||
let handle: CommandHandle | undefined
|
||||
let completion: Promise<CommandResult> | undefined
|
||||
try {
|
||||
handle = await sandbox.pty.create({
|
||||
rows: spec.rows,
|
||||
cols: spec.cols,
|
||||
cwd: spec.cwd,
|
||||
envs: { TERM: 'dumb' },
|
||||
timeoutMs: 0,
|
||||
...signalOpts(spec.signal),
|
||||
onData: (data) => { if (!output.destroyed) output.write(Buffer.from(data)) },
|
||||
})
|
||||
completion = handle.wait()
|
||||
void completion.catch(() => {})
|
||||
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
|
||||
throw new Error(`subprocess-e2b: E2B returned invalid terminal pid ${handle.pid}`)
|
||||
}
|
||||
const command = `exec /bin/bash ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(stateDir)}\r`
|
||||
await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
|
||||
await waitUntilReady(sandbox, paths, completion, spec.signal)
|
||||
const sessionId = await terminalSessionId(sandbox, handle.pid, spec.signal)
|
||||
return new E2BTerminalHandle(
|
||||
sandbox,
|
||||
handle,
|
||||
output,
|
||||
completion,
|
||||
sessionId,
|
||||
stateDir,
|
||||
spec.graceMs,
|
||||
spec.signal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
output.destroy()
|
||||
if (handle !== undefined) await handle.kill().catch(() => false)
|
||||
if (completion !== undefined) await completion.catch(() => {})
|
||||
await sandbox.files.remove(stateDir).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { once } from 'node:events'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
type CommandHandle,
|
||||
type CommandResult,
|
||||
type Sandbox,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
import { spawnE2BTerminal } from '../src/terminal.ts'
|
||||
|
||||
function commandError(exitCode: number): CommandExitError {
|
||||
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
|
||||
}
|
||||
|
||||
class FakeTerminalCommandHandle {
|
||||
pid = 123
|
||||
disconnects = 0
|
||||
sdkKills = 0
|
||||
disconnectError: unknown
|
||||
sdkKillError: unknown
|
||||
private readonly result = Promise.withResolvers<CommandResult>()
|
||||
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.sdkKills += 1
|
||||
if (this.sdkKillError !== undefined) {
|
||||
const error = this.sdkKillError
|
||||
this.fail(137)
|
||||
throw error
|
||||
}
|
||||
this.fail(137)
|
||||
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)
|
||||
}
|
||||
|
||||
asHandle(): CommandHandle {
|
||||
return this as unknown as CommandHandle
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTerminalSandbox {
|
||||
readonly handle = new FakeTerminalCommandHandle()
|
||||
readonly commands: string[] = []
|
||||
readonly inputs: Array<{ pid: number; data: Buffer }> = []
|
||||
readonly removed: string[] = []
|
||||
readonly directories: string[] = []
|
||||
readonly writes = new Map<string, string>()
|
||||
createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
|
||||
ambient = 'KEEP=visible\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
|
||||
ready: string | Error = 'ready\n'
|
||||
sessionId = '123\n'
|
||||
foreground = '456\n'
|
||||
groups = [123]
|
||||
createError: unknown
|
||||
sendError: unknown
|
||||
commandFailure: unknown
|
||||
foregroundFailure: unknown
|
||||
termFailure: unknown
|
||||
removeError: unknown
|
||||
clearOnTerm = true
|
||||
clearOnKill = true
|
||||
settleOnPtyKill = true
|
||||
ptyKills = 0
|
||||
resolvedExecutable = '/usr/bin/node\n'
|
||||
|
||||
readonly sandbox = {
|
||||
files: {
|
||||
makeDir: async (path: string): Promise<boolean> => {
|
||||
this.directories.push(path)
|
||||
return true
|
||||
},
|
||||
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
|
||||
for (const file of files) this.writes.set(file.path, file.data)
|
||||
return files.map(() => ({}))
|
||||
},
|
||||
read: async (): Promise<string> => {
|
||||
if (this.ready instanceof Error) throw this.ready
|
||||
return this.ready
|
||||
},
|
||||
remove: async (path: string): Promise<void> => {
|
||||
this.removed.push(path)
|
||||
if (this.removeError !== undefined) throw this.removeError
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
run: async (command: string, options?: { signal?: AbortSignal }): Promise<CommandResult> => {
|
||||
this.commands.push(command)
|
||||
options?.signal?.throwIfAborted()
|
||||
if (this.commandFailure !== undefined) {
|
||||
const error = this.commandFailure
|
||||
this.commandFailure = undefined
|
||||
throw error
|
||||
}
|
||||
if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' }
|
||||
if (command.includes('command -v -- ')) {
|
||||
return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('ps -o sid=')) return { exitCode: 0, stdout: this.sessionId, stderr: '' }
|
||||
if (command.startsWith('ps -o tpgid=')) {
|
||||
if (this.foregroundFailure !== undefined) throw this.foregroundFailure
|
||||
return { exitCode: 0, stdout: this.foreground, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM -- ')) {
|
||||
if (this.termFailure !== undefined) throw this.termFailure
|
||||
if (this.clearOnTerm) {
|
||||
this.groups = []
|
||||
this.handle.fail(143)
|
||||
}
|
||||
}
|
||||
if (command.startsWith('kill -KILL -- ') && this.clearOnKill) this.groups = []
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
|
||||
this.createOptions = options
|
||||
if (this.createError !== undefined) throw this.createError
|
||||
await options.onData(Buffer.from('buffered banner\n'))
|
||||
return this.handle.asHandle()
|
||||
},
|
||||
sendInput: async (pid: number, data: Uint8Array, options?: { signal?: AbortSignal }): Promise<void> => {
|
||||
options?.signal?.throwIfAborted()
|
||||
this.inputs.push({ pid, data: Buffer.from(data) })
|
||||
if (this.sendError !== undefined) throw this.sendError
|
||||
},
|
||||
kill: async (pid: number): Promise<boolean> => {
|
||||
this.ptyKills += 1
|
||||
if (this.settleOnPtyKill) this.handle.fail(137)
|
||||
return pid === this.handle.pid
|
||||
},
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
}
|
||||
|
||||
function runtime(fake: FakeTerminalSandbox): E2BSandboxService {
|
||||
return {
|
||||
cwd: '/workspace',
|
||||
runtimeRoot: '/workspace/.dsh-e2b',
|
||||
disposeMode: 'kill',
|
||||
getSandbox: async () => fake.sandbox,
|
||||
} as unknown as E2BSandboxService
|
||||
}
|
||||
|
||||
function spec(overrides: Partial<SubprocessTerminalSpawnSpec> = {}): SubprocessTerminalSpawnSpec {
|
||||
return {
|
||||
argv: ['/bin/bash', '--noprofile', '--norc'],
|
||||
cwd: '/workspace',
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
graceMs: 5,
|
||||
env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('E2B terminal allocation', () => {
|
||||
it('boots the requested argv through a private runner and preserves buffered bytes', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/terminal-one')
|
||||
let output = ''
|
||||
terminal.output.on('data', (chunk) => { output += String(chunk) })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(output).toBe('buffered banner\n')
|
||||
expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0, envs: { TERM: 'dumb' } })
|
||||
expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('TOKEN_EXPLICIT=kept\0')
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('secret')
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('DSH_STALE')
|
||||
expect(fake.writes.get('/runtime/terminal-one/argv')).toBe('/bin/bash\0--noprofile\0--norc\0')
|
||||
const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
|
||||
expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
|
||||
expect(runner).toContain('exec env -i "${dsh_env[@]}" "${dsh_argv[@]}"')
|
||||
expect(runner).not.toContain('\u007f')
|
||||
|
||||
await terminal.write(Buffer.from('echo ok\r'))
|
||||
expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r')
|
||||
await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false })
|
||||
await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456)
|
||||
expect(fake.commands).toContain('kill -INT -- -456')
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
expect(fake.removed).toContain('/runtime/terminal-one')
|
||||
})
|
||||
|
||||
it('inherits only safe ambient values and binds live abort to terminal cleanup', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const controller = new AbortController()
|
||||
const terminal = await spawnE2BTerminal(
|
||||
runtime(fake),
|
||||
spec({ env: undefined, signal: controller.signal }),
|
||||
'/runtime/abort-live',
|
||||
)
|
||||
const environment = fake.writes.get('/runtime/abort-live/environment') ?? ''
|
||||
expect(environment).toContain('KEEP=visible\0')
|
||||
expect(environment).not.toContain('secret')
|
||||
expect(environment).not.toContain('DSH_STALE')
|
||||
|
||||
controller.abort(new Error('stop'))
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
await expect(terminal.waitForExit(controller.signal)).resolves.toBe(false)
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects malformed environment and argv values before PTY allocation', async () => {
|
||||
const invalidName = new FakeTerminalSandbox()
|
||||
await expect(spawnE2BTerminal(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
|
||||
.rejects.toThrow('environment entries')
|
||||
expect(invalidName.createOptions).toBeUndefined()
|
||||
|
||||
const invalidValue = new FakeTerminalSandbox()
|
||||
await expect(spawnE2BTerminal(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value'))
|
||||
.rejects.toThrow('environment entries')
|
||||
|
||||
const invalidArg = new FakeTerminalSandbox()
|
||||
await expect(spawnE2BTerminal(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv'))
|
||||
.rejects.toThrow('argv must not contain NUL')
|
||||
})
|
||||
|
||||
it('cleans malformed handles, bootstrap failures, and readiness failures', async () => {
|
||||
const invalidPid = new FakeTerminalSandbox()
|
||||
invalidPid.handle.pid = 0
|
||||
await expect(spawnE2BTerminal(runtime(invalidPid), spec(), '/runtime/invalid-pid'))
|
||||
.rejects.toThrow('invalid terminal pid 0')
|
||||
expect(invalidPid.handle.sdkKills).toBe(1)
|
||||
expect(invalidPid.removed).toContain('/runtime/invalid-pid')
|
||||
|
||||
const failedInput = new FakeTerminalSandbox()
|
||||
failedInput.sendError = new Error('bootstrap failed')
|
||||
await expect(spawnE2BTerminal(runtime(failedInput), spec(), '/runtime/input'))
|
||||
.rejects.toThrow('bootstrap failed')
|
||||
expect(failedInput.handle.sdkKills).toBe(1)
|
||||
|
||||
const exited = new FakeTerminalSandbox()
|
||||
exited.ready = new FileNotFoundError('not ready')
|
||||
queueMicrotask(() => { exited.handle.succeed(0) })
|
||||
await expect(spawnE2BTerminal(runtime(exited), spec(), '/runtime/exited'))
|
||||
.rejects.toThrow('exited before publishing readiness')
|
||||
|
||||
const invalidSession = new FakeTerminalSandbox()
|
||||
invalidSession.sessionId = 'not-a-session\n'
|
||||
await expect(spawnE2BTerminal(runtime(invalidSession), spec(), '/runtime/session'))
|
||||
.rejects.toThrow('cannot resolve process session')
|
||||
expect(invalidSession.handle.sdkKills).toBe(1)
|
||||
|
||||
const cleanupFailed = new FakeTerminalSandbox()
|
||||
cleanupFailed.handle.pid = 0
|
||||
cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
|
||||
cleanupFailed.removeError = new Error('remove transport failed')
|
||||
await expect(spawnE2BTerminal(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
|
||||
.rejects.toThrow('invalid terminal pid 0')
|
||||
})
|
||||
|
||||
it('propagates setup cancellation and provider failures', async () => {
|
||||
const aborted = new FakeTerminalSandbox()
|
||||
await expect(spawnE2BTerminal(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort'))
|
||||
.rejects.toThrow('stop')
|
||||
|
||||
const createFailed = new FakeTerminalSandbox()
|
||||
createFailed.createError = new Error('create failed')
|
||||
await expect(spawnE2BTerminal(runtime(createFailed), spec(), '/runtime/create'))
|
||||
.rejects.toThrow('create failed')
|
||||
|
||||
const readFailed = new FakeTerminalSandbox()
|
||||
readFailed.ready = new Error('ready transport failed')
|
||||
await expect(spawnE2BTerminal(runtime(readFailed), spec(), '/runtime/read'))
|
||||
.rejects.toThrow('ready transport failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B terminal lifecycle', () => {
|
||||
it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/natural')
|
||||
terminal.output.resume()
|
||||
const ended = once(terminal.output, 'end')
|
||||
fake.handle.succeed(7)
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null })
|
||||
await ended
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await expect(terminal.write(Buffer.from('late'))).rejects.toThrow('exited')
|
||||
fake.foregroundFailure = commandError(1)
|
||||
await expect(terminal.inspectForeground()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[7, { exitCode: 7, signal: null }],
|
||||
[143, { exitCode: null, signal: 'SIGTERM' }],
|
||||
[255, { exitCode: 255, signal: null }],
|
||||
] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/exit-${exitCode}`)
|
||||
fake.handle.fail(exitCode)
|
||||
await expect(terminal.done).resolves.toEqual(expected)
|
||||
await expect(terminal.waitForExit(new AbortController().signal)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('lets an early quiescence observer follow a transport rejection', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/early-observer')
|
||||
terminal.output.on('error', () => {})
|
||||
const quiescence = terminal.waitForExit()
|
||||
fake.handle.crash(new Error('transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('transport failed')
|
||||
await expect(quiescence).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects killing the terminal shell and propagates live foreground failures', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.foreground = '123\n'
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/signal')
|
||||
await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
|
||||
fake.foreground = 'invalid\n'
|
||||
await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
|
||||
fake.foregroundFailure = commandError(1)
|
||||
await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
|
||||
fake.clearOnTerm = true
|
||||
terminal.terminate()
|
||||
await terminal.waitForExit()
|
||||
})
|
||||
|
||||
it('escalates surviving process groups and bounds an observing wait', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = [123, 456]
|
||||
fake.clearOnTerm = false
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/escalate')
|
||||
const controller = new AbortController()
|
||||
const observing = terminal.waitForExit(controller.signal)
|
||||
controller.abort()
|
||||
await expect(observing).resolves.toBe(false)
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.commands).toContain('kill -TERM -- -123 -456')
|
||||
expect(fake.commands).toContain('kill -KILL -- -123 -456')
|
||||
})
|
||||
|
||||
it('surfaces cleanup failures and allows a later retry', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = [1]
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit(new AbortController().signal)).rejects.toThrow('unsafe process group 1')
|
||||
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('propagates a process-group signalling transport failure before retry', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.termFailure = new Error('signal transport failed')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('signal transport failed')
|
||||
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps command rejection authoritative while cleanup is already waiting', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
fake.removeError = new Error('private state already gone')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/reject-during-cleanup')
|
||||
terminal.output.on('error', () => {})
|
||||
terminal.terminate()
|
||||
await Promise.resolve()
|
||||
fake.handle.crash(new Error('command transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('command transport failed')
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a late command rejection authoritative after PTY kill', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
fake.settleOnPtyKill = false
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
|
||||
terminal.output.on('error', () => {})
|
||||
terminal.terminate()
|
||||
while (fake.ptyKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await Promise.resolve()
|
||||
fake.handle.crash(new Error('late command transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('late command transport failed')
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports surviving groups, a surviving top-level pid, and transport failure', async () => {
|
||||
const survivor = new FakeTerminalSandbox()
|
||||
survivor.clearOnTerm = false
|
||||
survivor.clearOnKill = false
|
||||
const terminal = await spawnE2BTerminal(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('surviving process groups: 123')
|
||||
|
||||
const livePid = new FakeTerminalSandbox()
|
||||
livePid.groups = []
|
||||
livePid.settleOnPtyKill = false
|
||||
const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
|
||||
live.terminate()
|
||||
await expect(live.waitForExit()).rejects.toThrow('surviving pid: 123')
|
||||
livePid.handle.succeed(0)
|
||||
await live.done
|
||||
|
||||
const crashed = new FakeTerminalSandbox()
|
||||
crashed.groups = []
|
||||
const failed = await spawnE2BTerminal(runtime(crashed), spec(), '/runtime/crashed')
|
||||
const outputError = once(failed.output, 'error')
|
||||
crashed.handle.crash('transport gone')
|
||||
await expect(failed.done).rejects.toEqual('transport gone')
|
||||
await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }])
|
||||
await expect(failed.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B subprocess terminal service', () => {
|
||||
async function service(fake = new FakeTerminalSandbox()): Promise<{
|
||||
ctx: Context
|
||||
fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
fake: FakeTerminalSandbox
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
ctx.provide('e2b', runtime(fake))
|
||||
const fiber = await ctx.plugin(E2BSubprocessService)
|
||||
return { ctx, fiber, fake }
|
||||
}
|
||||
|
||||
it('publishes execution-world coordinates and resolves remote executables', async () => {
|
||||
const { ctx } = await service()
|
||||
expect(ctx.subprocess.cwd).toBe('/workspace')
|
||||
expect(ctx.subprocess.runtimeRoot).toBe('/workspace/.dsh-e2b')
|
||||
await expect(ctx.subprocess.resolveExecutable('/bin/bash')).resolves.toBe('/bin/bash')
|
||||
await expect(ctx.subprocess.resolveExecutable('node', { PATH: '/custom/bin' }, new AbortController().signal))
|
||||
.resolves.toBe('/usr/bin/node')
|
||||
expect((ctx.e2b)).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects invalid executable lookup inputs and results', async () => {
|
||||
const { ctx, fake } = await service()
|
||||
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('non-empty')
|
||||
await expect(ctx.subprocess.resolveExecutable('node', undefined, AbortSignal.abort(new Error('stop'))))
|
||||
.rejects.toThrow('stop')
|
||||
fake.resolvedExecutable = 'relative/node\n'
|
||||
await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
|
||||
fake.resolvedExecutable = '/one\n/two\n'
|
||||
await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
|
||||
})
|
||||
|
||||
it('owns live terminals through service disposal', async () => {
|
||||
const { ctx, fiber, fake } = await service()
|
||||
const terminal = await ctx.subprocess.spawnTerminal(spec())
|
||||
await fiber.dispose()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
})
|
||||
|
||||
it('releases naturally settled terminals and validates terminal requests', async () => {
|
||||
const { ctx, fiber, fake } = await service()
|
||||
for (const request of [
|
||||
spec({ argv: [] }),
|
||||
spec({ rows: 0 }),
|
||||
spec({ cols: 1.5 }),
|
||||
spec({ graceMs: 0 }),
|
||||
spec({ signal: AbortSignal.abort(new Error('cancelled')) }),
|
||||
]) {
|
||||
await expect(ctx.subprocess.spawnTerminal(request)).rejects.toThrow()
|
||||
}
|
||||
|
||||
fake.groups = []
|
||||
const terminal = await ctx.subprocess.spawnTerminal(spec())
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
await terminal.waitForExit()
|
||||
const signals = fake.commands.filter(command => command.startsWith('kill -')).length
|
||||
await fiber.dispose()
|
||||
expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
|
||||
})
|
||||
|
||||
it('contains a failed automatic terminal release until service disposal retries it', async () => {
|
||||
const { fiber, fake } = await service()
|
||||
fake.clearOnTerm = false
|
||||
fake.clearOnKill = false
|
||||
const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 }))
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(fake.commands).toContain('kill -KILL -- -123')
|
||||
|
||||
fake.groups = []
|
||||
await fiber.dispose()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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/fs/README.md
|
||||
README.md: 0b9a244d0c42542909460edf7109dfb9cee34503
|
||||
README.zh.md: 77da9b73f866fd88fcb81b28cbb622b0389bf5f0
|
||||
README.md: 96bbe6c95bd2aca7e66cf2d57abb3f056cd19390
|
||||
README.zh.md: 7f091b4f0955b847d21b8b3423dab421e610cda3
|
||||
@@ -15,7 +15,6 @@
|
||||
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_EXPECT_PROCESS_ID: expected JSON `initialize.processId`; mismatch exits nonzero.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
@@ -38,7 +37,6 @@ const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
|
||||
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const expectedProcessId = process.env.LSP_FAKE_EXPECT_PROCESS_ID
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
@@ -104,10 +102,6 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
return
|
||||
}
|
||||
if (method === 'initialize') {
|
||||
if (expectedProcessId !== undefined) {
|
||||
const params = message.params as { processId?: unknown } | undefined
|
||||
if (JSON.stringify(params?.processId) !== expectedProcessId) process.exit(2)
|
||||
}
|
||||
if (garbage) process.stdout.write('this is not a framed message\r\n')
|
||||
send({
|
||||
id,
|
||||
|
||||
@@ -111,11 +111,6 @@ const RESPONDING_SERVER =
|
||||
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
|
||||
describe('LspInstance server-request handling', () => {
|
||||
it('advertises a null process id across process namespaces', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_EXPECT_PROCESS_ID: 'null', LSP_FAKE_DEF: 'null' }, { clientProcessId: null })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('answers workspace/configuration with the static config per item', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
|
||||
@@ -1,6 +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/lsp/lsp/README.md
|
||||
README.md: bcffd460f192b0e6ac214ad0bc0d43da4c81d68c
|
||||
README.zh.md: e9df352e999834235a66ef7a500e300dadc2029c
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008
|
||||
README.zh.md: 13ae9700e284ff238147538a571622066efc5747
|
||||
@@ -10,7 +10,6 @@ This package is the interface third of the LSP capability:
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy |
|
||||
| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers |
|
||||
| `@deepseek-ai/dsh-lsp-e2b` | a generic E2B backend that registers configured remote stdio providers |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` |
|
||||
|
||||
The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以品牌化 id + 扩展名映射为 key 的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 |
|
||||
| `@deepseek-ai/dsh-lsp-local` | 通用本地后端,注册已配置的 stdio 语言服务器提供方 |
|
||||
| `@deepseek-ai/dsh-lsp-e2b` | 通用 E2B 后端,注册已配置的远程 stdio 提供方 |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | 面向模型的 `lsp` 工具,基于 `ctx.lsp` |
|
||||
|
||||
该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且没有通用 JSON-RPC 逃生口,因此任何协议载荷或未经评审的命令/修改都无法通过 `ctx.lsp` 到达提供方。
|
||||
|
||||
@@ -41,15 +41,6 @@ export type {
|
||||
PtyWaitReason,
|
||||
} from './types.ts'
|
||||
export { PtyBackendCleanupError } from './types.ts'
|
||||
export {
|
||||
normalizePtyTerminalText,
|
||||
PTY_PROMPT_MARKER_PREFIX,
|
||||
PtyTerminalSanitizer,
|
||||
PtyTextBuffer,
|
||||
ptySignalName,
|
||||
ptyUtf8Tail,
|
||||
} from './terminal.ts'
|
||||
export type { PtySanitizedChunk } from './terminal.ts'
|
||||
|
||||
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
|
||||
export type PtySessionId = PtySessionIdValue
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/** Backend-neutral line-oriented terminal buffering and control-sequence sanitization. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { constants } from 'node:os'
|
||||
import type { PtySendRead } from './types.ts'
|
||||
|
||||
/** OSC marker emitted by a controlled bash before each prompt. */
|
||||
export const PTY_PROMPT_MARKER_PREFIX = '133;D;'
|
||||
|
||||
/** One sanitized chunk plus whether it contained the controlled prompt marker. */
|
||||
export interface PtySanitizedChunk {
|
||||
/** Printable, line-normalized terminal text. */
|
||||
text: string
|
||||
/** Whether the chunk completed the controlled prompt marker. */
|
||||
prompt: boolean
|
||||
/** Present when printable text followed the latest controlled prompt marker. */
|
||||
promptText?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the largest code-point-aligned UTF-8 tail within a byte cap.
|
||||
* @param text - Candidate terminal text.
|
||||
* @param maxBytes - Maximum retained UTF-8 bytes.
|
||||
* @returns The retained tail and whether its head was dropped.
|
||||
*/
|
||||
export function ptyUtf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
|
||||
const chars = Array.from(text)
|
||||
let bytes = 0
|
||||
let start = chars.length
|
||||
while (start > 0) {
|
||||
const next = Buffer.byteLength(chars[start - 1] as string)
|
||||
if (bytes + next > maxBytes) break
|
||||
bytes += next
|
||||
start -= 1
|
||||
}
|
||||
return { text: chars.slice(start).join(''), truncated: true }
|
||||
}
|
||||
|
||||
/** UTF-8 and optionally line-bounded terminal text buffer. */
|
||||
export class PtyTextBuffer {
|
||||
private value = ''
|
||||
private dropped = false
|
||||
|
||||
/**
|
||||
* @param maxBytes - Maximum retained UTF-8 bytes.
|
||||
* @param maxLines - Optional maximum retained logical lines.
|
||||
*/
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly maxLines?: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Append terminal text and drop the oldest excess.
|
||||
* @param text - Decoded and sanitized terminal text.
|
||||
*/
|
||||
append(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.value += text
|
||||
if (this.maxLines !== undefined) {
|
||||
const lines = this.value.split('\n')
|
||||
if (lines.length > this.maxLines) {
|
||||
this.value = lines.slice(lines.length - this.maxLines).join('\n')
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
const tail = ptyUtf8Tail(this.value, this.maxBytes)
|
||||
this.value = tail.text
|
||||
this.dropped ||= tail.truncated
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume all currently retained operation text.
|
||||
* @returns The delta and whether older text was dropped.
|
||||
*/
|
||||
consume(): PtySendRead {
|
||||
const delta = this.value
|
||||
const truncated = this.dropped
|
||||
this.value = ''
|
||||
this.dropped = false
|
||||
return { delta, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the retained text without consuming it.
|
||||
* @returns The retained text and whether its head was dropped.
|
||||
*/
|
||||
snapshot(): { text: string; truncated: boolean } {
|
||||
return { text: this.value, truncated: this.dropped }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming terminal-control sanitizer for line-oriented PTY backends.
|
||||
* Full terminal emulation is deliberately outside the PTY seam.
|
||||
*/
|
||||
export class PtyTerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
private trailingCarriageReturn = false
|
||||
private awaitingPromptText = false
|
||||
|
||||
/** @param maxPendingBytes - Bound for an incomplete terminal-control sequence. */
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
/**
|
||||
* Consume one decoded PTY data chunk.
|
||||
* @param chunk - Decoded terminal data.
|
||||
* @returns Printable text and prompt-marker facts.
|
||||
*/
|
||||
push(chunk: string): PtySanitizedChunk {
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let promptText = false
|
||||
let index = 0
|
||||
const appendText = (value: string): boolean => {
|
||||
text += value
|
||||
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
|
||||
this.awaitingPromptText = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
while (index < this.pending.length) {
|
||||
const escape = this.pending.indexOf('\x1b', index)
|
||||
if (escape < 0) {
|
||||
promptText = appendText(this.pending.slice(index)) || promptText
|
||||
index = this.pending.length
|
||||
break
|
||||
}
|
||||
promptText = appendText(this.pending.slice(index, escape)) || promptText
|
||||
if (escape + 1 >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
const kind = this.pending[escape + 1]
|
||||
if (kind === ']') {
|
||||
const bel = this.pending.indexOf('\x07', escape + 2)
|
||||
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
|
||||
let end = -1
|
||||
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
|
||||
else if (bel >= 0) end = bel + 1
|
||||
else if (stringTerminator >= 0) end = stringTerminator + 2
|
||||
if (end < 0) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
|
||||
const content = this.pending.slice(escape + 2, end - terminatorBytes)
|
||||
if (content.startsWith(PTY_PROMPT_MARKER_PREFIX)) {
|
||||
prompt = true
|
||||
promptText = false
|
||||
this.awaitingPromptText = true
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (kind === '[') {
|
||||
let end = escape + 2
|
||||
while (end < this.pending.length) {
|
||||
const code = this.pending.charCodeAt(end)
|
||||
if (code >= 0x40 && code <= 0x7e) break
|
||||
end += 1
|
||||
}
|
||||
if (end >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
index = escape + 2
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush printable trailing data and discard incomplete controls.
|
||||
* @returns Remaining normalized printable text.
|
||||
*/
|
||||
flush(): string {
|
||||
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
this.awaitingPromptText = false
|
||||
const normalized = this.normalizeText(text)
|
||||
if (!this.trailingCarriageReturn) return normalized
|
||||
this.trailingCarriageReturn = false
|
||||
return `${normalized}\n`
|
||||
}
|
||||
|
||||
private normalizeText(text: string): string {
|
||||
let complete = this.trailingCarriageReturn ? `\r${text}` : text
|
||||
this.trailingCarriageReturn = false
|
||||
if (complete.endsWith('\r')) {
|
||||
complete = complete.slice(0, -1)
|
||||
this.trailingCarriageReturn = true
|
||||
}
|
||||
return normalizePtyTerminalText(complete)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
||||
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
||||
this.pending = ''
|
||||
}
|
||||
|
||||
private discardPrefix(chunk: string): string {
|
||||
if (this.discardMode === undefined) return chunk
|
||||
if (this.discardMode === 'csi') {
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let index = 0
|
||||
if (this.discardOscEscape) {
|
||||
this.discardOscEscape = false
|
||||
if (chunk.startsWith('\\')) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(1)
|
||||
}
|
||||
}
|
||||
while (index < chunk.length) {
|
||||
if (chunk[index] === '\x07') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
if (chunk[index] === '\x1b') {
|
||||
if (chunk[index + 1] === '\\') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 2)
|
||||
}
|
||||
if (index + 1 === chunk.length) this.discardOscEscape = true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
||||
* @param text - Sanitized terminal text.
|
||||
* @returns Line-normalized text with BEL removed.
|
||||
*/
|
||||
export function normalizePtyTerminalText(text: string): string {
|
||||
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a platform signal number into the seam's signal-name vocabulary.
|
||||
* @param number - Platform signal number, zero, or an absent signal.
|
||||
* @returns The matching Node signal name, or `null` when unknown or absent.
|
||||
*/
|
||||
export function ptySignalName(number: number | undefined): NodeJS.Signals | null {
|
||||
if (number === undefined || number === 0) return null
|
||||
for (const [name, value] of Object.entries(constants.signals)) {
|
||||
if (value === number) return name as NodeJS.Signals
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -47,7 +47,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/e2b/code-runtime-e2b': { kind: 'indirect', reason: 'The E2B backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
|
||||
@@ -99,7 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/e2b/lsp-e2b': { kind: 'indirect', reason: 'The E2B provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/e2b/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
|
||||
@@ -166,12 +166,10 @@
|
||||
{ "path": "./packages/e2b/subprocess-e2b" },
|
||||
{ "path": "./packages/bash/bash" },
|
||||
{ "path": "./packages/pty/pty" },
|
||||
{ "path": "./packages/e2b/pty-e2b" },
|
||||
{ "path": "./packages/pty/pty-local" },
|
||||
{ "path": "./packages/pty/tool-bash-persistent" },
|
||||
{ "path": "./packages/pty/tool-pty" },
|
||||
{ "path": "./packages/code-runtime/code-runtime" },
|
||||
{ "path": "./packages/e2b/code-runtime-e2b" },
|
||||
{ "path": "./packages/code-runtime/code-runtime-worker" },
|
||||
{ "path": "./packages/llm/llm-deepseek" },
|
||||
{ "path": "./packages/llm/llm-pi-ai" },
|
||||
@@ -268,7 +266,6 @@
|
||||
{ "path": "./packages/sdk/create-sdk" },
|
||||
{ "path": "./packages/sdk/telemetry" },
|
||||
{ "path": "./packages/lsp/lsp" },
|
||||
{ "path": "./packages/e2b/lsp-e2b" },
|
||||
{ "path": "./packages/lsp/lsp-local" },
|
||||
{ "path": "./packages/lsp/tool-lsp" },
|
||||
{ "path": "./apps/cli" }
|
||||
|
||||
Reference in New Issue
Block a user