diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml index fc187ec47b..b5e8e6e47c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md -2026-07-27-e2b-remote-runtime-poc.md: df0d6be97c502a9afe1a7aff3656a7567d302704 -2026-07-27-e2b-remote-runtime-poc.zh.md: 111d7aa63f8f7e51a82cfa0e42c489901f2b88e4 +2026-07-27-e2b-remote-runtime-poc.md: 029950cb2dae47bcf1ce819156135e58d46a4bb6 +2026-07-27-e2b-remote-runtime-poc.zh.md: 4029cc85d206e0dd0b7dbd91ec03674062df8531 diff --git a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md index df0d6be97c..029950cb2d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md @@ -28,7 +28,7 @@ E2B owns the mutable filesystem, managed command and Bash processes, terminal al 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. `dsh-subprocess-e2b` consumes E2B's byte PTY callback directly, retains terminal send identity across asynchronous foreground-group lookup, and owns whole-session cleanup. `dsh-fs-e2b` performs bounded source reads through a dependency-free helper that walks no-follow directory descriptors beneath the canonical target. Generic Code Runtime keeps its controller/worker protocol on validated ASCII/base64 frames and kills the provider-owned process group before inherited pipes drain. Generic LSP uses UTF-8 JSON over command pipes; E2B's decoded callback transport is not an arbitrary binary channel. +The fundamental adapters carry the substrate-specific mechanics. `dsh-subprocess-e2b` consumes E2B's byte PTY callback directly, retains terminal send identity across asynchronous foreground-group lookup, and owns whole-session cleanup. For ordinary command pipes, its remote wrapper branches raw spill bytes before framing each live chunk as newline-delimited base64 ASCII; the host restores raw bytes across arbitrary decoded SDK callback boundaries. `dsh-fs-e2b` performs bounded source reads through a dependency-free helper that walks no-follow directory descriptors beneath the canonical target. Generic Code Runtime keeps its controller/worker protocol on validated ASCII/base64 frames and kills the provider-owned process group before inherited pipes drain. Generic LSP uses 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. @@ -38,7 +38,7 @@ The POC has no session-persistence backend, template builder, volume, snapshot, Focused package suites pin owner lifecycle cleanup, filesystem paths/containment/bounded descriptor reads and commit metadata, subprocess executable lookup/process groups/publication rollback, terminal byte I/O/signal identity/default-environment scrubbing/session cleanup, output limits, abort ordering, disposal to quiescence, and package-owned invariant registrations. The generic PTY, LSP, and subprocess Code Runtime suites pin their provider-neutral readiness, cross-namespace `processId`, binding bridge, descriptor isolation, hostile traffic, and worker/descendant cleanup behavior. -A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, process-publication rollback, bounded spill output, PTY default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports. +A credential-gated Loader composition creates real E2B sandboxes and exercises FS-to-Bash and Bash-to-FS visibility, process-publication rollback, byte-split UTF-8 command output, bounded raw spill output, PTY default-secret scrubbing, stale-interrupt identity, and process-tree cleanup, parent-swap-safe bounded LSP source reads, Code Runtime host bindings, descriptor-isolated output accounting, descendant-held pipe cleanup, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same composition runs through source imports and built package exports. ## Alternatives considered @@ -62,4 +62,4 @@ A credential-gated Loader composition creates real E2B sandboxes and exercises F 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 complete command output in SDK memory, command callbacks are text-decoded rather than arbitrary binary streams, exact terminal stdin-wait inspection is unavailable, signal attribution is partly inferred, and reconnect cannot restore handles or protocol state. PTY uses E2B's byte API; Code Runtime uses validated ASCII/base64 framing; the exercised LSP path carries valid UTF-8 JSON. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a managed process group or terminal session does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions. +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 index 111d7aa63f..4029cc85d2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.zh.md @@ -28,7 +28,7 @@ E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终 宿主拥有 Cordis 与插件对象、agent loop、agent/会话/goal 状态、会话日志及持久化、LLM(大语言模型)调用、提示词与工具、权限决策、skill(技能)、subagent 编排、PTY 缓冲与就绪状态、LSP JSON-RPC id/队列/协议状态、Code Runtime 类型剥离/输出计量/绑定分发,以及 E2B SDK/网络编排。该 overlay 不会上传、挂载或同步宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。 -基础适配器承载基底专用机制。`dsh-subprocess-e2b` 直接消费 E2B 的字节 PTY 回调,在异步查找前台进程组的过程中保留终端发送身份,并负责全会话清理。`dsh-fs-e2b` 通过无依赖辅助程序执行有界源码读取,该程序会在规范化目标下逐级遍历不跟随符号链接的目录描述符。通用 Code Runtime 通过经过验证的 ASCII/base64 帧承载 controller/worker 协议,并在继承的管道排空前终止提供方拥有的进程组。通用 LSP 通过命令管道使用 UTF-8 JSON;E2B 的已解码回调传输并非任意二进制通道。 +基础适配器承载基底专用机制。`dsh-subprocess-e2b` 直接消费 E2B 的字节 PTY 回调,在异步查找前台进程组的过程中保留终端发送身份,并负责全会话清理。对于普通命令管道,其远程包装层会先分流原始 spill 字节,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意已解码 SDK 回调边界恢复原始字节。`dsh-fs-e2b` 通过无依赖辅助程序执行有界源码读取,该程序会在规范化目标下逐级遍历不跟随符号链接的目录描述符。通用 Code Runtime 通过经过验证的 ASCII/base64 帧承载 controller/worker 协议,并在继承的管道排空前终止提供方拥有的进程组。通用 LSP 通过命令管道使用字节保真的 UTF-8 JSON。 保留沙箱只会保存远程文件与未受管的远程状态。重新连接不会重建宿主 PTY 会话、缓冲、进程句柄、LSP 连接或请求、代码 worker、绑定调用、定时器、输出游标或锁。受管进程组会在所属提供方 dispose(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。 @@ -38,7 +38,7 @@ E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终 聚焦包测试套件固定所有者生命周期清理、文件系统路径/containment/有界描述符读取与提交元数据、子进程可执行文件查找/进程组/发布回滚、终端字节 I/O/信号身份/默认环境清理/会话清理、输出上限、中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。通用 PTY、LSP 与子进程 Code Runtime 测试套件固定其提供方无关的就绪判定、跨命名空间 `processId`、绑定桥接、描述符隔离、恶意通信,以及 worker/后代进程清理行为。 -凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、进程发布回滚、有界 spill 输出、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 +凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 输出、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 ## 曾考虑的替代方案 @@ -62,4 +62,4 @@ E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终 这个由 3 个包组成的组合证明,文件系统与进程管理这两个提供方 seam 足以把 agent 的可变 coding 环境移出宿主,而无需改变循环、上层功能实现或面向模型的工具包。Bash、PTY、LSP 与 Code Runtime 的修复仍与提供方无关。`sandboxId` 与 `pause`/`leave` 允许实验手动保留远程文件,演示仍以 `kill` 作为清理策略。 -这些适配器并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,命令回调传递的是已解码文本而非任意二进制流,无法精确检查终端 stdin 等待状态,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 API;Code Runtime 使用经过验证的 ASCII/base64 分帧;已演练的 LSP 路径承载有效的 UTF-8 JSON。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离受管理进程组或终端会话的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。 +这些适配器并不能对所有消费方与本地后端互换:远程启动无法同步公开 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 aeb447b0e8..7c5fce5535 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -72,6 +72,19 @@ try { ].every(entry => environmentLines.has(entry)) if (!explicitEnvironment) throw new Error(`E2B subprocess dropped an explicit environment entry: ${environmentText}`) + const splitUtf8Handle = ctx.subprocess.spawn({ + argv: ['bash', '-c', "printf '\\344'; sleep 0.05; printf '\\275'; sleep 0.05; printf '\\240'; sleep 0.05; printf '\\345'; sleep 0.05; printf '\\245'; sleep 0.05; printf '\\275'"], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 32 }, stderr: { maxBytes: 4_096 } }, + graceMs: 500, + env: {}, + }) + const splitUtf8Outcome = await splitUtf8Handle.done + const splitUtf8Output = splitUtf8Handle.collected.stdout?.readFrom(0).text + if (splitUtf8Outcome.exitCode !== 0 || splitUtf8Output !== '你好') { + throw new Error(`E2B subprocess corrupted split UTF-8 output: ${JSON.stringify({ splitUtf8Outcome, splitUtf8Output })}`) + } + const remoteFiles = sandbox.files as unknown as { read(path: string, options?: unknown): Promise } @@ -370,6 +383,7 @@ try { bashRead: bashRead.stdout.text, fsRead, explicitEnvironment, + splitUtf8Output, publicationRollback, spill: { liveBytes: liveSpillBytes, outcome: spillOutcome, read: spillRead }, hover, diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index 8ffb6e981f..229a9e9343 100644 --- a/packages/e2b/e2b/README.i18n.yaml +++ b/packages/e2b/e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md -README.md: 00264b8f0b03e4af8512025322fe3e457e7b6b9b -README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994 +README.md: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2 +README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671 diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 00264b8f0b..56606a22c3 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -30,7 +30,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, then sets that directory to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup. -Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A newly created sandbox is killed when initial directory setup fails; a reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it. +Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` means a kill-on-timeout sandbox is already quiescent; every other disposition failure rejects teardown. A newly created sandbox is killed when initial directory setup fails; a reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it. `pause` and `leave` retain remote filesystem and adapter artifacts for a later `sandboxId` connection, but a later harness process receives only a new SDK handle. The subprocess service still fulfills its seam contract by terminating managed groups before owner disposal; neither disposition recovers prior process objects, output cursors, or in-memory adapter locks. diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index 93fad661de..2e3213e431 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -30,7 +30,7 @@ 构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 -资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 +资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 `pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 184c49f811..96da912033 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -7,7 +7,7 @@ import { posix } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import { Sandbox } from 'e2b' +import { Sandbox, SandboxNotFoundError } from 'e2b' import type { Branded } from '@deepseek-ai/dsh-brand' export { @@ -159,16 +159,22 @@ export class E2BSandboxService extends Service { // there is no remote resource for teardown to own. return } - switch (this.config.onDispose) { - case 'kill': - await sandbox.kill() - return - case 'pause': { - await sandbox.pause() - return + try { + switch (this.config.onDispose) { + case 'kill': + await sandbox.kill() + return + case 'pause': { + await sandbox.pause() + return + } + case 'leave': + return } - case 'leave': - return + } catch (error: unknown) { + // A kill-on-timeout sandbox is already quiescent; every other disposal + // failure still reports that the configured final disposition is unknown. + if (!(error instanceof SandboxNotFoundError)) throw error } }, 'e2b sandbox teardown') } diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index e87627af8e..4e7be57c45 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -43,6 +43,22 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { const node = await ctx.subprocess.resolveExecutable('node') const relativeNodePath = posix.relative(ctx.subprocess.cwd, posix.dirname(node)) || '.' await expect(ctx.subprocess.resolveExecutable('node', { PATH: relativeNodePath })).resolves.toBe(node) + const environmentProbe = ctx.subprocess.spawn({ + argv: ['/bin/bash', '-c', [ + 'dsh_leak=0', + 'for dsh_pid in "$PPID" $(ps -o pid= --ppid "$PPID"); do', + ' [[ "$dsh_pid" == "$$" ]] && continue', + ' if tr "\\0" "\\n" < "/proc/$dsh_pid/environ" 2>/dev/null | grep -Fqx "NPM_TOKEN=sentinel-secret"; then dsh_leak=1; fi', + 'done', + 'printf "DIRECT=<%s> LEAK=<%s>\\n" "${NPM_TOKEN-}" "$dsh_leak"', + ].join('\n')], + cwd: '/home/user', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } }, + graceMs: 500, + env: {}, + }) + await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null }) + expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n') const ownerId = SessionId('e2b-pty-env-owner') const owner: Agent = { id: ownerId, @@ -106,6 +122,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { bashRead: 'written-by-fs\n', fsRead: 'written-by-bash\n', explicitEnvironment: true, + splitUtf8Output: '你好', publicationRollback: true, spill: { liveBytes: 6, diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index c0770f5917..bb96ca806a 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import type { Sandbox as SandboxType } from 'e2b' import E2BSandboxService, { E2BSandboxId, + SandboxNotFoundError, quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' import * as E2BInvariant from '../src/invariant.ts' @@ -140,6 +141,32 @@ describe('E2BSandboxService', () => { expect(fixture.pause).toHaveBeenCalledOnce() }) + it('treats a timeout-killed sandbox as already quiescent during disposal', async () => { + const fixture = fakeSandbox() + fixture.pause.mockRejectedValue(new SandboxNotFoundError('sandbox expired')) + sdk.create.mockResolvedValue(fixture.sandbox) + const ctx = new Context() + const fiber = await ctx.plugin(E2BSandboxService, { + apiKey: 'test-key', + onTimeout: 'kill', + onDispose: 'pause', + }) + await ctx.e2b.getSandbox() + await expect(fiber.dispose()).resolves.toBeUndefined() + expect(fixture.pause).toHaveBeenCalledOnce() + }) + + it('does not classify other disposal failures as an already-gone sandbox', async () => { + const fixture = fakeSandbox() + fixture.kill.mockRejectedValue(new Error('disposition unknown')) + sdk.create.mockResolvedValue(fixture.sandbox) + const ctx = new Context() + const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' }) + await ctx.e2b.getSandbox() + await expect(fiber.dispose()).resolves.toBeUndefined() + expect(fixture.kill).toHaveBeenCalledOnce() + }) + it('reconnects without applying creation lifecycle options and can leave state running', async () => { const fixture = fakeSandbox('existing') sdk.connect.mockResolvedValue(fixture.sandbox) diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index dca7b016c2..33a36141d0 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: e0805345e708c67c6de721d95641e2366a7f990e -README.zh.md: f83969c794e50f0658aa86218a0f5f3c89d53b56 +README.md: 58f13b264324d2168d4bc264b60100cdafa0a366 +README.zh.md: d547e4e8d66f629edcef753651180fe1806ad9bf diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index e0805345e7..58f13b2643 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -10,10 +10,10 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr - **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides. - **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback. If publication fails, the SDK PID remains the provisional `exec setsid` group id; rollback kills and verifies that group before startup rejects. Service disposal terminates and joins every retained handle before the sandbox owner disposes. - **Environment boundary** — the wrapper starts from the sandbox command environment, removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names, then restores every `spec.env` entry as an explicit caller opt-in. Host ambient variables never enter the sandbox implicitly. -- **Stdio projection** — pipe mode forwards E2B callbacks into host Node streams; inherit mode forwards them to the harness process streams; collect mode retains a bounded host tail with offset reads. Optional complete spill files are written remotely and advertised only while within their cap. Batch and streaming stdin use the SDK handle. +- **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. Batch and streaming stdin use the SDK handle. - **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every group in the remote terminal session before settlement. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`. -The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands and E2B PTY support. +The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands and E2B PTY support. ## Model Experience @@ -25,11 +25,10 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate even when this adapter exposes bounded tails, so the subprocess seam's normal host-memory bound is not achieved. -- **Command-pipe output is text-decoded by the SDK** — valid UTF-8 protocol traffic, including the exercised LSP composition and Code Runtime's ASCII/base64 frames, is supported; arbitrary binary protocols and invalid UTF-8 are not byte-faithful. +- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream. - **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged. - **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them. - **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep. -- **Signal attribution is inferred** — when termination was requested and E2B reports a nonzero exit code, the adapter reports the last requested signal because the SDK result does not identify the terminating signal. +- **E2B exposes no signal fact** — only an adapter-requested `SIGTERM` or `SIGKILL` is reported as a signal; 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 f83969c794..d547e4e8d6 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -10,10 +10,10 @@ - **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。 - **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。如果发布失败,SDK PID 仍为临时的 `exec setsid` 进程组 ID;回滚会终止并验证该进程组,随后启动操作才会以拒绝结束。服务 dispose(资源释放)会在沙箱所有者释放前终止并等待每个保留句柄退出。 - **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。 -- **stdio 投影**:pipe 模式把 E2B 回调转发到宿主 Node 流;inherit 模式把回调转发到 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。 +- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。批量 stdin 和流式 stdin 都使用 SDK 句柄。 - **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中的每个进程组。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。 -基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`chmod`、`tee`、`head` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。 +基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`chmod`、`tee`、`head` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。 ## 模型体验 @@ -25,11 +25,10 @@ ## 已知限制与延后工作 -- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会持续累积,因此无法达到进程管理 seam 通常提供的宿主内存边界。 -- **命令管道输出由 SDK 解码为文本**:支持有效的 UTF-8 协议流量,包括已经过测试的 LSP 组合与 Code Runtime 的 ASCII/base64 帧;任意二进制协议和无效 UTF-8 不具备字节保真。 +- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。 - **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。 - **重新连接不会重建句柄**:保留沙箱后,远程 PID/状态/spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。 - **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。 -- **信号归因依靠推断**:如果已经请求终止,而 E2B 报告非零退出码,适配器会报告最后请求的信号,因为 SDK 结果不标识终止信号。 +- **E2B 不公开信号事实**:只有适配器请求的 `SIGTERM` 或 `SIGKILL` 会报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。 - **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。 - **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。 diff --git a/packages/e2b/subprocess-e2b/src/output.ts b/packages/e2b/subprocess-e2b/src/output.ts index b6551983a0..6cb61e4f09 100644 --- a/packages/e2b/subprocess-e2b/src/output.ts +++ b/packages/e2b/subprocess-e2b/src/output.ts @@ -3,6 +3,64 @@ import { Buffer } from 'node:buffer' import type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +const BASE64_TEXT = /^[A-Za-z0-9+/]+={0,2}$/u + +/** Reserved non-base64 frame proving that one remote encoder reached clean EOF. */ +export const E2B_OUTPUT_COMPLETE_FRAME = '!dsh-e2b-output-complete!' + +/** Incrementally decode newline-delimited base64 frames emitted by one remote encoder. */ +export class E2BBase64Decoder { + private pending = '' + private complete = false + + /** + * Decode every complete newline-delimited frame in one arbitrarily split SDK callback. + * @param text - ASCII base64 frames from E2B's decoded callback. + * @returns the complete raw bytes made available by this callback. + */ + push(text: string): Buffer { + if (text.length === 0) return Buffer.alloc(0) + this.pending += text + const decoded: Buffer[] = [] + for (;;) { + const boundary = this.pending.indexOf('\n') + if (boundary < 0) break + const frame = this.pending.slice(0, boundary) + this.pending = this.pending.slice(boundary + 1) + if (frame === E2B_OUTPUT_COMPLETE_FRAME) { + if (this.complete) throw new Error('subprocess-e2b: duplicate output transport completion') + this.complete = true + continue + } + if (this.complete) throw new Error('subprocess-e2b: output transport continued after completion') + if (!BASE64_TEXT.test(frame)) { + throw new Error('subprocess-e2b: invalid base64 output transport') + } + const bytes = Buffer.from(frame, 'base64') + if (bytes.toString('base64') !== frame) { + throw new Error('subprocess-e2b: invalid base64 output transport') + } + decoded.push(bytes) + } + return Buffer.concat(decoded) + } + + /** + * Validate clean encoder completion, or discard an interrupted trailing frame after requested termination. + * @param requireComplete - Whether natural completion requires the reserved EOF frame. + */ + finish(requireComplete = true): void { + if (!requireComplete) { + this.pending = '' + return + } + if (this.pending.length > 0) { + throw new Error('subprocess-e2b: truncated base64 output transport') + } + if (!this.complete) throw new Error('subprocess-e2b: incomplete output transport') + } +} + /** Offset reader used for one collect-mode E2B stream. */ export class E2BOutputReader implements SubprocessOutputReader { private chunks: Buffer[] = [] @@ -27,12 +85,12 @@ export class E2BOutputReader implements SubprocessOutputReader { } /** - * Append one decoded SDK output event. - * @param text - Event text delivered by E2B. + * Append one byte-faithful decoded transport event. + * @param bytes - Raw command bytes recovered from the ASCII SDK transport. */ - push(text: string): void { - if (text.length === 0) return - const chunk = Buffer.from(text) + push(bytes: Uint8Array): void { + if (bytes.length === 0) return + const chunk = Buffer.from(bytes) this.totalBytes += chunk.length this.chunks.push(chunk) this.retainedBytes += chunk.length diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 3b9594a752..9eeee5d877 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -8,6 +8,7 @@ import { quoteE2BShellArg, } from '@deepseek-ai/dsh-e2b' import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b' +import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess' import type { SubprocessCollect, SubprocessHandle, @@ -16,9 +17,21 @@ import type { SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import type E2BSandboxService from '@deepseek-ai/dsh-e2b' -import { E2BOutputReader } from './output.ts' +import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts' const GROUP_POLL_MS = 20 +const OUTPUT_ENCODER_SOURCE = [ + '(async () => {', + ' for await (const chunk of process.stdin) {', + " if (!process.stdout.write(chunk.toString('base64') + '\\n')) {", + " await new Promise(resolve => process.stdout.once('drain', resolve))", + ' }', + ' }', + ` if (!process.stdout.write(${JSON.stringify(E2B_OUTPUT_COMPLETE_FRAME)} + '\\n')) {`, + " await new Promise(resolve => process.stdout.once('drain', resolve))", + ' }', + '})().catch(() => { process.exitCode = 1 })', +].join('\n') function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect { return mode !== 'pipe' && mode !== 'inherit' @@ -60,40 +73,65 @@ interface RemotePaths { stderr: string } -function explicitEnvironment(env: Readonly> | undefined): string { - return Object.entries(env ?? {}) - .map(([name, value]) => `${name}=${value}\0`) - .join('') +function remoteEnvironment(raw: string, explicit: Readonly> | undefined): string { + const environment = new Map() + for (const entry of raw.split('\0')) { + if (entry.length === 0) continue + const separator = entry.indexOf('=') + if (separator <= 0) continue + const name = entry.slice(0, separator) + if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue + environment.set(name, entry.slice(separator + 1)) + } + for (const [name, value] of Object.entries(explicit ?? {})) environment.set(name, value) + return [...environment].map(([name, value]) => `${name}=${value}\0`).join('') } function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string { + const encoder = `"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e ${quoteE2BShellArg(OUTPUT_ENCODER_SOURCE)}` const stdoutRedirect = hasSpill(spec.stdio.stdout) - ? `> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}))` - : '' + ? `> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}) | ${encoder} 2>/dev/null)` + : `> >(${encoder} 2>/dev/null)` const stderrRedirect = hasSpill(spec.stdio.stderr) - ? `2> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) >&2)` - : '' + ? `2> >("$dsh_e2b_tee" --output-error=warn-nopipe >("$dsh_e2b_head" -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) | ${encoder} >&2 2>/dev/null)` + : `2> >(${encoder} >&2 2>/dev/null)` const inner = [ 'set +e', 'umask 077', - 'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"', + 'dsh_e2b_env_bin=$1', + 'dsh_e2b_node=$2', + 'dsh_e2b_ps=$3', + 'dsh_e2b_tr=$4', + 'dsh_e2b_tee=$5', + 'dsh_e2b_head=$6', + 'shift 6', + 'dsh_e2b_pgid="$("$dsh_e2b_ps" -o pgid= -p "$$" | "$dsh_e2b_tr" -d " ")"', `printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`, - `mapfile -d '' -t dsh_e2b_explicit < ${quoteE2BShellArg(paths.environment)}`, + `mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`, `: > ${quoteE2BShellArg(paths.environment)}`, - 'dsh_e2b_env=()', - "while IFS= read -r -d '' dsh_e2b_entry; do", - ' dsh_e2b_name="${dsh_e2b_entry%%=*}"', - ' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac', - ' dsh_e2b_env+=("$dsh_e2b_entry")', - 'done < <(env -0)', - `env -i "\${dsh_e2b_env[@]}" "\${dsh_e2b_explicit[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(), + `"$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(), 'dsh_e2b_status=$?', 'wait', `printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`, 'exit "$dsh_e2b_status"', ].join('\n') const argv = spec.argv.map(quoteE2BShellArg).join(' ') - return `exec setsid --wait -- bash -c ${quoteE2BShellArg(inner)} dsh-e2b ${argv}` + const bootstrap = [ + `mapfile -d '' -t dsh_e2b_env < ${quoteE2BShellArg(paths.environment)}`, + 'dsh_e2b_env_bin="$(command -v env)"', + 'dsh_e2b_setsid="$(command -v setsid)"', + 'dsh_e2b_bash="$(command -v bash)"', + 'dsh_e2b_node="$(command -v node)"', + 'dsh_e2b_ps="$(command -v ps)"', + 'dsh_e2b_tr="$(command -v tr)"', + 'dsh_e2b_tee="$(command -v tee)"', + 'dsh_e2b_head="$(command -v head)"', + 'for dsh_e2b_tool in "$dsh_e2b_env_bin" "$dsh_e2b_setsid" "$dsh_e2b_bash" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head"; do', + ' [[ "$dsh_e2b_tool" == /* && -x "$dsh_e2b_tool" ]] || exit 125', + 'done', + `exec "$dsh_e2b_env_bin" -i "\${dsh_e2b_env[@]}" "$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c ${quoteE2BShellArg(inner)} dsh-e2b "$dsh_e2b_env_bin" "$dsh_e2b_node" "$dsh_e2b_ps" "$dsh_e2b_tr" "$dsh_e2b_tee" "$dsh_e2b_head" ${argv}`, + ].join('\n') + return bootstrap } function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } { @@ -128,10 +166,14 @@ export class E2BSubprocessHandle implements SubprocessHandle { readonly done: Promise private readonly readyState = Promise.withResolvers() + private readonly stdoutDecoder = new E2BBase64Decoder() + private readonly stderrDecoder = new E2BBase64Decoder() private readonly stdoutReader: E2BOutputReader | undefined private readonly stderrReader: E2BOutputReader | undefined private readonly paths: RemotePaths private remotePid = -1 + private commandHandle: CommandHandle | undefined + private outputTransportError: Error | undefined private terminationRequested = false private terminationSignal: NodeJS.Signals | null = null private termination: Promise | undefined @@ -195,7 +237,8 @@ export class E2BSubprocessHandle implements SubprocessHandle { try { handle = await this.readyForWait(signal) } catch { - return true + handle = this.commandHandle + if (handle === undefined) return true } if (handle === undefined) return false let sandbox: Sandbox @@ -205,7 +248,8 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (isAborted(signal)) return false throw error } - while (await this.groupAlive(sandbox, this.remotePid, signal)) { + const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid + while (await this.groupAlive(sandbox, processGroupId, signal)) { if (!await waitTick(signal)) return false } return !isAborted(signal) @@ -248,6 +292,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) { throw new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`) } + this.commandHandle = handle const completion = handle.wait() void completion.catch(() => {}) try { @@ -266,6 +311,10 @@ export class E2BSubprocessHandle implements SubprocessHandle { this.readyState.resolve(handle) await this.writeBatchStdin(handle) const outcome = await this.waitForCommand(completion) + if (this.outputTransportError !== undefined) throw this.outputTransportError + const requireCompleteOutput = this.terminationSignal === null + this.stdoutDecoder.finish(requireCompleteOutput) + this.stderrDecoder.finish(requireCompleteOutput) await this.finalizeSpills(sandbox) return outcome } catch (error: unknown) { @@ -279,12 +328,13 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async prepareState(sandbox: Sandbox): Promise { + const ambient = await sandbox.commands.run('env -0') await sandbox.files.makeDir(this.stateDir) await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`) const files = [ { path: this.paths.pid, data: '' }, { path: this.paths.status, data: '' }, - { path: this.paths.environment, data: explicitEnvironment(this.spec.env) }, + { path: this.paths.environment, data: remoteEnvironment(ambient.stdout, this.spec.env) }, ...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []), ...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []), ] @@ -303,25 +353,34 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async dispatchOutput(stream: 'stdout' | 'stderr', data: string): Promise { + let bytes: Buffer + try { + bytes = stream === 'stdout' ? this.stdoutDecoder.push(data) : this.stderrDecoder.push(data) + } catch (error: unknown) { + this.outputTransportError ??= asError(error) + const target = stream === 'stdout' ? this.stdout : this.stderr + target?.destroy(this.outputTransportError) + return + } try { if (stream === 'stdout') { - this.stdoutReader?.push(data) - await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, data) + this.stdoutReader?.push(bytes) + await this.writeOutput(this.stdout, this.spec.stdio.stdout === 'inherit' ? process.stdout : undefined, bytes) return } - this.stderrReader?.push(data) - await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, data) + this.stderrReader?.push(bytes) + await this.writeOutput(this.stderr, this.spec.stdio.stderr === 'inherit' ? process.stderr : undefined, bytes) } catch (error: unknown) { const target = stream === 'stdout' ? this.stdout : this.stderr target?.destroy(asError(error)) } } - private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: string): Promise { + private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise { const target = pipe ?? inherited if (target === undefined || data.length === 0) return if (target.destroyed) throw new Error('subprocess output stream is closed') - if (target.write(Buffer.from(data))) return + if (target.write(data)) return await new Promise((resolve, reject) => { const onDrain = (): void => { cleanup(); resolve() } const onError = (error: Error): void => { cleanup(); reject(error) } @@ -369,10 +428,10 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise { - // The background command begins with `exec setsid`, so E2B's command PID is - // the provisional group id even before the private publication file can be - // trusted. Kill that group before the SDK-PID fallback, then prove no group - // member survived before rejecting startup. + // The bootstrap ends in an exec chain through the scrubbed environment and + // `setsid`, so E2B's command PID is the provisional group id even before the + // private publication file can be trusted. Kill that group before the SDK-PID + // fallback, then prove no group member survived before rejecting startup. try { await this.signalGroup(sandbox, handle.pid, 'KILL') } finally { @@ -382,23 +441,25 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async terminateRemote(): Promise { - let handle: CommandHandle + let handle: CommandHandle | undefined try { handle = await this.readyState.promise } catch { - return + handle = this.commandHandle } + if (handle === undefined) return const sandbox = await this.runtime.getSandbox() + const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid this.terminationSignal = 'SIGTERM' - await this.signalGroup(sandbox, this.remotePid, 'TERM') + await this.signalGroup(sandbox, processGroupId, 'TERM') const deadline = Date.now() + this.spec.graceMs - while (Date.now() < deadline && await this.groupAlive(sandbox, this.remotePid)) { + while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) { await waitTick() } - if (!await this.groupAlive(sandbox, this.remotePid)) return + if (!await this.groupAlive(sandbox, processGroupId)) return this.terminationSignal = 'SIGKILL' try { - await this.signalGroup(sandbox, this.remotePid, 'KILL') + await this.signalGroup(sandbox, processGroupId, 'KILL') } finally { await handle.kill().catch(() => false) } diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 1a916c0fd5..565e406b97 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -1,7 +1,6 @@ /** E2B PTY allocation and process-session ownership for the subprocess seam. */ import { Buffer } from 'node:buffer' -import { constants } from 'node:os' import { PassThrough } from 'node:stream' import { posix } from 'node:path' import { @@ -53,13 +52,8 @@ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } -function commandSignal(exitCode: number): NodeJS.Signals | null { - const number = exitCode - 128 - if (number <= 0) return null - for (const [name, value] of Object.entries(constants.signals)) { - if (value === number) return name as NodeJS.Signals - } - return null +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) } function parsePositiveId(value: string, message: string): number { @@ -121,6 +115,132 @@ async function waitUntilReady( } } +async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise { + const result = await sandbox.commands.run( + `ps -eo sid=,pgid= | awk '$1 == ${sessionId} { print $2 }'`, + ) + const groups = new Set() + for (const raw of result.stdout.trim().split(/\s+/)) { + if (raw.length === 0) continue + const group = parsePositiveId( + raw, + `subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${sessionId}`, + ) + if (group <= 1) { + throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${sessionId}`) + } + groups.add(group) + } + return [...groups] +} + +async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | 'KILL'): Promise { + try { + await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`) + } catch (error: unknown) { + if (!(error instanceof CommandExitError)) throw error + } +} + +async function awaitSessionEmpty( + sandbox: Sandbox, + sessionId: number, + graceMs: number, + kill = false, +): Promise { + const deadline = Date.now() + graceMs + for (;;) { + const groups = await sessionProcessGroups(sandbox, sessionId) + if (groups.length === 0 || Date.now() >= deadline) return groups + if (kill) await signalGroups(sandbox, groups, 'KILL') + await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now()))) + } +} + +async function rollbackUnpublishedTerminal( + sandbox: Sandbox, + handle: CommandHandle, + completion: Promise, + graceMs: number, +): Promise { + let topLevelExited = false + void completion.then( + () => { topLevelExited = true }, + () => { topLevelExited = true }, + ) + const validPid = Number.isSafeInteger(handle.pid) && handle.pid > 1 + const attemptFailures: Error[] = [] + let sessionId: number | undefined + if (validPid) { + sessionId = handle.pid + try { + sessionId = await terminalSessionId(sandbox, handle.pid) + } catch (_sessionLookupFailure) { + // E2B's PTY leader is also the provisional POSIX session leader, so its + // PID remains usable after the setup lookup itself fails or is canceled. + } + try { + let groups = await sessionProcessGroups(sandbox, sessionId) + if (groups.length > 0) { + await signalGroups(sandbox, groups, 'TERM') + groups = await awaitSessionEmpty(sandbox, sessionId, graceMs) + } + if (groups.length > 0) { + await signalGroups(sandbox, groups, 'KILL') + await awaitSessionEmpty(sandbox, sessionId, graceMs, true) + } + } catch (error: unknown) { + attemptFailures.push(asError(error)) + } + } + // Completion can settle while any awaited provider cleanup above is running. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!topLevelExited) { + if (validPid) { + try { + await sandbox.pty.kill(handle.pid) + } catch (error: unknown) { + attemptFailures.push(asError(error)) + } + } + // The awaited PTY fallback can settle completion before the SDK fallback. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!topLevelExited) { + try { + await handle.kill() + } catch (error: unknown) { + attemptFailures.push(asError(error)) + } + } + await Promise.race([completion.catch(() => undefined), delay(graceMs)]) + } + const proofFailures: Error[] = [] + if (sessionId !== undefined) { + try { + const groups = await awaitSessionEmpty(sandbox, sessionId, graceMs, true) + if (groups.length > 0) { + proofFailures.push(new Error( + `subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`, + )) + } + } catch (error: unknown) { + proofFailures.push(asError(error)) + } + } + // The bounded completion race above updates this callback-owned state. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!topLevelExited) { + proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`)) + } + if (proofFailures.length > 0) { + throw new AggregateError( + [...attemptFailures, ...proofFailures], + 'subprocess-e2b: terminal setup rollback did not reach quiescence', + ) + } + await handle.disconnect() +} + /** One E2B PTY and all process groups in its remote process session. */ export class E2BTerminalHandle implements SubprocessTerminalHandle { readonly pid: number @@ -203,8 +323,9 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { return { exitCode: result.exitCode, signal: null } } catch (error: unknown) { if (error instanceof CommandExitError) { - const signal = this.terminationSignal ?? commandSignal(error.exitCode) - return signal === null ? { exitCode: error.exitCode, signal: null } : { exitCode: null, signal } + return this.terminationSignal === null + ? { exitCode: error.exitCode, signal: null } + : { exitCode: null, signal: this.terminationSignal } } this.output.destroy(error instanceof Error ? error : new Error(String(error))) throw error @@ -214,49 +335,12 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { } } - private async sessionProcessGroups(): Promise { - const result = await this.sandbox.commands.run( - `ps -eo sid=,pgid= | awk '$1 == ${this.sessionId} { print $2 }'`, - ) - const groups = new Set() - for (const raw of result.stdout.trim().split(/\s+/)) { - if (raw.length === 0) continue - const group = parsePositiveId( - raw, - `subprocess-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.sessionId}`, - ) - if (group <= 1) { - throw new Error(`subprocess-e2b: unsafe process group ${group} in terminal session ${this.sessionId}`) - } - groups.add(group) - } - return [...groups] - } - - private async signalGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise { - try { - await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`) - } catch (error: unknown) { - if (!(error instanceof CommandExitError)) throw error - } - } - - private async awaitSessionEmpty(kill = false): Promise { - const deadline = Date.now() + this.graceMs - for (;;) { - const groups = await this.sessionProcessGroups() - if (groups.length === 0 || Date.now() >= deadline) return groups - if (kill) await this.signalGroups(groups, 'KILL') - await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now()))) - } - } - private async closeOnce(): Promise { - let groups = await this.sessionProcessGroups() + let groups = await sessionProcessGroups(this.sandbox, this.sessionId) if (groups.length > 0) { this.terminationSignal = 'SIGTERM' - await this.signalGroups(groups, 'TERM') - groups = await this.awaitSessionEmpty() + await signalGroups(this.sandbox, groups, 'TERM') + groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs) } if (groups.length === 0 && !this.topLevelExited) { await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)]) @@ -264,7 +348,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { if (groups.length > 0 || !this.topLevelExited) { this.terminationSignal = 'SIGKILL' if (!this.topLevelExited) await this.sandbox.pty.kill(this.pid) - groups = await this.awaitSessionEmpty(true) + groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true) if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)]) } if (groups.length > 0) { @@ -348,9 +432,20 @@ export async function spawnE2BTerminal( ) } catch (error: unknown) { output.destroy() - if (handle !== undefined) await handle.kill().catch(() => false) - if (completion !== undefined) await completion.catch(() => {}) + let cleanupError: Error | undefined + if (handle !== undefined && completion !== undefined) { + try { + await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs) + } catch (rollbackError: unknown) { + cleanupError = asError(rollbackError) + } + } else if (handle !== undefined) { + await handle.kill().catch(() => false) + } await sandbox.files.remove(stateDir).catch(() => {}) + if (cleanupError !== undefined) { + throw new AggregateError([asError(error), cleanupError], asError(error).message) + } throw error } } diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index fd4cc97a83..32adf872b3 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -10,7 +10,7 @@ import type E2BSandboxService from '@deepseek-ai/dsh-e2b' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b' import * as E2BSubprocessInvariant from '../src/invariant.ts' -import { E2BOutputReader } from '../src/output.ts' +import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from '../src/output.ts' import { E2BSubprocessHandle } from '../src/process.ts' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it, vi } from 'vitest' @@ -91,6 +91,7 @@ class FakeSandbox { trapsTerm = false delaysKill = false alive = true + ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0' processGroupId = '4242\n' readonly processGroupReads: string[] = [] beforeProbe: (() => void) | undefined @@ -110,15 +111,35 @@ class FakeSandbox { finish(exitCode = 0): void { this.alive = false - if (exitCode === 0) this.handle.succeed(0) - else this.handle.fail(exitCode) + void this.completeOutput().then( + () => { + if (exitCode === 0) this.handle.succeed(0) + else this.handle.fail(exitCode) + }, + (error: unknown) => { this.handle.crash(error) }, + ) + } + + async completeOutput(): Promise { + await Promise.all([ + this.stdoutWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`), + this.stderrWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`), + ]) } async stdout(data: string): Promise { - await this.startOptions?.onStdout?.(data) + await this.stdoutWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`) } async stderr(data: string): Promise { + await this.stderrWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`) + } + + async stdoutWire(data: string): Promise { + await this.startOptions?.onStdout?.(data) + } + + async stderrWire(data: string): Promise { await this.startOptions?.onStderr?.(data) } @@ -147,6 +168,7 @@ class FakeSandbox { commands: { run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise => { this.commandsSeen.push(command) + if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' } if (command.startsWith('kill -0 ')) { this.beforeProbe?.() if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError') @@ -221,11 +243,35 @@ async function flush(): Promise { } describe('E2BOutputReader', () => { + it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => { + const decoder = new E2BBase64Decoder() + expect(decoder.push('')).toEqual(Buffer.alloc(0)) + expect(decoder.push('5')).toEqual(Buffer.alloc(0)) + expect(decoder.push('L2')).toEqual(Buffer.alloc(0)) + expect(decoder.push('g\n').toString()).toBe('你') + expect(decoder.push('YQ==\nYg==\n').toString()).toBe('ab') + expect(decoder.push(`${Buffer.from([0, 255]).toString('base64')}\n`)).toEqual(Buffer.from([0, 255])) + expect(decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toEqual(Buffer.alloc(0)) + decoder.finish() + + expect(() => new E2BBase64Decoder().push('%\n')).toThrow('invalid base64') + expect(() => new E2BBase64Decoder().push('AB==\n')).toThrow('invalid base64') + expect(() => decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toThrow('duplicate output transport completion') + expect(() => decoder.push('YQ==\n')).toThrow('continued after completion') + const truncated = new E2BBase64Decoder() + truncated.push('YQ') + expect(() => { truncated.finish() }).toThrow('truncated base64') + expect(() => { new E2BBase64Decoder().finish() }).toThrow('incomplete output transport') + const interrupted = new E2BBase64Decoder() + interrupted.push('YQ') + expect(() => { interrupted.finish(false) }).not.toThrow() + }) + it('keeps a byte-exact tail with independent whole-stream cursors', () => { const reader = new E2BOutputReader(4, 10, '/remote/spill') - reader.push('') - reader.push('ab') - reader.push('cdef') + reader.push(Buffer.alloc(0)) + reader.push(Buffer.from('ab')) + reader.push(Buffer.from('cdef')) expect(reader.size).toBe(6) expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' }) expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false }) @@ -235,11 +281,11 @@ describe('E2BOutputReader', () => { it('drops whole head chunks and withholds absent or over-cap spills', () => { const withoutSpill = new E2BOutputReader(2, undefined, '/unused') - withoutSpill.push('ab') - withoutSpill.push('cd') + withoutSpill.push(Buffer.from('ab')) + withoutSpill.push(Buffer.from('cd')) expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true }) const overCap = new E2BOutputReader(2, 3, '/too-small') - overCap.push('abcd') + overCap.push(Buffer.from('abcd')) expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true }) expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/) expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/) @@ -265,16 +311,22 @@ describe('E2BSubprocessHandle', () => { expect(fake.handle.sent.map(value => String(value))).toEqual(['hello']) expect(fake.handle.closes).toBe(1) expect(fake.startOptions?.envs).toBeUndefined() - const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))! - expect(command).toContain('exec setsid --wait -- bash -c') + const command = fake.commandsSeen.find(value => value.includes('exec "$dsh_e2b_env_bin" -i'))! + expect(command).toContain('"$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c') expect(command).not.toContain('DEEPSEEK_API_KEY') expect(command).not.toContain('DSH_MODE') expect(command).not.toContain('FOO-BAR') expect(command).not.toContain('explicit-secret') expect(command).not.toContain('hyphen-value') expect(command).not.toContain('${!dsh_e2b_name}') - expect(command).toContain('env -0') + expect(fake.commandsSeen).toContain('env -0') expect(command).toContain('mapfile -d') + expect(command).toContain('dsh_e2b_node="$(command -v node)"') + expect(command).toContain('"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e') + expect(command).toContain('exec "$dsh_e2b_env_bin" -i "${dsh_e2b_env[@]}"') + expect(command).toContain('>&2 2>/dev/null') + expect(command).not.toContain('2>/dev/null >&2') + expect(command).toContain('base64') expect(fake.writtenFiles[0]).toEqual([ '/workspace/.dsh-e2b/processes/one/pid', '/workspace/.dsh-e2b/processes/one/exit-code', @@ -282,7 +334,7 @@ describe('E2BSubprocessHandle', () => { '/workspace/.dsh-e2b/processes/one/stderr.log', ]) expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe( - 'PATH=/bin\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0', + 'PATH=/bin\0KEEP=safe\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0', ) let piped = '' @@ -297,6 +349,47 @@ describe('E2BSubprocessHandle', () => { await expect(handle.waitForExit()).resolves.toBe(true) }) + it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec({ + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } }, + }), '/runtime/split-utf8') + await flush() + const chunks: Buffer[] = [] + handle.stdout!.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + for (const character of `${Buffer.from('A你好B').toString('base64')}\n`) { + await fake.stdoutWire(character) + } + fake.finish() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + expect(Buffer.concat(chunks).toString('utf8')).toBe('A你好B') + }) + + it('rejects malformed output transport without confusing it with a consumer sink failure', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/malformed-output') + await flush() + await fake.stdoutWire('%\n') + fake.finish() + await expect(handle.done).rejects.toThrow('invalid base64 output transport') + + const stderrFake = new FakeSandbox() + const stderrHandle = new E2BSubprocessHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr') + await flush() + await stderrFake.stderrWire('%\n') + stderrFake.finish() + await expect(stderrHandle.done).rejects.toThrow('invalid base64 output transport') + }) + + it('rejects a naturally completed command whose encoder omits its completion frame', async () => { + const fake = new FakeSandbox() + const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/incomplete-output') + await flush() + fake.alive = false + fake.handle.succeed(0) + await expect(handle.done).rejects.toThrow('incomplete output transport') + }) + it('surfaces deferred piped-stdin write and close failures as stream errors', async () => { const writeFake = new FakeSandbox() writeFake.deferStart() @@ -362,10 +455,10 @@ describe('E2BSubprocessHandle', () => { await handle.done expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true }) expect(fake.removed).toContain('/runtime/oversize/stdout.log') - const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))! - expect(command).toContain('head -c 3') + const command = fake.commandsSeen.find(value => value.includes('dsh_e2b_tee='))! + expect(command).toContain('"$dsh_e2b_head" -c 3') expect(command).toContain('/runtime/oversize/stdout.log') - expect(command).toContain('tee --output-error=warn-nopipe') + expect(command).toContain('"$dsh_e2b_tee" --output-error=warn-nopipe') expect(command).not.toContain('tee -a') }) @@ -436,6 +529,7 @@ describe('E2BSubprocessHandle', () => { const fake = new FakeSandbox() const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group') await flush() + await fake.completeOutput() fake.handle.succeed(0) await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) expect(fake.alive).toBe(true) @@ -640,7 +734,13 @@ describe('E2BSubprocessHandle', () => { expect(failures[0].message).toContain('invalid process-group id') expect(failures[1].message).toBe('rollback signal failed') expect(fake.handle.kills).toBe(1) - fake.finish() + const bounded = new AbortController() + const waiting = handle.waitForExit(bounded.signal) + bounded.abort() + await expect(waiting).resolves.toBe(false) + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + expect(fake.commandsSeen).toContain('kill -TERM -- -4242') }) it('waits for delayed process-group publication', async () => { diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 81716c3d52..cefa432acd 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -30,10 +30,13 @@ class FakeTerminalCommandHandle { sdkKills = 0 disconnectError: unknown sdkKillError: unknown + waitError: unknown + settleOnSdkKill = true private readonly result = Promise.withResolvers() private settled = false wait(): Promise { + if (this.waitError !== undefined) throw this.waitError return this.result.promise } @@ -46,10 +49,10 @@ class FakeTerminalCommandHandle { this.sdkKills += 1 if (this.sdkKillError !== undefined) { const error = this.sdkKillError - this.fail(137) + if (this.settleOnSdkKill) this.fail(137) throw error } - this.fail(137) + if (this.settleOnSdkKill) this.fail(137) return true } @@ -94,8 +97,10 @@ class FakeTerminalSandbox { createError: unknown sendError: unknown commandFailure: unknown + sessionGroupsFailure: unknown foregroundFailure: unknown termFailure: unknown + ptyKillError: unknown removeError: unknown clearOnTerm = true clearOnKill = true @@ -146,6 +151,7 @@ class FakeTerminalSandbox { return { exitCode: 0, stdout: this.foreground, stderr: '' } } if (command.startsWith('ps -eo sid=')) { + if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' } } if (command.startsWith('kill -TERM -- ')) { @@ -173,6 +179,7 @@ class FakeTerminalSandbox { }, kill: async (pid: number): Promise => { this.ptyKills += 1 + if (this.ptyKillError !== undefined) throw this.ptyKillError if (this.settleOnPtyKill) this.handle.fail(137) return pid === this.handle.pid }, @@ -282,7 +289,8 @@ describe('E2B terminal allocation', () => { failedInput.sendError = new Error('bootstrap failed') await expect(spawnE2BTerminal(runtime(failedInput), spec(), '/runtime/input')) .rejects.toThrow('bootstrap failed') - expect(failedInput.handle.sdkKills).toBe(1) + expect(failedInput.commands).toContain('kill -TERM -- -123') + expect(failedInput.groups).toEqual([]) const exited = new FakeTerminalSandbox() exited.ready = new FileNotFoundError('not ready') @@ -292,13 +300,62 @@ describe('E2B terminal allocation', () => { const invalidSession = new FakeTerminalSandbox() invalidSession.sessionId = 'not-a-session\n' + invalidSession.clearOnTerm = false await expect(spawnE2BTerminal(runtime(invalidSession), spec(), '/runtime/session')) .rejects.toThrow('cannot resolve process session') - expect(invalidSession.handle.sdkKills).toBe(1) + expect(invalidSession.commands).toContain('kill -TERM -- -123') + expect(invalidSession.commands).toContain('kill -KILL -- -123') + expect(invalidSession.groups).toEqual([]) + expect(invalidSession.ptyKills).toBe(1) const lateData = invalidSession.createOptions?.onData if (lateData === undefined) throw new Error('missing captured terminal callback') expect(lateData(Buffer.from('late bytes'))).toBeUndefined() + const termFailed = new FakeTerminalSandbox() + termFailed.sendError = new Error('bootstrap failed') + termFailed.termFailure = new Error('TERM transport failed') + await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed')) + .rejects.toThrow('bootstrap failed') + expect(termFailed.commands).toContain('kill -KILL -- -123') + expect(termFailed.ptyKills).toBe(1) + + const uninspectable = new FakeTerminalSandbox() + uninspectable.sendError = new Error('bootstrap failed') + uninspectable.sessionGroupsFailure = 'session enumeration failed' + uninspectable.ptyKillError = new Error('PTY kill failed') + let uninspectableFailure: unknown + try { + await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable') + } catch (error: unknown) { + uninspectableFailure = error + } + expect(uninspectableFailure).toBeInstanceOf(AggregateError) + expect(uninspectable.ptyKills).toBe(1) + expect(uninspectable.handle.sdkKills).toBe(1) + + const survivingGroups = new FakeTerminalSandbox() + survivingGroups.sendError = new Error('bootstrap failed') + survivingGroups.clearOnTerm = false + survivingGroups.clearOnKill = false + await expect(spawnE2BTerminal(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups')) + .rejects.toThrow('bootstrap failed') + + const survivingPid = new FakeTerminalSandbox() + survivingPid.sendError = new Error('bootstrap failed') + survivingPid.groups = [] + survivingPid.settleOnPtyKill = false + survivingPid.handle.settleOnSdkKill = false + await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid')) + .rejects.toThrow('bootstrap failed') + + const waitFailed = new FakeTerminalSandbox() + waitFailed.handle.waitError = new Error('wait failed') + waitFailed.handle.settleOnSdkKill = false + waitFailed.handle.sdkKillError = new Error('kill failed') + await expect(spawnE2BTerminal(runtime(waitFailed), spec(), '/runtime/wait-failed')) + .rejects.toThrow('wait failed') + expect(waitFailed.handle.sdkKills).toBe(1) + const cleanupFailed = new FakeTerminalSandbox() cleanupFailed.handle.pid = 0 cleanupFailed.handle.sdkKillError = new Error('kill transport failed') @@ -362,7 +419,7 @@ describe('E2B terminal lifecycle', () => { it.each([ [7, { exitCode: 7, signal: null }], - [143, { exitCode: null, signal: 'SIGTERM' }], + [143, { exitCode: 143, signal: null }], [255, { exitCode: 255, signal: null }], ] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => { const fake = new FakeTerminalSandbox()