diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml index 06fd47f329..fc3b5b012f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.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/architecture/2026-07-28-portable-execution-world-consumers.md -2026-07-28-portable-execution-world-consumers.md: 402aa580255a5bd6aa0d046e3bcc16f712da520d -2026-07-28-portable-execution-world-consumers.zh.md: 26d493a79f8adb9e729ff69b0673f2f2775868b0 +2026-07-28-portable-execution-world-consumers.md: 250445cfed6a4fe2dd188562813d586df29992c1 +2026-07-28-portable-execution-world-consumers.zh.md: 361c4de4e813aa7f57a7eb5acc75087b2630db43 diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md index 402aa58025..4c593604f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md @@ -8,6 +8,8 @@ English | [中文](2026-07-28-portable-execution-world-consumers.zh.md) The filesystem and subprocess seams made file and ordinary process access replaceable, but PTY and LSP still reached host Node APIs directly. A remote execution provider therefore appeared to need separate PTY and LSP packages even though their domain behavior did not change. Those packages would be shallow adapters: each would duplicate an existing consumer merely to replace its file and process operations. +A remote coding world is useful only when file operations, commands, terminals, language servers, and model-written programs share one sandbox identity. Moving the complete harness into that sandbox would also entangle provider experimentation with plugin loading, credentials, model transport, session durability, supervision, and deployment. + Ordinary pipes do not cover one requirement. A persistent terminal needs PTY allocation, foreground-process-group inspection and signalling, and cleanup of the complete terminal session. Pretending those operations can be rebuilt in `dsh-pty-local` from an ordinary `spawn()` handle would either leak provider internals or weaken its lifecycle contract. ## Decision @@ -24,10 +26,26 @@ Generic consumers use that execution world: - `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. One provider-lifetime signal aborts filesystem and protocol work during disposal, including workspace lookup before queue ownership; its JSON-RPC, pooling, synchronization, and normalization stay unchanged. - `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; an in-flight readiness poll cannot release that reservation, and a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Startup cancellation begins terminal rollback without waiting for a stalled readiness or signalling call. Close rejects new public signals and delegates provider-observable session quiescence to the handle's awaited termination operation. +## E2B POC boundary + +The opt-in E2B realization has exactly three provider-specific packages under `packages/e2b/`: `dsh-e2b` creates or reconnects one sandbox and owns kill/pause/leave disposal, `dsh-fs-e2b` implements `ctx.fs`, and `dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands, PTYs, and remote Linux process groups. The two adapters obtain the sole sandbox identity from the owner and never create private sandboxes. + +E2B owns the mutable filesystem, managed command and Bash processes, terminal allocation and terminal-session groups, language-server processes and source reads, subprocess Code Runtime processes, and adapter-private files under `.dsh-e2b`. The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority, skills, subagent orchestration, PTY buffers and readiness, LSP protocol state, Code Runtime program/binding/output policy, and E2B SDK/network buffers. The overlay neither uploads nor synchronizes the host workspace. + +The adapters retain only substrate mechanics. Filesystem canonicalization crosses the SDK's decoded command transport as strict base64-encoded NUL framing; streamed reads leave byte ceilings with consumers. Subprocess command output and environment snapshots use ASCII/base64 where SDK chunk decoding would otherwise lose bytes, while private control shells isolate profiles and later launches blank discovered credential-shaped names. Process and terminal cleanup uses remote groups and proves quiescence before settlement. + +Retaining a sandbox preserves remote files and unmanaged state only. Reconnect does not reconstruct host process or terminal handles, protocol connections, pending calls, output cursors, timers, or locks. The POC adds no session-persistence backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, workspace synchronization, durable remote handles, or whole-harness execution. + +## Verification + +Focused package suites pin sandbox lifecycle, canonical path framing, filesystem metadata and atomic versions, subprocess publication/rollback, terminal text I/O and session cleanup, output limits, cancellation, disposal, and invariant registration. A credential-gated Loader composition exercises the same three-package provider through source imports and built exports, including FS/Bash visibility, post-rename version reread plus guarded edit, hostile login profiles, byte-split UTF-8 output, process and terminal cleanup, LSP document bounds, Code Runtime bindings/limits/cleanup, host-workspace isolation, and final sandbox deletion. + ## Alternatives considered **Keep one PTY and LSP package per remote provider.** Rejected because provider mechanics would be repeated above the existing seams. The deletion test exposes the problem: deleting those adapters should not scatter domain behavior into the remote provider; the generic consumers already own it. +**Create a separate sandbox per capability or tool.** Rejected because file and process operations would not share identity or state, defeating the coding use case and multiplying lifecycle owners. + **Model a terminal as an ordinary piped subprocess.** Rejected because pipes cannot allocate a controlling terminal, resolve the current foreground process group, or prove complete terminal-session cleanup. One terminal primitive is smaller and more honest than exposing substrate-specific escape hatches. **Move PTY readiness and session policy into the subprocess service.** Rejected because those are persistent-terminal consumer semantics, not OS process mechanics. A subprocess provider owns what only its substrate can do; `dsh-pty-local` owns what a Harness terminal means. @@ -38,6 +56,12 @@ Generic consumers use that execution world: **Run the whole harness inside the remote environment.** Rejected as a different deployment model. Making execution capabilities portable does not move model calls, session state, plugin state, or the agent loop. +**Put every provider operation in one shared owner package.** Rejected because sandbox identity and lifecycle are the owner's only concerns. Filesystem and subprocess retain distinct contracts, tests, and consumers without turning the owner into a capability grab bag. + +**Implement remote filesystem operations only through shell commands.** Rejected because that discards structured filesystem identity, errors, streaming, version guards, and atomic mutation semantics already consumed by the file tools. + +**Add a generic distributed-runtime abstraction or reconnect live handles.** Rejected because the existing capability seams carry the demonstrated contracts, while remote identity alone cannot reconstruct callbacks, pending promises, authority, protocol state, or output cursors. A new layer would speculate about persistence and synchronization beyond the POC. + ## Consequences A remote execution provider implements only its shared sandbox owner plus filesystem and subprocess adapters. Bash, PTY, and LSP compose above them, so fixes to those capabilities remain provider-neutral. @@ -45,3 +69,5 @@ A remote execution provider implements only its shared sandbox owner plus filesy The fundamental interfaces are wider, and a filesystem/subprocess pair must agree on one execution world. The added operations are limited to facts and lifecycle mechanics that current generic consumers require; model schemas, protocol framing, readiness policy, and presentation do not leak into the providers. The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer. + +The E2B composition demonstrates that a shared sandbox owner plus filesystem and subprocess adapters are sufficient to move the mutable coding world off-host while leaving higher capabilities provider-neutral. Its POC limits remain explicit: the SDK retains complete command transport in host memory, remote startup cannot publish a PID synchronously, exact terminal stdin-wait and independent signal facts are unavailable, numeric PID/PGID operations are not identity-fenced, the initial environment probe cannot hide unknown sandbox-default secrets from already-running same-UID processes, retained artifacts accumulate, and escaped processes or reconnect state are not recovered. These are provider constraints, not justification for compatibility shims or more E2B packages. diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md index 26d493a79f..361c4de4e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md @@ -6,7 +6,9 @@ Status: implemented ## 问题 -文件系统与进程管理 seam 使文件访问和普通进程访问具备可替换性,但 PTY 和 LSP 仍直接调用宿主 Node API。因此,即使领域行为没有变化,远程执行提供方看起来仍需要独立的 PTY 与 LSP 包(package)。这些包只会成为浅层适配器:每个包都仅为替换文件与进程操作而复制一个现有消费方。 +文件系统与进程管理 seam 使文件访问和普通进程访问具备可替换性,但若干上层能力仍直接调用宿主 Node API。因此,即使领域行为没有变化,远程执行提供方看起来仍需要独立的 PTY、LSP 与代码运行时包(package)。这些包只会成为浅层适配器:每个包都仅为替换文件与进程操作而复制一个现有消费方。 + +只有文件操作、命令、终端、语言服务器和模型编写的程序共享同一个沙箱身份时,远程编码世界才有用。若把完整 harness 移入该沙箱,还会把提供方实验与插件加载、凭据、模型传输、会话持久性、监督和部署纠缠在一起。 普通管道无法满足其中一项要求。持久终端需要分配 PTY、检查前台进程组并发送信号,以及清理完整的终端会话。如果假设可以在 `dsh-pty-local` 中基于普通 `spawn()` 句柄重建这些操作,最终不是泄漏提供方内部细节,就是削弱其生命周期契约。 @@ -16,16 +18,36 @@ Status: implemented 文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI 和包含关系。现有完整文本与流式文本操作仍归文件系统负责;协议消费方在消费流时执行各自的保留上限。 -进程管理接口负责可执行文件查找与进程原语:以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使提供方仍可观察到的每个会话成员完全停稳。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。 +进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使整个会话完全停稳。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。 通用消费方使用该执行世界: - `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。 - `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。一个提供方生命周期信号会在资源释放期间中止文件系统与协议操作,包括取得队列所有权之前的工作区查找;其 JSON-RPC、池化、同步和规范化保持不变。 -- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;在途就绪检查无法释放该预留,写入被拒绝时也不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。启动取消会立即开始终端回滚,而不等待停滞的就绪检查或信号发送调用。关闭操作会拒绝新的公开信号,并把提供方可观察会话成员的完全停稳委托给句柄上须等待的终止操作。 +- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;写入被拒绝时不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。关闭操作会拒绝新的公开信号,并把完整会话的完全停稳委托给句柄上须等待的终止操作。 +- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。固定 runner 是位于 `ctx.subprocess.runtimeRoot` 下的适配器自有基础设施,因此其写入携带显式 `danger-full-access` 策略,而不继承面向模型的文件系统模式。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在转发前执行相同的上限检查;原始子进程管道承载以换行符分隔的 UTF-8 JSON,无需冗余的 base64 表示;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。 + +`dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。 + +## E2B POC 边界 + +可选启用的 E2B 实现在 `packages/e2b/` 下恰好只有三个提供方专用包:`dsh-e2b` 创建或重新连接一个沙箱,并负责 kill、pause 或 leave 资源释放;`dsh-fs-e2b` 实现 `ctx.fs`;`dsh-subprocess-e2b` 基于 E2B Commands、PTY 和远程 Linux 进程组实现 `ctx.subprocess`。两个适配器都从所有者取得唯一的沙箱身份,绝不创建私有沙箱。 + +E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终端会话组、语言服务器进程与源文件读取、子进程代码运行时进程,以及 `.dsh-e2b` 下的适配器私有文件。宿主负责 Cordis 与插件对象、agent loop(智能体循环)、agent(智能体)状态、会话状态与目标状态、会话日志与持久化、LLM(大语言模型)调用、提示词与工具、权限、skill(技能)、subagent 编排、PTY 缓冲区与就绪状态、LSP 协议状态、代码运行时程序/绑定/输出策略,以及 E2B SDK/网络缓冲区。该叠加层既不上传,也不同步宿主工作区。 + +适配器只保留执行基底机制。文件系统规范化以严格的 base64 加 NUL 分帧穿过 SDK 已解码的命令传输;流式读取把字节上限留给消费方执行。进程管理命令输出与环境快照采用 ASCII/base64,避免 SDK 分片解码丢失字节;私有控制 shell 隔离 profile,后续启动会把已发现且名称呈凭据特征的环境变量置空。进程与终端清理使用远程进程组,并在结算前证明完全停稳。 + +保留沙箱只会保留远程文件与非托管状态。重新连接不会重建宿主进程或终端句柄、协议连接、待处理调用、输出游标、计时器或锁。该 POC 不会新增会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录、工作区同步、持久远程句柄,也不会在其中运行整个 harness。 + +## 验证 + +聚焦的包测试套件锁定了沙箱生命周期、规范化路径分帧、文件系统元数据与原子版本、进程管理发布/回滚、终端文本 I/O 与会话清理、输出上限、取消、资源释放和不变式注册。一项受凭据门控的 Loader 组合通过源代码导入与构建后导出运行同一套三包提供方组合,其中包括 FS/Bash 可见性、重命名后的版本重读与带保护编辑、恶意登录 profile、跨字节边界拆分的 UTF-8 输出、进程与终端清理、LSP 文档上限、代码运行时绑定/上限/清理、宿主工作区隔离,以及最终沙箱删除。 + ## 考虑过的替代方案 -**为每个远程提供方分别保留 PTY 与 LSP 包。** 不予采纳,因为这会在现有 seam 之上重复实现提供方机制。删除检验揭示了这一问题:删除这些适配器不应使领域行为散落到远程提供方中;通用消费方本已负责这些行为。 +**为每个远程提供方分别保留 PTY、LSP 和代码运行时包。** 不予采纳,因为这会在现有 seam 之上重复实现提供方机制。删除检验揭示了这一问题:删除这些适配器不应使领域行为散落到远程提供方中;通用消费方本已负责这些行为。 + +**为每项能力或工具创建独立沙箱。** 不予采纳,因为文件与进程操作将无法共享身份或状态,从而破坏编码用例,并增加生命周期所有者的数量。 **把终端建模为普通的管道子进程。** 不予采纳,因为管道无法分配控制终端、确定当前前台进程组或证明完整终端会话已清理。一项终端原语比公开特定于执行基底的逃生口更小,也更能如实表达契约。 @@ -35,12 +57,22 @@ Status: implemented **在文件系统 seam 中新增稳定的有界读取原语。** 不予采纳,因为只有 LSP 需要完整文档字节上限,而它可以在消费现有文本流时执行该上限。第二项原语会迫使每个提供方实现稳定句柄和不跟随符号链接的机制,远程提供方甚至需要辅助协议,却没有已观察到的并发替换缺陷。 -**在远程环境中运行整个 harness。** 不予采纳,因为这是另一种部署模型。让执行能力可移植,并不意味着移动模型调用、会话状态、插件状态或 agent loop(智能体循环)。 +**删除 worker 线程代码运行时。** 不予采纳,因为可移植性不会消除其当前部署需求。进程管理后端需要 Node 可执行文件和文件系统物化,而 worker 后端两者都不需要,并且仍是受支持的单进程路径。 + +**在远程环境中运行整个 harness。** 不予采纳,因为这是另一种部署模型。让执行能力可移植,并不意味着移动模型调用、会话状态、插件状态或 agent loop。 + +**把所有提供方操作都放进一个共享所有者包。** 不予采纳,因为沙箱身份与生命周期是所有者唯一的关注点。文件系统与进程管理保留各自独立的契约、测试和消费方,同时避免把所有者变成无边界的能力集合。 + +**只通过 shell 命令实现远程文件系统操作。** 不予采纳,因为这会丢弃现有文件工具已消费的结构化文件系统身份、错误、流式输出、版本保护和原子变更语义。 + +**新增通用分布式运行时抽象,或重新连接活跃句柄。** 不予采纳,因为现有能力 seam 已承载经证实的契约,而仅凭远程身份无法重建回调、待处理 promise、权限、协议状态或输出游标。新增一层只会推测 POC 边界之外的持久化与同步问题。 ## 后果 -远程执行提供方只需实现共享沙箱所有者,以及文件系统与进程管理适配器。Bash、PTY 与 LSP 组合在这些适配器之上,因此对这些能力的修复仍与提供方无关。 +远程执行提供方只需实现共享沙箱所有者,以及文件系统与进程管理适配器。Bash、PTY、LSP 和基于进程管理的代码运行时组合在这些适配器之上,因此对这些能力的修复仍与提供方无关。 基础接口更宽,一对文件系统/进程管理提供方必须在同一个执行世界上保持一致。新增操作仅限当前通用消费方所需的事实与生命周期机制;模型 schema、协议分帧、就绪策略和呈现不会渗入提供方。 本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。 + +E2B 组合证明,共享沙箱所有者加上文件系统与进程管理适配器,就足以在保持上层能力与提供方无关的同时,把可变编码世界移出宿主。其 POC 限制仍明确在案:SDK 会把完整命令传输内容保留在宿主内存中;远程启动无法同步发布 PID;无法获得精确的终端 stdin 等待状态与独立信号事实;基于数值 PID/PGID 的操作没有身份围栏;初始环境探测无法向已在运行的同 UID 进程隐藏未知的沙箱默认 secret;保留的产物会累积;也不会恢复逃逸进程或重连状态。这些是提供方限制,不是引入兼容性 shim 或更多 E2B 包的理由。 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 deleted file mode 100644 index 2c3db3fad4..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md -2026-07-27-e2b-remote-runtime-poc.md: 79c5bfe2dfa11cd695c7134e6ceffb639b449688 -2026-07-27-e2b-remote-runtime-poc.zh.md: 729a10ec7efa4dd55f041dc804d2252102b13c01 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 deleted file mode 100644 index 79c5bfe2df..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: Shared E2B remote runtime POC - -Status: implemented - -English | [中文](2026-07-27-e2b-remote-runtime-poc.zh.md) - -## Problem - -A remote coding-agent backend is useful only when filesystem operations, one-shot commands, persistent terminals, language servers, and model-written programs observe one coherent world. Attaching E2B independently at individual tools would let those capabilities address different sandboxes, while retaining host PTY, LSP, or worker backends would split state across machines even when the cwd strings match. - -Moving the complete harness into a remote VM would unify that state but also couple provider experimentation to plugin loading, credentials, model transport, agent/session durability, supervision, and deployment. The POC needs to test the existing capability boundaries without taking on those independent concerns. - -## Decision - -The E2B integration is an opt-in provider-composition POC. Its three E2B-specific packages live together under `packages/e2b/`: - -- `@deepseek-ai/dsh-e2b` creates or reconnects one secure E2B sandbox, creates its working and private runtime directories, and owns kill/pause/leave disposal. -- `@deepseek-ai/dsh-fs-e2b` implements `ctx.fs` over that sandbox's Filesystem API. -- `@deepseek-ai/dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands, byte PTYs, and remote Linux process groups. - -The higher capabilities use provider-neutral implementations. `dsh-bash-local` delegates command mechanics to `ctx.subprocess`; `dsh-pty-local` delegates terminal allocation and signalling to `ctx.subprocess.spawnTerminal()`; `dsh-lsp-local` reads through `ctx.fs` and launches through `ctx.subprocess`; `dsh-code-runtime-subprocess` materializes its runner through `ctx.fs` and starts it through `ctx.subprocess`. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns those generic interfaces and consumers. - -The E2B owner is the sole source of sandbox identity. Its two adapters never create private sandboxes, so filesystem tools, Bash, interactive shells, language servers, and code workers share one remote cwd, process namespace, and adapter-private directory while preserving the existing capability interfaces, generic implementations, model-facing tools, and agent loop. - -## POC boundary - -E2B owns the mutable filesystem, managed command and Bash processes, terminal allocation and terminal-session process groups, language-server processes and source reads, the Code Runtime launcher, controller, and worker, and adapter-private files under `.dsh-e2b`. - -The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority decisions, skills, subagent orchestration, PTY buffers and readiness state, LSP JSON-RPC ids/queues/protocol state, Code Runtime type stripping/output accounting/binding dispatch, and E2B SDK/network orchestration. The overlay does not upload, mount, or synchronize the host workspace; identical cwd strings name independent host and remote directories. - -The fundamental adapters carry the substrate-specific mechanics. Adapter-internal E2B command and PTY login shells use a fresh randomized root-level `HOME`; `dsh-subprocess-e2b` also gives them empty overrides for scrubbed credential names before user profiles run. The subprocess adapter consumes E2B's byte PTY callback, transports environment snapshots and command bytes as ASCII/base64 across decoded SDK callbacks, and uses one cancellation controller plus one retryable group-cleanup transaction that reports success only after proving quiescence. `dsh-fs-e2b` performs bounded source reads through a dependency-free helper that walks no-follow directory descriptors beneath the canonical target. Generic Code Runtime keeps its controller/worker protocol on validated newline-delimited UTF-8 JSON and kills the provider-owned process group before inherited pipes drain. Generic LSP uses byte-faithful UTF-8 JSON over command pipes. - -Retaining a sandbox preserves remote files and unmanaged remote state only. Reconnect does not reconstruct host PTY sessions, buffers, process handles, LSP connections or requests, code workers, binding calls, timers, output cursors, or locks. Managed groups terminate and join when their provider disposes before the shared owner pauses, leaves, or kills the sandbox. - -The POC has no session-persistence backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, workspace synchronization, durable remote handles, or whole-harness execution. - -## Verification - -Focused package suites pin owner lifecycle cleanup, filesystem paths/containment/bounded descriptor reads and commit metadata, subprocess executable lookup/process groups/publication rollback, terminal byte I/O/signal identity/default-environment scrubbing/session cleanup, output limits, abort ordering, disposal to quiescence, and package-owned invariant registrations. The generic PTY, LSP, and subprocess Code Runtime suites pin their provider-neutral readiness, cross-namespace `processId`, binding bridge, descriptor isolation, hostile traffic, and worker/descendant cleanup behavior. - -A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, real-directory runtime-state setup, process-publication rollback, byte-split UTF-8 command output, bounded raw spill and inherited-output draining, hostile command and PTY login-profile isolation, default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports. - -## Alternatives considered - -**A separate E2B sandbox per capability or tool** — rejected because file and command operations would not share identity or state, defeating the coding-agent use case and multiplying lifecycle ownership. - -**Run the entire harness process inside E2B** — rejected because it changes deployment, credential flow, model transport, session durability, plugin loading, and supervision at once. Those questions are independent of proving the provider seams. - -**Put every E2B operation in the shared owner package** — rejected because lifecycle identity is the owner's only concern. Filesystem and subprocess retain separate provider contracts, tests, and consumers; the owner exposes one shared SDK handle without becoming a capability grab bag. - -**Implement filesystem operations through shell commands only** — rejected because that bypasses `ctx.fs` identity, structured errors, version guards, streaming reads, and atomic mutation semantics already consumed by the file tools. - -**Keep E2B-specific PTY, LSP, and Code Runtime packages** — rejected because their domain behavior does not vary with E2B. They were shallow adapters that duplicated existing consumers to replace filesystem and process operations; moving those operations behind the fundamental seams gives every provider one implementation of readiness, protocol, binding, and presentation behavior. - -**Call E2B Filesystem, Commands, or PTY APIs directly from higher capabilities** — rejected because it bypasses the `ctx.fs` and `ctx.subprocess` contracts, duplicates execution-world policy in each consumer, and forks model-facing behavior. The subprocess seam includes the irreducible terminal primitive because ordinary pipes cannot supply foreground groups or whole-session cleanup. - -**Add a generic distributed-runtime abstraction first** — rejected because the existing capability seams already carry the required contracts. A new cross-cutting interface would speculate about persistence, synchronization, and reconnect semantics beyond the POC. - -**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 three-package composition demonstrates that filesystem and subprocess are the sufficient provider seams for moving an agent's mutable coding world off-host without changing the loop, higher capability implementations, or model-facing tool packages. Fixes to Bash, PTY, LSP, and Code Runtime remain provider-neutral. `sandboxId` plus pause/leave permits manual remote-file retention for experiments, while kill remains the demo's cleanup policy. - -The adapters are not interchangeable with local backends for every consumer: remote startup cannot synchronously expose a PID, E2B retains the complete base64 command transport in SDK memory, exact terminal stdin-wait inspection is unavailable, E2B supplies no independent signal fact, and reconnect cannot restore handles or protocol state. The adapter reports only its own requested TERM/KILL as signals and preserves every unrequested SDK exit as an exit code. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a managed process group or terminal session does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions. 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 deleted file mode 100644 index 729a10ec7e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: 共享 E2B 远程运行时 POC - -Status: implemented - -[English](2026-07-27-e2b-remote-runtime-poc.md) | 中文 - -## 问题 - -远程 coding agent(智能体)后端只有在文件系统操作、一次性命令、持久终端、语言服务器和模型编写的程序观察到同一个一致环境时才有用。若在各工具上分别接入 E2B,这些功能可能访问不同的沙箱;即使 cwd 字符串相同,保留宿主 PTY、LSP 或 worker 后端也会让状态分散在不同机器上。 - -把完整 harness 迁入远程 VM 可以统一这些状态,但也会把提供方实验与插件加载、凭据、模型传输、agent/会话持久性、监管和部署耦合在一起。这个 POC 只需测试现有功能边界,不应把这些彼此独立的问题纳入范围。 - -## 决策 - -E2B 集成是一个选择性启用的提供方组合 POC。它的 3 个 E2B 专用包(package)集中位于 `packages/e2b/` 下: - -- `@deepseek-ai/dsh-e2b` 创建或重新连接一个安全的 E2B 沙箱,创建其工作目录与私有运行时目录,并拥有 kill/pause/leave 资源释放操作。 -- `@deepseek-ai/dsh-fs-e2b` 在该沙箱的 Filesystem API 之上实现 `ctx.fs`。 -- `@deepseek-ai/dsh-subprocess-e2b` 在 E2B Commands、字节 PTY 和远程 Linux 进程组之上实现 `ctx.subprocess`。 - -上层功能使用提供方无关的实现。`dsh-bash-local` 把命令机制委托给 `ctx.subprocess`;`dsh-pty-local` 把终端分配与信号发送委托给 `ctx.subprocess.spawnTerminal()`;`dsh-lsp-local` 通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动;`dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化 runner,再通过 `ctx.subprocess` 启动它。这些通用接口与消费方由[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.md)负责定义。 - -E2B 所有者是沙箱身份的唯一真源。其两个适配器绝不创建私有沙箱,因此文件系统工具、Bash、交互式 shell、语言服务器和代码 worker 会共享一个远程 cwd、进程命名空间和适配器私有目录,同时保留现有功能接口、通用实现、面向模型的工具与 agent loop(智能体循环)。 - -## POC 边界 - -E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终端会话进程组、语言服务器进程及源码读取、Code Runtime launcher、controller 和 worker,以及 `.dsh-e2b` 下的适配器私有文件。 - -宿主拥有 Cordis 与插件对象、agent loop、agent/会话/goal 状态、会话日志及持久化、LLM(大语言模型)调用、提示词与工具、权限决策、skill(技能)、subagent 编排、PTY 缓冲与就绪状态、LSP JSON-RPC id/队列/协议状态、Code Runtime 类型剥离/输出计量/绑定分发,以及 E2B SDK/网络编排。该 overlay 不会上传、挂载或同步宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。 - -基础适配器承载基底专用机制。适配器内部的 E2B 命令与 PTY 登录 shell 使用位于根目录下、全新随机生成的 `HOME`;在用户 profile 脚本运行前,`dsh-subprocess-e2b` 还会为它们设置已清理凭据名称的空值覆盖。该子进程适配器消费 E2B 的字节 PTY 回调,以 ASCII/base64 跨越已解码的 SDK 回调传输环境快照与命令字节,并使用一个取消控制器与一个可重试的进程组清理事务,后者只有在证明完全停稳后才报告成功。`dsh-fs-e2b` 通过无依赖辅助程序执行有界源码读取,该程序会在规范化目标下逐级遍历不跟随符号链接的目录描述符。通用 Code Runtime 以经过验证、由换行符分隔的 UTF-8 JSON 承载 controller/worker 协议,并在继承的管道排空前终止提供方拥有的进程组。通用 LSP 通过命令管道使用字节保真的 UTF-8 JSON。 - -保留沙箱只会保存远程文件与未受管的远程状态。重新连接不会重建宿主 PTY 会话、缓冲、进程句柄、LSP 连接或请求、代码 worker、绑定调用、定时器、输出游标或锁。受管进程组会在所属提供方 dispose(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。 - -本 POC 没有会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录、工作区同步、持久远程句柄或完整 harness 执行。 - -## 验证 - -聚焦包测试套件固定所有者生命周期清理、文件系统路径/containment/有界描述符读取与提交元数据、子进程可执行文件查找/进程组/发布回滚、终端字节 I/O/信号身份/默认环境清理/会话清理、输出上限、中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。通用 PTY、LSP 与子进程 Code Runtime 测试套件固定其提供方无关的就绪判定、跨命名空间 `processId`、绑定桥接、描述符隔离、恶意通信,以及 worker/后代进程清理行为。 - -凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、真实目录形式的运行时状态设置、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 与继承输出排空、恶意命令与 PTY 登录 profile 隔离、默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 - -## 曾考虑的替代方案 - -**每项功能或每个工具使用独立的 E2B 沙箱。** 不予采纳,因为文件操作和命令操作将无法共享身份或状态,既违背 coding agent 用例,也会增加生命周期所有者的数量。 - -**在 E2B 内运行完整 harness 进程。** 不予采纳,因为这会同时改变部署、凭据流、模型传输、会话持久性、插件加载和监管方式。要证明提供方 seam,并不需要同时回答这些彼此独立的问题。 - -**把所有 E2B 操作放入共享所有者包。** 不予采纳,因为生命周期身份是该所有者唯一负责的事项。文件系统与进程管理各自保留独立的提供方契约、测试和消费方;所有者只公开一个共享 SDK 句柄,不会因此包揽各类功能。 - -**仅通过 shell 命令实现文件系统操作。** 不予采纳,因为这会绕过文件工具已经使用的 `ctx.fs` 身份、结构化错误、版本防护、流式读取和原子变更语义。 - -**保留 E2B 专用的 PTY、LSP 与 Code Runtime 包。** 不予采纳,因为它们的领域行为不会随 E2B 改变。这些浅层适配器为了替换文件系统与进程操作而重复现有消费方;把这些操作移到基础 seam 之后,可让所有提供方共享同一套就绪判定、协议、绑定与呈现行为实现。 - -**从上层功能直接调用 E2B Filesystem、Commands 或 PTY API。** 不予采纳,因为这会绕过 `ctx.fs` 与 `ctx.subprocess` 契约,在每个消费方中重复执行环境策略,并使面向模型的行为产生分叉。进程管理 seam 纳入不可约简的终端原语,因为普通管道无法提供前台进程组或全会话清理。 - -**先添加通用分布式运行时抽象。** 不予采纳,因为现有功能 seam 已承载所需契约。新的跨领域接口会预先假定 POC 范围之外的持久化、同步与重连语义。 - -**在 `sandboxId` 重连后恢复活动功能句柄。** 不予采纳,因为只有远程身份,无法重建宿主回调、待处理 promise、权限、协议状态或输出游标。若声称保持连续性,就会让陈旧的远程进程看似仍受管理,实际并非如此。 - -## 后果 - -这个由 3 个包组成的组合证明,文件系统与进程管理这两个提供方 seam 足以把 agent 的可变 coding 环境移出宿主,而无需改变循环、上层功能实现或面向模型的工具包。Bash、PTY、LSP 与 Code Runtime 的修复仍与提供方无关。`sandboxId` 与 `pause`/`leave` 允许实验手动保留远程文件,演示仍以 `kill` 作为清理策略。 - -这些适配器并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整的 base64 命令传输,无法精确检查终端 stdin 等待状态,E2B 不提供独立的信号事实,重新连接也无法恢复句柄或协议状态。适配器只会把自己请求的 TERM/KILL 报告为信号,其他未请求的 SDK 退出都保留为退出码。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离受管理进程组或终端会话的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。 diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts index b4acfd1e5a..856a26ed78 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -35,9 +35,19 @@ let terminalId: Awaited>['sessionId'] | undefin try { const sandbox = await ctx.e2b.getSandbox() const fromFs = await ctx.fs.resolve('from-fs.txt') - await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' }) + const written = await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' }) + const reread = await ctx.fs.stat(fromFs) + if (reread?.version !== written.version) { + throw new Error(`E2B rename did not preserve version metadata: ${JSON.stringify({ written, reread })}`) + } + await ctx.fs.editText( + fromFs, + { oldString: 'written-by-fs', newString: 'written-by-fs-versioned', replaceAll: false }, + { version: reread.version }, + ) + const fsVersionGuard = true const bashRead = await ctx.bash.run(ctx.bash.resolve({ command: 'cat from-fs.txt' })) - if (bashRead.exitCode !== 0 || bashRead.stdout.text !== 'written-by-fs\n') { + if (bashRead.exitCode !== 0 || bashRead.stdout.text !== 'written-by-fs-versioned\n') { throw new Error(`E2B Bash could not read the FS write: ${JSON.stringify(bashRead)}`) } @@ -183,44 +193,8 @@ try { workspaceRoot: process.cwd(), }) - const swappedParentPath = posix.join(process.cwd(), 'swapped-parent') - const swappedSourcePath = posix.join(swappedParentPath, 'source.ts') - const swappedOutsidePath = '/tmp/dsh-e2b-lsp-outside' - await sandbox.commands.run( - `mkdir -p -- ${quoteE2BShellArg(swappedParentPath)} ${quoteE2BShellArg(swappedOutsidePath)} && printf 'const safe = true\\n' > ${quoteE2BShellArg(swappedSourcePath)} && printf 'const outside = true\\n' > ${quoteE2BShellArg(posix.join(swappedOutsidePath, 'source.ts'))}`, - ) - const remoteCommands = sandbox.commands as unknown as { - run(command: string, options?: unknown): Promise<{ exitCode: number; stdout: string; stderr: string }> - } - const runRemoteCommand = remoteCommands.run.bind(sandbox.commands) - let containmentFaultInjected = false - remoteCommands.run = async (command, options) => { - if (!containmentFaultInjected && command.includes('dsh-e2b-bounded-reader') && command.includes('swapped-parent/source.ts')) { - containmentFaultInjected = true - await runRemoteCommand( - `rm -rf -- ${quoteE2BShellArg(swappedParentPath)} && ln -s -- ${quoteE2BShellArg(swappedOutsidePath)} ${quoteE2BShellArg(swappedParentPath)}`, - ) - } - return await runRemoteCommand(command, options) - } - let lspContainment = false - try { - await ctx.lsp.query({ - operation: 'hover', - filePath: 'swapped-parent/source.ts', - position: { line: 0, character: 1 }, - workspaceRoot: process.cwd(), - }) - } catch (error: unknown) { - lspContainment = containmentFaultInjected && String(error).includes('opened safely') - if (!lspContainment) throw error - } finally { - remoteCommands.run = runRemoteCommand - } - if (!lspContainment) throw new Error('E2B LSP source swap was not rejected') - const oversizedSourcePath = posix.join(process.cwd(), 'oversized-source.ts') - await sandbox.commands.run(`head -c 4000001 /dev/zero > ${quoteE2BShellArg(oversizedSourcePath)}`) + await sandbox.commands.run(`head -c 4000001 /dev/zero | tr '\\0' x > ${quoteE2BShellArg(oversizedSourcePath)}`) let lspDocumentBound = false try { await ctx.lsp.query({ @@ -235,6 +209,10 @@ try { } if (!lspDocumentBound) throw new Error('E2B LSP accepted an oversized remote source') + const remoteCommands = sandbox.commands as unknown as { + run(command: string, options?: unknown): Promise<{ exitCode: number; stdout: string; stderr: string }> + } + const runRemoteCommand = remoteCommands.run.bind(sandbox.commands) const terminal = await ctx.pty.spawn(owner, { type: 'shell' }) terminalId = terminal.sessionId const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, { @@ -429,6 +407,7 @@ try { process.stdout.write(`${JSON.stringify({ sandboxId: await ctx.e2b.sandboxId, bashRead: bashRead.stdout.text, + fsVersionGuard, fsRead, explicitEnvironment, splitUtf8Output, @@ -437,7 +416,6 @@ try { spill: { liveBytes: liveSpillBytes, outcome: spillOutcome, read: spillRead }, hover, definition, - lspContainment, lspDocumentBound, terminal: { motd: terminal.motd, diff --git a/packages/e2b/README.i18n.yaml b/packages/e2b/README.i18n.yaml index 9b56a3ef55..2b6a8efb69 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: ef2e5ef49056e6688630e785a61c01fa53febbb1 -README.zh.md: d76b05f9b60bc471c1793decd1def1d15615c852 +README.md: cb733ba00d21070773ff9b381e4a72d9d3b9a8df +README.zh.md: 29f8e58a0cdc59098cf8d74120cb360d11cfbcb3 diff --git a/packages/e2b/README.md b/packages/e2b/README.md index ef2e5ef490..cb733ba00d 100644 --- a/packages/e2b/README.md +++ b/packages/e2b/README.md @@ -12,4 +12,4 @@ An experimental provider-composition POC that places one filesystem/process exec The existing [`dsh-bash-local`](../bash/bash-local/README.md), [`dsh-pty-local`](../pty/pty-local/README.md), [`dsh-lsp-local`](../lsp/lsp-local/README.md), and [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) need no E2B-specific forks. They delegate every execution-world operation to `ctx.fs` and `ctx.subprocess`, so mounting the two E2B adapters places their mutable work in the same sandbox. -This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [shared-runtime decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary; the [portable-consumer decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns the generic composition. +This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [portable execution-world decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns both the generic composition and this POC boundary. diff --git a/packages/e2b/README.zh.md b/packages/e2b/README.zh.md index d76b05f9b6..29f8e58a0c 100644 --- a/packages/e2b/README.zh.md +++ b/packages/e2b/README.zh.md @@ -12,4 +12,4 @@ 现有的 [`dsh-bash-local`](../bash/bash-local/README.md)、[`dsh-pty-local`](../pty/pty-local/README.md)、[`dsh-lsp-local`](../lsp/lsp-local/README.md) 及 [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) 无需 E2B 专用 fork。它们把执行环境中的所有操作委托给 `ctx.fs` 和 `ctx.subprocess`,因此挂载这两个 E2B 适配器后,它们执行的可变操作都发生在同一个沙箱内。 -该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、更高层协议状态或 E2B SDK 缓冲。[共享运行时决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)界定 POC 边界;[可移植消费方决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)界定通用组合。 +该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、更高层协议状态或 E2B SDK 缓冲。[可移植执行世界决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)同时界定通用组合和此 POC 边界。 diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index f9fcbfae8a..6600f1046d 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -160,7 +160,8 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { expect(stderr).toBe('') const output = JSON.parse(stdout) as Record expect(output).toMatchObject({ - bashRead: 'written-by-fs\n', + bashRead: 'written-by-fs-versioned\n', + fsVersionGuard: true, fsRead: 'written-by-bash\n', explicitEnvironment: true, splitUtf8Output: '你好', @@ -184,7 +185,6 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { kind: 'locations', locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }], }, - lspContainment: true, lspDocumentBound: true, terminal: { echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } }, diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 87f1c7c69b..3f756bf2f9 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-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/fs-e2b/README.md -README.md: bb92b5785383e9703a382fddefcd1cff9b2644cb -README.zh.md: 57ebfdbb92799660e74078fcd0affcc8d5bc120a +README.md: 6827e16e9c45532590ee1aa986c18a353d175fdc +README.zh.md: 21b067829ea95c4581d7e89f3d225f9c90e630ef diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index bb92b57853..6827e16e9c 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -6,10 +6,9 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide ## Behavior -- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; `realpath -m` supplies canonical target identity without requiring the final file to exist. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. +- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. -- **Stable bounded reads** — a dependency-free Node helper walks directory descriptors with no-follow opens and reads one held regular-file descriptor through the byte cap. Generic LSP queries therefore reject parent swaps, non-files, invalid UTF-8, and growth past the configured document limit before server startup. - **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics. - **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point. @@ -27,5 +26,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **No host synchronization** — an empty E2B cwd stays empty until a tool, command, template, or external process populates it; local files are neither uploaded nor reflected back. - **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B. +- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC. - **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency. -- **Custom templates must support the used Linux, Node, procfs, and envd features** — `realpath`, `chmod`, `mv`, same-filesystem POSIX rename, streaming reads, file metadata extended attributes, `/proc/self/fd`, and no-follow descriptor opens are required; unsupported templates fail rather than degrade silently. +- **Custom templates must support the used Linux/GNU and E2B filesystem features** — `realpath -mz`, `base64 -w0`, `chmod`, same-filesystem rename, streaming reads, and file metadata extended attributes are required; unsupported templates fail rather than degrade silently. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index 57ebfdbb92..21b067829e 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -6,10 +6,9 @@ ## 行为 -- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;`realpath -m` 提供规范化目标身份,且不要求最终文件存在。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 +- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 -- **稳定的有界读取**:一个零依赖 Node 辅助程序会以不跟随链接的方式逐级打开目录描述符,并通过一个持续持有的常规文件描述符读取至字节上限。因此,通用 LSP 查询会在服务器启动前拒绝父目录交换、非文件、无效 UTF-8,以及增长后超出所配置文档上限的文件。 - **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。 - **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC,因此取消无法中断原子提交;成功 rename 是提交点。 @@ -27,5 +26,6 @@ - **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令、模板或外部进程填充它;本地文件既不会上传,也不会同步回本地。 - **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。 +- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。 - **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。 -- **自定义模板必须支持所用的 Linux、Node、procfs 与 envd 功能**:必须支持 `realpath`、`chmod`、`mv`、同一文件系统内的 POSIX rename、流式读取、文件元数据扩展属性、`/proc/self/fd` 和不跟随链接的描述符打开操作;不支持的模板会失败,而不会静默降级。 +- **自定义模板必须支持所用的 Linux/GNU 与 E2B 文件系统功能**:必须支持 `realpath -mz`、`base64 -w0`、`chmod`、同一文件系统内的 rename、流式读取和文件元数据扩展属性;不支持的模板会失败,而不会静默降级。 diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 7c81791d55..3b0c835d8c 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -26,17 +26,10 @@ import { quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b' -import { BOUNDED_READER_SOURCE } from './source-reader.ts' const VERSION_METADATA_KEY = 'dsh-version' const BINARY_SAMPLE_BYTES = 8192 - -type BoundedReadResponse = - | { kind: 'ok'; data: string } - | { kind: 'not-file' } - | { kind: 'oversize'; size: number } - | { kind: 'grew' } - | { kind: 'open-error'; message: string } +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ function assertNotAborted(signal: AbortSignal | undefined, operation: string): void { if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED') @@ -68,6 +61,27 @@ function decodeText(bytes: Uint8Array, displayPath: string, binarySampleBytes: n } } +function decodeCanonicalPath(encoded: string): string { + if (encoded.length === 0 || !BASE64.test(encoded)) { + throw new Error('fs-e2b: canonical path transport returned invalid base64') + } + const framed = Buffer.from(encoded, 'base64') + if (framed.toString('base64') !== encoded + || framed.length < 2 + || framed.at(-1) !== 0 + || framed.subarray(0, -1).includes(0)) { + throw new Error('fs-e2b: canonical path transport returned invalid NUL framing') + } + let path: string + try { + path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1)) + } catch (error: unknown) { + throw new Error('fs-e2b: canonical path is not valid UTF-8', { cause: error }) + } + if (!posix.isAbsolute(path)) throw new Error('fs-e2b: canonical path is not absolute') + return path +} + function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } { return signal === undefined ? {} : { signal } } @@ -213,69 +227,6 @@ export class E2BFileSystem extends FileSystem { } } - override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise { - if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { - throw new Error('bounded read maxBytes must be a positive safe integer') - } - assertNotAborted(signal, 'read') - const sandbox = await this.ctx.e2b.getSandbox() - try { - const node = await sandbox.commands.run('command -v -- node', commandOpts(signal)) - const executable = node.stdout.trim() - if (!posix.isAbsolute(executable) || executable.includes('\n')) { - throw new Error('fs-e2b: bounded reader requires one absolute Node executable') - } - const command = [ - quoteE2BShellArg(executable), - '--input-type=commonjs', - '-e', - quoteE2BShellArg(BOUNDED_READER_SOURCE), - quoteE2BShellArg(this.processPath(target)), - String(maxBytes), - ].join(' ') - const result = await sandbox.commands.run(command, commandOpts(signal)) - assertNotAborted(signal, 'read') - const response = this.parseBoundedRead(result.stdout, target) - if (response.kind === 'not-file') { - throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - } - if (response.kind === 'oversize' && Number.isSafeInteger(response.size)) { - throw new FsError( - `cannot read "${target.displayPath}": ${response.size} bytes exceeds the ${maxBytes}-byte limit`, - 'FS_IO_ERROR', - ) - } - if (response.kind === 'grew') { - throw new FsError( - `cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`, - 'FS_IO_ERROR', - ) - } - if (response.kind === 'open-error' && typeof response.message === 'string') { - if (/ENOENT|no such file/i.test(response.message)) { - throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') - } - if (/EACCES|EPERM|permission denied|operation not permitted/i.test(response.message)) { - throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED') - } - throw new FsError( - `cannot read "${target.displayPath}" safely: ${response.message}`, - 'FS_IO_ERROR', - ) - } - if (response.kind !== 'ok' || typeof response.data !== 'string') { - throw new FsError(`cannot read "${target.displayPath}": bounded reader returned an invalid response`, 'FS_IO_ERROR') - } - const bytes = Buffer.from(response.data, 'base64') - if (bytes.toString('base64') !== response.data || bytes.length > maxBytes) { - throw new FsError(`cannot read "${target.displayPath}": bounded reader returned invalid bytes`, 'FS_IO_ERROR') - } - return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES) - } catch (error: unknown) { - throw mapError(error, 'read', target.displayPath, signal) - } - } - override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { const sandbox = await this.ctx.e2b.getSandbox() await this.requireRegular(target, signal) @@ -339,18 +290,23 @@ export class E2BFileSystem extends FileSystem { try { const sandbox = await this.ctx.e2b.getSandbox() const listed = await sandbox.files.list(String(target.targetKey), { depth: 1, ...signalOpts(signal) }) - const entries = await Promise.all(listed.map(async (entry): Promise => { + const entries: FsDirEntry[] = [] + for (const entry of listed) { const displayPath = posix.join(target.displayPath, entry.name) - const canonical = await this.canonicalPath(sandbox, entry.path, signal) - const resolved = await this.probe(canonical, displayPath, signal) - return { + const canonical = entry.symlinkTarget === undefined + ? entry.path + : await this.canonicalPath(sandbox, entry.path, signal) + const resolved = entry.symlinkTarget === undefined + ? entry + : await this.probe(canonical, displayPath, signal) + entries.push({ name: entry.name, type: resolved === undefined ? 'other' : entryType(resolved), target: { targetKey: FsTargetKey(canonical), displayPath }, ...(resolved !== undefined ? { version: entryVersion(resolved) } : {}), ...(resolved?.type === FileType.FILE ? { size: resolved.size } : {}), - } - })) + }) + } return entries.sort((left, right) => left.name.localeCompare(right.name)) } catch (error: unknown) { throw mapError(error, 'list', target.displayPath, signal) @@ -420,26 +376,17 @@ export class E2BFileSystem extends FileSystem { private async canonicalPath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise { try { - const result = await sandbox.commands.run(`realpath -m -- ${quoteE2BShellArg(path)}`, commandOpts(signal)) - return result.stdout.replace(/\n$/, '') + const result = await sandbox.commands.run( + `set -o pipefail; realpath -mz -- ${quoteE2BShellArg(path)} | base64 -w0`, + commandOpts(signal), + ) + return decodeCanonicalPath(result.stdout) } catch (error: unknown) { if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error }) throw error } } - private parseBoundedRead(stdout: string, target: FsTarget): BoundedReadResponse { - try { - return JSON.parse(stdout) as BoundedReadResponse - } catch (error: unknown) { - throw new FsError( - `cannot read "${target.displayPath}": bounded reader returned invalid JSON`, - 'FS_IO_ERROR', - { cause: error }, - ) - } - } - private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise { assertNotAborted(signal, 'stat') try { diff --git a/packages/e2b/fs-e2b/src/source-reader.ts b/packages/e2b/fs-e2b/src/source-reader.ts deleted file mode 100644 index 9cb522ecbb..0000000000 --- a/packages/e2b/fs-e2b/src/source-reader.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** Dependency-free stable-handle bounded reader installed inside E2B. */ -export const BOUNDED_READER_SOURCE = String.raw` -/* dsh-e2b-bounded-reader */ -const fs = require('node:fs') -const target = process.argv[1] -const maxBytes = Number(process.argv[2]) -const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK -const fileFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK -let directory -let descriptor -let response - -const openChild = (parent, component, flags) => fs.openSync('/proc/self/fd/' + parent + '/' + component, flags) -const invalidComponent = component => component === '' || component === '.' || component === '..' - -try { - if (typeof target !== 'string' || !target.startsWith('/') || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) { - throw new Error('bounded reader requires an absolute target and positive byte limit') - } - const components = target === '/' ? [] : target.slice(1).split('/') - if (components.length === 0 || components.some(invalidComponent)) { - throw new Error('bounded reader received a non-canonical file path') - } - - directory = fs.openSync('/', directoryFlags) - for (const component of components.slice(0, -1)) { - const child = openChild(directory, component, directoryFlags) - fs.closeSync(directory) - directory = child - } - descriptor = openChild(directory, components.at(-1), fileFlags) - - const info = fs.fstatSync(descriptor) - if (!info.isFile()) response = { kind: 'not-file' } - else if (info.size > maxBytes) response = { kind: 'oversize', size: info.size } - else { - const chunks = [] - let total = 0 - while (total <= maxBytes) { - const chunk = Buffer.allocUnsafe(Math.min(65536, maxBytes - total + 1)) - const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null) - if (bytesRead === 0) break - chunks.push(chunk.subarray(0, bytesRead)) - total += bytesRead - } - response = total > maxBytes - ? { kind: 'grew' } - : { kind: 'ok', data: Buffer.concat(chunks, total).toString('base64') } - } -} catch (error) { - response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) } -} finally { - for (const openDescriptor of [descriptor, directory]) { - if (openDescriptor === undefined) continue - try { - fs.closeSync(openDescriptor) - } catch (error) { - response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) } - } - } -} -process.stdout.write(JSON.stringify(response)) -` diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 23835e9967..91f9a278fd 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -49,10 +49,7 @@ class FakeRemote { nextReadError: unknown nextRenameError: unknown nextRemoveError: unknown - boundedOutput: string | undefined - boundedError: unknown - nodeExecutable = '/usr/bin/node\n' - abortAfterBoundedCommand: AbortController | undefined + canonicalOutput: string | undefined abortAfterRename: AbortController | undefined disappearOnInfo = new Set() private clock = 1 @@ -239,18 +236,18 @@ class FakeRemote { this.nextCommandError = undefined throw error } - if (command.startsWith('realpath -m -- ')) { - const input = command.slice('realpath -m -- '.length).slice(1, -1) + const realpathPrefix = 'set -o pipefail; realpath -mz -- ' + const realpathSuffix = ' | base64 -w0' + if (command.startsWith(realpathPrefix) && command.endsWith(realpathSuffix)) { + const quoted = command.slice(realpathPrefix.length, -realpathSuffix.length) + const input = quoted.slice(1, -1).replaceAll(String.raw`'"'"'`, '\'') const node = this.nodes.get(input) - return { exitCode: 0, stdout: `${node?.symlinkTarget ?? input}\n`, stderr: '' } - } - if (command === 'command -v -- node') { - return { exitCode: 0, stdout: this.nodeExecutable, stderr: '' } - } - if (command.includes('dsh-e2b-bounded-reader')) { - if (this.boundedError !== undefined) throw this.boundedError - this.abortAfterBoundedCommand?.abort('after bounded read') - return { exitCode: 0, stdout: this.boundedOutput ?? '{"kind":"ok","data":""}', stderr: '' } + const canonical = `${node?.symlinkTarget ?? input}\0` + return { + exitCode: 0, + stdout: this.canonicalOutput ?? Buffer.from(canonical).toString('base64'), + stderr: '', + } } const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command) if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8) @@ -340,6 +337,28 @@ describe('E2BFileSystem identity, metadata, and reads', () => { .toThrow('expected an absolute process path') }) + it('preserves newline and multibyte canonical paths through strict ASCII framing', async () => { + const remote = new FakeRemote() + const path = '/workspace/你好\nfile.ts' + remote.file(path, 'text') + const { fs } = await setup(remote) + + await expect(fs.resolve(path)).resolves.toEqual({ targetKey: path, displayPath: path }) + }) + + it.each([ + ['invalid base64', '!!!!'], + ['missing terminator', Buffer.from('/workspace/file').toString('base64')], + ['multiple records', Buffer.from('/workspace/file\0/other\0').toString('base64')], + ['invalid UTF-8', Buffer.from([47, 0xff, 0]).toString('base64')], + ['relative path', Buffer.from('workspace/file\0').toString('base64')], + ])('rejects %s from canonical path transport', async (_label, output) => { + const remote = new FakeRemote() + remote.canonicalOutput = output + const { fs } = await setup(remote) + await expectCode(fs.resolve('file'), 'FS_IO_ERROR') + }) + it('reads whole and streamed UTF-8 across chunk boundaries', async () => { const remote = new FakeRemote() remote.file('/workspace/text.txt', 'A€B') @@ -424,78 +443,6 @@ describe('E2BFileSystem identity, metadata, and reads', () => { await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED') }) - it('performs stable bounded reads through the remote no-follow reader', async () => { - const remote = new FakeRemote() - remote.file('/workspace/a', 'unused') - const { fs } = await setup(remote) - const target = await fs.resolve('a') - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('hello 你好').toString('base64') }) - await expect(fs.readTextBounded(target, 64)).resolves.toBe('hello 你好') - expect(remote.commands.some(command => command.includes('dsh-e2b-bounded-reader'))).toBe(true) - - await expect(fs.readTextBounded(target, 0)).rejects.toThrow('positive safe integer') - await expect(fs.readTextBounded(target, 1.5)).rejects.toThrow('positive safe integer') - await expect(fs.readTextBounded(target, 64, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) - }) - - it('maps bounded-reader file, size, and open failures', async () => { - const remote = new FakeRemote() - remote.file('/workspace/a', 'unused') - const { fs } = await setup(remote) - const target = await fs.resolve('a') - const cases: Array<[unknown, string]> = [ - [{ kind: 'not-file' }, 'FS_NOT_REGULAR_FILE'], - [{ kind: 'oversize', size: 65 }, 'FS_IO_ERROR'], - [{ kind: 'grew' }, 'FS_IO_ERROR'], - [{ kind: 'open-error', message: 'ENOENT: no such file' }, 'FS_NOT_FOUND'], - [{ kind: 'open-error', message: 'EACCES: permission denied' }, 'FS_PERMISSION_DENIED'], - [{ kind: 'open-error', message: 'ELOOP: symbolic link' }, 'FS_IO_ERROR'], - [{ kind: 'oversize', size: 'large' }, 'FS_IO_ERROR'], - [{ kind: 'open-error', message: 7 }, 'FS_IO_ERROR'], - [{ kind: 'unknown' }, 'FS_IO_ERROR'], - ] - for (const [response, code] of cases) { - remote.boundedOutput = JSON.stringify(response) - await expectCode(fs.readTextBounded(target, 64), code) - } - }) - - it('rejects malformed bounded-reader transports and bytes', async () => { - const remote = new FakeRemote() - remote.file('/workspace/a', 'unused') - const { fs } = await setup(remote) - const target = await fs.resolve('a') - - remote.boundedOutput = 'not-json' - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: '!!!!' }) - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('12345').toString('base64') }) - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0]).toString('base64') }) - await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT') - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0xff]).toString('base64') }) - await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT') - - remote.nodeExecutable = 'node\n' - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - remote.nodeExecutable = '/usr/bin/node\n/other\n' - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - remote.nodeExecutable = '/usr/bin/node\n' - remote.boundedError = new Error('reader transport failed') - await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR') - }) - - it('does not turn a post-read abort into successful source text', async () => { - const remote = new FakeRemote() - remote.file('/workspace/a', 'unused') - const controller = new AbortController() - remote.abortAfterBoundedCommand = controller - remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('text').toString('base64') }) - const { fs } = await setup(remote) - await expectCode(fs.readTextBounded(await fs.resolve('a'), 4, controller.signal), 'FS_ABORTED') - }) - it('rejects empty paths and directory-listing type errors', async () => { const remote = new FakeRemote() remote.file('/workspace/file', 'x') @@ -686,17 +633,34 @@ describe('E2B filesystem adapter integration edges', () => { await expectCode(fs.readText(target), 'FS_IO_ERROR') }) - it('keeps a listed child whose metadata disappears as an other entry', async () => { + it('uses listing metadata directly and canonicalizes only symbolic links', async () => { const remote = new FakeRemote() remote.file('/workspace/a', 'a') - remote.disappearOnInfo.add('/workspace/a') + remote.file('/workspace/target', 'target') + remote.file('/workspace/gone', 'gone') + remote.symlink('/workspace/link', '/workspace/target') + remote.symlink('/workspace/vanished-link', '/workspace/gone') + remote.disappearOnInfo.add('/workspace/gone') const { fs } = await setup(remote) - const listed = await fs.listDir(await fs.resolve('/workspace')) - expect(listed).toEqual([{ - name: 'a', + const directory = await fs.resolve('/workspace') + const commandsBefore = remote.commands.length + const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo') + + const listed = await fs.listDir(directory) + + expect(listed.find(entry => entry.name === 'a')).toMatchObject({ + type: 'file', target: { targetKey: '/workspace/a' }, size: 1, + }) + expect(listed.find(entry => entry.name === 'link')).toMatchObject({ + type: 'file', target: { targetKey: '/workspace/target' }, size: 6, + }) + expect(listed.find(entry => entry.name === 'vanished-link')).toEqual({ + name: 'vanished-link', type: 'other', - target: { targetKey: '/workspace/a', displayPath: '/workspace/a' }, - }]) + target: { targetKey: '/workspace/gone', displayPath: '/workspace/vanished-link' }, + }) + expect(remote.commands.slice(commandsBefore)).toHaveLength(2) + expect(getInfo).toHaveBeenCalledTimes(3) }) it('registers the package-owned empty invariant installer', async () => { diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index ac7b8d42a5..20ba9b597e 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-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/subprocess-e2b/README.md -README.md: 9b86af428533ebcc2c0da56339e6ea2a28170fd3 -README.zh.md: e3e8368534266e35ec04dcf5a2d6718829b4a016 +README.md: 9e044c13b673461a876e016ec560db4897a27c66 +README.zh.md: b9180c0c0679915fae7a1db22353613708392a8c diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 9b86af4285..9e044c13b6 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -9,9 +9,9 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr - **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. - **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides. - **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes. -- **Environment boundary** — the sandbox command environment crosses the SDK callback boundary as base64 ASCII before one strict UTF-8 decode, then the wrapper removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in; empty names, `=`, and NUL framing violations reject before launch. E2B's fixed command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting. +- **Environment boundary** — one trusted control-shell probe transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting. - **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle. -- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session before settlement; zombie-only groups are already quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`. +- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; zombie-only groups are already quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`. - **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable. The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, and `kill`. A custom template must retain compatible commands and E2B PTY support. @@ -30,6 +30,8 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged. - **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them. - **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep. +- **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. +- **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. In a reconnected sandbox, a same-UID untrusted process could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive or a hardened template to close the gap. - **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`. - **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence. - **Linux utility and E2B transport semantics are assumed** — there is no Windows, arbitrary-template, escaped-session recovery, or network-partition fidelity layer. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index e3e8368534..b9180c0c06 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -9,9 +9,9 @@ - **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 - **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。 - **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。 -- **环境边界**:沙箱命令环境会先以 base64 ASCII 跨越 SDK 回调边界,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,E2B 固定的命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 才会接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 +- **环境边界**:一次受信任的控制 shell 探测会以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 才会接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 - **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。 -- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 +- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 - **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。 基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`base64`、`chmod`、`tee`、`head`、`rm` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。 @@ -30,6 +30,8 @@ - **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。 - **重新连接不会重建句柄**:保留沙箱后,远程 PID/状态/spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。 - **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。 +- **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 +- **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。在重新连接的沙箱中,一个同 UID 的不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语或经加固的模板才能弥合该缺口。 - **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。 - **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。 - **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。 diff --git a/packages/e2b/subprocess-e2b/src/environment.ts b/packages/e2b/subprocess-e2b/src/environment.ts index 5322491261..907810a1d7 100644 --- a/packages/e2b/subprocess-e2b/src/environment.ts +++ b/packages/e2b/subprocess-e2b/src/environment.ts @@ -26,6 +26,8 @@ function remoteEnvironmentEntries(raw: string): Array * @returns the complete NUL-delimited UTF-8 environment. */ export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise { + // TODO(e2b-replace-environment): Remove this ambient probe when E2B can start + // a command with a replacement environment instead of merged overrides. const result = await sandbox.commands.run( 'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0', { envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) }, diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 7318dfde95..990ab20a62 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -61,8 +61,7 @@ export class E2BSubprocessService extends SubprocessService { })) } for (const terminal of terminals) { - terminal.terminate() - pending.push(terminal.waitForExit().then(() => { this.terminals.delete(terminal) })) + pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) })) } for (const cleanup of failedTerminalSetupCleanups) { pending.push(cleanup().then(() => { this.failedTerminalSetupCleanups.delete(cleanup) })) @@ -125,7 +124,9 @@ export class E2BSubprocessService extends SubprocessService { await handle.waitForExit() this.live.delete(handle) } - void handle.done.then(release, release).catch(() => {}) + void handle.done.then(release, release).catch((_automaticReleaseFailure: unknown) => { + // Retain the handle so service disposal can retry its cleanup transaction. + }) return handle } @@ -158,16 +159,17 @@ export class E2BSubprocessService extends SubprocessService { ) this.terminals.add(terminal) if (this.isDisposing()) { - terminal.terminate() - await terminal.waitForExit() + await terminal.terminate() this.terminals.delete(terminal) throw new Error('subprocess-e2b: service disposed during terminal setup') } const release = async (): Promise => { - await terminal.waitForExit() + await terminal.terminate() this.terminals.delete(terminal) } - void terminal.done.then(release, release).catch(() => {}) + void terminal.done.then(release, release).catch((_automaticReleaseFailure: unknown) => { + // Retain the terminal so service disposal can retry its cleanup transaction. + }) return terminal } finally { this.terminalSetups.delete(setup.promise) diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 6b17f3b682..02e1583e56 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -726,7 +726,9 @@ export class E2BSubprocessHandle implements SubprocessHandle { // A spill mode is a collect mode, so construction always created its reader. const size = (reader as E2BOutputReader).size if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) { - removals.push(sandbox.files.remove(path).catch(() => {})) + removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => { + // The command outcome is authoritative; a retained sandbox tolerates private residue. + })) } } collect(this.spec.stdio.stdout, this.stdoutReader, this.paths.stdout) diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index ff8af60da9..6c4b23d1c1 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -12,7 +12,6 @@ import { quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b' -import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess' import type { SubprocessOutcome, SubprocessTerminalForeground, @@ -345,7 +344,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { readonly done: Promise private topLevelExited = false - private readonly lifecycle: SubprocessTerminalLifecycle + private cleanup: Promise | undefined private terminationSignal: NodeJS.Signals | null = null constructor( @@ -357,21 +356,17 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { private readonly controlEnvs: Record, private readonly stateDir: string, private readonly graceMs: number, - signal?: AbortSignal, ) { this.pid = handle.pid this.done = this.waitForCommand() - this.lifecycle = new SubprocessTerminalLifecycle({ - done: this.done, - cleanup: () => this.closeOnce(), - signal, - }) } + // TODO(e2b-pgid-identity): Replace retained numeric PTY/session ids when E2B + // exposes identity-bound input, foreground-signal, and cleanup operations. /** @inheritdoc */ - async write(data: Uint8Array): Promise { + async write(data: string): Promise { if (this.topLevelExited) throw new Error('terminal process has exited') - await this.sandbox.pty.sendInput(this.pid, data) + await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8')) } /** @inheritdoc */ @@ -413,13 +408,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { } /** @inheritdoc */ - terminate(): void { - this.lifecycle.terminate() - } - - /** @inheritdoc */ - async waitForExit(signal?: AbortSignal): Promise { - return await this.lifecycle.waitForExit(signal) + terminate(): Promise { + if (this.cleanup !== undefined) return this.cleanup + const cleanup = this.closeOnce() + this.cleanup = cleanup + void cleanup.catch((_cleanupFailure: unknown) => { + this.cleanup = undefined + }) + return cleanup } private async waitForCommand(): Promise { @@ -474,7 +470,11 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { } catch (error: unknown) { if (!(error instanceof SandboxNotFoundError)) throw error } - await this.sandbox.files.remove(this.stateDir).catch(() => {}) + try { + await this.sandbox.files.remove(this.stateDir) + } catch (_adapterPrivateStateRemovalFailure) { + // The terminal is quiescent; a retained sandbox tolerates private residue. + } } } @@ -558,7 +558,6 @@ export async function spawnE2BTerminal( controlEnvs, stateDir, spec.graceMs, - spec.signal, ) } catch (error: unknown) { output.destroy() diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 8ad0eb1dde..6c3ef477af 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -1,6 +1,5 @@ import { Buffer } from 'node:buffer' import { once } from 'node:events' -import { PassThrough } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { @@ -14,7 +13,7 @@ import { import type E2BSandboxService from '@deepseek-ai/dsh-e2b' import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b' -import { E2BTerminalHandle, spawnE2BTerminal } from '../src/terminal.ts' +import { spawnE2BTerminal } from '../src/terminal.ts' function commandError(exitCode: number): CommandExitError { return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` }) @@ -298,20 +297,20 @@ describe('E2B terminal allocation', () => { await fake.createOptions?.onData(Buffer.from('late bootstrap callback')) expect(output).toBe('requested-shell$ ') - await terminal.write(Buffer.from('echo ok\r')) + await terminal.write('echo ok\r') expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r') await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false }) await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456) expect(fake.commands).toContain('kill -INT -- -456') - terminal.terminate() + const terminated = terminal.terminate() await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminated expect(fake.handle.disconnects).toBe(1) expect(fake.removed).toContain('/runtime/terminal-one') }) - it('inherits only safe ambient values and binds live abort to terminal cleanup', async () => { + it('inherits only safe ambient values and limits the allocation signal to setup', async () => { const fake = new FakeTerminalSandbox() const controller = new AbortController() const terminal = await spawnE2BTerminal( @@ -325,9 +324,10 @@ describe('E2B terminal allocation', () => { expect(environment).not.toContain('DSH_STALE') controller.abort(new Error('stop')) + await terminal.write('still live\r') + expect(fake.inputs.at(-1)?.data.toString()).toBe('still live\r') + await terminal.terminate() await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) - await expect(terminal.waitForExit(controller.signal)).resolves.toBe(false) - await expect(terminal.waitForExit()).resolves.toBe(true) }) it('publishes the PTY handle before honoring allocation cancellation', async () => { @@ -553,31 +553,11 @@ describe('E2B terminal lifecycle', () => { fake.handle.succeed(7) await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null }) await ended - await expect(terminal.waitForExit()).resolves.toBe(true) - await expect(terminal.write(Buffer.from('late'))).rejects.toThrow('exited') + await expect(terminal.write('late')).rejects.toThrow('exited') fake.foregroundFailure = commandError(1) await expect(terminal.inspectForeground()).resolves.toBeUndefined() await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('cannot resolve foreground process group') - }) - - it('starts cleanup when the lifetime signal is already aborted at handle publication', async () => { - const fake = new FakeTerminalSandbox() - const controller = new AbortController() - controller.abort(new Error('publication cancelled')) - const terminal = new E2BTerminalHandle( - fake.sandbox, - fake.handle.asHandle(), - new PassThrough(), - fake.handle.wait(), - 123, - { TERM: 'dumb' }, - '/runtime/pre-aborted', - 1, - controller.signal, - ) - - await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() }) it.each([ @@ -590,18 +570,7 @@ describe('E2B terminal lifecycle', () => { const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/exit-${exitCode}`) fake.handle.fail(exitCode) await expect(terminal.done).resolves.toEqual(expected) - await expect(terminal.waitForExit(new AbortController().signal)).resolves.toBe(true) - }) - - it('lets an early quiescence observer follow a transport rejection', async () => { - const fake = new FakeTerminalSandbox() - fake.groups = [] - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/early-observer') - terminal.output.on('error', () => {}) - const quiescence = terminal.waitForExit() - fake.handle.crash(new Error('transport failed')) - await expect(terminal.done).rejects.toThrow('transport failed') - await expect(quiescence).resolves.toBe(true) + await terminal.terminate() }) it('treats a terminal session containing only zombies as quiescent', async () => { @@ -612,7 +581,7 @@ describe('E2B terminal lifecycle', () => { fake.handle.succeed(0) await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() expect(fake.commands).toContain( "set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'", ) @@ -625,7 +594,7 @@ describe('E2B terminal lifecycle', () => { fake.handle.succeed(0) await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() }) it('treats sandbox disappearance during PTY kill as quiescent', async () => { @@ -635,8 +604,7 @@ describe('E2B terminal lifecycle', () => { fake.ptyKillError = new SandboxNotFoundError('sandbox expired') const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill') - terminal.terminate() - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() expect(fake.ptyKills).toBe(1) }) @@ -647,8 +615,11 @@ describe('E2B terminal lifecycle', () => { fake.ptyKillError = new Error('PTY kill transport failed') const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill') - terminal.terminate() - await expect(terminal.waitForExit()).rejects.toThrow('PTY kill transport failed') + await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed') + fake.ptyKillError = undefined + fake.handle.succeed(0) + await terminal.done + await terminal.terminate() }) it.each([ @@ -661,8 +632,8 @@ describe('E2B terminal lifecycle', () => { fake.groups = [] fake.handle.succeed(0) - if (accepted) await expect(terminal.waitForExit()).resolves.toBe(true) - else await expect(terminal.waitForExit()).rejects.toThrow('disconnect failed') + if (accepted) await expect(terminal.terminate()).resolves.toBeUndefined() + else await expect(terminal.terminate()).rejects.toThrow('disconnect failed') }) it('rejects killing the terminal shell and propagates live foreground failures', async () => { @@ -677,23 +648,17 @@ describe('E2B terminal lifecycle', () => { fake.foregroundFailure = commandError(2) await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError) fake.clearOnTerm = true - terminal.terminate() - await terminal.waitForExit() + await terminal.terminate() }) - it('escalates surviving process groups and bounds an observing wait', async () => { + it('escalates surviving process groups', async () => { const fake = new FakeTerminalSandbox() fake.groups = [123, 456] fake.clearOnTerm = false const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/escalate') - const controller = new AbortController() - const observing = terminal.waitForExit(controller.signal) - controller.abort() - await expect(observing).resolves.toBe(false) - - terminal.terminate() + const terminating = terminal.terminate() await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminating expect(fake.commands).toContain('kill -TERM -- -123 -456') expect(fake.commands).toContain('kill -KILL -- -123 -456') }) @@ -702,49 +667,31 @@ describe('E2B terminal lifecycle', () => { const fake = new FakeTerminalSandbox() fake.groups = [1] const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry') - terminal.terminate() - await expect(terminal.waitForExit(new AbortController().signal)).rejects.toThrow('unsafe process group 1') + await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1') fake.groups = [] fake.handle.succeed(0) await terminal.done - terminal.terminate() - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() }) it('propagates a process-group signalling transport failure before retry', async () => { const fake = new FakeTerminalSandbox() fake.termFailure = new Error('signal transport failed') const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure') - terminal.terminate() - await expect(terminal.waitForExit()).rejects.toThrow('signal transport failed') + await expect(terminal.terminate()).rejects.toThrow('signal transport failed') fake.groups = [] fake.handle.succeed(0) await terminal.done - terminal.terminate() - await expect(terminal.waitForExit()).resolves.toBe(true) + await terminal.terminate() const alreadyExited = new FakeTerminalSandbox() alreadyExited.termFailure = commandError(1) const tolerant = await spawnE2BTerminal(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited') - tolerant.terminate() + const tolerantTermination = tolerant.terminate() await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) - await expect(tolerant.waitForExit()).resolves.toBe(true) - }) - - it('normalizes a non-Error cleanup rejection for an observing wait', async () => { - const fake = new FakeTerminalSandbox() - const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/non-error-cleanup') - fake.commandFailure = 'cleanup transport gone' - terminal.terminate() - await expect(terminal.waitForExit(new AbortController().signal)).rejects.toThrow('cleanup transport gone') - - fake.groups = [] - fake.handle.succeed(0) - await terminal.done - terminal.terminate() - await expect(terminal.waitForExit()).resolves.toBe(true) + await tolerantTermination }) it('keeps command rejection authoritative while cleanup is already waiting', async () => { @@ -753,11 +700,11 @@ describe('E2B terminal lifecycle', () => { fake.removeError = new Error('private state already gone') const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/reject-during-cleanup') terminal.output.on('error', () => {}) - terminal.terminate() + const cleanup = terminal.terminate() await Promise.resolve() fake.handle.crash(new Error('command transport failed')) await expect(terminal.done).rejects.toThrow('command transport failed') - await expect(terminal.waitForExit()).resolves.toBe(true) + await cleanup }) it('keeps a late command rejection authoritative after PTY kill', async () => { @@ -766,12 +713,12 @@ describe('E2B terminal lifecycle', () => { fake.settleOnPtyKill = false const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill') terminal.output.on('error', () => {}) - terminal.terminate() + const cleanup = terminal.terminate() while (fake.ptyKills === 0) await new Promise(resolve => setTimeout(resolve, 0)) await Promise.resolve() fake.handle.crash(new Error('late command transport failed')) await expect(terminal.done).rejects.toThrow('late command transport failed') - await expect(terminal.waitForExit()).resolves.toBe(true) + await cleanup }) it('reports surviving groups, a surviving top-level pid, and transport failure', async () => { @@ -779,15 +726,13 @@ describe('E2B terminal lifecycle', () => { survivor.clearOnTerm = false survivor.clearOnKill = false const terminal = await spawnE2BTerminal(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor') - terminal.terminate() - await expect(terminal.waitForExit()).rejects.toThrow('surviving process groups: 123') + await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123') const livePid = new FakeTerminalSandbox() livePid.groups = [] livePid.settleOnPtyKill = false const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid') - live.terminate() - await expect(live.waitForExit()).rejects.toThrow('surviving pid: 123') + await expect(live.terminate()).rejects.toThrow('surviving pid: 123') livePid.handle.succeed(0) await live.done @@ -798,7 +743,7 @@ describe('E2B terminal lifecycle', () => { crashed.handle.crash('transport gone') await expect(failed.done).rejects.toEqual('transport gone') await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }]) - await expect(failed.waitForExit()).resolves.toBe(true) + await failed.terminate() }) }) @@ -943,7 +888,7 @@ describe('E2B subprocess terminal service', () => { const terminal = await ctx.subprocess.spawnTerminal(spec()) fake.handle.succeed(0) await terminal.done - await terminal.waitForExit() + await terminal.terminate() const signals = fake.commands.filter(command => command.startsWith('kill -')).length await fiber.dispose() expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals) @@ -961,6 +906,6 @@ describe('E2B subprocess terminal service', () => { fake.groups = [] await fiber.dispose() - await expect(terminal.waitForExit()).resolves.toBe(true) + await expect(terminal.terminate()).resolves.toBeUndefined() }) }) diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index ae44721735..f4719447aa 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/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/fs/README.md -README.md: 96bbe6c95bd2aca7e66cf2d57abb3f056cd19390 -README.zh.md: 7f091b4f0955b847d21b8b3423dab421e610cda3 +README.md: b15012e882b60847e1ad22edf08d1202ba64fe5b +README.zh.md: 628f6c74894bc67559d49f7cf5d1378d0ece2382 diff --git a/packages/fs/README.md b/packages/fs/README.md index 96bbe6c95b..b15012e882 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -14,7 +14,7 @@ The filesystem stack: a provider seam (execution-world paths, bounded text IO, a | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote runtime shared with the E2B subprocess provider ([POC decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote execution world shared with the E2B subprocess provider ([decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). ## No timeouts on file IO diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index 7f091b4f09..628f6c7489 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -14,7 +14,7 @@ | `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) | | `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | -接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程运行时中([POC 决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 +接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程执行世界中([决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 ## 文件 I/O 不设超时