Add E2B PTY, LSP, and code runtime providers

This commit is contained in:
Tianyi Cui
2026-08-08 22:19:10 +08:00
parent e7b682f1f6
commit 6667102890
82 changed files with 5462 additions and 644 deletions
@@ -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: 307a08d6ca77c83bf836bc8d50b071fbf33ac49a
2026-07-27-e2b-remote-runtime-poc.zh.md: bb4e657d999760f000949d509228bbb65f22773a
2026-07-27-e2b-remote-runtime-poc.md: 730e6f0e1958ccab43a5a6a11931f9407b38ef16
2026-07-27-e2b-remote-runtime-poc.zh.md: 5f1ae07b985787a7dc1bd61f2a5b7ff0512d85c1
@@ -16,18 +16,19 @@ The E2B integration is a provider-composition POC with one shared lifecycle owne
- `@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.
- The existing `@deepseek-ai/dsh-bash-local` remains the Bash implementation because it delegates all process mechanics to `ctx.subprocess`.
- PTY, LSP, and Code Runtime remain separate capability providers over the same owner, with their runtime split recorded in the [extension decision](2026-07-28-e2b-interactive-semantic-code-runtime-poc.md).
The owner is the sole source of sandbox identity. Providers inject it and never create private sandboxes. The composition therefore gives filesystem tools and Bash one remote cwd, process namespace, and spill/state directory while preserving the existing capability interfaces and model-facing tools.
## POC boundary
Only filesystem state, command processes during the provider lifetime, and adapter-owned remote files move into E2B. The host retains Cordis and plugin objects, the agent loop, agent/session state, session logs and persistence, model requests, skills, subagent orchestration, and E2B SDK buffers. The overlay does not upload or mount the host workspace; identical cwd strings name independent host and remote directories. Managed process groups still terminate and join when the subprocess service disposes, including before a retained-sandbox pause or leave disposition.
Filesystem state, managed commands, interactive shells, language servers, code workers, and adapter-owned files move into E2B. The host retains Cordis and plugin objects, the agent loop, agent/session state, session logs and persistence, model requests, skills, subagent orchestration, capability protocol state, and E2B SDK buffers. The overlay does not upload or mount the host workspace; identical cwd strings name independent host and remote directories. Managed process groups still terminate and join when their provider disposes, including before a retained-sandbox pause or leave disposition.
The POC has no PTY adapter, LSP-specific integration, session-persistence backend, code-runtime backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, or workspace synchronization. Retained sandbox reconnect proves lifecycle continuity only; it does not reconstruct host process handles, output cursors, or locks.
The POC has no session-persistence backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, or workspace synchronization. Retained sandbox reconnect proves lifecycle continuity only; it does not reconstruct host PTY/LSP/code-runtime state, process handles, output cursors, pending calls, or locks.
## Verification
Package tests pin lifecycle cleanup, filesystem seam semantics, subprocess group/stdio/abort behavior, and the package-owned invariant registrations. A credential-gated real Loader composition creates one sandbox, proves FS-write→Bash-read and Bash-write→FS-read in the same remote cwd, proves neither file appears in the host cwd, disposes the composition, and confirms the sandbox id is gone.
Package tests pin lifecycle cleanup, filesystem semantics, subprocess groups, byte framing, PTY readiness/signals, LSP transport and containment, Code Runtime binding/output behavior, and package-owned invariant registrations. A credential-gated real Loader composition creates one sandbox, exercises filesystem, Bash/subprocess, PTY, LSP, and Code Runtime through source and built package paths, proves host-workspace isolation, disposes the composition, and confirms the sandbox id is gone.
## Alternatives considered
@@ -35,7 +36,7 @@ Package tests pin lifecycle cleanup, filesystem seam semantics, subprocess group
**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.
**Add E2B-specific Bash, PTY, LSP, persistence, and synchronization packages together** — rejected because Bash already has the required subprocess seam and the other capabilities need separate consumer evidence and lifecycle designs. Their absence is an explicit fidelity boundary, not an incomplete hidden plan.
**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.
**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.
@@ -43,4 +44,4 @@ Package tests pin lifecycle cleanup, filesystem seam semantics, subprocess group
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 state retention for experiments, while kill remains the demo's cleanup policy.
The provider is not interchangeable with the local subprocess backend for every consumer: remote startup cannot synchronously expose a PID, E2B retains complete command output in SDK memory, callback output is not byte-faithful, signal attribution is inferred, and reconnect cannot restore handles. Remote process/spill artifacts accumulate in a retained sandbox. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
The provider is 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. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
@@ -16,18 +16,19 @@ E2B 集成是一个提供方组合 POC,由一个共享生命周期所有者和
- `@deepseek-ai/dsh-fs-e2b` 在该沙箱的 Filesystem API 之上实现 `ctx.fs`
- `@deepseek-ai/dsh-subprocess-e2b` 在 E2B Commands 和远程 Linux 进程组之上实现 `ctx.subprocess`
- 现有的 `@deepseek-ai/dsh-bash-local` 继续作为 Bash 实现,因为它把所有进程机制委托给 `ctx.subprocess`
- PTY、LSP 与 Code Runtime 仍是基于同一所有者的独立功能提供方;其运行时划分见[扩展决策](2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)。
该所有者是沙箱身份的唯一真源。提供方会注入该所有者,绝不创建私有沙箱。因此,该组合让文件系统工具与 Bash 共享一个远程 cwd、进程命名空间和 spill/状态目录,同时保留现有功能接口与面向模型的工具。
## POC 边界
只有文件系统状态、提供方存续期内的命令进程,以及适配器拥有的远程文件会迁入 E2B。宿主仍保留 Cordis 和插件对象、agent loop(智能体循环)、agent/会话状态、会话日志及其持久化、模型请求、skill(技能)、subagent 编排和 E2B SDK 缓冲。该 overlay 不会上传或挂载宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。受管进程组仍会在进程管理服务 dispose(资源释放)时终止并等待退出,包括保留沙箱采用 `pause``leave` 处置方式之前。
文件系统状态、受管命令、交互式 shell、语言服务器、代码 worker 和适配器拥有的文件会迁入 E2B。宿主仍保留 Cordis 和插件对象、agent loop(智能体循环)、agent/会话状态、会话日志及其持久化、模型请求、skill(技能)、subagent 编排、功能协议状态和 E2B SDK 缓冲。该 overlay 不会上传或挂载宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。受管进程组仍会在所属提供方 dispose(资源释放)时终止并等待退出,包括保留沙箱采用 `pause``leave` 处置方式之前。
本 POC 没有 PTY 适配器、LSP 专用集成、会话持久化后端、代码运行时后端、模板构建器、卷、快照、网络策略层、沙箱目录或工作区同步。保留沙箱后重新连接只能证明生命周期连续性;它不会重建宿主进程句柄、输出游标或锁。
本 POC 没有会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录或工作区同步。保留沙箱后重新连接只能证明生命周期连续性;它不会重建宿主 PTYLSPCode Runtime 状态、进程句柄、输出游标、待处理调用或锁。
## 验证
包测试固定生命周期清理、文件系统 seam 语义、进程管理的进程组/stdio/中止行为,以及包自有不变式注册。凭据门控的真实 Loader 组合会创建一个沙箱,证明同一远程 cwd 中 FS-write→Bash-read 和 Bash-write→FS-read 双向可见,证明两个文件均未出现在宿主 cwd 中,释放组合,并确认该沙箱 id 已不存在。
包测试固定生命周期清理、文件系统语义、进程管理的进程组、字节分帧、PTY 就绪状态/信号、LSP 传输与源码路径约束、Code Runtime 绑定/输出行为,以及包自有不变式注册。凭据门控的真实 Loader 组合会创建一个沙箱,通过源代码与已构建包路径演练文件系统、Bash/进程管理、PTY、LSP 和 Code Runtime,证明宿主工作区隔离,释放组合,并确认该沙箱 id 已不存在。
## 曾考虑的替代方案
@@ -35,7 +36,7 @@ E2B 集成是一个提供方组合 POC,由一个共享生命周期所有者和
**在 E2B 内运行完整 harness 进程。** 不予采纳,因为这会同时改变部署、凭据流、模型传输、会话持久性、插件加载和监管方式。要证明提供方 seam,并不需要同时回答这些彼此独立的问题。
**同时添加 E2B 专用 Bash、PTY、LSP、持久化和同步包。** 不予采纳,因为 Bash 已经具备所需的进程管理 seam,其他功能则需要各自的消费方证据和生命周期设计。缺少它们是显式保真边界,而不是尚未公开的不完整计划
**把所有 E2B 功能放入共享所有者包。** 不予采纳,因为生命周期身份是该所有者唯一负责的事项。文件系统、进程管理、PTY、LSP 与 Code Runtime 各自保留独立的提供方契约、配置、测试和消费方;Bash 继续复用其进程管理 seam
**仅通过 shell 命令实现文件系统操作。** 不予采纳,因为这会绕过文件工具已经使用的 `ctx.fs` 身份、结构化错误、版本防护、流式读取和原子变更语义。
@@ -43,4 +44,4 @@ E2B 集成是一个提供方组合 POC,由一个共享生命周期所有者和
这个小型组合证明,现有功能 seam 可以把 agent 的可变 coding 环境移出宿主,而无需改变循环或面向模型的工具包。`sandboxId``pause``leave` 允许实验手动保留状态,演示仍以 `kill` 作为清理策略。
该提供方并不能对所有消费方与本地进程管理后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,回调输出并非字节保真,信号归因依靠推断,重新连接也无法恢复句柄。保留沙箱后会累积远程进程/spill 产物。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
该提供方并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,普通命令回调并非字节保真,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 APILSP 与 Code Runtime 则在必须保真处理协议字节之处增加经过验证的 ASCII 分帧。保留沙箱后会累积远程进程/spill 产物。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md
2026-07-28-e2b-interactive-semantic-code-runtime-poc.md: befaa47dd56b7f425e72978ae040bd0815cc9f92
2026-07-28-e2b-interactive-semantic-code-runtime-poc.zh.md: 677c3a09f6846320d3e000e9607becb3dee865cf
@@ -0,0 +1,57 @@
# Agent Note: E2B interactive, semantic, and code-runtime POC
Status: implemented
English | [中文](2026-07-28-e2b-interactive-semantic-code-runtime-poc.zh.md)
## Problem
The [shared E2B runtime](2026-07-27-e2b-remote-runtime-poc.md) proves that filesystem operations and one-shot commands can inhabit one remote coding world, but an assembled coding agent also uses persistent terminals, language servers, and model-written Code Mode programs. Falling back to host implementations for those capabilities splits observable state: a Bash edit exists in E2B while a host PTY, LSP process, or code worker addresses a different filesystem and process namespace.
Moving the complete harness process into E2B would unify that state but also changes plugin loading, credentials, model transport, session durability, supervision, and deployment. The POC needs to test the existing capability boundaries without taking on those independent concerns.
## Decision
Three opt-in providers extend the existing shared sandbox:
- `@deepseek-ai/dsh-pty-e2b` registers an E2B byte-PTY backend on `ctx.pty` and keeps exact-Agent ownership in the existing registry.
- `@deepseek-ai/dsh-lsp-e2b` registers configured remote language servers on `ctx.lsp`, reads source through E2B Filesystem APIs, 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.
All three inject `ctx.e2b`; none creates another sandbox. The opt-in overlay composes them with `dsh-fs-e2b`, `dsh-subprocess-e2b`, and the existing `dsh-bash-local`, so files, foreground commands, interactive shell processes, language servers, and code workers observe one remote cwd.
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 moves into `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.
## Runtime boundary
E2B owns the mutable filesystem, command and Bash processes, PTY shell and foreground process groups, language-server processes and source reads, the Code Runtime runner and worker, and adapter-private files under `.dsh-e2b`.
The host owns Cordis and plugin objects, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority decisions, 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 host workspace is not mounted or synchronized merely because its absolute cwd string is reused remotely.
Byte-sensitive protocols use the narrowest adapter needed for E2B's callback shapes. PTY consumes the SDK's byte callback directly. LSP and Code Runtime install dependency-free remote helpers that encode raw payloads as validated newline-delimited base64 JSON, keeping E2B's decoded command callbacks on an ASCII transport.
Retaining a sandbox preserves remote files and any 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 are terminated and awaited when their provider disposes before the shared owner pauses, leaves, or kills the sandbox.
## Verification
Focused unit suites pin configuration, publication rollback, byte framing, multibyte boundaries, readiness, signals, timeout/abort ordering, output limits, hostile Code Runtime traffic, and disposal to quiescence. Adjacent local-backend suites pin the shared PTY utilities and the LSP cross-namespace `processId` behavior.
A credential-gated Loader composition creates one real E2B sandbox and exercises FS-to-Bash and Bash-to-FS visibility, multibyte PTY output and `SIGINT`, multibyte LSP hover and definition results, Code Runtime host bindings and typed rejection under mutation of adapter-captured intrinsics, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same scenario runs through source imports and built package exports.
## Alternatives considered
**Run the complete harness inside E2B** — rejected because it couples this provider experiment to credentials, LLM transport, plugin deployment, session persistence, supervision, and remote package installation. None is necessary to prove the capability seams.
**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.
**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.
**Add a generic distributed-runtime abstraction first** — rejected because the three existing capability seams already carry the required contracts. A new cross-cutting interface would speculate about persistence, synchronization, and reconnect semantics beyond the POC.
**Restore live capability handles after `sandboxId` reconnect** — rejected because remote identity alone cannot reconstruct host callbacks, pending promises, authority, protocol state, or output cursors. Claiming continuity would make stale remote processes appear managed when they are not.
## Consequences
The assembled POC keeps the coding world remote without moving the agent runtime or changing model-visible tool contracts. It demonstrates that PTY, LSP, and Code Runtime can share E2B state through existing plugins, while making the remaining host state explicit.
This is not a deployment platform. Language-server installation, templates, volumes, snapshots, network policy, workspace synchronization, durable remote handles, and whole-harness execution remain outside scope. E2B SDK buffering and host protocol state remain memory costs. Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a captured remote process group is not made reconnectable or owned by this composition.
@@ -0,0 +1,57 @@
# Agent Note: E2B 交互式、语义与代码运行时 POC
Status: implemented
[English](2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) | 中文
## 问题
[共享 E2B 运行时](2026-07-27-e2b-remote-runtime-poc.md)证明,文件系统操作与一次性命令可以共处一个远程 coding 环境,但组装完成的 coding agent(智能体)还会使用持久终端、语言服务器,以及模型编写的 Code Mode 程序。若这些功能回退到宿主实现,可观测状态就会分裂:Bash 修改存在于 E2B 中,而宿主 PTY、LSP 进程或代码 worker 面向的却是另一个文件系统与进程命名空间。
把完整 harness 进程迁入 E2B 可以统一这些状态,但也会改变插件加载、凭据、模型传输、会话持久性、监管和部署。这个 POC 只需测试现有功能边界,不应把这些彼此独立的问题纳入范围。
## 决策
三个可选提供方扩展现有共享沙箱:
- `@deepseek-ai/dsh-pty-e2b``ctx.pty` 上注册 E2B 字节 PTY 后端,并把精确的 Agent 所有权保留在现有注册表中。
- `@deepseek-ai/dsh-lsp-e2b``ctx.lsp` 上注册已配置的远程语言服务器,通过 E2B Filesystem API 读取源代码,并通过 `dsh-subprocess-e2b` 运行服务器。
- `@deepseek-ai/dsh-code-runtime-e2b` 注册 `ctx.codeRuntime`,在全新的远程 worker 中运行每个模型程序,并在宿主进程中分发绑定函数。
三个提供方均注入 `ctx.e2b`,无一创建其他沙箱。可选叠加配置将它们与 `dsh-fs-e2b``dsh-subprocess-e2b` 和现有的 `dsh-bash-local` 组合,使文件、前台命令、交互式 shell 进程、语言服务器和代码 worker 观察到同一个远程 cwd。
这些提供方复用 PTY、LSP、Code Runtime 与进程管理 seam,不更改面向模型的消费方或 agent loop(智能体循环)。后端无关的 PTY 文本处理移入 `dsh-pty`;LSP 协议引擎允许位于另一个进程命名空间的服务器使用 `processId: null`Code Runtime 导出输出账本与无损 JSON 辅助函数,以保持各后端一致。
## 运行时边界
E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与前台进程组、语言服务器进程及源码读取、Code Runtime 运行器和 worker,以及 `.dsh-e2b` 下的适配器私有文件。
宿主拥有 Cordis 与插件对象、agent/会话/goal 状态、会话日志及持久化、LLM(大语言模型)调用、提示词与工具、权限决策、PTY 缓冲与就绪状态、LSP JSON-RPC id/队列/协议状态、Code Runtime 类型剥离/输出计量/绑定分发,以及 E2B SDK/网络编排。宿主工作区不会仅因远程复用了其绝对 cwd 字符串就被挂载或同步。
对字节敏感的协议只使用适配 E2B 回调形状所需的最窄适配器。PTY 直接消费 SDK 的字节回调。LSP 与 Code Runtime 会安装无依赖的远程辅助程序,把原始载荷编码为经过验证、以换行分隔的 base64 JSON,并通过 ASCII 传输承载 E2B 已解码的命令回调。
保留沙箱只会保存远程文件与任何未受管的远程状态。重新连接不会重建宿主 PTY 会话、缓冲、进程句柄、LSP 连接或请求、代码 worker、绑定调用、定时器、输出游标或锁。受管进程组会在所属提供方 dispose(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。
## 验证
聚焦单元测试固定配置、发布回滚、字节分帧、多字节边界、就绪状态、信号、超时/中止顺序、输出上限、恶意 Code Runtime 通信,以及等待完全停稳的资源释放。相邻本地后端测试固定共享 PTY 工具函数,以及 LSP 跨命名空间 `processId` 行为。
凭据门控的 Loader 组合会创建一个真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、多字节 PTY 输出和 `SIGINT`、多字节 LSP 悬停与定义结果、Code Runtime 宿主绑定,以及适配器已捕获 intrinsic 被修改时的类型化 reject、墙钟超时、中止、运行器清理、宿主工作区隔离,以及最终删除沙箱。同一场景分别通过源代码导入与已构建包导出运行。
## 曾考虑的替代方案
**在 E2B 内运行完整 harness。** 不予采纳,因为这会把这项提供方实验与凭据、LLM 传输、插件部署、会话持久化、监管和远程包安装耦合。要证明功能 seam,无需引入任何一项。
**原样使用宿主 PTY、LSP 与 worker 后端。** 不予采纳,因为它们使用宿主的进程与文件系统 API;在不同机器上复用同一个绝对 cwd 字符串并不会共享状态。
**把 E2B Commands 公开为通用传输并绕过功能提供方。** 不予采纳,因为 PTY 需要字节回调和前台信号,LSP 需要字节保真的 stdio 与远程源码路径约束,Code Runtime 则需要双向宿主绑定调用与不可信对等方验证。绕过其注册表还会使面向模型的行为产生分叉。
**先添加通用分布式运行时抽象。** 不予采纳,因为现有三个功能 seam 已承载所需契约。新的跨领域接口会预先假定 POC 范围之外的持久化、同步与重连语义。
**在 `sandboxId` 重连后恢复活动功能句柄。** 不予采纳,因为只有远程身份,无法重建宿主回调、待处理 promise、权限、协议状态或输出游标。若声称保持连续性,就会让陈旧的远程进程看似仍受管理,实际并非如此。
## 后果
组装后的 POC 在不迁移 agent 运行时、不改变模型可见工具契约的前提下,把 coding 环境保留在远程。它证明 PTY、LSP 与 Code Runtime 可以通过现有插件共享 E2B 状态,同时明确列出仍留在宿主的状态。
这不是部署平台。语言服务器安装、模板、卷、快照、网络策略、工作区同步、持久远程句柄和完整 harness 执行仍不在范围内。E2B SDK 缓冲与宿主协议状态仍会占用内存。模型程序与 Node worker 内部机制共享一个 JavaScript realm;有意逃离已捕获远程进程组的进程,也不会因此变得可重新连接或由该组合管理。
+2 -2
View File
@@ -591,7 +591,7 @@ Owns one lazily consumable E2B SDK handle and its final kill/pause/leave decisio
async getSandbox(): Promise<Sandbox>
```
Source: [`packages/e2b/e2b/src/index.ts:97`](../../packages/e2b/e2b/src/index.ts)
Source: [`packages/e2b/e2b/src/index.ts:119`](../../packages/e2b/e2b/src/index.ts)
## `ctx.fs` — `FileSystem` (abstract seam)
@@ -1180,7 +1180,7 @@ list(owner: Agent): PtySessionSnapshot[]
Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md)
Source: [`packages/pty/pty/src/index.ts:105`](../../packages/pty/pty/src/index.ts)
Source: [`packages/pty/pty/src/index.ts:114`](../../packages/pty/pty/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
+31 -3
View File
@@ -1,9 +1,10 @@
# POC overlay: keep the headless agent and model-facing tools, but place its
# filesystem and managed Bash process world in one short-lived E2B sandbox.
# 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.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
path: ./advanced.cordis.yml
patches:
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
@@ -11,6 +12,9 @@
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
disabled: true
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
disabled: true
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -28,3 +32,27 @@
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: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-e2b
name: '@deepseek-ai/dsh-pty-e2b'
- 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'
config:
servers:
typescript:
command: npx
args: [--yes, typescript-language-server@5.0.0, --stdio]
extensionToLanguage:
.ts: typescript
.tsx: typescriptreact
.js: javascript
.jsx: javascriptreact
- id: tool-lsp
name: '@deepseek-ai/dsh-tool-lsp'
+143
View File
@@ -1,13 +1,38 @@
import { readFile } from 'node:fs/promises'
import { 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-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'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('usage: bin.ts <cordis.yml>')
const ctx = await boot('e2b-composition', resolve(configPath))
const ownerFiber = ctx.plugin(() => {})
const ownerId = SessionId('e2b-live-owner')
const owner: Agent = {
id: ownerId,
options: {},
session: new Session(ownerId),
status: 'idle',
acceptsNextStep: false,
ctx: ownerFiber.ctx,
followup: () => AgentMessageId('unused'),
steer: () => AgentMessageId('unused'),
inject: () => AgentMessageId('unused'),
send: () => AgentMessageId('unused'),
cancel() {},
whenIdle: () => Promise.resolve(),
}
const unregisterOwner = ctx.agents.register(owner)
let terminalId: Awaited<ReturnType<typeof ctx.pty.spawn>>['sessionId'] | undefined
try {
const fromFs = await ctx.fs.resolve('from-fs.txt')
await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' })
@@ -22,11 +47,129 @@ try {
}
const fromBash = await ctx.fs.resolve('from-bash.txt')
const fsRead = await ctx.fs.readText(fromBash)
const lspFixture = await readFile(new URL('./fixture-lsp.mjs', import.meta.url), 'utf8')
const remoteLspFixture = await ctx.fs.resolve('fixture-lsp.mjs')
await ctx.fs.writeText(remoteLspFixture, lspFixture, { kind: 'createIfAbsent' })
const remoteSource = await ctx.fs.resolve('multibyte.ts')
await ctx.fs.writeText(remoteSource, 'const café = "你好"\nconsole.log(café)\n', { kind: 'createIfAbsent' })
const hover = await ctx.lsp.query({
operation: 'hover',
filePath: 'multibyte.ts',
position: { line: 0, character: 7 },
workspaceRoot: process.cwd(),
})
const definition = await ctx.lsp.query({
operation: 'goToDefinition',
filePath: 'multibyte.ts',
position: { line: 0, character: 7 },
workspaceRoot: process.cwd(),
})
const terminal = await ctx.pty.spawn(owner, { type: 'shell' })
terminalId = terminal.sessionId
const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, {
text: "printf 'PTY-你好\\n'",
submit: true,
}).done
const sleeping = ctx.pty.startSend(owner, terminal.sessionId, { text: 'sleep 30', submit: true })
await new Promise(resolveDelay => setTimeout(resolveDelay, 150))
const terminalSignal = await ctx.pty.signal(owner, terminal.sessionId, 'SIGINT')
const interrupted = await sleeping.done
const terminalScrollback = ctx.pty.read(owner, terminal.sessionId, { count: 50 })
await ctx.pty.kill(owner, terminal.sessionId, 'live E2B composition complete')
terminalId = undefined
const code = await ctx.codeRuntime.run({
program: `
console.log('remote-log 你好', 42)
const arrayPrototype = Array.prototype
const objectPrototype = Object.prototype
const setPrototype = Set.prototype
const stringPrototype = String.prototype
Array.isArray = () => false
Object.defineProperty = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') }
Object.hasOwn = () => false
Object.is = () => true
objectPrototype.propertyIsEnumerable = () => false
Number.isFinite = Number.isSafeInteger = () => false
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') }
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') }
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') }
Buffer.byteLength = () => 0
Function.prototype.toString = () => 'mutated'
objectPrototype.constructor = arrayPrototype.constructor = null
globalThis.Array = globalThis.Buffer = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Promise = globalThis.Reflect = globalThis.Set = globalThis.String = undefined
process.stdout.write('post-mutation', () => {})
const doubled: number = await bridge.double({ value: 21 })
let typed = false
try {
await bridge.fail({ reason: 'expected' })
} catch (error) {
typed = error instanceof BridgeError && (error as { member: string }).member === 'fail'
}
return { doubled, typed }
`,
bindings: [{
global: 'bridge',
errorClass: { name: 'BridgeError', memberNameProperty: 'member' },
functions: {
double: async (args) => {
const value = (args as { value: number }).value
return value * 2
},
fail: async () => { throw new Error('binding rejected') },
},
}],
})
const hostileOutput = await ctx.codeRuntime.run({
program: `
const payload = '🙂'.repeat(4096)
String.prototype[Symbol.iterator] = () => { throw new Error('mutated string iterator') }
console.log(payload)
return true
`,
bindings: [],
})
const timedOut = await ctx.codeRuntime.run({
program: 'await new Promise(() => {})',
bindings: [],
})
const abortController = new AbortController()
const aborting = ctx.codeRuntime.run({
program: 'await new Promise(() => {})',
bindings: [],
signal: abortController.signal,
})
setTimeout(() => { abortController.abort('live abort') }, 50)
const aborted = await aborting
const remoteProcesses = await (await ctx.e2b.getSandbox()).commands.list()
const lingeringCodeRunners = remoteProcesses.filter(processInfo =>
JSON.stringify([processInfo.cmd, processInfo.args]).includes('code-runtime-runner.mjs'),
)
process.stdout.write(`${JSON.stringify({
sandboxId: await ctx.e2b.sandboxId,
bashRead: bashRead.stdout.text,
fsRead,
hover,
definition,
terminal: {
motd: terminal.motd,
echo: terminalEcho,
signal: terminalSignal,
interrupted,
scrollback: terminalScrollback.text,
},
code,
hostileOutput,
timedOut,
aborted,
lingeringCodeRunners: lingeringCodeRunners.length,
})}\n`)
} finally {
if (terminalId !== undefined) await ctx.pty.kill(owner, terminalId, 'fixture cleanup').catch(() => false)
unregisterOwner()
await ownerFiber.dispose()
await ctx.fiber.dispose()
}
@@ -17,3 +17,43 @@
- id: fs-e2b
name: '@deepseek-ai/dsh-fs-e2b'
- id: agents
name: '@deepseek-ai/dsh-agent'
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-e2b
name: '@deepseek-ai/dsh-pty-e2b'
config:
pollIntervalMs: 25
idleSilenceMs: 2000
timeoutMs: 5000
disposeGraceMs: 1000
- id: lsp
name: '@deepseek-ai/dsh-lsp'
- id: lsp-e2b
name: '@deepseek-ai/dsh-lsp-e2b'
config:
servers:
fixture:
command: node
args:
- !!js process.cwd() + '/fixture-lsp.mjs'
extensionToLanguage:
.ts: typescript
shutdownTimeoutMs: 1000
killGraceMs: 500
- id: code-runtime-e2b
name: '@deepseek-ai/dsh-code-runtime-e2b'
config:
computeMs: 500
maxWallMs: 5000
maxOutputBytes: 4096
maxOldGenerationSizeMb: 128
maxFrameBytes: 4194304
killGraceMs: 500
@@ -0,0 +1,85 @@
import { Buffer } from 'node:buffer'
let pending = Buffer.alloc(0)
let source = ''
let sourceUri = ''
function send(message) {
const body = Buffer.from(JSON.stringify(message))
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`)
process.stdout.write(body)
}
function respond(id, result) {
send({ jsonrpc: '2.0', id, result })
}
function dispatch(message) {
switch (message.method) {
case 'initialize':
respond(message.id, {
capabilities: {
positionEncoding: 'utf-16',
textDocumentSync: { openClose: true, change: 1 },
definitionProvider: true,
referencesProvider: true,
implementationProvider: true,
hoverProvider: true,
},
})
return
case 'textDocument/didOpen':
source = message.params.textDocument.text
sourceUri = message.params.textDocument.uri
return
case 'textDocument/didClose':
source = ''
sourceUri = ''
return
case 'textDocument/hover':
if (!source.includes('const café = "你好"')) {
send({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'multibyte source was corrupted' } })
return
}
respond(message.id, {
contents: { kind: 'markdown', value: '**remote hover** 你好 café' },
range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } },
})
return
case 'textDocument/definition':
case 'textDocument/references':
case 'textDocument/implementation':
respond(message.id, [{
uri: sourceUri,
range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } },
}])
return
case 'shutdown':
respond(message.id, null)
return
case 'exit':
process.exit(0)
return
}
}
function drain() {
for (;;) {
const headerEnd = pending.indexOf('\r\n\r\n')
if (headerEnd < 0) return
const header = pending.subarray(0, headerEnd).toString('ascii')
const match = /(?:^|\r\n)Content-Length: ([0-9]+)(?:\r\n|$)/i.exec(header)
if (!match) throw new Error('missing Content-Length')
const length = Number(match[1])
const bodyStart = headerEnd + 4
if (pending.length < bodyStart + length) return
const body = pending.subarray(bodyStart, bodyStart + length)
pending = pending.subarray(bodyStart + length)
dispatch(JSON.parse(body.toString('utf8')))
}
}
process.stdin.on('data', chunk => {
pending = Buffer.concat([pending, chunk])
drain()
})
+5
View File
@@ -43,10 +43,12 @@
"@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:*",
@@ -54,7 +56,10 @@
"@deepseek-ai/dsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
<<<<<<< HEAD
"@deepseek-ai/dsh-scope": "workspace:*",
=======
>>>>>>> 3f6c905d5e (Add E2B PTY, LSP, and code runtime providers)
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/README.md
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819
README.md: f59fc3b15331b4799cbc7a3fbe27ce8cd9a12e51
README.zh.md: 02ade97887fab8a8a0c22efcf73bb2dd1f2cf33c
+5 -4
View File
@@ -2,11 +2,12 @@
English | [中文](README.zh.md)
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|
| [`code-runtime/`](code-runtime/README.md) | Code-execution seam and shared vocabulary | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend | registers `ctx.codeRuntime` |
| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend: fresh worker per run, TypeScript via host-side type-strip (annotations advisory, never type-checked), port-bridged bindings, budget/heap containment | registers `ctx.codeRuntime` |
| [`code-runtime-e2b/`](code-runtime-e2b/README.md) | E2B backend: host type-strip and bindings, fresh remote runner/worker, framed bridge, remote process-group cleanup | registers `ctx.codeRuntime` |
Backends register the seam without changing its consumer. The child READMEs own language, isolation, and execution-budget details.
Backends differ by execution substrate and source language—both readonly descriptors on the service—and register `ctx.codeRuntime` without touching the interface or its consumer. The E2B ownership split is recorded in the [remote extension note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md).
+7 -6
View File
@@ -1,12 +1,13 @@
# code-runtime/代码执行能力家族
# code-runtime/代码执行能力家族
[English](README.md) | 中文
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK);设计 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些是**产品**包。
代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些是**产品** 包。
| 包 | 职责 | ctx key |
| 包 | 职责 | ctx |
|---|---|---|
| [`code-runtime/`](code-runtime/README.md) | 代码执行 seam 与共享词汇 | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker 线程后端 | 注册 `ctx.codeRuntime` |
| `code-runtime/` | 抽象代码执行 seam(接口 + 词汇 | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | worker 线程后端:每次运行使用全新 worker,由宿主侧剥离 TypeScript 类型(类型注解仅供参考,绝不执行类型检查)、端口桥接绑定、预算/堆隔离 | 注册 `ctx.codeRuntime` |
| [`code-runtime-e2b/`](code-runtime-e2b/README.md) | E2B 后端:宿主侧类型剥离与绑定、全新远程 runner/worker、分帧桥、远程进程组清理 | 注册 `ctx.codeRuntime` |
后端在不改变消费方的情况下注册该 seam。子 README 负责语言、隔离和执行预算细节
不同后端的执行基底和源语言各异,二者都是服务上的只读描述符;后端注册 `ctx.codeRuntime`,无需修改接口或消费方。E2B 所有权拆分记录在[远程扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) 中
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-e2b/README.md
README.md: bf26174c59c1df18f63278acb7923fd409860b44
README.zh.md: c87945da30a6f8d77eb3722879510bb6159b3ee8
@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-code-runtime-e2b
English | [中文](README.zh.md)
E2B implementation of [`ctx.codeRuntime`](../code-runtime/README.md). Each run executes one model-written TypeScript program in a fresh remote Node worker while binding functions, type stripping, output accounting, and lifecycle orchestration remain on the host.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `computeMs` | `60000` | Remote worker event-loop busy-time budget. |
| `maxWallMs` | `600000` | Host-observed wall-clock ceiling. |
| `maxOutputBytes` | `67108864` | Combined serialized outer logs/value/diagnostic cap. |
| `maxOldGenerationSizeMb` | `512` | Remote worker old-generation heap cap in MiB. |
| `maxFrameBytes` | `268435456` | Largest decoded bridge frame, including binding traffic. |
| `killGraceMs` | `2000` | Remote process-group TERM-to-KILL grace. |
Every value is a positive safe integer. `maxOutputBytes` is at least four bytes, `maxWallMs` cannot exceed Node's maximum timer delay, and `maxFrameBytes` cannot be smaller than `maxOutputBytes`. The service requires the concrete `dsh-subprocess-e2b` backend so run cleanup has remote process-group semantics.
## Execution and bridge contract
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner creates a fresh worker thread with an empty environment and heap limit, measures active event-loop time, and destroys that worker after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
The bridge uses validated newline-delimited base64 JSON frames because E2B subprocess callbacks expose decoded text. Binding arguments and resolutions use the worker runtime's iterative lossless-JSON wire shape; binding functions execute on the host and typed rejection classes are materialized inside the remote worker. The worker captures the JavaScript intrinsics that its adapter boundary invokes before model code runs, hardening binding transport, output accounting, and completion validation against mutation of those references. The host repeats message validation, call-id deduplication, lossless-JSON checks, and the outer-output ledger.
Program failures resolve as `CodeRunResult.error`; only seam misuse rejects. `isolation` is reported as `container`, which is a deployment descriptor rather than a security claim.
## Model Experience
Indirectly, through Code Mode in `dsh-tools`, which returns program logs, values, or typed failures through the existing `run_code` result contract.
#### KV Cache effect
No direct invalidation; Code Mode owns request-prefix changes.
## Known Limitations and Deferred Work
- **Not a whole-agent runtime** — Cordis, sessions, LLM calls, binding dispatch, TypeScript stripping, output ledgers, and E2B SDK state remain on the host.
- **No reconnectable runs** — retaining a sandbox preserves files but not worker/subprocess handles, binding calls, timers, or output cursors.
- **Node worker internals share the model realm** — mutating realm-wide globals or prototypes that Node itself uses can terminate the worker; captured adapter intrinsics are not a separate JavaScript realm or a security boundary.
- **Deliberate process-group escape is not captured** — model code can create a new POSIX session; that unmanaged process is outside this backend's cleanup identity.
- **Intermediate binding traffic is memory-bounded only per frame** — it does not enter model context or the outer-output ledger, but aggregate host/remote process memory remains the limit.
- **Experimental type stripping** — the backend shares the worker implementation's reliance on Node's experimental erasable-syntax API.
- **Sandbox policy is template-owned** — this package adds no network, volume, snapshot, or workspace-synchronization policy.
@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-code-runtime-e2b
[English](README.md) | 中文
[`ctx.codeRuntime`](../code-runtime/README.md) 的 E2B 实现。每次运行都会在全新的远程 Node worker 中执行一段模型编写的 TypeScript 程序;绑定函数、类型剥离、输出记账和生命周期编排仍保留在宿主侧。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `computeMs` | `60000` | 远程 worker 的事件循环忙碌时间预算。 |
| `maxWallMs` | `600000` | 宿主观测到的墙钟时间上限。 |
| `maxOutputBytes` | `67108864` | 外层日志、值和诊断合计的序列化上限。 |
| `maxOldGenerationSizeMb` | `512` | 远程 worker 的老生代堆上限(MiB)。 |
| `maxFrameBytes` | `268435456` | 已解码桥接帧的最大大小,包括绑定流量。 |
| `killGraceMs` | `2000` | 远程进程组 TERM 到 KILL 的宽限期。 |
每个值都必须是正的安全整数。`maxOutputBytes` 必须至少为 4 字节,`maxWallMs` 不得超过 Node 的最大定时器延迟,且 `maxFrameBytes` 不得小于 `maxOutputBytes`。本服务要求使用具体的 `dsh-subprocess-e2b` 后端,使运行清理具备远程进程组语义。
## 执行与桥接契约
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会创建一个具有空环境与堆上限的全新 worker 线程,测量事件循环活跃时间,并在一次运行结算后销毁该 worker。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
由于 E2B 进程管理回调公开的是已解码文本,桥接层使用经过验证、以换行分隔的 base64 JSON 帧。绑定参数与 resolve 值使用 worker 运行时的迭代式无损 JSON wire 形状;绑定函数在宿主执行,类型化的 reject 类则在远程 worker 内物化。worker 会在模型代码运行前捕获其适配器边界调用的 JavaScript intrinsic,从而增强绑定传输、输出记账与完成值验证对这些引用修改的抵御能力。宿主会再次执行消息验证、调用 id 去重和无损 JSON 检查,并用外层输出账本再次计量。
程序失败会 resolve 为 `CodeRunResult.error`;只有 seam 误用才会 reject。`isolation` 报告为 `container`;这是部署描述符,不构成安全声明。
## 模型体验
通过 `dsh-tools` 中的 Code Mode 间接影响模型;它会通过现有 `run_code` 结果契约返回程序日志、值或类型化失败。
#### KV Cache 影响
不会直接失效;请求前缀变更由 Code Mode 负责。
## 已知限制与暂缓工作
- **并非完整的 agent(智能体)运行时**:Cordis、会话、LLM(大语言模型)调用、绑定分发、TypeScript 类型剥离、输出账本和 E2B SDK 状态仍保留在宿主侧。
- **运行不可重连**:保留沙箱会保留文件,但不会保留 worker/进程管理句柄、绑定调用、定时器或输出游标。
- **Node worker 内部机制与模型共享同一 realm**:修改 Node 自身使用、影响整个 realm 的全局对象或原型可能会终止 worker;已捕获的适配器 intrinsic 并不构成独立的 JavaScript realm 或安全边界。
- **不会捕获有意逃逸进程组的行为**:模型代码可以创建新的 POSIX 会话;该非受管进程不属于此后端的清理身份范围。
- **中间绑定流量的内存边界仅适用于单帧**:它不会进入模型上下文或外层输出账本,但其总量仍只受宿主/远程进程内存限制。
- **实验性类型剥离**:该后端与 worker 实现一样,依赖 Node 的实验性可擦除语法 API。
- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。
@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-code-runtime-e2b",
"description": "E2B code-runtime implementation for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"@deepseek-ai/dsh-code-runtime-worker": "^0.0.1",
"@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subprocess-e2b": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,441 @@
/** E2B process/worker implementation of the harness code-runtime seam. */
import { posix } from 'node:path'
import { stripTypeScriptTypes } from 'node:module'
import type { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type {
CodeBindingNamespace,
CodeJsonValue,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,
} from '@deepseek-ai/dsh-code-runtime'
import {
E2BFrameDecoder,
encodeE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import {
decodeWorkerJson,
encodeWorkerJson,
OutputLedger,
} from '@deepseek-ai/dsh-code-runtime-worker'
import type { WorkerJsonWire } from '@deepseek-ai/dsh-code-runtime-worker'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { CODE_RUNNER_SOURCE } from './runner-source.ts'
/** Runtime configuration; every execution and bridge bound is deployment-tunable. */
export interface Config {
/** Remote worker measured event-loop busy-time budget. */
computeMs?: number
/** Host-observed wall-clock ceiling. */
maxWallMs?: number
/** Combined serialized outer logs/value/diagnostic cap. */
maxOutputBytes?: number
/** Remote worker old-generation heap cap in MiB. */
maxOldGenerationSizeMb?: number
/** Largest decoded bridge frame, including binding traffic. */
maxFrameBytes?: number
/** Remote process-group TERM-to-KILL grace. */
killGraceMs?: number
}
type ResolvedConfig = Required<Config>
interface LiveRun {
settle(failure: CodeRunFailure): void
finished: Promise<void>
}
interface CallMessage {
type: 'call'
id: number
global: string
name: string
args: WorkerJsonWire
}
interface LogMessage {
type: 'log'
text: string
}
interface DoneMessage {
type: 'done'
value?: WorkerJsonWire
error?: CodeRunFailure
}
type RunnerMessage = CallMessage | LogMessage | DoneMessage | { type: 'output-limit' }
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
const MIN_OUTPUT_BYTES = 4
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/* jscpd:ignore-start -- Backends enforce the same injected-global vocabulary without coupling lifecycle implementations. */
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
])
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
/* jscpd:ignore-end */
const FAILURE_KINDS = new Set<CodeRunFailure['kind']>([
'exception', 'timeout', 'abort', 'worker-exit', 'invalid-output', 'output-limit',
])
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function parseRunnerMessage(raw: unknown): RunnerMessage | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const record = raw as Record<string, unknown>
if (record.type === 'output-limit') return { type: 'output-limit' }
if (record.type === 'log') return typeof record.text === 'string' ? { type: 'log', text: record.text } : undefined
if (record.type === 'call') {
if (!Number.isSafeInteger(record.id) || (record.id as number) < 1 || typeof record.global !== 'string' || typeof record.name !== 'string' || !Array.isArray(record.args)) return undefined
return { type: 'call', id: record.id as number, global: record.global, name: record.name, args: record.args as WorkerJsonWire }
}
if (record.type !== 'done') return undefined
if (record.error === undefined) {
return { type: 'done', ...record.value === undefined ? {} : { value: record.value as WorkerJsonWire } }
}
if (typeof record.error !== 'object' || record.error === null) return undefined
const error = record.error as Record<string, unknown>
if (typeof error.kind !== 'string' || !FAILURE_KINDS.has(error.kind as CodeRunFailure['kind']) || typeof error.message !== 'string') return undefined
return { type: 'done', error: { kind: error.kind as CodeRunFailure['kind'], message: error.message } }
}
/** E2B-backed runtime: host-side type stripping, remote worker execution, host binding dispatch. */
export class E2BCodeRuntime extends CodeRuntime {
static inject = ['e2b', 'subprocess']
static Config: z<Config> = z.object({
computeMs: z.number().default(60_000),
maxWallMs: z.number().default(600_000),
maxOutputBytes: z.number().default(67_108_864),
maxOldGenerationSizeMb: z.number().default(512),
maxFrameBytes: z.number().default(268_435_456),
killGraceMs: z.number().default(2_000),
})
readonly language = 'typescript'
readonly isolation = 'container'
private readonly config: ResolvedConfig
private readonly ready: Promise<{ node: string; runner: string }>
private readonly live = new Set<LiveRun>()
private readonly subprocess: E2BSubprocessService
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
throw new Error('code-runtime-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
}
this.subprocess = ctx.subprocess
this.config = config as ResolvedConfig
for (const [key, value] of Object.entries(this.config)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`code-runtime-e2b: config.${key} must be a positive safe integer`)
}
}
if (this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
throw new Error(`code-runtime-e2b: config.maxOutputBytes must be at least ${MIN_OUTPUT_BYTES}`)
}
if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
throw new Error(`code-runtime-e2b: config.maxWallMs must be at most ${MAX_TIMER_DELAY_MS}`)
}
if (this.config.maxFrameBytes < this.config.maxOutputBytes) {
throw new Error('code-runtime-e2b: config.maxFrameBytes must be at least maxOutputBytes')
}
this.ready = this.prepare()
void this.ready.catch(() => {})
ctx.effect(() => () => this.teardown(), 'E2B code-runtime teardown')
}
/* jscpd:ignore-start -- Seam-level abort and type-strip results remain identical across execution substrates. */
/** Execute one type-stripped program in a fresh E2B worker process. */
async run(request: CodeRunRequest): Promise<CodeRunResult> {
if (this.disposed) throw new Error('code-runtime-e2b: run() after disposal')
const bindings = this.validateBindings(request)
if (request.signal?.aborted === true) {
return this.failure({ kind: 'abort', message: String(request.signal.reason) })
}
let code: string
try {
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
} catch (error: unknown) {
return this.failure({ kind: 'exception', message: messageOf(error) })
}
let runtime: Awaited<typeof this.ready>
try {
runtime = await this.ready
} catch (error: unknown) {
return this.failure({ kind: 'worker-exit', message: `E2B runtime setup failed: ${messageOf(error)}` })
}
// Disposal can race the awaited remote setup after the pre-await check.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
return await this.execute(request, code, bindings, runtime)
}
/* jscpd:ignore-end */
private async prepare(): Promise<{ node: string; runner: string }> {
const sandbox = await this.ctx.e2b.getSandbox()
const runner = posix.join(this.ctx.e2b.runtimeRoot, 'code-runtime-runner.mjs')
await sandbox.files.write([{ path: runner, data: CODE_RUNNER_SOURCE }])
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(runner)}`)
const node = await resolveE2BExecutable(sandbox, 'node')
return { node, runner }
}
private failure(error: CodeRunFailure): CodeRunResult {
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
}
/* jscpd:ignore-start -- Binding names have one seam contract while dispatch and teardown remain backend-owned. */
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`code-runtime-e2b: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`code-runtime-e2b: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (descriptor === undefined) continue
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`code-runtime-e2b: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`code-runtime-e2b: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
throw new Error(`code-runtime-e2b: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
/* jscpd:ignore-end */
private async execute(
request: CodeRunRequest,
code: string,
bindings: Map<string, CodeBindingNamespace>,
runtime: { node: string; runner: string },
): Promise<CodeRunResult> {
const handle = this.subprocess.spawn({
argv: [runtime.node, runtime.runner],
cwd: this.ctx.e2b.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: this.config.maxOutputBytes } },
graceMs: this.config.killGraceMs,
...request.signal === undefined ? {} : { signal: request.signal },
env: {},
})
if (handle.stdin === undefined || handle.stdout === undefined) {
handle.terminate()
await Promise.allSettled([handle.done])
try {
await handle.waitForExit()
} catch (error: unknown) {
return this.failure({ kind: 'worker-exit', message: `E2B runtime cleanup failed: ${messageOf(error)}` })
}
return this.failure({ kind: 'worker-exit', message: 'E2B subprocess dropped a piped runtime stream' })
}
const stdin = handle.stdin
const stdout = handle.stdout
return new Promise<CodeRunResult>((resolve) => {
const output = new OutputLedger(this.config.maxOutputBytes)
const logs: string[] = []
const answered = new Set<number>()
const decoder = new E2BFrameDecoder(this.config.maxFrameBytes)
let settled = false
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const wallTimer: { current: NodeJS.Timeout | undefined } = { current: undefined }
const live: LiveRun = {
finished,
settle: (failure) => { finish(() => output.failure(logs, failure)) },
}
const finish = (result: CodeRunResult | (() => CodeRunResult)): void => {
if (settled) return
settled = true
clearTimeout(wallTimer.current)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
handle.terminate()
await handle.done.catch(() => {})
let cleanupError: unknown
try {
await handle.waitForExit()
} catch (error: unknown) {
cleanupError = error
}
try {
decoder.finish()
} catch (error: unknown) {
result = output.failure(logs, { kind: 'worker-exit', message: messageOf(error) })
}
if (cleanupError !== undefined) {
result = output.failure(logs, { kind: 'worker-exit', message: `E2B runtime cleanup failed: ${messageOf(cleanupError)}` })
}
const final = typeof result === 'function' ? result() : result
finishResolve()
resolve(final)
})
}
const sendReply = (message: unknown): void => {
if (settled) return
stdin.write(encodeE2BFrame(message), (error?: Error | null) => {
if (error !== undefined && error !== null) {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge write failed: ${error.message}` }))
}
})
}
/* jscpd:ignore-start -- Host binding resolution mirrors worker semantics over a different transport. */
const onCall = (message: CallMessage): void => {
if (answered.has(message.id)) return
answered.add(message.id)
const functions = bindings.get(message.global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, message.name) ? functions[message.name] : undefined
if (typeof fn !== 'function') {
sendReply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
return
}
const args = decodeWorkerJson(message.args)
if (args === undefined) {
sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
return
}
void (async () => {
try {
const resolved = await fn(args)
let value: CodeJsonValue | undefined
try {
value = snapshotJsonValue(resolved)
} catch {
value = undefined
}
if (value === undefined) {
sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
} else {
sendReply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
}
} catch (error: unknown) {
sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
}
})()
}
/* jscpd:ignore-end */
const onMessage = (raw: unknown): void => {
if (settled) return
const message = parseRunnerMessage(raw)
if (message === undefined) return
if (message.type === 'log') {
if (!output.admit(message.text, logs)) finish(output.limit([...logs, message.text]))
return
}
if (message.type === 'output-limit') {
finish(output.limit(logs))
return
}
if (message.type === 'call') {
onCall(message)
return
}
if (message.error !== undefined) {
finish(() => output.failure(logs, message.error as CodeRunFailure))
} else if (message.value === undefined) {
finish(() => output.success(logs))
} else {
const value = decodeWorkerJson(message.value)
if (value === undefined) finish(() => output.failure(logs, { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
else finish(() => output.success(logs, value))
}
}
stdout.on('data', (chunk: Buffer) => {
if (settled) return
try {
for (const frame of decoder.push(chunk.toString('utf8'))) onMessage(frame)
} catch (error: unknown) {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge failed: ${messageOf(error)}` }))
}
})
stdout.on('error', (error: Error) => {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime stdout failed: ${error.message}` }))
})
stdin.on('error', (error: Error) => {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime stdin failed: ${error.message}` }))
})
void handle.done.then(
() => {
if (!settled) {
const stderr = handle.collected.stderr?.readFrom(0).text.trim()
finish(() => output.failure(logs, { kind: 'worker-exit', message: stderr === undefined || stderr === '' ? 'E2B runtime exited before completing' : `E2B runtime exited before completing: ${stderr}` }))
}
},
(error: unknown) => {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime spawn failed: ${messageOf(error)}` }))
},
)
const onAbort = (): void => {
finish(() => output.failure(logs, { kind: 'abort', message: String(request.signal?.reason) }))
}
request.signal?.addEventListener('abort', onAbort, { once: true })
wallTimer.current = setTimeout(() => {
finish(() => output.failure(logs, { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
}, this.config.maxWallMs)
this.live.add(live)
if (request.signal?.aborted === true) {
onAbort()
return
}
sendReply({
type: 'boot',
code,
namespaces: [...bindings].map(([global, namespace]) => ({
global,
names: Object.keys(namespace.functions),
...namespace.errorClass === undefined ? {} : { errorClass: namespace.errorClass },
})),
computeMs: this.config.computeMs,
maxOutputBytes: this.config.maxOutputBytes,
maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb,
})
})
}
/* jscpd:ignore-start -- Code-runtime backends share the service lifecycle but own different child identities. */
private async teardown(): Promise<void> {
this.disposed = true
const runs = [...this.live]
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
await Promise.all(runs.map(run => run.finished))
}
/* jscpd:ignore-end */
}
export default E2BCodeRuntime
@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-e2b'
/** Cordis companion plugin name. */
export const name = 'code-runtime-e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the service owns every one-shot remote run. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,460 @@
/** Dependency-free remote code runner installed inside the E2B sandbox. */
/** Node program that runs one model program in a fresh remote worker thread. */
export const CODE_RUNNER_SOURCE = String.raw`import { Buffer } from 'node:buffer'
import { inspect } from 'node:util'
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
import { createInterface } from 'node:readline'
const emitFrame = message => {
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
}
const parseFrame = line => JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
if (isMainThread) {
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
let worker
let finished = false
let computeTimer
const finish = message => {
if (finished) return
finished = true
clearInterval(computeTimer)
emitFrame(message)
const current = worker
worker = undefined
Promise.resolve(current ? current.terminate() : undefined).finally(() => {
input.close()
process.stdin.destroy()
})
}
input.on('line', line => {
let message
try {
message = parseFrame(line)
} catch (error) {
process.stderr.write('code-runtime-e2b frame error: ' + String(error) + '\n')
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received a malformed frame' } })
return
}
if (!worker) {
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces)) {
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
return
}
worker = new Worker(new URL(import.meta.url), {
workerData: message,
env: {},
stdout: true,
stderr: true,
resourceLimits: { maxOldGenerationSizeMb: message.maxOldGenerationSizeMb },
})
worker.stdout.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
worker.stderr.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
worker.on('message', raw => {
if (!raw || typeof raw !== 'object') return
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
} else if (raw.type === 'log' && typeof raw.text === 'string') {
emitFrame({ type: 'log', text: raw.text })
} else if (raw.type === 'output-limit') {
finish({ type: 'output-limit' })
} else if (raw.type === 'done') {
if (raw.error && typeof raw.error === 'object' && typeof raw.error.kind === 'string' && typeof raw.error.message === 'string') {
finish({ type: 'done', error: { kind: raw.error.kind, message: raw.error.message } })
} else if (raw.value === undefined || Array.isArray(raw.value)) {
finish({ type: 'done', ...(raw.value === undefined ? {} : { value: raw.value }) })
}
}
})
worker.on('error', error => {
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote worker error: ' + error.message } })
})
worker.on('exit', code => {
if (!finished) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote worker exited with code ' + code + ' before completing' } })
})
computeTimer = setInterval(() => {
if (!worker) return
if (worker.performance.eventLoopUtilization().active > message.computeMs) {
finish({ type: 'done', error: { kind: 'timeout', message: 'compute budget exhausted (' + message.computeMs + 'ms busy)' } })
}
}, 25)
return
}
if (message && message.type === 'reply' && typeof message.id === 'number' && typeof message.ok === 'boolean') {
worker.postMessage(message.ok
? { type: 'reply', id: message.id, ok: true, value: message.value }
: { type: 'reply', id: message.id, ok: false, message: String(message.message) })
}
})
input.on('close', () => { if (worker && !finished) void worker.terminate() })
} else {
const port = parentPort
if (!port) throw new Error('remote worker requires parentPort')
const CapturedError = Error
const ArrayIsArray = Array.isArray
const ArrayPrototype = Array.prototype
const ObjectPrototype = Object.prototype
const ObjectCreate = Object.create
const ObjectDefineProperty = Object.defineProperty
const ObjectGetPrototypeOf = Object.getPrototypeOf
const ObjectHasOwn = Object.hasOwn
const ObjectKeys = Object.keys
const ObjectIs = Object.is
const ObjectPropertyIsEnumerable = Object.prototype.propertyIsEnumerable
const ReflectOwnKeys = Reflect.ownKeys
const ReflectApply = Reflect.apply
const NumberIsFinite = Number.isFinite
const NumberIsSafeInteger = Number.isSafeInteger
const PromiseCtor = Promise
const PromiseReject = Promise.reject
const QueueMicrotask = queueMicrotask
const BufferByteLength = Buffer.byteLength
const SetCtor = Set
const SetAdd = Set.prototype.add
const SetDelete = Set.prototype.delete
const SetHas = Set.prototype.has
const MapDelete = Map.prototype.delete
const MapGet = Map.prototype.get
const MapSet = Map.prototype.set
const ArrayJoin = Array.prototype.join
const ArrayPop = Array.prototype.pop
const StringCharCodeAt = String.prototype.charCodeAt
const StringSlice = String.prototype.slice
const JSONStringify = JSON.stringify
const StringValue = String
const define = (target, key, value) => {
const descriptor = ObjectCreate(null)
descriptor.value = value
descriptor.enumerable = true
descriptor.configurable = true
descriptor.writable = true
ObjectDefineProperty(target, key, descriptor)
}
const append = (target, value) => { define(target, target.length, value) }
const pop = target => ReflectApply(ArrayPop, target, [])
const setAdd = (target, value) => { ReflectApply(SetAdd, target, [value]) }
const setDelete = (target, value) => { ReflectApply(SetDelete, target, [value]) }
const setHas = (target, value) => ReflectApply(SetHas, target, [value])
const mapDelete = (target, key) => { ReflectApply(MapDelete, target, [key]) }
const mapGet = (target, key) => ReflectApply(MapGet, target, [key])
const mapSet = (target, key, value) => { ReflectApply(MapSet, target, [key, value]) }
const plainObject = value => {
const prototype = ObjectGetPrototypeOf(value)
return prototype === null || prototype === ObjectPrototype
}
const ownEnumerableStringKeys = value => {
const keys = ReflectOwnKeys(value)
for (let index = 0; index < keys.length; index++) {
const key = keys[index]
if (typeof key !== 'string' || !ReflectApply(ObjectPropertyIsEnumerable, value, [key])) return undefined
}
return keys
}
const assign = (destination, value) => {
if (destination.kind === 'root') destination.holder.value = value
else define(destination.target, destination.key, value)
}
const snapshot = input => {
const active = new SetCtor()
const holder = ObjectCreate(null)
const tasks = [{ kind: 'visit', value: input, destination: { kind: 'root', holder } }]
while (tasks.length) {
const task = pop(tasks)
if (task.kind === 'leave') { setDelete(active, task.source); continue }
const candidate = task.value
if (candidate === null || typeof candidate === 'boolean' || typeof candidate === 'string') {
assign(task.destination, candidate); continue
}
if (typeof candidate === 'number') {
if (!NumberIsFinite(candidate) || ObjectIs(candidate, -0)) return undefined
assign(task.destination, candidate); continue
}
if (typeof candidate !== 'object' || setHas(active, candidate)) return undefined
if (ArrayIsArray(candidate)) {
if (ObjectGetPrototypeOf(candidate) !== ArrayPrototype || ReflectOwnKeys(candidate).length !== candidate.length + 1) return undefined
const target = []
assign(task.destination, target)
setAdd(active, candidate)
append(tasks, { kind: 'leave', source: candidate })
for (let index = candidate.length - 1; index >= 0; index--) {
if (!ObjectHasOwn(candidate, index)) return undefined
append(tasks, { kind: 'visit', value: candidate[index], destination: { kind: 'slot', target, key: index } })
}
continue
}
if (!plainObject(candidate)) return undefined
const keys = ownEnumerableStringKeys(candidate)
if (!keys) return undefined
const target = {}
assign(task.destination, target)
setAdd(active, candidate)
append(tasks, { kind: 'leave', source: candidate })
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
append(tasks, { kind: 'visit', value: candidate[key], destination: { kind: 'slot', target, key } })
}
}
return holder.value
}
const encodeWire = value => {
const wire = []
const pending = [value]
while (pending.length) {
const current = pop(pending)
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
append(wire, current); continue
}
if (ArrayIsArray(current)) {
append(wire, { kind: 'array', length: current.length })
for (let index = current.length - 1; index >= 0; index--) append(pending, current[index])
} else {
const keys = ObjectKeys(current)
append(wire, { kind: 'object', keys })
for (let index = keys.length - 1; index >= 0; index--) append(pending, current[keys[index]])
}
}
return wire
}
const decodeWire = wire => {
if (!ArrayIsArray(wire) || wire.length === 0) return undefined
const frames = []
let root
let assigned = false
const attach = value => {
const parent = frames[frames.length - 1]
if (!parent) {
if (assigned) return false
root = value; assigned = true; return true
}
if (parent.kind === 'array') append(parent.target, value)
else define(parent.target, parent.keys[parent.index], value)
parent.index += 1
return true
}
for (let tokenIndex = 0; tokenIndex < wire.length; tokenIndex++) {
const token = wire[tokenIndex]
let value
let frame
if (token === null || typeof token === 'boolean' || typeof token === 'string') value = token
else if (typeof token === 'number') {
if (!NumberIsFinite(token) || ObjectIs(token, -0)) return undefined
value = token
} else {
if (!plainObject(token)) return undefined
const keys = ownEnumerableStringKeys(token)
if (!keys || keys.length !== 2 || keys[0] !== 'kind') return undefined
if (token.kind === 'array' && keys[1] === 'length' && NumberIsSafeInteger(token.length) && token.length >= 0) {
value = []
if (token.length > wire.length - tokenIndex - 1) return undefined
if (token.length) frame = { kind: 'array', target: value, length: token.length, index: 0 }
} else if (token.kind === 'object' && keys[1] === 'keys' && ArrayIsArray(token.keys)) {
const unique = new SetCtor()
const objectKeys = []
for (const key of token.keys) {
if (typeof key !== 'string' || setHas(unique, key)) return undefined
setAdd(unique, key); append(objectKeys, key)
}
if (objectKeys.length > wire.length - tokenIndex - 1) return undefined
value = {}
if (objectKeys.length) frame = { kind: 'object', target: value, keys: objectKeys, index: 0 }
} else return undefined
}
if (!attach(value)) return undefined
if (frame) append(frames, frame)
while (frames.length) {
const current = frames[frames.length - 1]
const length = current.kind === 'array' ? current.length : current.keys.length
if (current.index < length) break
pop(frames)
}
}
return frames.length === 0 ? root : undefined
}
const byteLength = text => ReflectApply(BufferByteLength, Buffer, [text])
const jsonStringBytes = text => byteLength(JSONStringify(text))
const jsonValueBytes = value => {
let bytes = 0
const tasks = [{ kind: 'value', value }]
while (tasks.length) {
const task = pop(tasks)
if (task.kind === 'separator') { bytes += 1; continue }
if (task.kind === 'key') { bytes += jsonStringBytes(task.value) + 1; continue }
const current = task.value
if (current === null) bytes += 4
else if (typeof current === 'string') bytes += jsonStringBytes(current)
else if (typeof current === 'number' || typeof current === 'boolean') bytes += byteLength(StringValue(current))
else if (ArrayIsArray(current)) {
bytes += 2
for (let index = current.length - 1; index >= 0; index--) {
append(tasks, { kind: 'value', value: current[index] })
if (index > 0) append(tasks, { kind: 'separator' })
}
} else {
bytes += 2
const keys = ObjectKeys(current)
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
append(tasks, { kind: 'value', value: current[key] })
append(tasks, { kind: 'key', value: key })
if (index > 0) append(tasks, { kind: 'separator' })
}
}
}
return bytes
}
const truncate = (text, available) => {
if (available < 2) return ''
let result = ''
let bytes = 2
let index = 0
while (index < text.length) {
const first = ReflectApply(StringCharCodeAt, text, [index])
let end = index + 1
if (first >= 0xd800 && first <= 0xdbff && end < text.length) {
const second = ReflectApply(StringCharCodeAt, text, [end])
if (second >= 0xdc00 && second <= 0xdfff) end += 1
}
const character = ReflectApply(StringSlice, text, [index, end])
const cost = jsonStringBytes(character) - 2
if (bytes + cost > available) break
bytes += cost
result += character
index = end
}
return result
}
let logBytes = 2
let logEntries = 0
let limited = false
const pushLog = text => {
if (limited) return
const separator = logEntries > 0 ? 1 : 0
const available = workerData.maxOutputBytes - logBytes - separator
const cost = jsonStringBytes(text)
if (cost > available) {
const prefix = truncate(text, available)
if (prefix) {
logBytes += jsonStringBytes(prefix) + separator
logEntries += 1
port.postMessage({ type: 'log', text: prefix })
}
limited = true
port.postMessage({ type: 'output-limit' })
return
}
logBytes += cost + separator
logEntries += 1
port.postMessage({ type: 'log', text })
}
const originalStdout = process.stdout.write
const originalStderr = process.stderr.write
process.stdout.write = (chunk, ...rest) => {
pushLog(typeof chunk === 'string' ? chunk : StringValue(chunk))
let callback
for (let index = 0; index < rest.length; index++) {
if (typeof rest[index] === 'function') { callback = rest[index]; break }
}
if (callback) QueueMicrotask(() => { callback(null) })
return true
}
process.stderr.write = process.stdout.write
const consoleShim = ObjectCreate(null)
for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
define(consoleShim, level, (...args) => {
const rendered = []
for (let index = 0; index < args.length; index++) {
const value = args[index]
append(rendered, typeof value === 'string' ? value : inspect(value, { depth: 4, maxArrayLength: 100, maxStringLength: 10000 }))
}
pushLog(ReflectApply(ArrayJoin, rendered, [' ']))
})
}
const pending = new Map()
let nextId = 1
const errorClasses = new Map()
for (const namespace of workerData.namespaces) {
if (!namespace.errorClass) continue
const descriptor = namespace.errorClass
mapSet(errorClasses, namespace.global, class BindingCallError extends CapturedError {
constructor(memberName, message) {
super(message)
ObjectDefineProperty(this, 'name', { value: descriptor.name, enumerable: true })
ObjectDefineProperty(this, descriptor.memberNameProperty, { value: memberName, enumerable: true })
}
})
}
port.on('message', message => {
if (!message || message.type !== 'reply' || typeof message.id !== 'number') return
const entry = mapGet(pending, message.id)
if (!entry) return
mapDelete(pending, message.id)
if (!message.ok) { entry.reject(new CapturedError(StringValue(message.message))); return }
const value = decodeWire(message.value)
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
else entry.resolve(value)
})
const namespaces = workerData.namespaces.map(namespace => {
const target = ObjectCreate(null)
const ErrorClass = mapGet(errorClasses, namespace.global)
for (const name of namespace.names) {
define(target, name, args => {
const detached = snapshot(args)
if (detached === undefined) {
return ReflectApply(PromiseReject, PromiseCtor, [ErrorClass ? new ErrorClass(name, 'binding arguments must be lossless JSON') : new CapturedError('binding arguments must be lossless JSON')])
}
return new PromiseCtor((resolve, reject) => {
const id = nextId++
mapSet(pending, id, {
resolve,
reject: error => { reject(ErrorClass ? new ErrorClass(name, error.message) : error) },
})
port.postMessage({ type: 'call', id, global: namespace.global, name, args: encodeWire(detached) })
})
})
}
return target
})
const errorClassNames = []
const errorClassValues = []
for (const namespace of workerData.namespaces) {
if (!namespace.errorClass) continue
append(errorClassNames, namespace.errorClass.name)
append(errorClassValues, mapGet(errorClasses, namespace.global))
}
const AsyncFunction = ObjectGetPrototypeOf(async function () {}).constructor
try {
const fn = new AsyncFunction(...workerData.namespaces.map(value => value.global), ...errorClassNames, 'console', '"use strict";\n' + workerData.code)
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
if (!limited) {
if (value === undefined) port.postMessage({ type: 'done' })
else {
const detached = snapshot(value)
if (detached === undefined) {
const message = 'program completion must be lossless JSON'
if (jsonStringBytes(message) > workerData.maxOutputBytes - logBytes) port.postMessage({ type: 'output-limit' })
else port.postMessage({ type: 'done', error: { kind: 'invalid-output', message } })
} else if (jsonValueBytes(detached) > workerData.maxOutputBytes - logBytes) {
port.postMessage({ type: 'output-limit' })
} else {
port.postMessage({ type: 'done', value: encodeWire(detached) })
}
}
}
} catch (error) {
if (!limited) {
let message
try { message = error instanceof CapturedError ? error.stack || error.message : StringValue(error) }
catch { message = 'program threw an unrenderable value' }
if (jsonStringBytes(message) > workerData.maxOutputBytes - logBytes) port.postMessage({ type: 'output-limit' })
else port.postMessage({ type: 'done', error: { kind: 'exception', message } })
}
} finally {
process.stdout.write = originalStdout
process.stderr.write = originalStderr
}
}
`
@@ -0,0 +1,460 @@
import { PassThrough, Writable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import {
E2BFrameDecoder,
encodeE2BFrame,
} from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import {
encodeWorkerJson,
} from '@deepseek-ai/dsh-code-runtime-worker'
import E2BCodeRuntime from '@deepseek-ai/dsh-code-runtime-e2b'
import * as E2BCodeRuntimeInvariant from '../src/invariant.ts'
import { CODE_RUNNER_SOURCE } from '../src/runner-source.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
class FakeHandle implements SubprocessHandle {
readonly pid = 123
readonly stdin: Writable | undefined
readonly stdout: PassThrough | undefined
readonly stderr = undefined
readonly collected: SubprocessHandle['collected']
readonly done: Promise<SubprocessOutcome>
readonly writes: unknown[] = []
readonly result = Promise.withResolvers<SubprocessOutcome>()
terminated = 0
waitCalls = 0
private readonly decoder = new E2BFrameDecoder(10_000_000)
private readonly waitError: Error | undefined
private settled = false
constructor(
private readonly onMessage: (message: unknown, handle: FakeHandle) => void = () => {},
options: { stdin?: boolean; stdout?: boolean; stderr?: string; writeError?: Error; waitError?: Error } = {},
) {
this.waitError = options.waitError
this.stdin = options.stdin === false
? undefined
: options.writeError === undefined
? new PassThrough()
: new Writable({ write: (_chunk, _encoding, callback) => { callback(options.writeError) } })
this.stdout = options.stdout === false ? undefined : new PassThrough()
this.collected = options.stderr === undefined
? {}
: { stderr: { readFrom: () => ({ text: options.stderr as string, nextOffset: 0, lossy: false }) } }
this.done = this.result.promise
this.stdin?.on('data', (chunk: Buffer) => {
for (const message of this.decoder.push(chunk.toString('ascii'))) {
this.writes.push(message)
this.onMessage(message, this)
}
})
}
emit(message: unknown): void {
this.stdout?.write(encodeE2BFrame(message))
}
emitRaw(text: string): void {
this.stdout?.write(text)
}
exit(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
if (this.settled) return
this.settled = true
this.stdout?.end()
this.result.resolve(outcome)
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.stdout?.end()
this.result.reject(error)
}
terminate(): void {
this.terminated += 1
this.exit({ exitCode: null, signal: 'SIGTERM' })
}
async waitForExit(): Promise<boolean> {
this.waitCalls += 1
if (this.waitError !== undefined) throw this.waitError
return true
}
}
interface RuntimeFixture {
ctx: Context
fiber: Awaited<ReturnType<Context['plugin']>>
runtime: E2BCodeRuntime
sandbox: Sandbox
spawn: ReturnType<typeof vi.fn<(spec: SubprocessSpawnSpec) => SubprocessHandle>>
write: ReturnType<typeof vi.fn>
run: ReturnType<typeof vi.fn>
}
async function setup(
handles: FakeHandle[] = [],
config: Record<string, number> = {},
sandboxOverrides: Partial<Sandbox> = {},
getSandbox?: () => Promise<Sandbox>,
): Promise<RuntimeFixture> {
const write = vi.fn().mockResolvedValue([])
const run = vi.fn().mockImplementation(async (command: string) => ({
exitCode: 0,
stdout: command.startsWith('command -v') ? '/usr/bin/node\n' : '',
stderr: '',
}))
const sandbox = {
files: { write },
commands: { run },
...sandboxOverrides,
} as unknown as Sandbox
const e2b = {
cwd: '/workspace',
runtimeRoot: '/workspace/.dsh-e2b',
getSandbox: getSandbox ?? (async () => sandbox),
} as unknown as E2BSandboxService
const spawn = vi.fn<(spec: SubprocessSpawnSpec) => SubprocessHandle>(() => {
const handle = handles.shift()
if (handle === undefined) throw new Error('no fake handle queued')
return handle
})
const subprocess = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
Object.defineProperty(subprocess, 'spawn', { value: spawn })
const ctx = new Context()
ctx.provide('e2b', e2b)
ctx.provide('subprocess', subprocess)
const fiber = await ctx.plugin(E2BCodeRuntime, config)
return { ctx, fiber, runtime: ctx.codeRuntime as E2BCodeRuntime, sandbox, spawn, write, run }
}
function request(program = 'return 1') {
return { program, bindings: [] }
}
describe('E2BCodeRuntime', () => {
it('prepares the remote runner and returns logs and a lossless completion', async () => {
const handle = new FakeHandle((message, current) => {
if ((message as { type?: string }).type !== 'boot') return
current.emit({ type: 'log', text: 'remote 你好' })
current.emitRaw(
encodeE2BFrame({ type: 'done', value: encodeWorkerJson({ answer: 42 }) })
+ encodeE2BFrame({ type: 'log', text: 'ignored after done' }),
)
current.emit({ type: 'log', text: 'also ignored after done' })
})
const fixture = await setup([handle])
await expect(fixture.runtime.run(request('const answer: number = 42; return { answer }')))
.resolves.toEqual({ logs: ['remote 你好'], value: { answer: 42 } })
expect(fixture.runtime.language).toBe('typescript')
expect(fixture.runtime.isolation).toBe('container')
expect(fixture.write).toHaveBeenCalledWith([{ path: '/workspace/.dsh-e2b/code-runtime-runner.mjs', data: CODE_RUNNER_SOURCE }])
expect(fixture.run).toHaveBeenCalledWith("chmod 600 -- '/workspace/.dsh-e2b/code-runtime-runner.mjs'")
expect(fixture.spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/code-runtime-runner.mjs'],
cwd: '/workspace',
env: {},
}))
expect(handle.terminated).toBe(1)
expect(handle.waitCalls).toBe(1)
await fixture.fiber.dispose()
})
it('bridges binding success, host rejection, unknown members, and invalid values', async () => {
const replies: unknown[] = []
const handle = new FakeHandle((message, current) => {
const record = message as { type?: string; id?: number; ok?: boolean }
if (record.type === 'boot') {
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'double', args: encodeWorkerJson({ value: 4 }) })
current.emit({ type: 'call', id: 2, global: 'bridge', name: 'fail', args: encodeWorkerJson(null) })
current.emit({ type: 'call', id: 3, global: 'bridge', name: 'missing', args: encodeWorkerJson(null) })
current.emit({ type: 'call', id: 4, global: 'bridge', name: 'double', args: [] })
current.emit({ type: 'call', id: 5, global: 'bridge', name: 'invalid', args: encodeWorkerJson(null) })
current.emit({ type: 'call', id: 6, global: 'bridge', name: 'throwing', args: encodeWorkerJson(null) })
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'double', args: encodeWorkerJson({ value: 99 }) })
return
}
if (record.type === 'reply') {
replies.push(message)
if (replies.length === 6) current.emit({ type: 'done', value: encodeWorkerJson('done') })
}
})
const fixture = await setup([handle])
const result = await fixture.runtime.run({
program: 'return await bridge.double({ value: 4 })',
bindings: [
{
global: 'bridge',
errorClass: { name: 'BridgeError', memberNameProperty: 'member' },
functions: {
double: async args => (args as { value: number }).value * 2,
fail: async () => { throw 'nope' },
invalid: (async () => undefined) as never,
throwing: async () => Object.defineProperty({}, 'value', {
enumerable: true,
get: () => { throw new Error('getter failed') },
}),
},
},
{ global: 'plain', functions: {} },
],
})
expect(result).toEqual({ logs: [], value: 'done' })
expect(replies.sort((left, right) => (left as { id: number }).id - (right as { id: number }).id)).toEqual([
{ type: 'reply', id: 1, ok: true, value: encodeWorkerJson(8) },
{ type: 'reply', id: 2, ok: false, message: 'nope' },
{ type: 'reply', id: 3, ok: false, message: 'unknown binding "bridge.missing"' },
{ type: 'reply', id: 4, ok: false, message: 'binding arguments must be lossless JSON' },
{ type: 'reply', id: 5, ok: false, message: 'binding resolution must be lossless JSON' },
{ type: 'reply', id: 6, ok: false, message: 'binding resolution must be lossless JSON' },
])
await fixture.fiber.dispose()
})
it('ignores malformed runner traffic and classifies terminal runner messages', async () => {
const ignored = [
null, 1, {}, { type: 'log' }, { type: 'call' },
{ type: 'call', id: 0, global: 'x', name: 'y', args: [] },
{ type: 'call', id: 1, global: 1, name: 'y', args: [] },
{ type: 'call', id: 1, global: 'x', name: 1, args: [] },
{ type: 'call', id: 1, global: 'x', name: 'y', args: {} },
{ type: 'done', error: null },
{ type: 'done', error: { kind: 'invented', message: 'x' } },
{ type: 'done', error: { kind: 'exception', message: 1 } },
]
const handles = [
new FakeHandle((message, current) => {
if ((message as { type?: string }).type !== 'boot') return
for (const item of ignored) current.emit(item)
current.emit({ type: 'done' })
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done', error: { kind: 'exception', message: 'boom' } })
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done', value: [] })
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'output-limit' })
}),
]
const fixture = await setup(handles, { maxOutputBytes: 64, maxFrameBytes: 128 })
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [] })
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'exception', message: 'boom' } })
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
await expect(fixture.runtime.run(request())).resolves.toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
await fixture.fiber.dispose()
})
it('enforces the host output ledger and catches malformed bridge output', async () => {
const handles = [
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'log', text: 'x'.repeat(1_000) })
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emitRaw('not-base64\n')
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emitRaw('é')
}),
new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.stdout?.emit('error', new Error('stdout broke'))
}),
]
const fixture = await setup(handles, { maxOutputBytes: 128, maxFrameBytes: 4_096 })
expect((await fixture.runtime.run(request())).error?.kind).toBe('output-limit')
const malformed = (await fixture.runtime.run(request())).error
expect(malformed?.kind).toBe('worker-exit')
expect(malformed?.message).toContain('bridge failed')
expect((await fixture.runtime.run(request())).error?.message).toContain('non-ASCII')
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime stdout failed: stdout broke' })
await fixture.fiber.dispose()
})
it('contains stdin errors, process exits, spawn failures, and missing pipes', async () => {
const writeError = new FakeHandle(() => {}, { writeError: new Error('write callback broke') })
const stdinError = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.stdin?.emit('error', new Error('stdin broke'))
})
const earlyExit = new FakeHandle(() => {}, { stderr: 'remote diagnostic' })
const quietExit = new FakeHandle()
const emptyStderrExit = new FakeHandle(() => {}, { stderr: '' })
const spawnFailure = new FakeHandle()
const missingStdin = new FakeHandle(() => {}, { stdin: false })
const missingStdout = new FakeHandle(() => {}, { stdout: false, waitError: new Error('missing-stream process query failed') })
const truncated = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') {
current.emitRaw('YQ==')
setImmediate(() => { current.exit() })
}
})
const cleanupFailure = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done' })
}, { waitError: new Error('process query failed') })
const fixture = await setup([
writeError, stdinError, earlyExit, quietExit, emptyStderrExit,
spawnFailure, missingStdin, missingStdout, truncated, cleanupFailure,
])
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime bridge write failed: write callback broke' })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime stdin failed: stdin broke' })
setImmediate(() => { earlyExit.exit() })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing: remote diagnostic' })
setImmediate(() => { quietExit.exit() })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing' })
setImmediate(() => { emptyStderrExit.exit() })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime exited before completing' })
setImmediate(() => { spawnFailure.crash('spawn rejected') })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime spawn failed: spawn rejected' })
expect((await fixture.runtime.run(request())).error?.message).toContain('dropped a piped runtime stream')
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime cleanup failed: missing-stream process query failed' })
expect(missingStdin.terminated).toBe(1)
expect(missingStdin.waitCalls).toBe(1)
expect(missingStdout.terminated).toBe(1)
expect(missingStdout.waitCalls).toBe(1)
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B frame stream ended mid-frame' })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'worker-exit', message: 'E2B runtime cleanup failed: process query failed' })
await fixture.fiber.dispose()
})
it('reports wall timeout, abort, pre-abort, type-strip failure, and disposal', async () => {
const timeout = new FakeHandle()
const abort = new FakeHandle()
const disposing = new FakeHandle()
const fixture = await setup([timeout, abort, disposing], { maxWallMs: 20 })
expect((await fixture.runtime.run(request())).error).toEqual({ kind: 'timeout', message: 'wall-clock ceiling reached (20ms)' })
const controller = new AbortController()
const aborting = fixture.runtime.run({ ...request(), signal: controller.signal })
controller.abort('stop')
expect((await aborting).error).toEqual({ kind: 'abort', message: 'stop' })
expect((await fixture.runtime.run({ ...request(), signal: AbortSignal.abort('already') })).error)
.toEqual({ kind: 'abort', message: 'already' })
expect((await fixture.runtime.run(request('enum E { A }'))).error?.kind).toBe('exception')
const live = fixture.runtime.run(request())
await new Promise(resolve => setImmediate(resolve))
await fixture.fiber.dispose()
expect((await live).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
await expect(fixture.runtime.run(request())).rejects.toThrow('after disposal')
})
it('drops binding replies that settle after abort', async () => {
const controller = new AbortController()
const resolution = Promise.withResolvers<string>()
const invoked = Promise.withResolvers<undefined>()
const handle = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') {
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'late', args: encodeWorkerJson(null) })
}
})
const fixture = await setup([handle])
const running = fixture.runtime.run({
program: 'return await bridge.late(null)',
bindings: [{
global: 'bridge',
functions: {
late: async () => {
invoked.resolve(undefined)
return await resolution.promise
},
},
}],
signal: controller.signal,
})
await invoked.promise
controller.abort('stop')
expect((await running).error).toEqual({ kind: 'abort', message: 'stop' })
resolution.resolve('late')
await new Promise(resolve => setImmediate(resolve))
expect(handle.writes).toHaveLength(1)
await fixture.fiber.dispose()
})
it('validates binding and runtime configuration before remote execution', async () => {
const fixture = await setup([])
const invalidRequests = [
{ global: 'not-valid!', functions: {} },
{ global: 'await', functions: {} },
{ global: 'console', functions: {} },
{ global: 'same', functions: {} },
{ global: 'same', functions: {} },
{ global: 'ok', functions: {}, errorClass: { name: 'not-valid!', memberNameProperty: 'member' } },
{ global: 'ok', functions: {}, errorClass: { name: 'await', memberNameProperty: 'member' } },
{ global: 'Clash', functions: {}, errorClass: { name: 'Clash', memberNameProperty: 'member' } },
{ global: 'one', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'member' } },
{ global: 'two', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'member' } },
{ global: 'ok', functions: {}, errorClass: { name: 'Err', memberNameProperty: '' } },
{ global: 'ok', functions: {}, errorClass: { name: 'Err', memberNameProperty: 'message' } },
]
for (const bindings of [
[invalidRequests[0]], [invalidRequests[1]], [invalidRequests[2]],
invalidRequests.slice(3, 5), [invalidRequests[5]], [invalidRequests[6]],
[invalidRequests[7]], invalidRequests.slice(8, 10), [invalidRequests[10]], [invalidRequests[11]],
]) {
await expect(fixture.runtime.run({ program: 'return 1', bindings: bindings as never })).rejects.toThrow()
}
await fixture.fiber.dispose()
for (const config of [
{ computeMs: 0 }, { computeMs: 1.5 }, { maxOutputBytes: 3 },
{ maxWallMs: 2_147_483_648 }, { maxFrameBytes: 10, maxOutputBytes: 20 },
]) {
const ctx = new Context()
const subprocess = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
ctx.provide('e2b', { getSandbox: async () => ({}) } as never)
ctx.provide('subprocess', subprocess)
await expect(ctx.plugin(E2BCodeRuntime, config)).rejects.toThrow()
}
const wrong = new Context()
wrong.provide('e2b', { getSandbox: async () => ({}) } as never)
wrong.provide('subprocess', {} as never)
await expect(wrong.plugin(E2BCodeRuntime, {})).rejects.toThrow('dsh-subprocess-e2b')
})
it('turns asynchronous runtime preparation failure into a run result', async () => {
const sandbox = {
files: { write: vi.fn().mockRejectedValue(new Error('upload failed')) },
commands: { run: vi.fn() },
} as unknown as Sandbox
const fixture = await setup([], {}, sandbox)
expect((await fixture.runtime.run(request())).error).toEqual({
kind: 'worker-exit',
message: 'E2B runtime setup failed: upload failed',
})
await fixture.fiber.dispose()
})
it('returns disposal when remote preparation completes after teardown', async () => {
const gate = Promise.withResolvers<Sandbox>()
const fixture = await setup([], {}, {}, () => gate.promise)
const running = fixture.runtime.run(request())
await (fixture.runtime as unknown as { teardown(): Promise<void> }).teardown()
gate.resolve(fixture.sandbox)
expect((await running).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
await fixture.fiber.dispose()
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BCodeRuntimeInvariant).await()
await fiber.dispose()
})
})
@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../code-runtime" },
{ "path": "../code-runtime-worker" },
{ "path": "../../e2b/e2b" },
{ "path": "../../core/session" },
{ "path": "../../subprocess/subprocess-e2b" },
{ "path": "../../util/timeout" },
{ "path": "../../support/invariants" }
]
}
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-worker/README.md
README.md: 590b79dcd1bc322350060b55767b09c6305edacc
README.zh.md: 12c25f892bd20892cb47e593f16b6aadd9ffa84c
README.md: 4b14c6fad5d1e491faeb54c9cb0e4403c8e2d8dd
README.zh.md: c522917835b562cf7648d8bc78f0315b7c52d2d2
@@ -32,7 +32,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
## The worker entry, unbuilt and built
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local and session-owned JSON boundaries both flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. The repository-wide requirement to exercise this published entry path belongs to the [testing policy](../../../docs/testing.md).
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. The worker-local JSON snapshotter is parity-tested against the session-owned canonical boundary; both sides flatten and rebuild validated values around the message port so application nesting never reaches structured clone. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
@@ -46,8 +46,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists.
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — amaro or sucrase are the named drop-in replacements if the relied-on behavior shifts.
- **OS processes a program spawns survive this backend's termination** — `worker.terminate()` ends only the thread; deployments needing remote process-group cleanup can select the E2B backend, whose separate limitations still apply.
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts.
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface.
- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output.
@@ -23,18 +23,18 @@
- **每次运行使用一个全新 worker,不设池化**:程序所在的世界会随 worker 一同终止,不会留下需要记录的跨运行状态,也无法发生状态泄漏;仅凭会话日志即可重建运行。
- **在执行上下文中,由宿主侧剥离类型**:程序会包裹在异步函数外壳中,通过 `node:module``stripTypeScriptTypes` 剥离类型(只支持可擦除语法;`enum`namespace 会作为程序 `exception` 被拒绝,且不会启动 worker),再按字节位置切回原内容。之后程序作为 `AsyncFunction` 的函数体执行,因此顶层 `await``return` 可用。
- **端口把对端视为不可信**:模型代码能够访问 `parentPort` 并伪造通信,因此任何代码读取入站消息前,系统都会验证其形状并重新构建(`null`、原始值、无效类型和格式错误的载荷会被静默丢弃;伪造的额外字段绝不会被带入);宿主对每个调用 id 最多响应一次,只将绑定名称解析为自有属性(伪造的 `constructor` 无法沿原型链访问),丢弃结算后的回复,并验证每个绑定 resolve 值与完成值是否为无损 JSON。伪造的 `log``done` 消息无法绕过外层上限:宿主会再次验证,并统计每条获准日志以及完成值或诊断。worker 侧命名空间使用 null-prototype 和 `defineProperty`,因此形似 `__proto__` 的绑定名称只是普通键。
- **绑定调用被拒绝时使用的异常类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收调用失败成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools``ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误 intrinsic 与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。
- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟限制为 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。
- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为自身嵌套深度有界的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。
- **日志主动流入一个外层账本**consolestdoutstderr 文本按产生顺序经端口传输,因此超时或被终止的程序仍会显示已经打印的内容。worker 会精确统计 JSON 字符串的字节数,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。
- **空环境**worker 使用 `env: {}``execArgv: []`,既不会获得环境变量中的凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。
- **dispose(资源释放)时等待完全停稳**:清理会使进行中的运行 `abort` 失败,并会等待每个 worker 退出后再完成
- **绑定 reject 类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收失败成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools``ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。
- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟夹到 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。
- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为有深度上限的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。
- **日志主动流入一个外层账本**consolestdoutstderr 文本按发送顺序穿过端口,因此超时或被终止的程序仍会显示已经打印的内容。worker 会 JSON 字符串精确计费,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。
- **空环境**worker 使用 `env: {}``execArgv: []`,既没有环境凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。
- **释放资源时等待完全停稳**:清理会进行中的运行标记为 `abort`,并在 resolve 前等待每个 worker 退出。
## 未构建与已构建的 worker 入口
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地和会话自有的 JSON 边界都会在消息端口周围展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFSWorker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。演练这个已发布入口路径的仓库级要求由[测试策略](../../../docs/testing.md)规定
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的 VFS Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径
SDK 对外提供默认具名导出的 `WorkerCodeRuntime`,以及 `Config`运行所用`./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。
SDK 接口是默认具名 `WorkerCodeRuntime` `Config`可操作`./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。
## 模型体验
@@ -42,13 +42,13 @@ SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Confi
#### KV Cache 影响
不会直接失效;由上述消费方负责请求前缀变更。
不会直接失效;由具名消费方负责请求前缀变更。
## 已知限制与暂缓事项
## 已知限制与暂缓工作
- **程序派生的 OS 进程在程序终止后仍会存活**`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿进程清理属于部署职责
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**依赖的行为发生变化,amarosucrase 是已经点名的直接替代品。
- **程序 spawn 的 OS 进程在该后端终止后仍会存活**`worker.terminate()` 只结束线程;需要清理远程进程组的部署可以选择 E2B 后端,但该后端自身的限制仍然适用
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amarosucrase 是已经点名的直接替代品。
- **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。
- **程序获得一个含 5 方法的 `console` shim**`log``info``warn``error``debug`):有意不提供 Node 的完整 console 接口。
- **程序获得一个含 5 方法的 `console` shim**`log``info``warn``error``debug`):有意不提供 Node 的完整 console 接口。
- **中间绑定值没有字节上限**:程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。
- **默认 64 MiB 是拒绝边界,不是可恢复存储**:外层落盘只能保存发生 `output-limit` 后返回的有界日志和诊断;在运行时上限之外被拒绝的字节永远不会到达落盘层。
@@ -21,6 +21,10 @@ import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
import type { WorkerJsonWire } from './worker-json.ts'
export { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
export { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
export type { WorkerJsonWire } from './worker-json.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
/**
@@ -165,14 +169,19 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
}
/** One run's combined outer-output ledger; binding values never enter it. */
class OutputLedger {
/** Shared outer-output accounting for one isolated run; binding values never enter it. */
export class OutputLedger {
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
constructor(private readonly maxBytes: number) {}
/** Admit one exact log entry, or report that the hard cap was crossed. */
/**
* Admit one exact log entry, or report that the hard cap was crossed.
* @param text - Candidate log entry.
* @param sink - Accepted log entries for the current run.
* @returns Whether the complete entry fits the remaining outer-output budget.
*/
admit(text: string, sink: string[]): boolean {
const separatorBytes = this.entries > 0 ? 1 : 0
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
@@ -183,19 +192,33 @@ class OutputLedger {
return true
}
/** Finalize a successful absent-or-JSON completion against the combined cap. */
/**
* Finalize a successful absent-or-JSON completion against the combined cap.
* @param logs - Already accepted log entries.
* @param value - Optional lossless-JSON completion value.
* @returns A success result or an output-limit failure.
*/
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, ...value !== undefined ? { value } : {} }
}
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
/**
* Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap.
* @param logs - Already accepted log entries.
* @param error - Candidate failure diagnostic.
* @returns The diagnostic result or an output-limit failure.
*/
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, error }
}
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
/**
* Build the explicit output-limit failure while retaining a fitting prefix of the final log.
* @param logs - Candidate log entries in original order.
* @returns A capped output-limit result.
*/
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md
README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2
README.md: c7690412d0f1bc8556fc758da4e94c6ce5a08d8f
README.zh.md: ecfa48a97c46113c52f13c7a6edfc5d6fbbc2285
+1 -1
View File
@@ -34,5 +34,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
- **Isolation is backend-specific** — the worker backend is process-local, while the E2B backend reports `container` and keeps orchestration and bindings on the host; the descriptor remains informational rather than a security claim.
- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound.
@@ -4,23 +4,21 @@
这是**代码执行 seam**:抽象的 `CodeRuntime` 服务(`ctx.codeRuntime`)只定义代码运行时做什么,即针对宿主提供的一组异步绑定运行一段模型编写的程序,并报告 `{ value, logs, error? }`,而不规定如何实现。
此包承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。这两项职责均由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有工具有关的内容都留在消费方。
此包该能力的接口(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode,它生成面向模型的 SDK,并桥接工具分发。两者都由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有工具形状的内容都留在消费方。
## 服务 API`ctx.codeRuntime`
| 成员 | 语义 |
|---|---|
| `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose资源释放后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await``return` 可用,无损 JSON 完成值会成为 `result.value`。 |
| `language` | 只读描述符:`run` 期望的源语言已知值为 `'typescript'``'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
| `run(request)` | 针对请求的绑定执行一段程序。**每一种程序结果都通过 error 字段完成 resolve**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底死亡(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如资源释放后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await``return` 可用,无损 JSON 完成值会成为 `result.value`。 |
| `language` | 只读描述符:`run` 期望的源语言已知值为 `'typescript'`。仅供参考,不作门禁;生成语言专用呈现的消费方会对该值执行分支,遇到无法呈现的语言时明确失败。 |
| `isolation` | 只读描述符:执行基底(`'worker-thread'``'process'``'container'`)。供部署与诊断使用,**不构成安全声明**。 |
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。
每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为不可信对等方(任意绑定名称都自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;资源释放会终止进行中的运行,并且在完成前等待其退出。
## 词汇
`CodeRunRequest``program``bindings``signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings``CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]``error?``CodeRunFailure``kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`
binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`ECMAScript Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global)、`RESERVED_ERROR_MEMBERS``DUNDER_MEMBER`error-member 排除)——因此 `$tools``lambda``__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject,而非只在某些后端。确切集合与理由见 `src/index.ts`
`CodeRunRequest``program``bindings``signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings``CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收 reject 成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]``error?``CodeRunFailure``kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`
## 模型体验
@@ -28,11 +26,11 @@ binding-global 与 error-class 名称是**语言可移植**的:必须匹配标
#### KV Cache 影响
不会直接失效;由上述消费方负责请求前缀变更。
不会直接失效;由具名消费方负责请求前缀变更。
## 已知限制与暂缓事项
## 已知限制与暂缓工作
- **`run()` 是一次性的**`logs` 只有在 `CodeRunResult` resolve 后才能获得;seam 不提供正在运行的程序所产生输出的流式日志或进度接口。
- **`run()` 是一次性的**`logs` 只有在 `CodeRunResult` resolve 后才能获得;seam 不提供活跃程序输出的流式日志或进度接口。
- **持久 REPL 风格内核已记录为未来工作**:在持久内核后端带来自己的日志方案前,运行之间不保留状态的契约继续有效(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md))。
- **目前只提供 worker 线程后端**:`'process'``'container'` 是已经声明但没有实现的已知 `isolation` 值;强安全边界需要等待容器后端
- **隔离方式由后端决定**:worker 后端位于宿主进程内,而 E2B 后端报告 `container`,并把编排与绑定留在宿主;该描述符仍只提供信息,不构成安全声明
- **中间绑定值没有字节上限**:实现仍受 structured-clone 成本与进程内存约束,而提供方或执行器可能已经应用自己的获取上限。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/README.md
README.md: 4b28ec7c0452189a579a6d7e0cd22a0b8561784d
README.zh.md: ab254eecad82c05cd6a6021e7f8bc4eda6cf3076
README.md: 9e7f1a98ada9f56f3d1e3f74d2a906f8a1c7bc15
README.zh.md: 53fdf30eaf405ab5313274a7fadde0b044b7fba1
+5 -2
View File
@@ -2,12 +2,15 @@
English | [中文](README.zh.md)
An experimental provider-composition POC that places the filesystem and managed subprocess world in one E2B Linux sandbox. The shared owner is separate from the capability adapters so every remote provider awaits the same sandbox identity and lifecycle.
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.
| Package | ctx key | Role |
|---|---|---|
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create or reconnect one sandbox, create its working/runtime directories, expose the shared SDK handle, and apply the configured kill/pause/leave disposition |
| [`fs-e2b`](../fs/fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement managed process groups, stdio projection, and remote spill files over E2B Commands |
| [`pty-e2b`](../pty/pty-e2b/README.md) (`@deepseek-ai/dsh-pty-e2b`) | `ctx.pty` backend | Run persistent interactive shells through E2B's byte PTY API |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md) (`@deepseek-ai/dsh-lsp-e2b`) | `ctx.lsp` provider | Run configured language servers and read query sources inside E2B |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md) (`@deepseek-ai/dsh-code-runtime-e2b`) | `ctx.codeRuntime` | Run model-written programs remotely while bridging bindings to the host |
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it already delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world as `ctx.fs`. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, or E2B SDK buffers. The [decision record](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary and rejected expansion.
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, protocol state, or E2B SDK buffers. The [base decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) and [runtime-extension decision](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) own the POC boundary.
+5 -2
View File
@@ -2,12 +2,15 @@
[English](README.md) | 中文
这是一个实验性提供方组合 POC,把文件系统和受管子进程环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器彼此分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
这是一个实验性提供方组合 POC,把可变的编码环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
| 包(package | ctx 键 | 职责 |
|---|---|---|
| [`e2b`](e2b/README.md)`@deepseek-ai/dsh-e2b` | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
| [`fs-e2b`](../fs/fs-e2b/README.md)`@deepseek-ai/dsh-fs-e2b` | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md)`@deepseek-ai/dsh-subprocess-e2b` | `ctx.subprocess` | 通过 E2B Commands 实现受管进程组、stdio 投影与远程 spill 文件 |
| [`pty-e2b`](../pty/pty-e2b/README.md)`@deepseek-ai/dsh-pty-e2b` | `ctx.pty` 后端 | 通过 E2B 的字节 PTY API 运行持久交互式 shell |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md)`@deepseek-ai/dsh-lsp-e2b` | `ctx.lsp` 提供方 | 在 E2B 内运行已配置的语言服务器并读取查询源代码 |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md)`@deepseek-ai/dsh-code-runtime-e2b` | `ctx.codeRuntime` | 远程运行模型编写的程序,同时把绑定桥接到宿主 |
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork:它已经把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash`ctx.fs` 进入同一个远程环境。该边界不会迁移 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) 无需 E2B 专用 fork:它把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、协议状态或 E2B SDK 缓冲。[基础决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)与[运行时扩展决策](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)共同界定 POC 边界
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
README.md: bf62cb7d4811ca92f263bd3d337fff9fe41ce223
README.zh.md: 8c7603aa501a2481aba6dab1f22750056e43ad26
README.md: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shared lifecycle owner for one E2B sandbox. 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`.
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.
## Configuration
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
一个 E2B 沙箱的共享生命周期所有者。文件系统与进程管理适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`
一个 E2B 沙箱的共享生命周期所有者。功能适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`;可选适配器见[包族索引](../README.md)
## 配置
+75
View File
@@ -0,0 +1,75 @@
/** 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 {
const json: unknown = JSON.stringify(value)
if (typeof json !== 'string') throw new Error('E2B frame value is not JSON-serializable')
return `${Buffer.from(json).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 })
}
}
}
+23 -1
View File
@@ -10,6 +10,8 @@ import z from 'schemastery'
import { Sandbox } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export { E2BFrameDecoder, encodeE2BFrame } from './frame.ts'
export {
CommandExitError,
FileNotFoundError,
@@ -19,7 +21,7 @@ export {
SandboxNotFoundError,
TimeoutError,
} from 'e2b'
export type { CommandHandle, CommandResult, EntryInfo } from 'e2b'
export type { CommandHandle, CommandResult, EntryInfo, ProcessInfo, PtyOutput } from 'e2b'
/** Opaque E2B sandbox identity used for reconnecting a later harness process. */
export type E2BSandboxId = Branded<'E2BSandboxId'>
@@ -42,6 +44,26 @@ 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'
+37 -5
View File
@@ -11,7 +11,7 @@ const configPath = join(fixtureRoot, 'cordis.yml')
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
it('shares remote state across FS and Bash without creating host workspace files', async () => {
it('runs FS, Bash, PTY, LSP, and Code Runtime in one sandbox and deletes it', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'E2B composition',
tempDirPrefix: 'dsh-e2b-composition-',
@@ -19,10 +19,14 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
libBinScript: binScript,
configPath,
tsconfigPath,
processTimeoutMs: 90_000,
env: {
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
processTimeoutMs: 120_000,
inspect: async (cwd) => {
await expect(access(join(cwd, 'from-fs.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
await expect(access(join(cwd, 'from-bash.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte.ts', 'fixture-lsp.mjs']) {
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
}
},
})
@@ -31,9 +35,37 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
expect(output).toMatchObject({
bashRead: 'written-by-fs\n',
fsRead: 'written-by-bash\n',
hover: {
kind: 'hover',
hover: { contents: '**remote hover** 你好 café' },
},
definition: {
kind: 'locations',
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
},
terminal: {
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
signal: { delivered: true },
interrupted: { sessionStatus: { kind: 'running' } },
},
hostileOutput: { error: { kind: 'output-limit' } },
timedOut: { error: { kind: 'timeout' } },
aborted: { error: { kind: 'abort', message: 'live abort' } },
lingeringCodeRunners: 0,
})
expect((output.terminal as { motd: string }).motd.length).toBeGreaterThan(0)
expect((output.terminal as { echo: { viewport: string } }).echo.viewport).toContain('PTY-你好')
expect((output.terminal as { scrollback: string }).scrollback).toContain('PTY-你好')
expect((output.terminal as { signal: { targetPgid: number } }).signal.targetPgid).toBeGreaterThan(0)
expect(['stdin_read', 'inferred_idle']).toContain(
(output.terminal as { interrupted: { waitReason: string } }).interrupted.waitReason,
)
expect(output.code).toEqual({
value: { doubled: 42, typed: true },
logs: ['remote-log 你好 42', 'post-mutation'],
})
const apiKey = process.env.E2B_API_KEY
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
}, 105_000)
}, 135_000)
})
+46
View File
@@ -2,8 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BFrameDecoder,
E2BSandboxId,
encodeE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import * as E2BInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -209,6 +212,49 @@ 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('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 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/README.md
README.md: 7fbdf071735673fb0158f6fa66148be1c644a433
README.zh.md: e059dbd80b7e38c0e447e54178162316dfd127c7
README.md: 74ed729d97013de3d7f502f728e3abe62348846f
README.zh.md: 5a08d54ca6c54d8355454fc3021a0da48772000b
+3 -2
View File
@@ -7,9 +7,10 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
| Package | Role | ctx key |
|---|---|---|
| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` |
| `lsp-local/` | Generic multi-server stdio backend over `ctx.fs` and `ctx.subprocess` (JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) |
| [`lsp-local/`](lsp-local/README.md) | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) |
| [`lsp-e2b/`](lsp-e2b/README.md) | Remote E2B backend (remote source reads and servers, byte-framed stdio bridge) | (registers providers on `ctx.lsp`) |
| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the stdio host consumes the shared filesystem/subprocess execution world, and why extension ownership is exclusive within one runtime.
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the protocol design and the [E2B extension note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) for the remote process/filesystem boundary.
+3 -2
View File
@@ -7,9 +7,10 @@
| 包 | 职责 | ctx key |
|---|---|---|
| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError` | `ctx.lsp` |
| `lsp-local/` | 基于 `ctx.fs``ctx.subprocess` 的通用多服务器 stdio 后端(JSON-RPC、临时打开查询) | (在 `ctx.lsp` 上注册提供方) |
| [`lsp-local/`](lsp-local/README.md) | 通用多服务器本地后端(spawn、JSON-RPC、临时打开查询) | (在 `ctx.lsp` 上注册提供方) |
| [`lsp-e2b/`](lsp-e2b/README.md) | 远程 E2B 后端(在远程读取源文件并运行服务器、采用字节分帧的 stdio 桥) | (在 `ctx.lsp` 上注册提供方) |
| `tool-lsp/` | 面向模型的 `lsp` 工具(四种操作、从 1 开始的 UTF-16 光标坐标) | (注册到 `ctx.tools` |
接口位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition``findReferences``goToImplementation``hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型契约。提供方注册的是**能力** 而非工具;`tool-lsp` 是面向模型名称、schema、提示词指引和呈现的唯一 owner。
设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥
协议设计见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)远程进程/文件系统边界见 [E2B 扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp-e2b/README.md
README.md: 968487b7c5f63b2165cf66797a4182642507a8e9
README.zh.md: 2b937f01156c3194f64ed9fb54478fec5c77305b
+47
View File
@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-lsp-e2b
English | [中文](README.zh.md)
Generic E2B language-server backend for [`ctx.lsp`](../lsp/README.md). It runs configured stdio servers and reads their source documents inside the shared `ctx.e2b` sandbox; the provider registry, normalized query results, queues, and protocol connection state remain on the host.
## Plugin and configuration
The `lsp-e2b` plugin injects `e2b`, `lsp`, and the concrete `dsh-subprocess-e2b` service. `servers` is a non-empty provider-id table:
| Server key | Default | Meaning |
|---|---|---|
| `command` | required | Remote executable, absolute or resolved on the sandbox PATH at load. |
| `args` | `[]` | Remote server arguments. |
| `env` | `{}` | Explicit environment entries passed through the subprocess adapter. |
| `extensionToLanguage` | required | Lowercase leading-dot extension to LSP language id. |
| `initializationOptions` / `configuration` | `null` / `null` | Static initialize options and `workspace/configuration` answer. |
| `maxMessageBytes` | `16000000` | Largest LSP message accepted from the server. |
| `maxStderrBytes` | `1000000` | Retained raw server stderr tail. |
| `maxDocumentBytes` | `4000000` | Largest remote source opened for one query. |
| `shutdownTimeoutMs` | `5000` | Graceful protocol-shutdown budget. |
| `killGraceMs` | `2000` | Request-cancel and TERM-to-KILL grace. |
Provider ids and commands are non-empty; numeric bounds are positive safe integers, and timer values cannot exceed Node's maximum timer delay. Setup uploads one owner-private proxy under `ctx.e2b.runtimeRoot`, resolves Node and every configured server executable remotely, then registers all providers atomically.
## Remote protocol and filesystem
E2B command callbacks are text, while LSP is byte-framed. The installed proxy therefore base64-frames raw server stdout, stderr, and stdin as newline-delimited ASCII JSON; the host validates and decodes every frame before handing bytes to the shared `LspInstance` protocol engine. `initialize.processId` is `null` because host and server do not share a process namespace.
One language-server process is pooled per provider and canonical remote workspace. Queries serialize per workspace but different workspaces run concurrently. Each query canonicalizes the remote workspace and source with `realpath`, rejects paths outside that workspace, requires a regular file, enforces the size bound before and after reading, decodes strict UTF-8, and uses the ordinary transient `didOpen` / request / `didClose` lifecycle. A transport failure disposes the instance and retries the read-only query once on a fresh remote process.
The subprocess adapter owns process groups and escalation, so cancellation and disposal await remote server quiescence. The host owns LSP request ids, pending requests, provider queues, and normalized results.
## Model Experience
Indirectly, through `@deepseek-ai/dsh-tool-lsp`, which exposes normalized semantic navigation and hover results without changing its model-facing schema.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **Configured servers only** — this package does not install language servers, select presets, or synchronize a host workspace into E2B.
- **Host protocol state is not reconnectable** — retaining a sandbox does not restore provider queues, JSON-RPC requests, subprocess handles, or document lifecycle state.
- **SDK output retention remains** — ASCII framing preserves protocol bytes, but E2B and the subprocess adapter still retain callback output in host memory.
- **Sandbox policy is template-owned** — this provider adds no volume, snapshot, credential, or network-policy layer.
+47
View File
@@ -0,0 +1,47 @@
# @deepseek-ai/dsh-lsp-e2b
[English](README.md) | 中文
用于 [`ctx.lsp`](../lsp/README.md) 的通用 E2B 语言服务器后端。它在共享的 `ctx.e2b` 沙箱内运行已配置的 stdio 服务器并读取其源文档;提供方注册表、规范化查询结果、队列和协议连接状态仍保留在宿主侧。
## 插件与配置
`lsp-e2b` 插件注入 `e2b``lsp` 和具体的 `dsh-subprocess-e2b` 服务。`servers` 是一张非空的提供方 id 表:
| 服务器键 | 默认值 | 含义 |
|---|---|---|
| `command` | 必填 | 远程可执行文件:绝对路径,或在加载时通过沙箱 PATH 解析。 |
| `args` | `[]` | 远程服务器参数。 |
| `env` | `{}` | 经由进程管理适配器传入的显式环境条目。 |
| `extensionToLanguage` | 必填 | 小写、以点开头的扩展名到 LSP language id 的映射。 |
| `initializationOptions` / `configuration` | `null` / `null` | 静态初始化选项和 `workspace/configuration` 应答。 |
| `maxMessageBytes` | `16000000` | 从服务器接受的 LSP 消息大小上限。 |
| `maxStderrBytes` | `1000000` | 保留的服务器原始 stderr 尾部上限。 |
| `maxDocumentBytes` | `4000000` | 单次查询可打开的最大远程源文件。 |
| `shutdownTimeoutMs` | `5000` | 协议优雅关闭预算。 |
| `killGraceMs` | `2000` | 请求取消与 TERM 到 KILL 升级的宽限期。 |
提供方 id 与命令必须非空;数值上限必须是正的安全整数,定时器取值不得超过 Node 的最大定时器延迟。设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个仅供所有者使用的私有代理,在远程解析 Node 和每个已配置服务器的可执行文件,再以原子方式注册所有提供方。
## 远程协议与文件系统
E2B 命令回调交付的是文本,而 LSP 按字节分帧。因此,已安装的代理会把服务器 stdout、stderr 和 stdin 的原始字节进行 base64 编码,封装为以换行分隔的 ASCII JSON 帧;宿主会验证并解码每一帧,再把字节交给共享的 `LspInstance` 协议引擎。`initialize.processId``null`,因为宿主与服务器不共享进程命名空间。
每个提供方与规范化远程工作区的组合共享一个池化语言服务器进程。同一工作区的查询串行执行,不同工作区的查询并发运行。每项查询都会使用 `realpath` 规范化远程工作区与源文件,拒绝工作区外的路径,要求源文件为普通文件,在读取前后都检查大小上限,使用严格的 UTF-8 解码,并采用常规的临时 `didOpen`/请求/`didClose` 生命周期。传输失败会 dispose(资源释放)该实例,并在全新的远程进程上重试一次只读查询。
进程管理适配器负责进程组和终止升级,因此取消与资源释放都会等待远程服务器完全停稳。宿主负责 LSP 请求 id、待完成请求、提供方队列和规范化结果。
## 模型体验
通过 `@deepseek-ai/dsh-tool-lsp` 间接影响模型;该包会公开规范化的语义导航与悬停结果,而不改变其面向模型的 schema。
#### KV Cache 影响
不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。
## 已知限制与暂缓工作
- **仅支持已配置的服务器**:本包不会安装语言服务器、选择 preset,或把宿主工作区同步到 E2B。
- **宿主协议状态不可重连**:保留沙箱并不会恢复提供方队列、JSON-RPC 请求、进程管理句柄或文档生命周期状态。
- **SDK 仍会保留输出**:ASCII 分帧能保留协议字节,但 E2B 和进程管理适配器仍会在宿主内存中保留回调输出。
- **沙箱策略归模板负责**:本提供方不会额外增加卷、快照、凭据或网络策略层。
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-lsp-e2b",
"description": "E2B language-server provider for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-lsp-local": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-subprocess-e2b": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-local": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+358
View File
@@ -0,0 +1,358 @@
/** E2B filesystem and process backend for the harness LSP capability seam. */
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
FileType,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
import type { LspProvider, LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { LSP_PROXY_SOURCE } from './proxy-source.ts'
import { E2BLspTransport } from './transport.ts'
export { E2BLspTransport } from './transport.ts'
/** Cordis plugin name. */
export const name = 'lsp-e2b'
/** Services required by the remote provider. */
export const inject = ['e2b', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const DEFAULT_KILL_GRACE_MS = 2_000
/* jscpd:ignore-start -- Loader requires each backend to expose its own statically walkable server schema. */
/** One configured language server inside the shared E2B sandbox. */
export interface LspE2BServerConfig {
/** Remote executable, absolute or resolved on the sandbox PATH. */
command: string
/** Lowercase leading-dot extension to LSP language id. */
extensionToLanguage: Record<string, string>
/** Remote executable arguments. */
args?: string[]
/** Explicit remote environment overrides. */
env?: Record<string, string>
/** Static `initialize` options. */
initializationOptions?: unknown
/** Static answer to every `workspace/configuration` item. */
configuration?: unknown
/** Largest LSP message accepted from the server. */
maxMessageBytes?: number
/** Largest remote stderr tail retained for diagnostics. */
maxStderrBytes?: number
/** Largest remote source opened for one query. */
maxDocumentBytes?: number
/** Graceful LSP shutdown budget. */
shutdownTimeoutMs?: number
/** Request-cancel and TERM-to-KILL grace. */
killGraceMs?: number
}
/** Plugin configuration. */
export interface Config {
/** Non-empty provider-id to remote-server table. */
servers: Record<string, LspE2BServerConfig>
}
type ResolvedServerConfig = Required<LspE2BServerConfig>
const ServerConfig: z<LspE2BServerConfig> = z.object({
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
extensionToLanguage: z.dict(String).required(),
initializationOptions: z.any().default(null),
configuration: z.any().default(null),
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
})
/** Schemastery plugin configuration. */
export const Config: z<Config> = z.object({
servers: z.dict(ServerConfig).required(),
})
/* jscpd:ignore-end */
interface RemoteSource {
canonicalPath: string
text: string
}
function abortReason(signal: AbortSignal): unknown {
try {
signal.throwIfAborted()
} catch (error: unknown) {
return error
}
return new DOMException('The operation was aborted', 'AbortError')
}
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return promise
// AbortSignal permits opaque reasons, and callers observe the exact reason.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
cleanup()
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the signal's exact opaque reason.
reject(abortReason(signal))
}
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => { cleanup(); resolve(value) },
(error: unknown) => { cleanup(); reject(error instanceof Error ? error : new Error(String(error))) },
)
})
}
function validateServerConfig(providerId: string, config: ResolvedServerConfig): void {
if (config.command.length === 0) throw new Error(`lsp-e2b: servers.${providerId}.command must be non-empty`)
for (const name of ['maxMessageBytes', 'maxStderrBytes', 'maxDocumentBytes', 'shutdownTimeoutMs', 'killGraceMs'] as const) {
const value = config[name]
if (!Number.isSafeInteger(value) || value <= 0 || (name.endsWith('Ms') && value > MAX_TIMER_DELAY_MS)) {
throw new Error(`lsp-e2b: servers.${providerId}.${name} must be a positive safe integer${name.endsWith('Ms') ? ` no greater than ${MAX_TIMER_DELAY_MS}` : ''}`)
}
}
}
async function canonicalRemotePath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted()
const result = await sandbox.commands.run(`realpath -e -- ${quoteE2BShellArg(path)}`, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
const canonical = result.stdout.trim()
if (!posix.isAbsolute(canonical) || canonical.includes('\n')) throw new Error(`remote path ${JSON.stringify(path)} did not resolve canonically`)
return canonical
}
/**
* Canonicalize and validate one workspace inside E2B.
* @param sandbox - Shared sandbox that owns the workspace.
* @param workspaceRoot - Remote workspace path supplied by the query.
* @param signal - Optional query cancellation signal.
* @returns The canonical remote directory path.
*/
export async function canonicalizeE2BWorkspace(
sandbox: Sandbox,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<string> {
const canonical = await canonicalRemotePath(sandbox, workspaceRoot, signal)
const info = await sandbox.files.getInfo(canonical, signal === undefined ? {} : { signal })
signal?.throwIfAborted()
if (info.type !== FileType.DIR) throw new Error(`workspace root ${JSON.stringify(workspaceRoot)} is not a directory`)
return canonical
}
/**
* Resolve, contain, and read one UTF-8 query source inside E2B.
* @param sandbox - Shared sandbox that owns the source.
* @param filePath - Absolute path or path relative to the canonical workspace.
* @param workspace - Canonical remote workspace directory.
* @param maxDocumentBytes - Maximum source size before and after reading.
* @param signal - Optional query cancellation signal.
* @returns The canonical source path and decoded text.
*/
export async function readE2BSource(
sandbox: Sandbox,
filePath: string,
workspace: string,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<RemoteSource> {
const requested = posix.isAbsolute(filePath) ? filePath : posix.resolve(workspace, filePath)
const canonicalPath = await canonicalRemotePath(sandbox, requested, signal)
const relative = posix.relative(workspace, canonicalPath)
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) {
throw new Error(`source ${JSON.stringify(filePath)} resolves outside the workspace`)
}
const info = await sandbox.files.getInfo(canonicalPath, signal === undefined ? {} : { signal })
if (info.type !== FileType.FILE) throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
if (info.size > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
}
const bytes = await sandbox.files.read(canonicalPath, { format: 'bytes', ...signal === undefined ? {} : { signal } })
signal?.throwIfAborted()
if (bytes.length > maxDocumentBytes) {
throw new Error(`source ${JSON.stringify(filePath)} grew past the ${maxDocumentBytes}-byte limit while reading`)
}
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new Error(`source ${JSON.stringify(filePath)} is not valid UTF-8 text`, { cause: error })
}
return { canonicalPath, text }
}
/* jscpd:ignore-start -- Provider identity mirrors the seam while remote source and process ownership stay local. */
/** One pooled remote provider with an isolated server per canonical workspace. */
export class E2BLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
private readonly instances = new Map<string, LspInstance>()
private readonly queues = new Map<string, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly sandbox: Sandbox,
private readonly subprocess: E2BSubprocessService,
private readonly config: ResolvedServerConfig,
private readonly executable: string,
private readonly nodeExecutable: string,
private readonly proxyPath: string,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}
/* jscpd:ignore-end */
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
this.assertActive(signal)
const workspace = await canonicalizeE2BWorkspace(this.sandbox, request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
const source = await readE2BSource(this.sandbox, request.filePath, workspace, this.config.maxDocumentBytes, signal)
this.assertActive(signal)
let instance = this.instanceFor(workspace)
try {
return await instance.query(request, source, signal)
} catch (error: unknown) {
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evict(workspace, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
return await instance.query(request, source, signal)
} finally {
if (instance.dead) {
await instance.dispose()
this.evict(workspace, instance)
}
}
})
}
/* jscpd:ignore-start -- Queue and pooling semantics are shared; transport failure and disposal identities differ. */
/** Stop accepting work and await every remote server and queued query. */
async disposeAll(): Promise<void> {
this.disposed = true
const instances = [...this.instances.values()]
const queues = [...this.queues.values()]
this.instances.clear()
await Promise.all([...instances.map(instance => instance.dispose()), ...queues])
this.queues.clear()
}
private assertActive(signal?: AbortSignal): void {
if (this.disposed) throw new LspError('lsp-e2b provider is disposed', 'LSP_DISPOSED')
signal?.throwIfAborted()
}
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
const tail = previous.then(() => result).then(() => undefined, () => undefined)
this.queues.set(workspace, tail)
void tail.then(() => { if (this.queues.get(workspace) === tail) this.queues.delete(workspace) })
return result
}
private instanceFor(workspace: string): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
return created
}
/* jscpd:ignore-end */
private createInstance(workspace: string): LspInstance {
return new LspInstance({
command: this.executable,
args: this.config.args,
cwd: workspace,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
clientProcessId: null,
}, (spec: SubprocessSpawnSpec) => {
const originalArgv = Buffer.from(JSON.stringify(spec.argv)).toString('base64')
const inner = this.subprocess.spawn({
...spec,
argv: [this.nodeExecutable, this.proxyPath, originalArgv],
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: this.config.maxStderrBytes },
},
})
const rawBound = Math.max(this.config.maxMessageBytes, this.config.maxStderrBytes)
return new E2BLspTransport(inner, rawBound * 2 + 1024, this.config.maxStderrBytes)
})
}
private evict(workspace: string, instance: LspInstance): void {
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
}
/** Install the proxy, resolve remote commands, and atomically register providers. */
export async function apply(ctx: Context, config: Config): Promise<void> {
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
throw new Error('lsp-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
}
const subprocess = ctx.subprocess
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-e2b: servers must contain at least one server')
const sandbox = await ctx.e2b.getSandbox()
const proxyPath = posix.join(ctx.e2b.runtimeRoot, 'lsp-stdio-proxy.mjs')
await sandbox.files.write([{ path: proxyPath, data: LSP_PROXY_SOURCE }])
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(proxyPath)}`)
const nodeExecutable = await resolveE2BExecutable(sandbox, 'node')
const providers = await Promise.all(entries.map(async ([providerId, raw]) => {
if (providerId.trim() === '') throw new Error('lsp-e2b: server ids must be non-empty strings')
const resolved = raw as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const executable = await resolveE2BExecutable(sandbox, resolved.command)
return new E2BLspProvider(providerId, sandbox, subprocess, resolved, executable, nodeExecutable, proxyPath)
}))
/* jscpd:ignore-start -- Every provider table publishes atomically through the same registry contract. */
ctx.effect(() => {
const disposers: Array<() => void> = []
try {
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
} catch (error: unknown) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return async () => {
for (const dispose of disposers.reverse()) dispose()
await Promise.all(providers.map(provider => provider.disposeAll()))
}
}, 'lsp-e2b.registerProviders')
/* jscpd:ignore-end */
}
+20
View File
@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-lsp-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-e2b'
/** Cordis companion plugin name. */
export const name = 'lsp-e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the LSP registry owns provider publication. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+55
View File
@@ -0,0 +1,55 @@
/** Dependency-free remote stdio proxy installed inside the E2B sandbox. */
/**
* Node program that base64-frames raw child stdio so E2B's text callbacks
* never decode the language server's byte stream.
*/
export const LSP_PROXY_SOURCE = String.raw`import { Buffer } from 'node:buffer'
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
const emit = (message) => {
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
}
let argv
try {
argv = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'))
if (!Array.isArray(argv) || argv.length === 0 || argv.some(value => typeof value !== 'string')) throw new Error('invalid argv')
} catch (error) {
process.stderr.write('lsp-e2b proxy argv error: ' + String(error) + '\n')
process.exitCode = 125
process.stdin.destroy()
}
if (argv) {
const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], env: process.env })
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
child.stdout.on('data', data => { emit({ type: 'stdout', data: data.toString('base64') }) })
child.stderr.on('data', data => { emit({ type: 'stderr', data: data.toString('base64') }) })
child.on('error', error => {
emit({ type: 'stderr', data: Buffer.from('language server spawn failed: ' + error.message).toString('base64') })
})
child.on('close', (code, signal) => {
emit({ type: 'exit', code, signal })
input.close()
process.stdin.destroy()
process.exitCode = code === null ? 1 : code
})
input.on('line', line => {
input.pause()
try {
const message = JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
if (!message || message.type !== 'stdin' || typeof message.data !== 'string') throw new Error('invalid stdin frame')
const data = Buffer.from(message.data, 'base64')
if (data.toString('base64') !== message.data) throw new Error('invalid stdin base64')
if (child.stdin.write(data)) input.resume()
else child.stdin.once('drain', () => { input.resume() })
} catch (error) {
process.stderr.write('lsp-e2b proxy stdin error: ' + String(error) + '\n')
child.kill('SIGTERM')
}
})
input.on('close', () => { child.stdin.end() })
}
`
+183
View File
@@ -0,0 +1,183 @@
/** Byte-faithful stdio transport over an E2B subprocess and ASCII/base64 frames. */
import { Buffer } from 'node:buffer'
import { PassThrough, Writable } from 'node:stream'
import { E2BFrameDecoder, encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputRead,
SubprocessOutputReader,
} from '@deepseek-ai/dsh-subprocess'
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
class ByteTailReader implements SubprocessOutputReader {
private chunks: Buffer[] = []
private totalBytes = 0
private retainedBytes = 0
private dropped = false
constructor(private readonly maxBytes: number) {}
append(data: Buffer): void {
if (data.length === 0) return
this.chunks.push(data)
this.totalBytes += data.length
this.retainedBytes += data.length
while (this.retainedBytes > this.maxBytes && this.chunks.length > 0) {
const first = this.chunks[0] as Buffer
const excess = this.retainedBytes - this.maxBytes
if (first.length <= excess) {
this.chunks.shift()
this.retainedBytes -= first.length
} else {
this.chunks[0] = first.subarray(excess)
this.retainedBytes -= excess
}
this.dropped = true
}
}
readFrom(fromByte: number): SubprocessOutputRead {
if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
throw new Error('subprocess output offset must be a non-negative safe integer')
}
const retainedStart = this.totalBytes - this.retainedBytes
const lossy = fromByte < retainedStart
const start = lossy ? 0 : Math.min(this.retainedBytes, fromByte - retainedStart)
const bytes = Buffer.concat(this.chunks).subarray(start)
return { text: bytes.toString('utf8'), nextOffset: this.totalBytes, lossy: lossy || this.dropped && fromByte === 0 }
}
}
class FramedInput extends Writable {
constructor(private readonly target: Writable) {
super()
target.on('error', (error: Error) => { this.destroy(error) })
}
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.target.write(encodeE2BFrame({ type: 'stdin', data: chunk.toString('base64') }), callback)
}
override _final(callback: (error?: Error | null) => void): void {
this.target.end(callback)
}
}
/** Subprocess handle that decodes a remote proxy's stdout/stderr byte frames. */
export class E2BLspTransport implements SubprocessHandle {
readonly stdin: Writable
readonly stdout = new PassThrough()
readonly stderr = undefined
readonly collected: SubprocessHandle['collected']
readonly done: Promise<SubprocessOutcome>
private readonly stderrTail: ByteTailReader
private readonly decoder: E2BFrameDecoder
private failed: Error | undefined
/**
* @param inner - E2B subprocess running the installed proxy.
* @param maxFrameBytes - Maximum decoded proxy frame size.
* @param maxStderrBytes - Retained raw language-server stderr tail.
*/
constructor(
private readonly inner: SubprocessHandle,
maxFrameBytes: number,
maxStderrBytes: number,
) {
if (inner.stdin === undefined || inner.stdout === undefined) {
inner.terminate()
throw new Error('lsp-e2b: proxy subprocess dropped a piped stream')
}
this.stdin = new FramedInput(inner.stdin)
this.stderrTail = new ByteTailReader(maxStderrBytes)
this.collected = { stderr: this.stderrTail }
this.decoder = new E2BFrameDecoder(maxFrameBytes)
inner.stdout.on('data', (chunk: Buffer) => { this.onProxyData(chunk) })
inner.stdout.on('error', (error: Error) => { this.fail(error) })
this.done = inner.done.then(
(outcome) => {
this.finishFrames()
this.captureProxyStderr()
this.stdout.end()
if (this.failed !== undefined) throw this.failed
return outcome
},
(error: unknown) => {
this.captureProxyStderr()
this.stdout.end()
throw error
},
)
void this.done.catch(() => {})
}
get pid(): number {
return this.inner.pid
}
terminate(): void {
this.inner.terminate()
}
async waitForExit(signal?: AbortSignal): Promise<boolean> {
return await this.inner.waitForExit(signal)
}
private onProxyData(chunk: Buffer): void {
if (this.failed !== undefined) return
let frames: unknown[]
try {
frames = this.decoder.push(chunk.toString('utf8'))
} catch (error: unknown) {
this.fail(asError(error))
return
}
for (const frame of frames) this.dispatch(frame)
}
private dispatch(frame: unknown): void {
if (typeof frame !== 'object' || frame === null) {
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
return
}
const record = frame as Record<string, unknown>
if (record.type === 'exit' && (record.code === null || typeof record.code === 'number') && (record.signal === null || typeof record.signal === 'string')) return
if ((record.type !== 'stdout' && record.type !== 'stderr') || typeof record.data !== 'string') {
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
return
}
const data = Buffer.from(record.data, 'base64')
if (data.toString('base64') !== record.data) {
this.fail(new Error('lsp-e2b: proxy emitted invalid base64'))
return
}
if (record.type === 'stdout') this.stdout.write(data)
else this.stderrTail.append(data)
}
private finishFrames(): void {
if (this.failed !== undefined) return
try {
this.decoder.finish()
} catch (error: unknown) {
this.fail(asError(error))
}
}
private captureProxyStderr(): void {
const diagnostic = this.inner.collected.stderr?.readFrom(0).text
if (diagnostic !== undefined && diagnostic.length > 0) this.stderrTail.append(Buffer.from(diagnostic))
}
private fail(error: Error): void {
if (this.failed !== undefined) return
this.failed = error
this.inner.terminate()
this.stdout.end()
}
}
+397
View File
@@ -0,0 +1,397 @@
import { PassThrough } from 'node:stream'
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
FileType,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
const mockedLsp = vi.hoisted(() => {
interface Plan {
query?: (...args: unknown[]) => unknown
transportFailure?: unknown
dead?: boolean
deadAfterQuery?: boolean
disposeError?: unknown
}
class FakeLspInstance {
static readonly instances: FakeLspInstance[] = []
static readonly plans: Plan[] = []
readonly plan: Plan
readonly transport: unknown
readonly queries: unknown[][] = []
dead: boolean
disposals = 0
constructor(
readonly spec: Record<string, unknown>,
spawner: (spec: SubprocessSpawnSpec) => unknown,
) {
this.plan = FakeLspInstance.plans.shift() ?? {}
this.dead = this.plan.dead === true
this.transport = spawner({
argv: [String(spec.command), ...(spec.args as string[])],
cwd: String(spec.cwd),
env: spec.env as Record<string, string>,
graceMs: Number(spec.killGraceMs),
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: Number(spec.maxStderrBytes) } },
})
FakeLspInstance.instances.push(this)
}
async query(...args: unknown[]): Promise<unknown> {
this.queries.push(args)
const result = await Promise.resolve(this.plan.query?.(...args) ?? { kind: 'hover', hover: null })
if (this.plan.deadAfterQuery === true) this.dead = true
return result
}
isTransportFailure(error: unknown): boolean {
return error === this.plan.transportFailure
}
async dispose(): Promise<void> {
this.disposals += 1
this.dead = true
if (this.plan.disposeError !== undefined) throw this.plan.disposeError
}
}
return { FakeLspInstance }
})
vi.mock('@deepseek-ai/dsh-lsp-local', () => ({ LspInstance: mockedLsp.FakeLspInstance }))
import {
E2BLspProvider,
apply,
canonicalizeE2BWorkspace,
readE2BSource,
} from '@deepseek-ai/dsh-lsp-e2b'
import type { LspE2BServerConfig } from '@deepseek-ai/dsh-lsp-e2b'
import * as E2BLspInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
class FakeInnerHandle implements SubprocessHandle {
readonly pid = 777
readonly stdin = new PassThrough()
readonly stdout = new PassThrough()
readonly stderr = undefined
readonly collected = { stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }
readonly done = Promise.resolve({ exitCode: 0, signal: null })
terminate(): void {}
async waitForExit(): Promise<boolean> { return true }
}
class FakeRemote {
readonly writes: Array<Array<{ path: string; data: string }>> = []
readonly commands: string[] = []
readonly infos = new Map<string, { type: FileType; size: number }>()
readonly contents = new Map<string, Uint8Array>()
readonly realpaths = new Map<string, string>()
forcedRealpath: string | undefined
constructor() {
this.infos.set('/workspace', { type: FileType.DIR, size: 0 })
this.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 12 })
this.contents.set('/workspace/file.ts', Buffer.from('const x = 1'))
}
readonly sandbox = {
commands: {
run: async (command: string) => {
this.commands.push(command)
if (command.startsWith('realpath ')) {
const match = /'([^']*)'$/.exec(command)
const requested = match?.[1] ?? ''
return { exitCode: 0, stdout: `${this.forcedRealpath ?? this.realpaths.get(requested) ?? requested}\n`, stderr: '' }
}
if (command.startsWith('command -v')) return { exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' }
return { exitCode: 0, stdout: '', stderr: '' }
},
},
files: {
write: async (files: Array<{ path: string; data: string }>) => {
this.writes.push(files)
return files.map(() => ({}))
},
getInfo: async (path: string) => {
const info = this.infos.get(path)
if (info === undefined) throw new Error(`missing info for ${path}`)
return info
},
read: async (path: string) => this.contents.get(path) ?? new Uint8Array(),
},
} as unknown as Sandbox
}
function subprocess(spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())): E2BSubprocessService {
const service = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
Object.defineProperty(service, 'spawn', { value: spawn })
return service
}
function server(overrides: Partial<LspE2BServerConfig> = {}): Required<LspE2BServerConfig> {
return {
command: '/usr/bin/server', args: ['--stdio'], env: {},
extensionToLanguage: { '.ts': 'typescript' },
initializationOptions: null, configuration: null,
maxMessageBytes: 1_024, maxStderrBytes: 128, maxDocumentBytes: 1_024,
shutdownTimeoutMs: 100, killGraceMs: 50,
...overrides,
}
}
function provider(remote = new FakeRemote(), service = subprocess()): E2BLspProvider {
return new E2BLspProvider(
'fixture', remote.sandbox, service, server(),
'/usr/bin/server', '/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs',
)
}
function query(workspaceRoot = '/workspace') {
return {
operation: 'hover' as const,
filePath: 'file.ts',
position: { line: 0, character: 1 },
workspaceRoot,
languageId: 'typescript',
}
}
beforeEach(() => {
mockedLsp.FakeLspInstance.instances.length = 0
mockedLsp.FakeLspInstance.plans.length = 0
})
describe('E2B LSP filesystem boundary', () => {
it('canonicalizes a directory and reads a contained UTF-8 source', async () => {
const remote = new FakeRemote()
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace')).resolves.toBe('/workspace')
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024)).resolves.toEqual({
canonicalPath: '/workspace/file.ts',
text: 'const x = 1',
})
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024)).resolves.toMatchObject({
canonicalPath: '/workspace/file.ts',
})
const signal = new AbortController().signal
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace', signal)).resolves.toBe('/workspace')
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, signal)).resolves.toMatchObject({
canonicalPath: '/workspace/file.ts',
})
})
it('rejects malformed workspaces and source containment/type/size/encoding failures', async () => {
const malformed = new FakeRemote()
malformed.forcedRealpath = 'relative'
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
malformed.forcedRealpath = '/workspace\nother'
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
const notDirectory = new FakeRemote()
notDirectory.infos.set('/workspace', { type: FileType.FILE, size: 0 })
await expect(canonicalizeE2BWorkspace(notDirectory.sandbox, '/workspace')).rejects.toThrow('not a directory')
const outside = new FakeRemote()
outside.realpaths.set('/workspace/file.ts', '/outside/file.ts')
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('outside the workspace')
const notFile = new FakeRemote()
notFile.infos.set('/workspace/file.ts', { type: FileType.DIR, size: 0 })
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not a regular file')
const tooLarge = new FakeRemote()
tooLarge.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 21 })
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('over the 20-byte limit')
const grew = new FakeRemote()
grew.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
grew.contents.set('/workspace/file.ts', Buffer.alloc(21))
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('grew past')
const invalid = new FakeRemote()
invalid.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
invalid.contents.set('/workspace/file.ts', Uint8Array.from([0xff]))
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not valid UTF-8')
await expect(canonicalizeE2BWorkspace(new FakeRemote().sandbox, '/workspace', AbortSignal.abort('stop')))
.rejects.toBe('stop')
})
})
describe('E2BLspProvider pooling and lifecycle', () => {
it('reuses one canonical-workspace instance and constructs the remote proxy transport', async () => {
const spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())
const remote = new FakeRemote()
mockedLsp.FakeLspInstance.plans.push({ query: async () => ({ kind: 'hover', hover: { contents: 'ok' } }) })
const current = provider(remote, subprocess(spawn))
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(1)
expect(mockedLsp.FakeLspInstance.instances[0]?.spec).toMatchObject({ clientProcessId: null, cwd: '/workspace' })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs', expect.any(String)],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 128 } },
}))
expect(current.id).toBe('fixture')
expect(current.extensionToLanguage).toEqual({ '.ts': 'typescript' })
await current.disposeAll()
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
})
it('replaces one transport failure, but preserves ordinary query errors', async () => {
const transportFailure = new Error('transport failed')
mockedLsp.FakeLspInstance.plans.push(
{ transportFailure, query: async () => { throw transportFailure } },
{ query: async () => ({ kind: 'hover', hover: { contents: 'retried' } }) },
)
const retried = provider()
await expect(retried.query(query())).resolves.toMatchObject({ hover: { contents: 'retried' } })
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
const ordinary = new Error('ordinary failure')
mockedLsp.FakeLspInstance.plans.push(
{ query: async () => { throw ordinary }, dead: true },
{ query: async () => ({ kind: 'hover', hover: null }) },
)
const failed = provider()
await expect(failed.query(query())).rejects.toBe(ordinary)
await expect(failed.query(query())).resolves.toMatchObject({ kind: 'hover' })
})
it('evicts a server that dies after a successful query', async () => {
mockedLsp.FakeLspInstance.plans.push(
{ deadAfterQuery: true, query: async () => ({ kind: 'hover', hover: null }) },
{ query: async () => ({ kind: 'hover', hover: null }) },
)
const current = provider()
await current.query(query())
await current.query(query())
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
})
it('serializes a workspace queue, observes queued abort, and awaits work on disposal', async () => {
const first = Promise.withResolvers<unknown>()
mockedLsp.FakeLspInstance.plans.push({ query: () => first.promise })
const current = provider()
const running = current.query(query())
const controller = new AbortController()
const queued = current.query(query(), controller.signal)
await new Promise(resolve => setImmediate(resolve))
controller.abort('queued stop')
await expect(queued).rejects.toBe('queued stop')
const disposing = current.disposeAll()
first.resolve({ kind: 'hover', hover: null })
await expect(running).resolves.toMatchObject({ kind: 'hover' })
await disposing
await expect(current.query(query())).rejects.toMatchObject({ code: 'LSP_DISPOSED' })
})
it('covers pre-abort, synthetic abort, resolve, and rejection in the queue race', async () => {
const current = provider()
const internal = current as unknown as {
queues: Map<string, Promise<void>>
enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>
}
await expect(internal.enqueue('pre', AbortSignal.abort('pre-stop'), async () => 'unused')).rejects.toBe('pre-stop')
const signal = new AbortController().signal
await expect(internal.enqueue('resolve', signal, async () => 'ok')).resolves.toBe('ok')
const failure = new Error('queue failed')
const rejected = Promise.reject<undefined>(failure)
void rejected.catch(() => {})
internal.queues.set('reject', rejected)
await expect(internal.enqueue('reject', signal, async () => 'unused')).rejects.toBe(failure)
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Exercise normalization at the promise boundary.
const opaque = Promise.reject<undefined>('opaque queue failure')
void opaque.catch(() => {})
internal.queues.set('opaque', opaque)
await expect(internal.enqueue('opaque', signal, async () => 'unused')).rejects.toEqual(new Error('opaque queue failure'))
const synthetic = {
aborted: false,
reason: undefined,
throwIfAborted() {},
addEventListener(_type: string, listener: () => void) { listener() },
removeEventListener() {},
} as unknown as AbortSignal
await expect(internal.enqueue('synthetic', synthetic, async () => 'unused')).rejects.toMatchObject({ name: 'AbortError' })
await current.disposeAll()
})
})
describe('lsp-e2b plugin composition', () => {
function pluginContext(
remote: FakeRemote,
service: E2BSubprocessService,
registerProvider = vi.fn(() => vi.fn()),
) {
const effects: Array<() => void | Promise<void>> = []
const ctx = {
subprocess: service,
e2b: {
runtimeRoot: '/workspace/.dsh-e2b',
getSandbox: async () => remote.sandbox,
},
lsp: { registerProvider },
effect: (callback: () => (() => void | Promise<void>)) => { effects.push(callback()) },
} as unknown as Context
return { ctx, effects, registerProvider }
}
it('installs one proxy, resolves commands, registers providers, and disposes them', async () => {
const remote = new FakeRemote()
const fixture = pluginContext(remote, subprocess())
await apply(fixture.ctx, { servers: { one: server(), two: server({ command: 'server-two' }) } })
expect(remote.writes).toHaveLength(1)
expect(remote.writes[0]?.[0]?.path).toBe('/workspace/.dsh-e2b/lsp-stdio-proxy.mjs')
expect(remote.commands).toContain("chmod 600 -- '/workspace/.dsh-e2b/lsp-stdio-proxy.mjs'")
expect(fixture.registerProvider).toHaveBeenCalledTimes(2)
await fixture.effects[0]?.()
})
it('rolls back partial registration and rejects invalid composition/configuration', async () => {
const remote = new FakeRemote()
const firstDispose = vi.fn()
const register = vi.fn()
.mockReturnValueOnce(firstDispose)
.mockImplementationOnce(() => { throw new Error('duplicate provider') })
const rollback = pluginContext(remote, subprocess(), register)
await expect(apply(rollback.ctx, { servers: { one: server(), two: server() } })).rejects.toThrow('duplicate provider')
expect(firstDispose).toHaveBeenCalledOnce()
const wrong = pluginContext(remote, {} as E2BSubprocessService)
await expect(apply(wrong.ctx, { servers: { one: server() } })).rejects.toThrow('dsh-subprocess-e2b')
const empty = pluginContext(remote, subprocess())
await expect(apply(empty.ctx, { servers: {} })).rejects.toThrow('at least one server')
for (const [id, config] of [
['', server()],
['one', server({ command: '' })],
['one', server({ maxMessageBytes: 0 })],
['one', server({ maxStderrBytes: 1.5 })],
['one', server({ shutdownTimeoutMs: 2_147_483_648 })],
] as const) {
const fixture = pluginContext(new FakeRemote(), subprocess())
await expect(apply(fixture.ctx, { servers: { [id]: config } })).rejects.toThrow()
}
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BLspInvariant).await()
await fiber.dispose()
})
})
@@ -0,0 +1,181 @@
import { once } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import { E2BLspTransport } from '@deepseek-ai/dsh-lsp-e2b'
class FakeHandle implements SubprocessHandle {
readonly pid = 321
readonly stdin: PassThrough | undefined
readonly stdout: PassThrough | undefined
readonly stderr = undefined
readonly collected: SubprocessHandle['collected']
readonly result = Promise.withResolvers<SubprocessOutcome>()
readonly done = this.result.promise
terminated = 0
waitResult = true
constructor(options: { stdin?: boolean; stdout?: boolean; diagnostic?: string } = {}) {
this.stdin = options.stdin === false ? undefined : new PassThrough()
this.stdout = options.stdout === false ? undefined : new PassThrough()
this.collected = options.diagnostic === undefined
? {}
: { stderr: { readFrom: () => ({ text: options.diagnostic as string, nextOffset: 0, lossy: false }) } }
}
terminate(): void {
this.terminated += 1
}
async waitForExit(): Promise<boolean> {
return this.waitResult
}
resolve(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
this.stdout?.end()
this.result.resolve(outcome)
}
reject(error: unknown): void {
this.stdout?.end()
this.result.reject(error)
}
}
function proxyFrame(type: 'stdout' | 'stderr', data: string | Buffer): string {
return encodeE2BFrame({ type, data: Buffer.from(data).toString('base64') })
}
describe('E2BLspTransport', () => {
it('frames stdin, decodes split byte output, and exposes handle lifecycle', async () => {
const inner = new FakeHandle({ diagnostic: 'proxy diagnostic' })
const transport = new E2BLspTransport(inner, 1_024, 64)
let stdin = ''
inner.stdin?.on('data', (chunk) => { stdin += String(chunk) })
let stdout = Buffer.alloc(0)
transport.stdout.on('data', (chunk) => { stdout = Buffer.concat([stdout, chunk]) })
transport.stdin.write(Buffer.from([0, 0xff]))
await new Promise(resolve => setImmediate(resolve))
const encodedInput = stdin.trim()
const input = JSON.parse(Buffer.from(encodedInput, 'base64').toString('utf8')) as Record<string, string>
expect(input).toEqual({ type: 'stdin', data: 'AP8=' })
const frames = proxyFrame('stdout', Buffer.from([0, 0xff]))
+ proxyFrame('stderr', 'server diagnostic')
+ encodeE2BFrame({ type: 'exit', code: 0, signal: null })
inner.stdout?.write(frames.slice(0, 7))
inner.stdout?.write(frames.slice(7))
inner.stdout?.write(proxyFrame('stderr', ''))
inner.stdout?.write(encodeE2BFrame({ type: 'exit', code: null, signal: 'SIGTERM' }))
inner.resolve()
await expect(transport.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(stdout).toEqual(Buffer.from([0, 0xff]))
expect(transport.collected.stderr?.readFrom(0).text).toBe('server diagnosticproxy diagnostic')
expect(transport.pid).toBe(321)
inner.waitResult = false
await expect(transport.waitForExit()).resolves.toBe(false)
transport.terminate()
expect(inner.terminated).toBe(1)
})
it('ends the inner stdin and retains a bounded byte tail with independent offsets', async () => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 4)
const finished = once(inner.stdin!, 'finish')
transport.stdin.end()
await finished
inner.stdout?.write(proxyFrame('stderr', 'ab'))
inner.stdout?.write(proxyFrame('stderr', 'cdef'))
const reader = transport.collected.stderr!
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
expect(() => reader.readFrom(-1)).toThrow('non-negative safe integer')
expect(() => reader.readFrom(1.5)).toThrow('non-negative safe integer')
inner.resolve()
await transport.done
const partialInner = new FakeHandle()
const partial = new E2BLspTransport(partialInner, 1_024, 4)
partialInner.stdout?.write(proxyFrame('stderr', 'abcdef'))
expect(partial.collected.stderr?.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
partialInner.resolve()
await partial.done
})
it.each([
['non-object', encodeE2BFrame(null), 'malformed frame'],
['wrong type', encodeE2BFrame({ type: 'other', data: '' }), 'malformed frame'],
['invalid exit', encodeE2BFrame({ type: 'exit', code: 'zero', signal: null }), 'malformed frame'],
['missing data', encodeE2BFrame({ type: 'stdout' }), 'malformed frame'],
['invalid base64', encodeE2BFrame({ type: 'stdout', data: 'abc' }), 'invalid base64'],
['invalid outer frame', 'not-base64\n', 'invalid base64'],
['non-ASCII outer frame', 'é', 'non-ASCII'],
])('fails %s proxy traffic and terminates the group', async (_name, frame, message) => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 32)
inner.stdout?.write(frame)
inner.stdout?.write(proxyFrame('stdout', 'ignored'))
inner.stdout?.emit('error', new Error('second failure'))
inner.resolve()
await expect(transport.done).rejects.toThrow(message)
expect(inner.terminated).toBe(1)
})
it('fails truncated frames and preserves inner spawn rejection', async () => {
const truncatedInner = new FakeHandle()
const truncated = new E2BLspTransport(truncatedInner, 1_024, 32)
truncatedInner.stdout?.write('YQ==')
truncatedInner.resolve()
await expect(truncated.done).rejects.toThrow('mid-frame')
const rejectedInner = new FakeHandle({ diagnostic: 'tail' })
const rejected = new E2BLspTransport(rejectedInner, 1_024, 32)
rejectedInner.reject(new Error('spawn failed'))
await expect(rejected.done).rejects.toThrow('spawn failed')
expect(rejected.collected.stderr?.readFrom(0).text).toBe('tail')
})
it('forwards output and input stream errors without an unhandled inner error', async () => {
const outputInner = new FakeHandle()
const output = new E2BLspTransport(outputInner, 1_024, 32)
outputInner.stdout?.emit('error', new Error('proxy stdout failed'))
outputInner.resolve()
await expect(output.done).rejects.toThrow('proxy stdout failed')
const inputInner = new FakeHandle()
const input = new E2BLspTransport(inputInner, 1_024, 32)
const outerError = once(input.stdin, 'error')
inputInner.stdin?.emit('error', new Error('proxy stdin failed'))
await expect(outerError).resolves.toMatchObject([{ message: 'proxy stdin failed' }])
inputInner.resolve()
await input.done
})
it('normalizes a non-Error decoder throw', async () => {
const inner = new FakeHandle()
const transport = new E2BLspTransport(inner, 1_024, 32)
const internal = transport as unknown as {
decoder: { push(chunk: string): unknown[] }
onProxyData(chunk: Buffer): void
}
internal.decoder = { push: () => { throw 'raw decoder failure' } }
internal.onProxyData(Buffer.from('x'))
inner.resolve()
await expect(transport.done).rejects.toThrow('raw decoder failure')
})
it('rejects a subprocess that drops either required pipe', () => {
const missingStdin = new FakeHandle({ stdin: false })
const missingStdout = new FakeHandle({ stdout: false })
expect(() => new E2BLspTransport(missingStdin, 10, 10)).toThrow('dropped a piped stream')
expect(() => new E2BLspTransport(missingStdout, 10, 10)).toThrow('dropped a piped stream')
expect(missingStdin.terminated).toBe(1)
expect(missingStdout.terminated).toBe(1)
})
})
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../e2b/e2b" },
{ "path": "../lsp" },
{ "path": "../lsp-local" },
{ "path": "../../subprocess/subprocess" },
{ "path": "../../subprocess/subprocess-e2b" },
{ "path": "../../util/timeout" },
{ "path": "../../support/invariants" }
]
}
+11 -11
View File
@@ -7,6 +7,7 @@
* @module @deepseek-ai/dsh-lsp-local/instance
*/
import { pathToFileURL } from 'node:url'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {
LspOperation,
@@ -30,12 +31,12 @@ import {
/** Everything an instance needs beyond the connection spec. */
export interface InstanceSpec extends ConnectionSpec {
/** Canonical workspace file URI supplied by the filesystem provider. */
readonly workspaceUri: string
/** Static `initialize` options forwarded to the server. */
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
/** PID advertised to the server; `null` when client and server do not share a process namespace. */
readonly clientProcessId?: number | null
}
/**
@@ -109,11 +110,9 @@ export class LspInstance {
private async initialize(): Promise<void> {
const initializeResult = await this.connection.request('initialize', {
// A subprocess provider may run in another PID namespace or machine;
// the host PID would let the server monitor an unrelated process.
processId: null,
rootUri: this.spec.workspaceUri,
workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
processId: this.spec.clientProcessId === undefined ? process.pid : this.spec.clientProcessId,
rootUri: pathToFileURL(this.spec.cwd).href,
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
capabilities: CLIENT_CAPABILITIES,
initializationOptions: this.spec.initializationOptions,
}) as WireInitializeResult
@@ -150,7 +149,7 @@ export class LspInstance {
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
}
const uri = source.fileUrl
const uri = pathToFileURL(source.canonicalPath).href
let opened = false
try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
@@ -244,9 +243,10 @@ export class LspInstance {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
// The filesystem provider owns URI syntax for the execution platform, which may differ from the
// harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
@@ -15,6 +15,7 @@
* - 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).
@@ -37,6 +38,7 @@ 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
@@ -102,6 +104,10 @@ 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,
@@ -109,6 +109,11 @@ 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
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md
README.md: 5c1044be50368acf13d8c36a15d5b2bd99d02701
README.zh.md: cc412333e63b9469319240d67269bf0192ad3858
README.md: bcffd460f192b0e6ac214ad0bc0d43da4c81d68c
README.zh.md: e9df352e999834235a66ef7a500e300dadc2029c
+1
View File
@@ -10,6 +10,7 @@ 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`.
+1
View File
@@ -10,6 +10,7 @@
|---|---|
| `@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` 到达提供方。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/README.md
README.md: a4f743056b4a524be9623b0f700f37e0534b463f
README.zh.md: c84ad3f1b59afcdbbd111f1b82c57c56aa24fdcf
README.md: 239c751f30f8e5c543e6737e65a67f9bb22f79ad
README.zh.md: edaae7f948d957e2fce2b3f331cc2af61cfabd36
+3 -2
View File
@@ -7,7 +7,8 @@ English | [中文](README.zh.md)
| Package | Role | ctx key |
|---|---|---|
| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` |
| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Shell backend over `ctx.subprocess.spawnTerminal`: readiness detection, bounded terminal state, sandbox policy, and session operations | registers on `ctx.pty` |
| [`pty-local`](pty-local/README.md) (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` |
| [`pty-e2b`](pty-e2b/README.md) (`@deepseek-ai/dsh-pty-e2b`) | E2B byte-PTY backend, remote foreground signaling, bounded terminal state, and awaited remote cleanup | registers on `ctx.pty` |
| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` |
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).
The core design lives in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md); the remote ownership boundary lives in the [E2B extension note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md).
+3 -2
View File
@@ -7,7 +7,8 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`pty`](pty/README.md)`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` |
| `pty-local``@deepseek-ai/dsh-pty-local` | `ctx.subprocess.spawnTerminal` 之上的 shell 后端就绪检测、有界终端状态、沙箱策略与会话操作 | 注册到 `ctx.pty` |
| [`pty-local`](pty-local/README.md)`@deepseek-ai/dsh-pty-local` | 本地 `node-pty` 后端就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` |
| [`pty-e2b`](pty-e2b/README.md)`@deepseek-ai/dsh-pty-e2b`) | E2B 字节 PTY 后端、远程前台信号传递、有界终端状态与等待完成的远程清理 | 注册到 `ctx.pty` |
| `tool-pty``@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` |
设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。
核心设计记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中;远程所有权边界记录在 [E2B 扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) 中。
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/pty-e2b/README.md
README.md: 7a8ddc9e08f4d28d69b97825232b00e2d8f394b7
README.zh.md: 2151ca1f2d91b01a7b0106153f708ace2c3d2726
+54
View File
@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-pty-e2b
English | [中文](README.zh.md)
E2B byte-PTY backend for [`ctx.pty`](../pty/README.md). It creates persistent interactive shells inside the shared `ctx.e2b` sandbox while the PTY registry keeps session identity, exact-Agent ownership, and cleanup policy on the host.
## Plugin and configuration
The `pty-e2b` plugin injects `e2b` and `pty`, then registers one backend under `backendType`.
| Key | Default | Meaning |
|---|---|---|
| `backendType` | `shell` | Registry type selected by `terminal_open`. |
| `rows` / `cols` | `40` / `160` | Initial remote PTY size. |
| `scrollbackLines` | `10000` | Maximum retained logical lines. |
| `scrollbackMaxBytes` | `4194304` | Maximum retained UTF-8 scrollback bytes. |
| `maxReadBytes` | `262144` | Maximum bytes returned by one read or settled send. |
| `pollIntervalMs` | `50` | Host readiness-poll interval. |
| `idleSilenceMs` | `3000` | Output silence that yields `inferred_idle`. |
| `timeoutMs` | `30000` | Absolute startup and send wait bound. |
| `disposeGraceMs` | `3000` | TERM-to-KILL cleanup grace. |
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute.
## Runtime contract
The backend uses E2B's byte-oriented PTY callback with a streaming fatal UTF-8 decoder, then the backend-neutral line sanitizer and bounded buffers from `dsh-pty`. It installs a controlled Bash prompt marker and waits for printable prompt text; when that marker is unavailable, observed output plus the configured silence bound yields `inferred_idle`. Startup with no output reaches the absolute timeout and fails instead of publishing an empty session.
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. Close sends `SIGTERM` to the PTY process group, waits, escalates through E2B's PTY kill, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
The remote PTY process and its child processes live in E2B. Prompt/readiness state, scrollback, operation handles, owner authority, and SDK event delivery remain in host memory.
## Model Experience
### Indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, signal results, and cleanup failures.
#### Token effect
None until a consumer returns bounded backend output. Retained host PTY scrollback is not placed in model history by this package.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
## Known Limitations and Deferred Work
- **Line-oriented terminal model** — CSI/OSC control sequences are removed; alternate-screen and full terminal emulation remain unsupported.
- **Readiness is marker-or-silence based** — E2B exposes foreground process groups but not the local backend's Linux syscall inspection, so `inferred_idle` is deliberately possible.
- **UTF-8 only** — invalid byte sequences fail the session instead of returning lossy text.
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.
+54
View File
@@ -0,0 +1,54 @@
# @deepseek-ai/dsh-pty-e2b
[English](README.md) | 中文
用于 [`ctx.pty`](../pty/README.md) 的 E2B 字节 PTY 后端。它在共享的 `ctx.e2b` 沙箱内创建持久交互式 shell;PTY 注册表则在宿主侧维护会话身份、精确的 Agent 所有权和清理策略。
## 插件与配置
`pty-e2b` 插件注入 `e2b``pty`,然后以 `backendType` 注册一个后端。
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `backendType` | `shell` | `terminal_open` 选择的注册表类型。 |
| `rows` / `cols` | `40` / `160` | 远程 PTY 的初始尺寸。 |
| `scrollbackLines` | `10000` | 保留的逻辑行数上限。 |
| `scrollbackMaxBytes` | `4194304` | 保留的 UTF-8 scrollback 字节数上限。 |
| `maxReadBytes` | `262144` | 单次读取或发送结算时返回的字节数上限。 |
| `pollIntervalMs` | `50` | 宿主就绪轮询间隔。 |
| `idleSilenceMs` | `3000` | 触发 `inferred_idle` 的输出静默时长。 |
| `timeoutMs` | `30000` | 启动与发送等待的绝对上限。 |
| `disposeGraceMs` | `3000` | TERM 到 KILL 的清理宽限期。 |
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。
## 运行时契约
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。关闭操作向 PTY 进程组发送 `SIGTERM`,等待后通过 E2B 的 PTY kill 操作升级,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
远程 PTY 进程及其子进程位于 E2B。提示符/就绪状态、scrollback、操作句柄、所有者权限和 SDK 事件交付仍保留在宿主内存中。
## 模型体验
### 间接消费方
#### 模型看到的内容
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因、信号结果和清理失败。
#### Token 影响
消费方返回有界的后端输出前没有影响。本包不会把宿主保留的 PTY scrollback 放入模型历史。
#### KV Cache 影响
不会直接失效;提示词、schema 和追加结果由消费方负责。
## 已知限制与暂缓工作
- **面向行的终端模型**:CSI/OSC 控制序列会被移除;备用屏幕与完整终端仿真仍不受支持。
- **就绪判断基于标记或静默**:E2B 会公开前台进程组,但不提供本地后端使用的 Linux syscall 检查,因此系统有意保留返回 `inferred_idle` 的可能性。
- **仅支持 UTF-8**:无效字节序列会使会话失败,而不是返回有损文本。
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-pty-e2b",
"description": "E2B PTY provider for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-e2b": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-e2b": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+64
View File
@@ -0,0 +1,64 @@
/** Validated configuration for the E2B PTY backend. */
import z from 'schemastery'
/** Public plugin configuration. */
export interface Config {
/** Backend registry type. */
backendType?: string
/** Initial terminal rows. */
rows?: number
/** Initial terminal columns. */
cols?: number
/** Maximum retained logical lines. */
scrollbackLines?: number
/** Maximum retained UTF-8 bytes. */
scrollbackMaxBytes?: number
/** Maximum bytes returned by one read or settled viewport. */
maxReadBytes?: number
/** Readiness polling interval. */
pollIntervalMs?: number
/** Output silence duration that yields `inferred_idle`. */
idleSilenceMs?: number
/** Absolute send and startup wait bound. */
timeoutMs?: number
/** Grace before PTY teardown escalates from TERM to KILL. */
disposeGraceMs?: number
}
/** Configuration after Schemastery defaults. */
export type ResolvedConfig = Required<Config>
/* jscpd:ignore-start -- Loader requires a backend-local schema and load-time diagnostics. */
/** Schemastery config exposed by the plugin. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
rows: z.number().default(40),
cols: z.number().default(160),
scrollbackLines: z.number().default(10_000),
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
maxReadBytes: z.number().default(256 * 1024),
pollIntervalMs: z.number().default(50),
idleSilenceMs: z.number().default(3_000),
timeoutMs: z.number().default(30_000),
disposeGraceMs: z.number().default(3_000),
})
/**
* Validate the resolved configuration before publishing the backend.
* @param config - Schemastery-resolved plugin configuration.
* @returns Nothing; success narrows every optional field to its resolved value.
*/
export function validateConfig(config: Config): asserts config is ResolvedConfig {
const resolved = config as ResolvedConfig
if (resolved.backendType.length === 0) throw new Error('pty-e2b: backendType must be non-empty')
for (const [name, value] of Object.entries(resolved)) {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`pty-e2b: ${name} must be a positive safe integer`)
}
}
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
throw new Error('pty-e2b: maxReadBytes must not exceed scrollbackMaxBytes')
}
}
/* jscpd:ignore-end */
+93
View File
@@ -0,0 +1,93 @@
/** E2B byte-PTY backend for persistent interactive terminal sessions. */
import { posix } from 'node:path'
import type { Context } from 'cordis'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { E2BPtySession } from './session.ts'
export { Config } from './config.ts'
export type { Config as PtyE2BConfig } from './config.ts'
export { E2BPtySession } from './session.ts'
/** Cordis plugin name. */
export const name = 'pty-e2b'
/** Required shared sandbox owner and PTY registry. */
export const inject = ['e2b', 'pty']
function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
return {
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
DSH_SESSION_ID: spec.owner.id,
DSH_PTY_SESSION_ID: spec.sessionId,
}
}
/** E2B backend registered under the configured terminal type. */
export class E2BPtyBackend implements PtyBackend {
readonly type: string
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly createPty: (
sandbox: Sandbox,
options: Parameters<Sandbox['pty']['create']>[0],
) => Promise<CommandHandle> = (sandbox, options) => sandbox.pty.create(options),
) {
this.type = config.backendType
}
/** Create, initialize, and publish one remote PTY session. */
async spawn(spec: PtyBackendSpawnSpec): Promise<E2BPtySession> {
spec.signal?.throwIfAborted()
const sandbox = await this.ctx.e2b.getSandbox()
spec.signal?.throwIfAborted()
const pending: Uint8Array[] = []
const created: { session?: E2BPtySession } = {}
const handle = await this.createPty(sandbox, {
rows: this.config.rows,
cols: this.config.cols,
cwd: posix.resolve(this.ctx.e2b.cwd, spec.cwd ?? this.ctx.e2b.cwd),
envs: terminalEnvironment(spec),
timeoutMs: 0,
...spec.signal === undefined ? {} : { signal: spec.signal },
onData: (data) => {
if (created.session === undefined) pending.push(Uint8Array.from(data))
else created.session.onData(data)
},
})
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
await handle.kill().catch(() => false)
throw new Error(`pty-e2b: E2B returned invalid PTY pid ${handle.pid}`)
}
const session = new E2BPtySession(sandbox, handle, this.config)
created.session = session
for (const data of pending) session.onData(data)
try {
await session.initialize(spec.signal)
return session
} catch (error: unknown) {
try {
await session.close('E2B PTY startup failed')
} catch (cleanupError: unknown) {
throw new PtyBackendCleanupError(error, cleanupError)
}
throw error
}
}
}
/** Register the E2B PTY backend. */
export function apply(ctx: Context, config: Config): void {
validateConfig(config)
ctx.pty.registerBackend(new E2BPtyBackend(ctx, config))
}
+20
View File
@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-pty-e2b`. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-pty-e2b'
/** Cordis companion plugin name. */
export const name = 'pty-e2b-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the PTY registry owns publication and cleanup. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+367
View File
@@ -0,0 +1,367 @@
/** One byte-oriented E2B PTY session projected onto the harness PTY seam. */
import { Buffer } from 'node:buffer'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import { CommandExitError } from '@deepseek-ai/dsh-e2b'
import {
PtyTerminalSanitizer,
PtyTextBuffer,
ptySignalName,
ptyUtf8Tail,
} from '@deepseek-ai/dsh-pty'
import type {
PtyBackendSession,
PtyReadRequest,
PtyReadResult,
PtySendOperation,
PtySendRead,
PtySendRequest,
PtySendResult,
PtySessionStatus,
PtySignal,
PtySignalResult,
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/* jscpd:ignore-start -- Operation state stays backend-local because process readiness and cleanup identities diverge. */
class E2BSendOperation implements PtySendOperation {
private readonly output: PtyTextBuffer
private readonly result = Promise.withResolvers<PtySendResult>()
private finished = false
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new PtyTextBuffer(maxBytes)
}
get done(): Promise<PtySendResult> {
return this.result.promise
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
if (this.finished) return
this.finished = true
const read = this.output.snapshot()
this.result.resolve({
viewport: read.text,
waitReason,
sessionStatus,
truncated: read.truncated || inheritedTruncation,
})
}
fail(error: unknown): void {
if (this.finished) return
this.finished = true
this.result.reject(error)
}
readOutput(): PtySendRead {
return this.output.consume()
}
cancel(): boolean {
if (this.finished) return false
this.onCancel()
return true
}
}
/* jscpd:ignore-end */
/** Live session around one E2B SDK PTY handle. */
export class E2BPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
private readonly sanitizer: PtyTerminalSanitizer
private readonly scrollback: PtyTextBuffer
private readonly exited = Promise.withResolvers<void>()
private statusValue: PtySessionStatus = { kind: 'running' }
private active: E2BSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private promptTextSeen = false
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private closeSignal: NodeJS.Signals | null = null
private transportFailure: Error | undefined
private remoteExited = false
constructor(
private readonly sandbox: Sandbox,
private readonly handle: CommandHandle,
private readonly config: ResolvedConfig,
) {
this.pid = handle.pid
this.sanitizer = new PtyTerminalSanitizer(config.maxReadBytes)
this.scrollback = new PtyTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
const completion = handle.wait()
void completion.then(
(result) => { this.onExit(result.exitCode) },
(error: unknown) => {
if (error instanceof CommandExitError) this.onExit(error.exitCode)
else this.onTransportFailure(error)
},
)
}
/**
* Consume bytes received by the SDK's PTY callback.
* @param data - Exact callback bytes in delivery order.
*/
onData(data: Uint8Array): void {
let decoded: string
try {
decoded = this.decoder.decode(data, { stream: true })
} catch (error: unknown) {
this.onTransportFailure(new Error('pty-e2b: PTY emitted invalid UTF-8', { cause: error }))
return
}
const sanitized = this.sanitizer.push(decoded)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
}
/**
* Await the first prompt or bounded startup fallback.
* @param signal - Optional startup cancellation signal.
*/
async initialize(signal?: AbortSignal): Promise<void> {
this.initializing = true
try {
const operation = this.startSend({ text: '', submit: false, ...signal === undefined ? {} : { signal } })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('E2B PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('E2B PTY shell did not reach readiness before startup timeout')
this.motd = result.viewport
} catch (error: unknown) {
signal?.throwIfAborted()
throw error
} finally {
this.initializing = false
}
}
/* jscpd:ignore-start -- PTY backends share request admission while owning distinct input and readiness transports. */
startSend(request: PtySendRequest): PtySendOperation {
if (this.closing) throw new Error('E2B PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('E2B PTY session has exited')
if (this.active !== undefined) throw new Error('E2B PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('E2B PTY send aborted before write')
const operation = new E2BSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
}
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0) {
void this.sandbox.pty.sendInput(this.pid, Buffer.from(input)).catch((error: unknown) => {
if (this.active === operation) this.failActive(error)
})
}
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
return operation
}
/* jscpd:ignore-end */
/* jscpd:ignore-start -- The seam requires identical bounded-read coordinates across backend buffers. */
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
const offset = request.offset ?? 0
const count = request.count ?? 500
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
if (offset >= totalLines) {
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
}
const end = totalLines - offset
const start = Math.max(0, end - count)
const bounded = ptyUtf8Tail(lines.slice(start, end).join('\n'), this.config.maxReadBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text,
totalLines,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: snapshot.truncated || bounded.truncated,
}
}
/* jscpd:ignore-end */
/* jscpd:ignore-start -- Signal, status, and close methods preserve the seam shape around remote identities. */
async signal(signal: PtySignal): Promise<PtySignalResult> {
const pgid = await this.foregroundPgid()
if (signal === 'SIGKILL' && pgid === this.pid) {
throw new Error('refusing to SIGKILL the E2B PTY shell; use terminal_close')
}
await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${pgid}`)
return { delivered: true, targetPgid: pgid }
}
status(): PtySessionStatus {
return this.statusValue
}
close(reason: string): Promise<void> {
this.closing = true
if (this.closePromise !== undefined) return this.closePromise
const closing = this.closeOnce(reason).catch((error: unknown) => {
this.closePromise = undefined
this.failActive(error)
throw error
})
this.closePromise = closing
return closing
}
/* jscpd:ignore-end */
private appendOutput(text: string): void {
if (text.length === 0) return
this.lastOutputAt = Date.now()
this.scrollback.append(text)
this.active?.append(text)
}
private pollReadiness(operation: E2BSendOperation): void {
if (this.active !== operation) return
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const elapsed = Date.now() - operation.startedAt
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason): void {
const operation = this.active
if (operation === undefined) return
const inherited = this.scrollback.snapshot().truncated
this.clearActive()
operation.settle(waitReason, this.statusValue, inherited)
}
private clearActive(): void {
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.activeTimer = undefined
this.activeAbort?.()
this.activeAbort = undefined
this.active = undefined
}
private failActive(error: unknown): void {
const operation = this.active
if (operation === undefined) return
this.clearActive()
operation.fail(error)
}
private interrupt(operation: E2BSendOperation): void {
if (this.active !== operation) return
void this.signal('SIGINT').catch((error: unknown) => { this.failActive(error) })
}
private async foregroundPgid(): Promise<number> {
const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`)
const raw = result.stdout.trim()
const pgid = Number(raw)
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid)) {
throw new Error(`cannot resolve foreground process group for E2B PTY ${this.pid}`)
}
return pgid
}
private onExit(exitCode: number): void {
this.remoteExited = true
let tail = ''
try {
tail = this.decoder.decode()
} catch (error: unknown) {
this.transportFailure ??= new Error('pty-e2b: PTY ended with invalid UTF-8', { cause: error })
}
this.appendOutput(this.sanitizer.push(tail).text)
this.appendOutput(this.sanitizer.flush())
const inferredSignal = this.closeSignal ?? (exitCode > 128 ? ptySignalName(exitCode - 128) : null)
this.statusValue = {
kind: 'exited',
exitCode: inferredSignal === null ? exitCode : null,
signal: inferredSignal,
}
if (this.transportFailure === undefined) this.settleActive('session_exit')
else this.failActive(this.transportFailure)
this.exited.resolve()
}
private onTransportFailure(error: unknown): void {
const failure = error instanceof Error ? error : new Error(String(error))
this.transportFailure ??= failure
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
this.failActive(failure)
}
private async closeOnce(reason: string): Promise<void> {
if (!this.remoteExited) {
this.closeSignal = 'SIGTERM'
try {
await this.sandbox.commands.run(`kill -TERM -- -${this.pid}`)
} catch (error: unknown) {
if (!(error instanceof CommandExitError)) throw error
}
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
}
if (!this.remoteExited) {
this.closeSignal = 'SIGKILL'
await this.sandbox.pty.kill(this.pid)
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
}
if (!this.remoteExited) {
throw new Error(`E2B PTY cleanup failed (${reason}); surviving pid: ${this.pid}`)
}
this.settleActive('session_exit')
await this.handle.disconnect().catch(() => {})
if (this.transportFailure !== undefined) throw this.transportFailure
}
}
+195
View File
@@ -0,0 +1,195 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
import { E2BPtyBackend, apply } from '@deepseek-ai/dsh-pty-e2b'
import { validateConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
import * as E2BPtyInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
function config() {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 1, idleSilenceMs: 2, timeoutMs: 5, disposeGraceMs: 1,
}
}
function owner(ctx: Context): Agent {
const id = SessionId('owner')
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('unused'), steer: () => AgentMessageId('unused'),
inject: () => AgentMessageId('unused'), send: () => AgentMessageId('unused'),
cancel() {}, whenIdle: () => Promise.resolve(),
}
}
function handle(pid = 123, kill = vi.fn().mockResolvedValue(true)): CommandHandle {
const result = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
return {
pid,
wait: () => result.promise,
kill,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
}
describe('E2BPtyBackend and plugin', () => {
it('creates a remote PTY with isolated environment and initializes the session', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', {
cwd: '/workspace',
getSandbox: async () => sandbox,
} as E2BSandboxService)
const created = handle()
let options: Parameters<Sandbox['pty']['create']>[0] | undefined
const backend = new E2BPtyBackend(ctx, config(), async (_sandbox, received) => {
options = received
void received.onData(Buffer.from('banner\n'))
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const pending = backend.spawn({
sessionId: PtySessionId('pty-1'), owner: owner(ctx), type: 'shell', cwd: 'project',
signal: new AbortController().signal,
})
await vi.advanceTimersByTimeAsync(2)
const session = await pending
expect(session.motd).toBe('dsh> ')
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
expect(options?.envs).toMatchObject({
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
})
vi.useRealTimers()
})
it('uses the SDK PTY create method and the shared cwd by default', async () => {
vi.useFakeTimers()
const ctx = new Context()
const created = handle()
const create = vi.fn(async (received: Parameters<Sandbox['pty']['create']>[0]) => {
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const sandbox = { pty: { create } } as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config())
const pending = backend.spawn({ sessionId: PtySessionId('default'), owner: owner(ctx), type: 'shell' })
await vi.advanceTimersByTimeAsync(2)
await pending
expect(create).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace' }))
vi.useRealTimers()
})
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const create = vi.fn().mockResolvedValue(handle(0))
const backend = new E2BPtyBackend(ctx, config(), create)
const aborted = AbortSignal.abort(new Error('stop'))
await expect(backend.spawn({ sessionId: PtySessionId('one'), owner: owner(ctx), type: 'shell', signal: aborted })).rejects.toThrow('stop')
expect(create).not.toHaveBeenCalled()
const malformedKill = vi.fn().mockResolvedValue(true)
const malformed = handle(0, malformedKill)
const invalid = new E2BPtyBackend(ctx, config(), async () => malformed)
await expect(invalid.spawn({ sessionId: PtySessionId('two'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
expect(malformedKill).toHaveBeenCalledOnce()
const killFailureKill = vi.fn().mockRejectedValue(new Error('already gone'))
const killFailure = handle(0, killFailureKill)
const raced = new E2BPtyBackend(ctx, config(), async () => killFailure)
await expect(raced.spawn({ sessionId: PtySessionId('three'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
})
it('cleans failed startup and aggregates a cleanup failure', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) },
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const failedHandle = handle()
const backend = new E2BPtyBackend(ctx, config(), async () => failedHandle)
const pending = backend.spawn({ sessionId: PtySessionId('failed'), owner: owner(ctx), type: 'shell' })
const rejected = expect(pending).rejects.toMatchObject({
name: 'PtyBackendCleanupError',
cleanupError: expect.objectContaining({ message: 'cleanup failed' }),
} satisfies Partial<PtyBackendCleanupError>)
await vi.advanceTimersByTimeAsync(6)
await vi.advanceTimersByTimeAsync(2)
await rejected
vi.useRealTimers()
})
it('preserves startup failure when cleanup succeeds', async () => {
vi.useFakeTimers()
const ctx = new Context()
const completion = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
const created = {
pid: 123,
wait: () => completion.promise,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
const sandbox = {
commands: {
run: vi.fn(async (command: string) => {
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
return { exitCode: 0, stdout: '', stderr: '' }
}),
},
pty: { kill: vi.fn().mockResolvedValue(true) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config(), async () => created)
const rejected = expect(backend.spawn({ sessionId: PtySessionId('failed-clean'), owner: owner(ctx), type: 'shell' }))
.rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(6)
await rejected
vi.useRealTimers()
})
it('validates configuration and registers the selected backend type', async () => {
const valid = config()
expect(() => { validateConfig(valid) }).not.toThrow()
for (const invalid of [
{ ...valid, backendType: '' },
{ ...valid, rows: 0 },
{ ...valid, rows: 1.5 },
{ ...valid, maxReadBytes: 129 },
]) {
expect(() => { validateConfig(invalid) }).toThrow()
}
const registerBackend = vi.fn()
apply({ pty: { registerBackend } } as unknown as Context, valid)
expect(registerBackend).toHaveBeenCalledWith(expect.objectContaining({ type: 'shell' }))
const ctx = new Context()
await ctx.plugin(PtyService)
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => ({}) } as never)
const fiber = await ctx.plugin({
inject: ['pty', 'e2b'],
apply: (pluginCtx: Context) => { apply(pluginCtx, valid) },
})
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BPtyInvariant).await()
await fiber.dispose()
})
})
+398
View File
@@ -0,0 +1,398 @@
import { Buffer } from 'node:buffer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CommandExitError,
type CommandHandle,
type CommandResult,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type { PtySendOperation, PtySessionStatus } from '@deepseek-ai/dsh-pty'
import { E2BPtySession } from '@deepseek-ai/dsh-pty-e2b'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
function commandError(exitCode: number): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
}
class FakePtyHandle {
pid = 123
readonly result = Promise.withResolvers<CommandResult>()
disconnects = 0
kills = 0
disconnectError: unknown
private settled = false
wait(): Promise<CommandResult> {
return this.result.promise
}
async disconnect(): Promise<void> {
this.disconnects += 1
if (this.disconnectError !== undefined) throw this.disconnectError
}
async kill(): Promise<boolean> {
this.kills += 1
return true
}
exit(exitCode = 0): void {
if (this.settled) return
this.settled = true
this.result.resolve({ exitCode, stdout: '', stderr: '' })
}
failExit(exitCode: number): void {
if (this.settled) return
this.settled = true
this.result.reject(commandError(exitCode))
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.result.reject(error)
}
asHandle(): CommandHandle {
return this as unknown as CommandHandle
}
}
class FakeSandbox {
readonly sent: Array<{ pid: number; data: Buffer }> = []
readonly commands: string[] = []
readonly killed: number[] = []
pgid = '456\n'
sendError: unknown
commandError: unknown
killError: unknown
onTerm: (() => void) | undefined
onKill: (() => void) | undefined
readonly sandbox = {
pty: {
sendInput: async (pid: number, data: Uint8Array): Promise<void> => {
this.sent.push({ pid, data: Buffer.from(data) })
if (this.sendError !== undefined) throw this.sendError
},
kill: async (pid: number): Promise<boolean> => {
this.killed.push(pid)
if (this.killError !== undefined) throw this.killError
this.onKill?.()
return true
},
},
commands: {
run: async (command: string): Promise<CommandResult> => {
this.commands.push(command)
if (this.commandError !== undefined) {
const error = this.commandError
this.commandError = undefined
throw error
}
if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
if (command.startsWith('kill -TERM')) this.onTerm?.()
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, idleSilenceMs: 40, timeoutMs: 100, disposeGraceMs: 20,
...overrides,
}
}
async function initialize(session: E2BPtySession): Promise<void> {
const pending = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await pending
}
afterEach(() => { vi.useRealTimers() })
describe('E2BPtySession readiness, output, and signals', () => {
it('initializes, sends UTF-8 input, settles at a prompt, and reads bounded scrollback', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
await initialize(session)
expect(session.motd).toBe('dsh> ')
const operation = session.startSend({ text: 'printf 你好', submit: true })
expect(fake.sent).toEqual([{ pid: 123, data: Buffer.from('printf 你好\r') }])
session.onData(Buffer.from('一\n二\n三\x1b]133;D;0\x07dsh> '))
const bounded = operation.readOutput()
expect(bounded.delta).toContain('三')
expect(bounded.truncated).toBe(true)
await vi.advanceTimersByTimeAsync(10)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
expect(session.read({ count: 2 }).text).toContain('dsh>')
expect(session.read({ offset: 99 })).toMatchObject({ text: '', lineBegin: 99, lineEnd: 99 })
expect(() => session.read({ offset: -1 })).toThrow('non-negative safe integer')
expect(() => session.read({ offset: 1.5 })).toThrow('non-negative safe integer')
expect(() => session.read({ count: 0 })).toThrow('positive safe integer')
expect(() => session.read({ count: 1.5 })).toThrow('positive safe integer')
await expect(session.signal('SIGTERM')).resolves.toEqual({ delivered: true, targetPgid: 456 })
expect(fake.commands).toContain('kill -TERM -- -456')
expect(session.status()).toEqual({ kind: 'running' })
})
it('distinguishes inferred idle, timeout, session exit, and no-output startup timeout', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const inferred = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(40)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: '', submit: false })
for (let index = 0; index < 3; index += 1) {
await vi.advanceTimersByTimeAsync(30)
session.onData(Buffer.from('.'))
}
await vi.advanceTimersByTimeAsync(10)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: '', submit: false })
handle.failExit(143)
expect(await exiting.done).toMatchObject({
waitReason: 'session_exit',
sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' },
})
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
const startupHandle = new FakePtyHandle()
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('handles split prompt text, stale operations, and explicit cancellation', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const initializing = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07'))
await vi.advanceTimersByTimeAsync(20)
session.onData(Buffer.from('dsh> '))
await vi.advanceTimersByTimeAsync(10)
await initializing
const operation = session.startSend({ text: 'sleep', submit: true })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
interrupt(operation: PtySendOperation): void
settleActive(reason: 'timeout'): void
failActive(error: unknown): void
appendOutput(text: string): void
statusValue: PtySessionStatus
}
internal.pollReadiness({} as PtySendOperation)
internal.interrupt({} as PtySendOperation)
internal.appendOutput('')
fake.pgid = '789\n'
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(fake.commands).toContain('kill -INT -- -789')
session.onData(Buffer.from('\x1b]133;D;130\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await operation.done
internal.settleActive('timeout')
internal.failActive(new Error('ignored'))
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
fail(error: unknown): void
}
operationInternal.append('ignored')
operationInternal.settle('timeout', { kind: 'running' }, false)
operationInternal.fail(new Error('ignored'))
})
it('observes AbortSignal and contains send or foreground lookup failures', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const controller = new AbortController()
const aborting = session.startSend({ text: '', submit: false, signal: controller.signal })
expect(() => session.startSend({ text: '', submit: false })).toThrow('active send')
fake.pgid = 'not-a-pgid\n'
controller.abort()
await expect(aborting.done).rejects.toThrow('cannot resolve foreground process group')
const already = new AbortController()
already.abort()
expect(() => session.startSend({ text: '', submit: false, signal: already.signal })).toThrow('aborted before write')
fake.sendError = new Error('send failed')
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('send failed')
fake.pgid = '123\n'
await expect(session.signal('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
fake.pgid = '0\n'
await expect(session.signal('SIGINT')).rejects.toThrow('cannot resolve')
const deferred = Promise.withResolvers<undefined>()
fake.sendError = undefined
const sendInput = vi.spyOn(fake.sandbox.pty, 'sendInput').mockReturnValueOnce(deferred.promise)
const late = session.startSend({ text: 'late', submit: false })
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await late.done
deferred.reject(new Error('late failure'))
await vi.advanceTimersByTimeAsync(0)
expect(sendInput).toHaveBeenCalled()
})
it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => {
const fake = new FakeSandbox()
const abortHandle = new FakePtyHandle()
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config())
const controller = new AbortController()
const reason = new Error('startup cancelled')
const initializing = abortSession.initialize(controller.signal)
const rejected = expect(initializing).rejects.toBe(reason)
controller.abort(reason)
await rejected
const invalidHandle = new FakePtyHandle()
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config())
const pending = invalid.startSend({ text: '', submit: false })
invalid.onData(Uint8Array.from([0xff]))
await expect(pending.done).rejects.toThrow('invalid UTF-8')
expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
const crashHandle = new FakePtyHandle()
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config())
const active = crashed.startSend({ text: '', submit: false })
crashHandle.crash('transport gone')
await expect(active.done).rejects.toEqual(new Error('transport gone'))
const startupExitHandle = new FakePtyHandle()
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config())
const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
startupExitHandle.exit(7)
await exitedDuringStartup
})
it('covers empty bounded reads and polling an exited active session', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const tinyHandle = new FakePtyHandle()
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 }))
tiny.onData(Buffer.from('你'))
expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 })
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const operation = session.startSend({ text: '', submit: false })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
clearActive(): void
statusValue: PtySessionStatus
}
internal.statusValue = { kind: 'exited', exitCode: 7, signal: null }
internal.pollReadiness(operation)
expect((await operation.done).waitReason).toBe('session_exit')
internal.clearActive()
})
})
describe('E2BPtySession teardown', () => {
it('terminates the process group once, awaits exit, and disconnects', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.onTerm = () => { handle.failExit(143) }
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const first = session.close('done')
expect(session.close('again')).toBe(first)
await first
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGTERM' })
expect(handle.disconnects).toBe(1)
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => {
vi.useFakeTimers()
const gone = new FakeSandbox()
const goneHandle = new FakePtyHandle()
gone.commandError = commandError(1)
gone.onKill = () => { goneHandle.failExit(137) }
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
const closingGone = goneSession.close('gone')
await vi.advanceTimersByTimeAsync(20)
await closingGone
expect(gone.killed).toEqual([123])
expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' })
const survivor = new FakeSandbox()
const survivorHandle = new FakePtyHandle()
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config())
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123')
await vi.advanceTimersByTimeAsync(40)
await failed
survivorHandle.exit()
await expect(survivorSession.close('retry')).resolves.toBeUndefined()
})
it('propagates cleanup transport failures and lets close retry', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.commandError = new Error('TERM transport failed')
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
handle.exit()
await expect(session.close('retry')).resolves.toBeUndefined()
const invalidTailHandle = new FakePtyHandle()
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config())
invalidTail.onData(Uint8Array.from([0xe2]))
invalidTailHandle.exit()
await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8')
const normalHandle = new FakePtyHandle()
normalHandle.disconnectError = new Error('disconnect raced')
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config())
normalHandle.exit(7)
await Promise.resolve()
expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null })
await expect(normal.close('already exited')).resolves.toBeUndefined()
})
it('kills a remotely live PTY after its host transport fails', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const active = session.startSend({ text: '', submit: false })
session.onData(Uint8Array.from([0xff]))
await expect(active.done).rejects.toThrow('invalid UTF-8')
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
fake.onTerm = () => { handle.failExit(143) }
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
expect(fake.commands).toContain('kill -TERM -- -123')
expect(handle.disconnects).toBe(1)
})
})
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../e2b/e2b" },
{ "path": "../pty" },
{ "path": "../../support/invariants" }
]
}
-188
View File
@@ -1,188 +0,0 @@
/** Streaming terminal-control sanitizer for the line-oriented first release. */
import { Buffer } from 'node:buffer'
/** OSC marker emitted by the controlled bash before each prompt. */
export const PROMPT_MARKER_PREFIX = '133;D;'
/** Exact printable prompt emitted after the private marker. */
export const CONTROLLED_PROMPT = 'dsh> '
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
/** Printable text after the latest owned marker in this chunk. */
promptTail?: string
}
/**
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
* Full terminal emulation is deliberately deferred; ordinary line output and
* the private prompt marker are the supported contract.
*/
export class TerminalSanitizer {
private pending = ''
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private trackingPromptTail = false
constructor(private readonly maxPendingBytes: number) {}
/**
* Consume one decoded `node-pty` data chunk.
* @param chunk - decoded terminal data.
* @returns Printable text and whether the private prompt marker completed.
*/
push(chunk: string): SanitizedChunk {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let includePromptTail = this.trackingPromptTail
let promptTail = ''
let index = 0
const appendText = (value: string): void => {
text += value
if (this.trackingPromptTail) promptTail += value
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
appendText(this.pending.slice(index))
index = this.pending.length
break
}
appendText(this.pending.slice(index, escape))
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(PROMPT_MARKER_PREFIX)) {
prompt = true
this.trackingPromptTail = true
includePromptTail = true
promptTail = ''
}
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
}
// Two-byte escape family (save/restore cursor and similar).
index = escape + 2
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return {
text: this.normalizeText(text),
prompt,
...includePromptTail ? { promptTail } : {},
}
}
/**
* Flush a trailing printable fragment when the PTY exits.
* @returns Remaining printable text; incomplete escapes are discarded.
*/
flush(): string {
const text = this.pending.startsWith('\x1b') ? '' : this.pending
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
this.trackingPromptTail = 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 normalizeTerminalText(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 normalizeTerminalText(text: string): string {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
}
+180 -331
View File
@@ -1,12 +1,7 @@
/** Persistent PTY session over the subprocess seam's terminal primitive. */
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
import { Buffer } from 'node:buffer'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
} from '@deepseek-ai/dsh-subprocess'
import { PtyError } from '@deepseek-ai/dsh-pty'
import type { IDisposable, IPty } from 'node-pty'
import { PtyTerminalSanitizer, PtyTextBuffer, ptySignalName, ptyUtf8Tail } from '@deepseek-ai/dsh-pty'
import type {
PtyBackendSession,
PtyReadRequest,
@@ -21,89 +16,30 @@ import type {
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
function utf8Tail(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 }
}
class BoundedTextBuffer {
private value = ''
private dropped = false
constructor(
private readonly maxBytes: number,
private readonly maxLines?: number,
) {}
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 = utf8Tail(this.value, this.maxBytes)
this.value = tail.text
this.dropped ||= tail.truncated
}
consume(): PtySendRead {
const delta = this.value
const truncated = this.dropped
this.value = ''
this.dropped = false
return { delta, truncated }
}
snapshot(): { text: string; truncated: boolean } {
return { text: this.value, truncated: this.dropped }
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
class LocalSendOperation implements PtySendOperation {
private readonly output: BoundedTextBuffer
private readonly output: PtyTextBuffer
private readonly promise: PromiseWithResolvers<PtySendResult>
private finished = false
private cancellationRequested = false
private initialForegroundLeftWait: boolean
private initialForegroundPgid: number | undefined
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.output = new PtyTextBuffer(maxBytes)
this.promise = Promise.withResolvers<PtySendResult>()
this.initialForegroundLeftWait = true
}
get done(): Promise<PtySendResult> {
return this.promise.promise
}
get settled(): boolean {
return this.finished
}
get cancelRequested(): boolean {
return this.cancellationRequested
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
@@ -130,75 +66,50 @@ class LocalSendOperation implements PtySendOperation {
return this.output.consume()
}
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
this.initialForegroundPgid = foreground?.processGroupId
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
}
acceptsStdinWait(pgid: number, waiting: boolean): boolean {
// The same group may still expose the wait that existed before terminal.write.
// Observe every poll so a departure before the exact-settlement threshold
// still makes a later return to that wait post-write evidence.
if (pgid !== this.initialForegroundPgid) return waiting
if (!waiting) this.initialForegroundLeftWait = true
return waiting && this.initialForegroundLeftWait
}
cancel(): boolean {
if (this.finished) return false
this.cancellationRequested = true
this.onCancel()
return true
}
}
/** Backend session wrapping one provider-owned terminal process. */
/** Backend session wrapping one `node-pty` process and its captured process tree. */
export class LocalPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder()
private readonly sanitizer: TerminalSanitizer
private readonly scrollback: BoundedTextBuffer
private readonly outputEnded = Promise.withResolvers<void>()
private readonly completion: Promise<void>
private readonly sanitizer: PtyTerminalSanitizer
private readonly scrollback: PtyTextBuffer
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
private readonly dataDisposable: IDisposable
private readonly exitDisposable: IDisposable
private statusValue: PtySessionStatus = { kind: 'running' }
// TODO(pty-send-state-consolidation): Fold the per-send fields below
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
// cancellation/readiness interplay now has enough pinned tests to carry
// that refactor safely.
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeDeadlineTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private interrupting: LocalSendOperation | undefined
private activeWrite: Promise<boolean> | undefined
private pollingReady: LocalSendOperation | undefined
private polling = false
private promptSeen = false
private promptTextSeen = false
private promptTail = ''
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private transportFailure: Error | undefined
constructor(
private readonly terminal: SubprocessTerminalHandle,
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly config: ResolvedConfig,
) {
this.pid = terminal.pid
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
terminal.output.on('data', this.onTerminalData)
terminal.output.once('end', this.onTerminalEnd)
terminal.output.once('error', this.onTerminalError)
this.completion = terminal.done.then(
outcome => this.onExit(outcome),
(error: unknown) => { this.onTransportFailure(error) },
)
this.sanitizer = new PtyTerminalSanitizer(config.maxReadBytes)
this.scrollback = new PtyTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
const tail = this.sanitizer.flush()
this.appendOutput(tail)
this.statusValue = { kind: 'exited', exitCode, signal: ptySignalName(signal) }
this.settleActive('session_exit')
this.exitPromise.resolve()
})
}
/**
@@ -225,14 +136,7 @@ export class LocalPtySession implements PtyBackendSession {
startSend(request: PtySendRequest): PtySendOperation {
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
: this.interrupting !== undefined
? ' or draining foreground interrupt'
: ''
throw new PtyError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (this.active !== undefined) throw new Error('PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(
@@ -241,79 +145,29 @@ export class LocalPtySession implements PtyBackendSession {
() => { this.interrupt(operation) },
)
this.active = operation
this.resetReadinessEvidence()
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)
}
this.activeDeadlineTimer = setTimeout(() => {
if (this.active === operation) {
this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
}
}, this.config.timeoutMs)
void this.beginSend(operation, request)
try {
if (request.text.length > 0) this.terminal.write(request.text)
if (request.submit) this.terminal.write('\r')
} catch (error: unknown) {
this.clearActive()
operation.fail(error)
return operation
}
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
return operation
}
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
let foreground: SubprocessTerminalForeground | undefined
try {
foreground = await this.terminal.inspectForeground()
} catch (error: unknown) {
// A pre-write inspection failure while cancellation owns the slot must not
// release it: interruptOnce's in-flight foreground signal could land on a
// successor's foreground group. The interrupt path's post-signal tail
// resumes polling, whose guarded catch propagates a persistent failure.
// A retained settled operation implies that same in-flight interrupt, so
// this guard admits only an unsettled active send.
if (this.active === operation && !this.closing && this.interrupting !== operation) {
this.failActive(error)
}
return
}
try {
if (this.active !== operation || this.closing || this.interrupting === operation) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0 && !operation.cancelRequested) {
this.resetReadinessEvidence()
const write = this.terminal.write(input)
this.activeWrite = write.then(() => true, () => false)
try {
await write
} finally {
this.activeWrite = undefined
}
}
// Cancellation owns post-write signalling and reservation release.
if (operation.cancelRequested) return
if (this.active === operation && operation.settled) {
this.clearActive()
return
}
// Closing can race the awaited provider write even though static analysis sees only local assignments.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited provider writes can close the session.
if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation)
}
} catch (error: unknown) {
if (this.active === operation && !this.closing) {
if (operation.settled) this.clearActive()
else this.failActive(error)
}
}
}
private resetReadinessEvidence(): void {
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.promptTail = ''
}
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
@@ -328,7 +182,7 @@ export class LocalPtySession implements PtyBackendSession {
const end = totalLines - offset
const start = Math.max(0, end - count)
const requested = lines.slice(start, end).join('\n')
const bounded = utf8Tail(requested, this.config.maxReadBytes)
const bounded = ptyUtf8Tail(requested, this.config.maxReadBytes)
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
return {
text: bounded.text,
@@ -339,10 +193,16 @@ export class LocalPtySession implements PtyBackendSession {
}
}
async signal(signal: PtySignal): Promise<PtySignalResult> {
if (this.closing) throw new Error('PTY session is closing')
const targetPgid = await this.terminal.signalForeground(signal)
return { delivered: true, targetPgid }
signal(signal: PtySignal): Promise<PtySignalResult> {
return Promise.resolve().then(() => {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
if (signal === 'SIGKILL' && pgid === this.pid) {
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
}
this.inspector.signalGroup(pgid, signal)
return { delivered: true, targetPgid: pgid }
})
}
status(): PtySessionStatus {
@@ -361,56 +221,21 @@ export class LocalPtySession implements PtyBackendSession {
return closing
}
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
this.onData(this.decoder.decode(bytes, { stream: true }))
}
private readonly onTerminalEnd = (): void => {
this.onData(this.decoder.decode())
this.appendOutput(this.sanitizer.flush())
this.outputEnded.resolve()
}
private readonly onTerminalError = (error: Error): void => {
this.onTransportFailure(error)
this.outputEnded.resolve()
}
private onData(data: string): void {
const sanitized = this.sanitizer.push(data)
this.appendOutput(sanitized.text)
if (sanitized.prompt) {
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
// before attributing a signal-delayed prompt to a later send.
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
// Bash can print PROMPT_COMMAND before the kernel publishes its return
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTail = ''
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
if (this.promptSeen && sanitized.promptTail !== undefined) {
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
this.promptTail += sanitized.promptTail.slice(0, remaining)
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
}
}
private async onExit(outcome: SubprocessOutcome): Promise<void> {
await this.outputEnded.promise
if (this.transportFailure !== undefined) return
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
this.settleActive('session_exit')
}
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)
void this.terminal.terminate().catch(() => {})
}
private appendOutput(text: string): void {
@@ -420,94 +245,60 @@ export class LocalPtySession implements PtyBackendSession {
this.active?.append(text)
}
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
if (this.active !== operation || this.interrupting === operation || this.polling) return
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = setTimeout(() => {
this.activeTimer = undefined
void this.pollReadiness(operation)
}, delayMs)
}
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
if (this.active !== operation || this.polling) return
this.polling = true
try {
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const foreground = await this.terminal.inspectForeground()
if (this.active !== operation || this.closing || this.interrupting === operation) return
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
this.shellPgid = foreground.processGroupId
}
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
&& foreground?.processGroupId === this.shellPgid) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
const acceptsStdinWait = startupHasOutput && foreground !== undefined
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
this.settleActive('stdin_read')
return
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
}
} catch (error: unknown) {
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
} finally {
this.polling = false
const active = this.active
// Awaited provider inspection can clear or replace the active send despite static analysis.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited inspection can replace the active send.
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
private pollReadiness(operation: LocalSendOperation): void {
if (this.active !== operation) return
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid !== undefined && pgid === this.shellPgid) {
this.settleActive('stdin_read')
return
}
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) {
this.settleActive('stdin_read')
return
}
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout. When a prompt marker was seen, the
// configured grace holds the fallback past the silence bound so polls in
// that window can observe the foreground handoff and settle as stdin_read.
const idleFor = Date.now() - this.lastOutputAt
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void {
private settleActive(waitReason: PtyWaitReason): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
if (retainOwnership) {
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
} else {
this.clearActive()
}
this.clearActive()
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private stopPolling(): void {
this.stopReadinessPolling()
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
this.activeDeadlineTimer = undefined
}
private stopReadinessPolling(): void {
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.activeTimer = undefined
this.pollingReady = undefined
}
private clearActive(): void {
const operation = this.active
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
if (this.interrupting === operation) this.interrupting = undefined
this.pollingReady = undefined
this.active = undefined
}
@@ -520,46 +311,104 @@ export class LocalPtySession implements PtyBackendSession {
private interrupt(operation: LocalSendOperation): void {
if (this.active !== operation) return
this.interrupting = operation
this.stopReadinessPolling()
void this.interruptOnce(operation)
try {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
this.inspector.signalGroup(pgid, 'SIGINT')
} catch (error: unknown) {
this.failActive(error)
}
}
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
try {
const activeWrite = this.activeWrite
if (activeWrite !== undefined && !await activeWrite) return
await this.terminal.signalForeground('SIGINT')
} catch (error: unknown) {
if (this.active === operation && !this.closing) this.onTransportFailure(error)
return
} finally {
if (this.interrupting === operation) this.interrupting = undefined
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
survivors = this.survivors(members)
}
if (this.active === operation && operation.settled) {
this.clearActive()
} else if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation, 0)
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = JSON.stringify([member.pid, member.started])
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForExit(captured)
// A TERM-handling descendant may have forked while winding down. Rescan
// while the shell can still reap every member, then kill both the fresh
// tree and captured survivors that were reparented out of that tree.
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForExit(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit notification remains authoritative.
}
if (this.statusValue.kind === 'running') {
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit notification remains authoritative.
}
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
}
}
private async closeOnce(reason: string): Promise<void> {
this.dataDisposable.dispose()
// Stop readiness polling but retain the active operation: teardown settles
// it as session_exit below, so an in-flight send is never mis-settled as
// stdin_read/inferred_idle/timeout during the grace period.
this.stopPolling()
try {
await this.terminal.terminate()
} catch (error: unknown) {
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
const survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
// Quiescence is the active send's terminal outcome.
await this.stopShell()
this.settleActive('session_exit')
await this.completion
this.terminal.output.off('data', this.onTerminalData)
this.terminal.output.off('end', this.onTerminalEnd)
this.terminal.output.off('error', this.onTerminalError)
if (this.transportFailure !== undefined) throw this.transportFailure
this.exitDisposable.dispose()
}
}
+17 -17
View File
@@ -1,17 +1,17 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-local/src/sanitize.ts'
import { normalizePtyTerminalText, PtyTerminalSanitizer } from '@deepseek-ai/dsh-pty'
describe('TerminalSanitizer', () => {
describe('PtyTerminalSanitizer', () => {
it('removes split CSI and owned OSC prompt markers', () => {
const sanitizer = new TerminalSanitizer(64)
const sanitizer = new PtyTerminalSanitizer(64)
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptTail: 'dsh> ' })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
const sanitizer = new TerminalSanitizer(64)
const sanitizer = new PtyTerminalSanitizer(64)
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
expect(sanitizer.flush()).toBe('')
@@ -22,11 +22,11 @@ describe('TerminalSanitizer', () => {
})
it('normalizes CRLF and standalone carriage returns', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
expect(normalizePtyTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
it('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
const sanitizer = new TerminalSanitizer(64)
const sanitizer = new PtyTerminalSanitizer(64)
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
@@ -34,41 +34,41 @@ describe('TerminalSanitizer', () => {
})
it('reports printable prompt text that follows a marker in a later chunk', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true, promptTail: '' })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptTail: 'dsh> ' })
const sanitizer = new PtyTerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true })
})
it('bounds and discards unterminated control sequences through their terminators', () => {
const oscBel = new TerminalSanitizer(8)
const oscBel = new PtyTerminalSanitizer(8)
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscSt = new TerminalSanitizer(8)
const oscSt = new PtyTerminalSanitizer(8)
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
const oscDirectSt = new TerminalSanitizer(8)
const oscDirectSt = new PtyTerminalSanitizer(8)
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
const oscFalseSt = new TerminalSanitizer(8)
const oscFalseSt = new PtyTerminalSanitizer(8)
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
oscFalseSt.push('\x1b')
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscNonTerminatingEscape = new TerminalSanitizer(8)
const oscNonTerminatingEscape = new PtyTerminalSanitizer(8)
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
const csi = new TerminalSanitizer(8)
const csi = new PtyTerminalSanitizer(8)
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
expect(csi.push('123')).toEqual({ text: '', prompt: false })
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
const flushed = new TerminalSanitizer(8)
const flushed = new PtyTerminalSanitizer(8)
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
expect(flushed.flush()).toBe('')
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
+9
View File
@@ -41,6 +41,15 @@ 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
+273
View File
@@ -0,0 +1,273 @@
/** 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,6 +47,7 @@ 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/code-runtime/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.' },
@@ -98,6 +99,7 @@ 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/lsp/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/subprocess/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
+3
View File
@@ -166,10 +166,12 @@
{ "path": "./packages/subprocess/subprocess-e2b" },
{ "path": "./packages/bash/bash" },
{ "path": "./packages/pty/pty" },
{ "path": "./packages/pty/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/code-runtime/code-runtime-e2b" },
{ "path": "./packages/code-runtime/code-runtime-worker" },
{ "path": "./packages/llm/llm-deepseek" },
{ "path": "./packages/llm/llm-pi-ai" },
@@ -266,6 +268,7 @@
{ "path": "./packages/sdk/create-sdk" },
{ "path": "./packages/sdk/telemetry" },
{ "path": "./packages/lsp/lsp" },
{ "path": "./packages/lsp/lsp-e2b" },
{ "path": "./packages/lsp/lsp-local" },
{ "path": "./packages/lsp/tool-lsp" },
{ "path": "./apps/cli" }