diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml index 13d081c28f..46f1849b07 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md index 307a08d6ca..730e6f0e19 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md index bb4e657d99..5f1ae07b98 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md @@ -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 没有会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录或工作区同步。保留沙箱后重新连接只能证明生命周期连续性;它不会重建宿主 PTY/LSP/Code 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 的字节 API;LSP 与 Code Runtime 则在必须保真处理协议字节之处增加经过验证的 ASCII 分帧。保留沙箱后会累积远程进程/spill 产物。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。 diff --git a/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.i18n.yaml new file mode 100644 index 0000000000..e34f939a7f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md new file mode 100644 index 0000000000..befaa47dd5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.zh.md b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.zh.md new file mode 100644 index 0000000000..677c3a09f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.zh.md @@ -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;有意逃离已捕获远程进程组的进程,也不会因此变得可重新连接或由该组合管理。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7ce45c6817..33f357d2bf 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -591,7 +591,7 @@ Owns one lazily consumable E2B SDK handle and its final kill/pause/leave decisio async getSandbox(): Promise ``` -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) diff --git a/examples/headless-agent/e2b.cordis.yml b/examples/headless-agent/e2b.cordis.yml index 4f961100b4..877172c343 100644 --- a/examples/headless-agent/e2b.cordis.yml +++ b/examples/headless-agent/e2b.cordis.yml @@ -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' diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts index 3b2cef609b..33e9530708 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -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 ') 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>['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() } diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml b/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml index 08a7e7225c..f5cf403c4b 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml @@ -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 diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/fixture-lsp.mjs b/examples/headless-agent/tests/fixtures/e2b/e2b/fixture-lsp.mjs new file mode 100644 index 0000000000..dc4f7bb219 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/fixture-lsp.mjs @@ -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() +}) diff --git a/examples/package.json b/examples/package.json index 48ac39f5ca..8dd631e760 100644 --- a/examples/package.json +++ b/examples/package.json @@ -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:*", diff --git a/packages/code-runtime/README.i18n.yaml b/packages/code-runtime/README.i18n.yaml index 3eb462d513..f9160b4f8e 100644 --- a/packages/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/README.i18n.yaml @@ -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 diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index f20a287419..f59fc3b153 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -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). diff --git a/packages/code-runtime/README.zh.md b/packages/code-runtime/README.zh.md index f5cd4c9949..02ade97887 100644 --- a/packages/code-runtime/README.zh.md +++ b/packages/code-runtime/README.zh.md @@ -1,12 +1,13 @@ -# code-runtime/ — 代码执行能力家族 +# code-runtime/:代码执行能力家族 [English](README.md) | 中文 -代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK);设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。 +代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品** 包。 -| 包 | 职责 | ctx key | +| 包 | 职责 | ctx 键 | |---|---|---| -| [`code-runtime/`](code-runtime/README.md) | 代码执行 seam 与共享词汇 | `ctx.codeRuntime` | -| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker 线程后端 | 注册 `ctx.codeRuntime` | +| `code-runtime/` | 抽象代码执行 seam(接口 + 词汇) | `ctx.codeRuntime` | +| [`code-runtime-worker/`](code-runtime-worker/README.md) | worker 线程后端:每次运行使用全新 worker,由宿主侧剥离 TypeScript 类型(类型注解仅供参考,绝不执行类型检查)、端口桥接绑定、预算/堆隔离 | 注册 `ctx.codeRuntime` | +| [`code-runtime-e2b/`](code-runtime-e2b/README.md) | E2B 后端:宿主侧类型剥离与绑定、全新远程 runner/worker、分帧桥、远程进程组清理 | 注册 `ctx.codeRuntime` | -后端在不改变消费方的情况下注册该 seam。子 README 负责语言、隔离和执行预算细节。 +不同后端的执行基底和源语言各异,二者都是服务上的只读描述符;后端注册 `ctx.codeRuntime`,无需修改接口或消费方。E2B 所有权拆分记录在[远程扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) 中。 diff --git a/packages/code-runtime/code-runtime-e2b/README.i18n.yaml b/packages/code-runtime/code-runtime-e2b/README.i18n.yaml new file mode 100644 index 0000000000..c00b40f4a2 --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/README.i18n.yaml @@ -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 diff --git a/packages/code-runtime/code-runtime-e2b/README.md b/packages/code-runtime/code-runtime-e2b/README.md new file mode 100644 index 0000000000..bf26174c59 --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/README.md @@ -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. diff --git a/packages/code-runtime/code-runtime-e2b/README.zh.md b/packages/code-runtime/code-runtime-e2b/README.zh.md new file mode 100644 index 0000000000..c87945da30 --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/README.zh.md @@ -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。 +- **沙箱策略归模板负责**:本包不会额外增加网络、卷、快照或工作区同步策略。 diff --git a/packages/code-runtime/code-runtime-e2b/package.json b/packages/code-runtime/code-runtime-e2b/package.json new file mode 100644 index 0000000000..222ae71bef --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/package.json @@ -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" + } +} diff --git a/packages/code-runtime/code-runtime-e2b/src/index.ts b/packages/code-runtime/code-runtime-e2b/src/index.ts new file mode 100644 index 0000000000..fbfbdea91f --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/src/index.ts @@ -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 + +interface LiveRun { + settle(failure: CodeRunFailure): void + finished: Promise +} + +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([ + '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 + 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 + 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 = 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() + 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 { + 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 + 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 { + const bindings = new Map() + 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() + 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, + runtime: { node: string; runner: string }, + ): Promise { + 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((resolve) => { + const output = new OutputLedger(this.config.maxOutputBytes) + const logs: string[] = [] + const answered = new Set() + const decoder = new E2BFrameDecoder(this.config.maxFrameBytes) + let settled = false + let finishResolve!: () => void + const finished = new Promise((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((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 { + 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 diff --git a/packages/code-runtime/code-runtime-e2b/src/invariant.ts b/packages/code-runtime/code-runtime-e2b/src/invariant.ts new file mode 100644 index 0000000000..4112cdc76b --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/src/invariant.ts @@ -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 */ diff --git a/packages/code-runtime/code-runtime-e2b/src/runner-source.ts b/packages/code-runtime/code-runtime-e2b/src/runner-source.ts new file mode 100644 index 0000000000..b0355f20ce --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/src/runner-source.ts @@ -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 + } +} +` diff --git a/packages/code-runtime/code-runtime-e2b/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-e2b/tests/runtime.spec.ts new file mode 100644 index 0000000000..ff1c5dd84a --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/tests/runtime.spec.ts @@ -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 + readonly writes: unknown[] = [] + readonly result = Promise.withResolvers() + 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 { + this.waitCalls += 1 + if (this.waitError !== undefined) throw this.waitError + return true + } +} + +interface RuntimeFixture { + ctx: Context + fiber: Awaited> + runtime: E2BCodeRuntime + sandbox: Sandbox + spawn: ReturnType SubprocessHandle>> + write: ReturnType + run: ReturnType +} + +async function setup( + handles: FakeHandle[] = [], + config: Record = {}, + sandboxOverrides: Partial = {}, + getSandbox?: () => Promise, +): Promise { + 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() + const invoked = Promise.withResolvers() + 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() + const fixture = await setup([], {}, {}, () => gate.promise) + const running = fixture.runtime.run(request()) + await (fixture.runtime as unknown as { teardown(): Promise }).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() + }) +}) diff --git a/packages/code-runtime/code-runtime-e2b/tsconfig.json b/packages/code-runtime/code-runtime-e2b/tsconfig.json new file mode 100644 index 0000000000..4ddc3b5ffe --- /dev/null +++ b/packages/code-runtime/code-runtime-e2b/tsconfig.json @@ -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" } + ] +} diff --git a/packages/code-runtime/code-runtime-worker/README.i18n.yaml b/packages/code-runtime/code-runtime-worker/README.i18n.yaml index 8d3568e82e..d3472a697e 100644 --- a/packages/code-runtime/code-runtime-worker/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-worker/README.i18n.yaml @@ -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 diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 590b79dcd1..4b14c6fad5 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -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. diff --git a/packages/code-runtime/code-runtime-worker/README.zh.md b/packages/code-runtime/code-runtime-worker/README.zh.md index 12c25f892b..c522917835 100644 --- a/packages/code-runtime/code-runtime-worker/README.zh.md +++ b/packages/code-runtime/code-runtime-worker/README.zh.md @@ -23,18 +23,18 @@ - **每次运行使用一个全新 worker,不设池化**:程序所在的世界会随 worker 一同终止,不会留下需要记录的跨运行状态,也无法发生状态泄漏;仅凭会话日志即可重建运行。 - **在执行上下文中,由宿主侧剥离类型**:程序会包裹在异步函数外壳中,通过 `node:module` 的 `stripTypeScriptTypes` 剥离类型(只支持可擦除语法;`enum`/namespace 会作为程序 `exception` 被拒绝,且不会启动 worker),再按字节位置切回原内容。之后程序作为 `AsyncFunction` 的函数体执行,因此顶层 `await`/`return` 可用。 - **端口把对端视为不可信**:模型代码能够访问 `parentPort` 并伪造通信,因此任何代码读取入站消息前,系统都会验证其形状并重新构建(`null`、原始值、无效类型和格式错误的载荷会被静默丢弃;伪造的额外字段绝不会被带入);宿主对每个调用 id 最多响应一次,只将绑定名称解析为自有属性(伪造的 `constructor` 无法沿原型链访问),丢弃结算后的回复,并验证每个绑定 resolve 值与完成值是否为无损 JSON。伪造的 `log`/`done` 消息无法绕过外层上限:宿主会再次验证,并统计每条获准日志以及完成值或诊断。worker 侧命名空间使用 null-prototype 和 `defineProperty`,因此形似 `__proto__` 的绑定名称只是普通键。 -- **绑定调用被拒绝时使用的异常类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收调用失败的成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools` 或 `ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误 intrinsic 与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。 -- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟限制为 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。 -- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为自身嵌套深度有界的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。 -- **日志主动流入一个外层账本**:console/stdout/stderr 文本按产生顺序经端口传输,因此超时或被终止的程序仍会显示已经打印的内容。worker 会精确统计 JSON 字符串的字节数,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。 -- **空环境**:worker 使用 `env: {}` 和 `execArgv: []`,既不会获得环境变量中的凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。 -- **dispose(资源释放)时等待完全停稳**:清理会使进行中的运行以 `abort` 失败,并会等待每个 worker 退出后再完成。 +- **绑定 reject 类属于请求数据**:可选命名空间描述符会指定构造器全局变量,以及用于接收失败成员名称的自有属性。worker 会创建并注入该真实类,使 `instanceof` 生效,同时无需硬编码 `tools` 或 `ToolCallError`;全局变量无效或冲突的声明会在启动 worker 前失败。失败路径使用模块捕获的错误与属性定义 intrinsic,以及 null-prototype 描述符,因此模型之后的修改无法把被拒绝的绑定变成 worker 崩溃。 +- **两个独立预算,因为对端不可信**:`computeMs` 统计 worker 实际测得的忙碌时间(轮询 `worker.performance.eventLoopUtilization()`);热循环无法借助待完成的诱饵 dispatch 隐藏,程序等待慢工具时则不累计。`maxWallMs` 为忙碌时间无法观测的情况兜底(例如等待永远不会 resolve 的 promise)。二者最终都会调用 `worker.terminate()`,连同步热循环也能终止;堆溢出会表现为 worker 的 OOM 退出(`kind: 'worker-exit'`)。`maxWallMs` 在加载时会对照 `MAX_TIMER_DELAY_MS` 做范围校验:`setTimeout` 会把更长的延迟夹到 1 ms,仅有正数校验会放行一个在第一个 tick 就到期的上限。`computeMs` 不需要这道上界,因为它对照的是实测占用率,而不是喂给定时器。 +- **中间绑定值是完整 JSON**:绑定参数与 resolve 值会接受迭代式无损 JSON 验证。程序执行前,worker 会捕获自己 realm 中的普通容器原型身份,以及只用于外部 realm 的原生函数源码检查,因此构造器槽修改和用户编写的仿冒对象都无法改变容器分类。它还会捕获该 JSON 边界使用的每一个结构与计量 intrinsic,以无原型对象创建属性描述符,并绕过可变集合原型管理私有遍历状态;因此,模型对全局对象、原型方法或 `Object.prototype` 上形似描述符字段的修改,都无法改变验证、wire 传输或字节计量。值会展平为有深度上限的前序 wire 值,供 structured clone 使用,并在另一侧迭代式重建。它们没有字节、JavaScript 调用栈或嵌套 structured-clone 深度上限,绝不会进入外层输出账本或模型上下文;上限仍来自提供方/执行器获取限制与进程/worker 内存。 +- **日志主动流入一个外层账本**:console/stdout/stderr 文本按发送顺序穿过端口,因此超时或被终止的程序仍会显示已经打印的内容。worker 会按 JSON 字符串精确计费,并在发送完成值和异常诊断前,根据组合预算的剩余量预检;因此,抛出的百万字节 stack 会在 worker 边界变成固定的 `output-limit` 诊断。绕过补丁 stream 槽的原生写入会到达独立于完成端口的 pipe,因此宿主会针对这些字节和不可信伪造通信再次执行账本统计;在物化结果前,结算过程会持续进行有界 pipe 捕获,直到 worker 完成终止。`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名、花括号、有界错误 kind 标签,以及后续呈现空白不计入这份可变载荷账本。未超过上限时会返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留能容纳的已捕获前缀,之后按普通外层 `run_code` 落盘策略处理。 +- **空环境**:worker 使用 `env: {}` 和 `execArgv: []`,既没有环境凭据(比 spawn 命令的清理环境规则更严格),也不会继承 loader 标志。 +- **释放资源时等待完全停稳**:清理会把进行中的运行标记为 `abort`,并在 resolve 前等待每个 worker 退出。 ## 未构建与已构建的 worker 入口 -源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地和会话自有的 JSON 边界都会在消息端口周围展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFS)Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。演练这个已发布入口路径的仓库级要求由[测试策略](../../../docs/testing.md)规定。 +源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的 VFS Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。 -SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Config`。运行所用的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。 +SDK 接口是默认/具名 `WorkerCodeRuntime` 类与 `Config`。可操作的 `./worker` 子路径仅作为打包后的 spawn 入口存在;wire 协议与启动辅助模块是源代码私有的实现细节。 ## 模型体验 @@ -42,13 +42,13 @@ SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Confi #### KV Cache 影响 -不会直接失效;由上述消费方负责请求前缀变更。 +不会直接失效;由具名消费方负责请求前缀变更。 -## 已知限制与暂缓事项 +## 已知限制与暂缓工作 -- **程序派生的 OS 进程在程序终止后仍会存活**:`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿进程清理属于部署职责。 -- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:如依赖的行为发生变化,amaro 或 sucrase 是已经点名的直接替代品。 +- **程序 spawn 的 OS 进程在该后端终止后仍会存活**:`worker.terminate()` 只结束线程;需要清理远程进程组的部署可以选择 E2B 后端,但该后端自身的限制仍然适用。 +- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**:依赖的行为由单元测试固定;如其发生变化,amaro/sucrase 是已经点名的直接替代品。 - **`computeMs` 到期最多可能超过一个轮询间隔**:系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。 -- **程序获得一个含 5 个方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。 +- **程序获得一个含 5 种方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`):有意不提供 Node 的完整 console 接口。 - **中间绑定值没有字节上限**:程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。 - **默认 64 MiB 是拒绝边界,不是可恢复存储**:外层落盘只能保存发生 `output-limit` 后返回的有界日志和诊断;在运行时上限之外被拒绝的字节永远不会到达落盘层。 diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 43801899a2..2ece0b5c39 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -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. diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index a6bd209c7b..51c1466872 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-runtime/README.i18n.yaml @@ -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 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index bb1c20d00a..4018cdf508 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -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. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index 15fbcecf77..ecfa48a97c 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -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 成本与进程内存约束,而提供方或执行器可能已经应用自己的获取上限。 diff --git a/packages/e2b/README.i18n.yaml b/packages/e2b/README.i18n.yaml index 1bc9761930..444145fbb8 100644 --- a/packages/e2b/README.i18n.yaml +++ b/packages/e2b/README.i18n.yaml @@ -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 diff --git a/packages/e2b/README.md b/packages/e2b/README.md index 4b28ec7c04..9e7f1a98ad 100644 --- a/packages/e2b/README.md +++ b/packages/e2b/README.md @@ -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. diff --git a/packages/e2b/README.zh.md b/packages/e2b/README.zh.md index ab254eecad..53fdf30eaf 100644 --- a/packages/e2b/README.zh.md +++ b/packages/e2b/README.zh.md @@ -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 边界。 diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index 9255c21577..164bb47784 100644 --- a/packages/e2b/e2b/README.i18n.yaml +++ b/packages/e2b/e2b/README.i18n.yaml @@ -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 diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index bf62cb7d48..5f89a4bcff 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -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 diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index 8c7603aa50..b4956033ba 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -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)。 ## 配置 diff --git a/packages/e2b/e2b/src/frame.ts b/packages/e2b/e2b/src/frame.ts new file mode 100644 index 0000000000..39800b4428 --- /dev/null +++ b/packages/e2b/e2b/src/frame.ts @@ -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 }) + } + } +} diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 29cbf1dc53..4c4597f6ef 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -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 { + 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' diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 8c01fae7b4..d056d4b851 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -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) }) diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index c842731f5c..541e69df9c 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -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 }) diff --git a/packages/lsp/README.i18n.yaml b/packages/lsp/README.i18n.yaml index 9919cdaba5..c28c0282c2 100644 --- a/packages/lsp/README.i18n.yaml +++ b/packages/lsp/README.i18n.yaml @@ -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 diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 7fbdf07173..74ed729d97 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -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. diff --git a/packages/lsp/README.zh.md b/packages/lsp/README.zh.md index e059dbd80b..5a08d54ca6 100644 --- a/packages/lsp/README.zh.md +++ b/packages/lsp/README.zh.md @@ -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)。 diff --git a/packages/lsp/lsp-e2b/README.i18n.yaml b/packages/lsp/lsp-e2b/README.i18n.yaml new file mode 100644 index 0000000000..fa159820a9 --- /dev/null +++ b/packages/lsp/lsp-e2b/README.i18n.yaml @@ -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 diff --git a/packages/lsp/lsp-e2b/README.md b/packages/lsp/lsp-e2b/README.md new file mode 100644 index 0000000000..968487b7c5 --- /dev/null +++ b/packages/lsp/lsp-e2b/README.md @@ -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. diff --git a/packages/lsp/lsp-e2b/README.zh.md b/packages/lsp/lsp-e2b/README.zh.md new file mode 100644 index 0000000000..2b937f0115 --- /dev/null +++ b/packages/lsp/lsp-e2b/README.zh.md @@ -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 和进程管理适配器仍会在宿主内存中保留回调输出。 +- **沙箱策略归模板负责**:本提供方不会额外增加卷、快照、凭据或网络策略层。 diff --git a/packages/lsp/lsp-e2b/package.json b/packages/lsp/lsp-e2b/package.json new file mode 100644 index 0000000000..0671341f3c --- /dev/null +++ b/packages/lsp/lsp-e2b/package.json @@ -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" + } +} diff --git a/packages/lsp/lsp-e2b/src/index.ts b/packages/lsp/lsp-e2b/src/index.ts new file mode 100644 index 0000000000..eb9596db5f --- /dev/null +++ b/packages/lsp/lsp-e2b/src/index.ts @@ -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 + /** Remote executable arguments. */ + args?: string[] + /** Explicit remote environment overrides. */ + env?: Record + /** 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 +} + +type ResolvedServerConfig = Required + +const ServerConfig: z = 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 = 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(promise: Promise, signal?: AbortSignal): Promise { + 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((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 { + 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 { + 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 { + 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> + private readonly instances = new Map() + private readonly queues = new Map>() + 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 { + 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 { + 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(workspace: string, signal: AbortSignal | undefined, run: () => Promise): Promise { + 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 { + 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 */ +} diff --git a/packages/lsp/lsp-e2b/src/invariant.ts b/packages/lsp/lsp-e2b/src/invariant.ts new file mode 100644 index 0000000000..129cf0a6d4 --- /dev/null +++ b/packages/lsp/lsp-e2b/src/invariant.ts @@ -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 */ diff --git a/packages/lsp/lsp-e2b/src/proxy-source.ts b/packages/lsp/lsp-e2b/src/proxy-source.ts new file mode 100644 index 0000000000..5b6fe42d07 --- /dev/null +++ b/packages/lsp/lsp-e2b/src/proxy-source.ts @@ -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() }) +} +` diff --git a/packages/lsp/lsp-e2b/src/transport.ts b/packages/lsp/lsp-e2b/src/transport.ts new file mode 100644 index 0000000000..ddd3445db8 --- /dev/null +++ b/packages/lsp/lsp-e2b/src/transport.ts @@ -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 + 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 { + 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 + 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() + } +} diff --git a/packages/lsp/lsp-e2b/tests/provider.spec.ts b/packages/lsp/lsp-e2b/tests/provider.spec.ts new file mode 100644 index 0000000000..0b41be12a3 --- /dev/null +++ b/packages/lsp/lsp-e2b/tests/provider.spec.ts @@ -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, + 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, + graceMs: Number(spec.killGraceMs), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: Number(spec.maxStderrBytes) } }, + }) + FakeLspInstance.instances.push(this) + } + + async query(...args: unknown[]): Promise { + 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 { + 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 { return true } +} + +class FakeRemote { + readonly writes: Array> = [] + readonly commands: string[] = [] + readonly infos = new Map() + readonly contents = new Map() + readonly realpaths = new Map() + 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 = {}): Required { + 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() + 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> + enqueue(workspace: string, signal: AbortSignal | undefined, run: () => Promise): Promise + } + 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(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('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> = [] + const ctx = { + subprocess: service, + e2b: { + runtimeRoot: '/workspace/.dsh-e2b', + getSandbox: async () => remote.sandbox, + }, + lsp: { registerProvider }, + effect: (callback: () => (() => void | Promise)) => { 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() + }) +}) diff --git a/packages/lsp/lsp-e2b/tests/transport.spec.ts b/packages/lsp/lsp-e2b/tests/transport.spec.ts new file mode 100644 index 0000000000..ae5106bc09 --- /dev/null +++ b/packages/lsp/lsp-e2b/tests/transport.spec.ts @@ -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() + 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 { + 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 + 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) + }) +}) diff --git a/packages/lsp/lsp-e2b/tsconfig.json b/packages/lsp/lsp-e2b/tsconfig.json new file mode 100644 index 0000000000..4679845db8 --- /dev/null +++ b/packages/lsp/lsp-e2b/tsconfig.json @@ -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" } + ] +} diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 318704b9fc..d643db1528 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -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 { 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 { diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 9399b5a1a7..89053894ee 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -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, diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 08bad67ae2..939b58f0b1 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -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 diff --git a/packages/lsp/lsp/README.i18n.yaml b/packages/lsp/lsp/README.i18n.yaml index 02649a8d39..b672a405de 100644 --- a/packages/lsp/lsp/README.i18n.yaml +++ b/packages/lsp/lsp/README.i18n.yaml @@ -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 diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 5c1044be50..4dc0436bbb 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -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`. diff --git a/packages/lsp/lsp/README.zh.md b/packages/lsp/lsp/README.zh.md index cc412333e6..fdaedc9a96 100644 --- a/packages/lsp/lsp/README.zh.md +++ b/packages/lsp/lsp/README.zh.md @@ -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` 到达提供方。 diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml index 65d541a7c1..c0fa873642 100644 --- a/packages/pty/README.i18n.yaml +++ b/packages/pty/README.i18n.yaml @@ -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 diff --git a/packages/pty/README.md b/packages/pty/README.md index a4f743056b..239c751f30 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -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). diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md index c84ad3f1b5..edaae7f948 100644 --- a/packages/pty/README.zh.md +++ b/packages/pty/README.zh.md @@ -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) 中。 diff --git a/packages/pty/pty-e2b/README.i18n.yaml b/packages/pty/pty-e2b/README.i18n.yaml new file mode 100644 index 0000000000..bfd7795349 --- /dev/null +++ b/packages/pty/pty-e2b/README.i18n.yaml @@ -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 diff --git a/packages/pty/pty-e2b/README.md b/packages/pty/pty-e2b/README.md new file mode 100644 index 0000000000..7a8ddc9e08 --- /dev/null +++ b/packages/pty/pty-e2b/README.md @@ -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. diff --git a/packages/pty/pty-e2b/README.zh.md b/packages/pty/pty-e2b/README.zh.md new file mode 100644 index 0000000000..2151ca1f2d --- /dev/null +++ b/packages/pty/pty-e2b/README.zh.md @@ -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 会话。 diff --git a/packages/pty/pty-e2b/package.json b/packages/pty/pty-e2b/package.json new file mode 100644 index 0000000000..cc31699ab1 --- /dev/null +++ b/packages/pty/pty-e2b/package.json @@ -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" + } +} diff --git a/packages/pty/pty-e2b/src/config.ts b/packages/pty/pty-e2b/src/config.ts new file mode 100644 index 0000000000..e6849bf54d --- /dev/null +++ b/packages/pty/pty-e2b/src/config.ts @@ -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 + +/* jscpd:ignore-start -- Loader requires a backend-local schema and load-time diagnostics. */ +/** Schemastery config exposed by the plugin. */ +export const Config: z = 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 */ diff --git a/packages/pty/pty-e2b/src/index.ts b/packages/pty/pty-e2b/src/index.ts new file mode 100644 index 0000000000..fca16cf5c7 --- /dev/null +++ b/packages/pty/pty-e2b/src/index.ts @@ -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 { + 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[0], + ) => Promise = (sandbox, options) => sandbox.pty.create(options), + ) { + this.type = config.backendType + } + + /** Create, initialize, and publish one remote PTY session. */ + async spawn(spec: PtyBackendSpawnSpec): Promise { + 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)) +} diff --git a/packages/pty/pty-e2b/src/invariant.ts b/packages/pty/pty-e2b/src/invariant.ts new file mode 100644 index 0000000000..b86fdffc5d --- /dev/null +++ b/packages/pty/pty-e2b/src/invariant.ts @@ -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 */ diff --git a/packages/pty/pty-e2b/src/session.ts b/packages/pty/pty-e2b/src/session.ts new file mode 100644 index 0000000000..e5dfc05efe --- /dev/null +++ b/packages/pty/pty-e2b/src/session.ts @@ -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 { + 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() + private finished = false + + constructor( + maxBytes: number, + readonly startedAt: number, + private readonly onCancel: () => void, + ) { + this.output = new PtyTextBuffer(maxBytes) + } + + get done(): Promise { + 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() + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/packages/pty/pty-e2b/tests/index.spec.ts b/packages/pty/pty-e2b/tests/index.spec.ts new file mode 100644 index 0000000000..567b3b9215 --- /dev/null +++ b/packages/pty/pty-e2b/tests/index.spec.ts @@ -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[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[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) + 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() + }) +}) diff --git a/packages/pty/pty-e2b/tests/session.spec.ts b/packages/pty/pty-e2b/tests/session.spec.ts new file mode 100644 index 0000000000..6dced22ed2 --- /dev/null +++ b/packages/pty/pty-e2b/tests/session.spec.ts @@ -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() + disconnects = 0 + kills = 0 + disconnectError: unknown + private settled = false + + wait(): Promise { + return this.result.promise + } + + async disconnect(): Promise { + this.disconnects += 1 + if (this.disconnectError !== undefined) throw this.disconnectError + } + + async kill(): Promise { + 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 => { + this.sent.push({ pid, data: Buffer.from(data) }) + if (this.sendError !== undefined) throw this.sendError + }, + kill: async (pid: number): Promise => { + this.killed.push(pid) + if (this.killError !== undefined) throw this.killError + this.onKill?.() + return true + }, + }, + commands: { + run: async (command: string): Promise => { + 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 { + 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 { + 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() + 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) + }) +}) diff --git a/packages/pty/pty-e2b/tsconfig.json b/packages/pty/pty-e2b/tsconfig.json new file mode 100644 index 0000000000..0a7647aa80 --- /dev/null +++ b/packages/pty/pty-e2b/tsconfig.json @@ -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" } + ] +} diff --git a/packages/pty/pty-local/src/sanitize.ts b/packages/pty/pty-local/src/sanitize.ts deleted file mode 100644 index 1f28315fb1..0000000000 --- a/packages/pty/pty-local/src/sanitize.ts +++ /dev/null @@ -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', '') -} diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 25ed8fe586..ebf6b559d1 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -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 { + return new Promise(resolve => setTimeout(resolve, ms)) } class LocalSendOperation implements PtySendOperation { - private readonly output: BoundedTextBuffer + private readonly output: PtyTextBuffer private readonly promise: PromiseWithResolvers 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() - this.initialForegroundLeftWait = true } get done(): Promise { 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() - private readonly completion: Promise + private readonly sanitizer: PtyTerminalSanitizer + private readonly scrollback: PtyTextBuffer + private readonly exitPromise: PromiseWithResolvers = Promise.withResolvers() + 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 | 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 | 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 { - 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 { - 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 { + 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 { - 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 { - 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 { - 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 { + 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() + 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 { + 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 { + 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 { + 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() } } diff --git a/packages/pty/pty-local/tests/sanitize.spec.ts b/packages/pty/pty-local/tests/sanitize.spec.ts index f6649b3c6f..06ec076358 100644 --- a/packages/pty/pty-local/tests/sanitize.spec.ts +++ b/packages/pty/pty-local/tests/sanitize.spec.ts @@ -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 }) diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index 8ff4168e1a..5cb375fbca 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -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 diff --git a/packages/pty/pty/src/terminal.ts b/packages/pty/pty/src/terminal.ts new file mode 100644 index 0000000000..ed005dda41 --- /dev/null +++ b/packages/pty/pty/src/terminal.ts @@ -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 +} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 90aa22e322..5ab5dc2e4e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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> = { '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.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 03b1bcd2dd..1f70e79a4f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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" }