From 81e2e1f647b07b20ec7974176770fdd9a7764c64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:30:32 +0800 Subject: [PATCH] fix(e2b): harden SDK shell and cleanup boundaries E2B starts command and PTY requests through login shells, so isolate each control shell behind a fresh randomized HOME and blank sandbox credential names before mutable profiles can run. Preserve the real remote HOME only for the requested argv. Collapse duplicate termination state, keep failed force cleanup retryable until quiescence is observed, and make terminal state allocation cancellable. Leave numeric PGID reuse as an explicit provider-level TODO because a userspace precheck would remain TOCTOU. --- ...026-07-27-e2b-remote-runtime-poc.i18n.yaml | 4 +- .../2026-07-27-e2b-remote-runtime-poc.md | 4 +- .../2026-07-27-e2b-remote-runtime-poc.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- packages/e2b/e2b/README.i18n.yaml | 4 +- packages/e2b/e2b/README.md | 2 +- packages/e2b/e2b/README.zh.md | 2 +- packages/e2b/e2b/src/index.ts | 17 +- packages/e2b/e2b/tests/composition.e2e.ts | 26 ++- packages/e2b/e2b/tests/e2b.spec.ts | 27 ++- packages/e2b/fs-e2b/src/index.ts | 15 +- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 8 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 4 +- packages/e2b/subprocess-e2b/README.zh.md | 4 +- .../e2b/subprocess-e2b/src/environment.ts | 57 ++++-- packages/e2b/subprocess-e2b/src/index.ts | 6 +- packages/e2b/subprocess-e2b/src/process.ts | 182 ++++++++---------- packages/e2b/subprocess-e2b/src/terminal.ts | 99 +++++++--- .../subprocess-e2b/tests/subprocess.spec.ts | 127 ++++++++---- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 51 ++++- 21 files changed, 438 insertions(+), 211 deletions(-) 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 1343d33032..2c3db3fad4 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: cccee91998e832cc9cf53e554e104ccc5caada38 -2026-07-27-e2b-remote-runtime-poc.zh.md: b1a30c92e313b1395006ebd4fd17eacda2546ad5 +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 index cccee91998..79c5bfe2df 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. Ambient environments and ordinary command pipes cross decoded SDK callback boundaries as ASCII/base64 before strict UTF-8 or raw-byte restoration; startup preparation carries the handle's owned cancellation signal, and provisional termination proves the remote group empty after SDK fallback. `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. +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. @@ -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, real-directory runtime-state setup, process-publication rollback, byte-split UTF-8 command output, bounded raw spill and inherited-output draining, 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, 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 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 b1a30c92e3..729a10ec7e 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 回调,在异步查找前台进程组的过程中保留终端发送身份,并负责全会话清理。环境快照与普通命令管道会先以 ASCII/base64 跨越已解码的 SDK 回调边界,再执行严格 UTF-8 解码或恢复原始字节;启动准备会携带句柄自有的取消信号,临时终止则会在 SDK 回退后证明远程进程组为空。`dsh-fs-e2b` 通过无依赖辅助程序执行有界源码读取,该程序会在规范化目标下逐级遍历不跟随符号链接的目录描述符。通用 Code Runtime 通过经过验证的 ASCII/base64 帧承载 controller/worker 协议,并在继承的管道排空前终止提供方拥有的进程组。通用 LSP 通过命令管道使用字节保真的 UTF-8 JSON。 +基础适配器承载基底专用机制。适配器内部的 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(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。 @@ -38,7 +38,7 @@ E2B 拥有可变文件系统、受管命令与 Bash 进程、终端分配与终 聚焦包测试套件固定所有者生命周期清理、文件系统路径/containment/有界描述符读取与提交元数据、子进程可执行文件查找/进程组/发布回滚、终端字节 I/O/信号身份/默认环境清理/会话清理、输出上限、中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。通用 PTY、LSP 与子进程 Code Runtime 测试套件固定其提供方无关的就绪判定、跨命名空间 `processId`、绑定桥接、描述符隔离、恶意通信,以及 worker/后代进程清理行为。 -凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、真实目录形式的运行时状态设置、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 与继承输出排空、PTY 默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 +凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、真实目录形式的运行时状态设置、进程发布回滚、按字节切分的 UTF-8 命令输出、有界原始 spill 与继承输出排空、恶意命令与 PTY 登录 profile 隔离、默认秘密清理、陈旧中断身份与进程树清理、可抵御父目录替换的有界 LSP 源码读取、Code Runtime 宿主绑定、描述符隔离的输出记账、后代进程所持管道的清理、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。 ## 曾考虑的替代方案 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7ce45c6817..cdd2d87435 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -591,7 +591,7 @@ Owns one lazily consumable E2B SDK handle and its final kill/pause/leave decisio async getSandbox(): Promise ``` -Source: [`packages/e2b/e2b/src/index.ts:97`](../../packages/e2b/e2b/src/index.ts) +Source: [`packages/e2b/e2b/src/index.ts:109`](../../packages/e2b/e2b/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/e2b/e2b/README.i18n.yaml b/packages/e2b/e2b/README.i18n.yaml index 2fc8d403d1..fb645c2e36 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: 6eb7d69dd6355de870db6bf05540f2629f464bc8 -README.zh.md: b2e2616cf779bce659fdef58840aa51f095a73cf +README.md: 402903184934903eceb36a04d670e4490879ac65 +README.zh.md: 1fec6ab66858f04a47c8a2a7cb5f6a8907ab6cac diff --git a/packages/e2b/e2b/README.md b/packages/e2b/e2b/README.md index 6eb7d69dd6..4029031849 100644 --- a/packages/e2b/e2b/README.md +++ b/packages/e2b/e2b/README.md @@ -28,7 +28,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one ## Lifecycle and ownership -Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup. +Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command. `sandboxId` resolves to a branded `E2BSandboxId` after setup. Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` is accepted when disposal requests `kill`, or when this service created a sandbox with `onTimeout: kill`; otherwise, a not-found error from a requested `pause` rejects teardown because retention was not proved. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. 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. diff --git a/packages/e2b/e2b/README.zh.md b/packages/e2b/e2b/README.zh.md index b2e2616cf7..1fec6ab668 100644 --- a/packages/e2b/e2b/README.zh.md +++ b/packages/e2b/e2b/README.zh.md @@ -28,7 +28,7 @@ ## 生命周期与所有权 -构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 +构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。 资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 仅在资源释放请求 `kill`,或本服务创建了配置为 `onTimeout: kill` 的沙箱时才可接受;否则,`pause` 请求返回的未找到错误会导致 teardown 拒绝,因为无法证明保留成功。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 150961a897..9e8960a685 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-e2b */ +import { randomUUID } from 'node:crypto' import { posix } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' @@ -42,6 +43,17 @@ export function quoteE2BShellArg(value: string): string { return `'${value.replaceAll('\'', "'\"'\"'")}'` } +/** + * Isolate E2B's hard-coded login shell behind a fresh randomized home path. + * @param overrides - Additional environment entries for the internal command. + * @returns A fresh mutable map that the E2B SDK may extend. + */ +export function e2bControlEnvs( + overrides: Readonly> = {}, +): Record { + return { ...overrides, HOME: `/.dsh-e2b-control-${randomUUID()}` } +} + /** Action taken on the owned sandbox when the Cordis service is disposed. */ export type E2BDisposeMode = 'kill' | 'pause' | 'leave' @@ -249,7 +261,10 @@ export class E2BSandboxService extends Service { if (runtimeRoot.type !== FileType.DIR || runtimeRoot.symlinkTarget !== undefined) { throw new Error(`dsh-e2b: runtime root must be a real directory: ${this.runtimeRoot}`) } - await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`) + await sandbox.commands.run( + `chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`, + { envs: e2bControlEnvs() }, + ) return sandbox } catch (error: unknown) { if (this.created) { diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 34875d5153..f9fcbfae8a 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -5,7 +5,12 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import E2BSandboxService, { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b' +import E2BSandboxService, { + e2bControlEnvs, + FileNotFoundError, + Sandbox, + SandboxNotFoundError, +} from '@deepseek-ai/dsh-e2b' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -17,7 +22,7 @@ const configPath = join(fixtureRoot, 'cordis.yml') const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { - it('scrubs sandbox-default credentials from an actual E2B PTY', async () => { + it('scrubs credentials before actual E2B command and PTY login shells', async () => { const apiKey = process.env.E2B_API_KEY if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared before the PTY environment test') const sandbox = await Sandbox.create({ @@ -28,6 +33,18 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { lifecycle: { onTimeout: 'kill' }, }) try { + const profileLeakPath = '/home/user/dsh-e2b-bootstrap-profile-leak' + const hostileProfile = [ + 'if [[ "${NPM_TOKEN-}" == "sentinel-secret" ]]; then', + ` printf leaked > ${profileLeakPath}`, + 'fi', + '', + ].join('\n') + await sandbox.files.write([ + { path: '/home/user/.bash_profile', data: hostileProfile }, + { path: '/home/user/.profile', data: hostileProfile }, + { path: '/home/user/.bashrc', data: hostileProfile }, + ]) const ctx = new Context() ctx.provide('e2b', { cwd: '/home/user', @@ -43,6 +60,7 @@ 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) + await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError) const environmentProbe = ctx.subprocess.spawn({ argv: ['/bin/bash', '-c', [ 'dsh_leak=0', @@ -59,6 +77,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { }) await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null }) expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n') + await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError) const ownerId = SessionId('e2b-pty-env-owner') const owner: Agent = { id: ownerId, @@ -89,6 +108,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { expect(result.viewport).toContain('NPM=<> DSH=<> KEEP=') expect(result.viewport).not.toContain('sentinel-secret') expect(result.viewport).not.toContain('sentinel-stale') + await expect(sandbox.files.read(profileLeakPath)).rejects.toBeInstanceOf(FileNotFoundError) await session.close('environment test complete') await subprocessFiber.dispose() await ptyFiber.dispose() @@ -98,7 +118,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { 'mkdir -p -- /home/user/dsh-e2b-runtime-target', 'chmod 755 -- /home/user/dsh-e2b-runtime-target', 'ln -s -- /home/user/dsh-e2b-runtime-target /home/user/.dsh-e2b', - ].join('\n')) + ].join('\n'), { envs: e2bControlEnvs({ NPM_TOKEN: '' }) }) const linkedCtx = new Context() const linkedFiber = await linkedCtx.plugin(E2BSandboxService, { apiKey, diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index a4eba5ebc9..46ba452210 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' import { Context } from 'cordis' import type { Sandbox as SandboxType } from 'e2b' import E2BSandboxService, { + e2bControlEnvs, E2BSandboxId, FileType, SandboxNotFoundError, @@ -35,15 +37,20 @@ interface SandboxFixture { sandbox: SandboxType makeDir: ReturnType getInfo: ReturnType - run: ReturnType + run: Mock kill: ReturnType pause: ReturnType } +type RunCommand = ( + command: string, + options?: { envs?: Record }, +) => Promise<{ exitCode: number; stdout: string; stderr: string }> + function fakeSandbox(id = 'sandbox-1'): SandboxFixture { const makeDir = vi.fn().mockResolvedValue(true) const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR }) - const run = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const run = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) const kill = vi.fn().mockResolvedValue(undefined) const pause = vi.fn().mockResolvedValue(true) const sandbox = { @@ -63,6 +70,15 @@ beforeEach(() => { }) describe('E2BSandboxService', () => { + it('gives each SDK login shell a fresh non-overridable control home', () => { + const first = e2bControlEnvs({ HOME: '/hostile', NPM_TOKEN: '' }) + const second = e2bControlEnvs() + + expect(first.HOME).toMatch(/^\/\.dsh-e2b-control-/) + expect(first).toEqual({ HOME: first.HOME, NPM_TOKEN: '' }) + expect(first.HOME).not.toBe(second.HOME) + }) + it('creates one protected shared sandbox and kills it on default disposal', async () => { const fixture = fakeSandbox() sdk.create.mockResolvedValue(fixture.sandbox) @@ -86,7 +102,12 @@ describe('E2BSandboxService', () => { expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace') expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b') expect(fixture.getInfo).toHaveBeenCalledWith('/home/user/workspace/.dsh-e2b') - expect(fixture.run).toHaveBeenCalledWith("chmod 700 -- '/home/user/workspace/.dsh-e2b'") + const runOptions = fixture.run.mock.calls[0]?.[1] + expect(runOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/) + expect(fixture.run).toHaveBeenCalledWith( + "chmod 700 -- '/home/user/workspace/.dsh-e2b'", + { envs: { HOME: runOptions?.envs?.HOME } }, + ) await fiber.dispose() expect(fixture.kill).toHaveBeenCalledOnce() diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index d41c2e3d32..7c81791d55 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -20,6 +20,7 @@ import type { } from '@deepseek-ai/dsh-fs' import { CommandExitError, + e2bControlEnvs, FileNotFoundError, FileType, quoteE2BShellArg, @@ -71,6 +72,10 @@ function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } { return signal === undefined ? {} : { signal } } +function commandOpts(signal: AbortSignal | undefined): { envs: Record; signal?: AbortSignal } { + return { envs: e2bControlEnvs(), ...signalOpts(signal) } +} + function entryType(entry: EntryInfo): FsInfo['type'] { switch (entry.type) { case FileType.FILE: @@ -215,7 +220,7 @@ export class E2BFileSystem extends FileSystem { assertNotAborted(signal, 'read') const sandbox = await this.ctx.e2b.getSandbox() try { - const node = await sandbox.commands.run('command -v -- node', signalOpts(signal)) + 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') @@ -228,7 +233,7 @@ export class E2BFileSystem extends FileSystem { quoteE2BShellArg(this.processPath(target)), String(maxBytes), ].join(' ') - const result = await sandbox.commands.run(command, signalOpts(signal)) + const result = await sandbox.commands.run(command, commandOpts(signal)) assertNotAborted(signal, 'read') const response = this.parseBoundedRead(result.stdout, target) if (response.kind === 'not-file') { @@ -415,7 +420,7 @@ 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)}`, signalOpts(signal)) + const result = await sandbox.commands.run(`realpath -m -- ${quoteE2BShellArg(path)}`, commandOpts(signal)) return result.stdout.replace(/\n$/, '') } catch (error: unknown) { if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error }) @@ -505,7 +510,7 @@ export class E2BFileSystem extends FileSystem { const created = await sandbox.files.makeDir(stagingDirectory, signalOpts(signal)) if (!created) throw new Error('private staging directory already exists') stagingDirectoryCreated = true - await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stagingDirectory)}`, signalOpts(signal)) + await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stagingDirectory)}`, commandOpts(signal)) assertNotAborted(signal, 'write') await sandbox.files.write(temporary, content, { metadata: { [VERSION_METADATA_KEY]: versionId }, @@ -515,7 +520,7 @@ export class E2BFileSystem extends FileSystem { const mode = existing === undefined ? 0o600 : existing.mode & 0o777 await sandbox.commands.run( `chmod ${mode.toString(8)} -- ${quoteE2BShellArg(temporary)}`, - signalOpts(signal), + commandOpts(signal), ) assertNotAborted(signal, 'write') const committed = await sandbox.files.rename(temporary, targetPath) diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 1689fb4023..23835e9967 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -225,8 +225,14 @@ class FakeRemote { }, }, commands: { - run: async (command: string, options?: { signal?: AbortSignal }): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + run: async ( + command: string, + options?: { envs?: Record; signal?: AbortSignal }, + ): Promise<{ exitCode: number; stdout: string; stderr: string }> => { this.checkAbort(options) + const home = options?.envs?.HOME + expect(home).toMatch(/^\/\.dsh-e2b-control-/) + expect(options?.envs).toEqual({ HOME: home }) this.commands.push(command) if (this.nextCommandError !== undefined) { const error = this.nextCommandError diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 3f64c67b7e..ac7b8d42a5 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: aaac559e60162c3051cc6f06cbcfac011f5eb59a -README.zh.md: 904e4fd1d9a13082ddfc8183c0e42abc25bd1caf +README.md: 9b86af428533ebcc2c0da56339e6ea2a28170fd3 +README.zh.md: e3e8368534266e35ec04dcf5a2d6718829b4a016 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index aaac559e60..9b86af4285 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -8,8 +8,8 @@ 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. A failed transaction is observable through `waitForExit()` and may be retried, while any proven quiescence permanently fences later termination against PID reuse. Before publication, cancellation uses both the provisional group and SDK handle and proves the group empty before reporting quiescence; if publication fails, rollback applies the same proof before startup rejects. After publication, a monitoring failure also rolls back the group 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. 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. +- **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. - **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`. - **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. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 904e4fd1d9..e3e8368534 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -8,8 +8,8 @@ - **异步远程启动**:同步 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()` 观察,并可重试;任何已证明的完全停稳都会永久防止后续终止操作命中复用的 PID。发布前,取消操作会同时使用临时进程组与 SDK 句柄,并在报告完全停稳前证明进程组为空;如果发布失败,回滚会在启动拒绝前执行相同证明。发布后,监控失败也会在拒绝前回滚进程组。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。 -- **环境边界**:沙箱命令环境会先以 base64 ASCII 跨越 SDK 回调边界,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 +- **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 才会接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 - **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` 所有。 - **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。 diff --git a/packages/e2b/subprocess-e2b/src/environment.ts b/packages/e2b/subprocess-e2b/src/environment.ts index 2b2516a706..5322491261 100644 --- a/packages/e2b/subprocess-e2b/src/environment.ts +++ b/packages/e2b/subprocess-e2b/src/environment.ts @@ -1,11 +1,24 @@ /** Shared remote-environment scrubbing for E2B process and terminal launchers. */ import { Buffer } from 'node:buffer' +import { posix } from 'node:path' +import { e2bControlEnvs } from '@deepseek-ai/dsh-e2b' import type { Sandbox } from '@deepseek-ai/dsh-e2b' import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess' const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ +function remoteEnvironmentEntries(raw: string): Array { + const entries: Array = [] + for (const entry of raw.split('\0')) { + if (entry.length === 0) continue + const separator = entry.indexOf('=') + if (separator <= 0) continue + entries.push([entry.slice(0, separator), entry.slice(separator + 1)]) + } + return entries +} + /** * Read the remote environment through ASCII base64 so SDK callback chunking cannot corrupt UTF-8. * @param sandbox - shared E2B execution world. @@ -14,16 +27,29 @@ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ */ export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise { const result = await sandbox.commands.run( - 'set -o pipefail; env -0 | base64 -w 0', - signal === undefined ? {} : { signal }, + 'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0', + { envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) }, ) - const encoded = result.stdout.trim() - if (!BASE64.test(encoded)) throw new Error('subprocess-e2b: remote environment transport returned invalid base64') + const lines = result.stdout.trim().split('\n') + if (lines.length !== 2 || !lines.every(line => BASE64.test(line))) { + throw new Error('subprocess-e2b: remote environment transport returned invalid base64') + } + const [encodedHome, encodedEnvironment] = lines as [string, string] + let home: string + let raw: string try { - return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(encoded, 'base64')) + const decoder = new TextDecoder('utf-8', { fatal: true }) + home = decoder.decode(Buffer.from(encodedHome, 'base64')) + raw = decoder.decode(Buffer.from(encodedEnvironment, 'base64')) } catch (error: unknown) { throw new Error('subprocess-e2b: remote environment is not valid UTF-8', { cause: error }) } + if (!posix.isAbsolute(home) || home.includes('\0')) { + throw new Error(`subprocess-e2b: remote login home is invalid: ${JSON.stringify(home)}`) + } + const environment = new Map(remoteEnvironmentEntries(raw)) + environment.set('HOME', home) + return [...environment].map(([name, value]) => `${name}=${value}\0`).join('') } /** @@ -33,13 +59,22 @@ export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSign */ export function scrubRemoteEnvironment(raw: string): Map { 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) + for (const [name, value] of remoteEnvironmentEntries(raw)) { if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue - environment.set(name, entry.slice(separator + 1)) + environment.set(name, value) + } + return environment +} + +/** + * Isolate E2B's fixed login-shell bootstrap from user profiles and ambient credentials. + * @param raw - The complete NUL-delimited remote environment. + * @returns Explicit E2B command or PTY overrides for bootstrap-shell startup. + */ +export function bootstrapEnvironment(raw: string): Record { + const environment: Record = { TERM: 'dumb' } + for (const [name] of remoteEnvironmentEntries(raw)) { + if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) environment[name] = '' } return environment } diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 4516bffa84..7318dfde95 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -14,7 +14,7 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' +import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' import { E2BSubprocessHandle } from './process.ts' import { spawnE2BTerminal } from './terminal.ts' @@ -86,7 +86,7 @@ export class E2BSubprocessService extends SubprocessService { if (posix.isAbsolute(command)) { await sandbox.commands.run( `test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`, - signalOpts(signal), + { envs: e2bControlEnvs(), ...signalOpts(signal) }, ) signal?.throwIfAborted() return command @@ -95,7 +95,7 @@ export class E2BSubprocessService extends SubprocessService { const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} ` const result = await sandbox.commands.run( `${prefix}command -v -- ${quoteE2BShellArg(command)}`, - { cwd: this.cwd, ...signalOpts(signal) }, + { cwd: this.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) }, ) signal?.throwIfAborted() const executable = result.stdout.trim() diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 8c83c4b84b..6b17f3b682 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream' import { posix } from 'node:path' import { CommandExitError, + e2bControlEnvs, FileNotFoundError, SandboxNotFoundError, quoteE2BShellArg, @@ -18,7 +19,7 @@ import type { SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import type E2BSandboxService from '@deepseek-ai/dsh-e2b' -import { readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts' +import { bootstrapEnvironment, readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts' import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts' const GROUP_POLL_MS = 20 @@ -142,8 +143,11 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string { return bootstrap } -function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } { - return signal === undefined ? {} : { signal } +function commandOpts( + envs: Record, + signal: AbortSignal | undefined, +): { envs: Record; signal?: AbortSignal } { + return { envs: e2bControlEnvs(envs), ...(signal === undefined ? {} : { signal }) } } function isAborted(signal: AbortSignal | undefined): boolean { @@ -197,21 +201,18 @@ export class E2BSubprocessHandle implements SubprocessHandle { private readonly readyState = Promise.withResolvers() private readonly stdoutDecoder = new E2BBase64Decoder() private readonly stderrDecoder = new E2BBase64Decoder() - private readonly outputTermination = new AbortController() - private readonly startupController = new AbortController() + private readonly terminationController = new AbortController() private readonly stdoutReader: E2BOutputReader | undefined private readonly stderrReader: E2BOutputReader | undefined private readonly paths: RemotePaths + private controlEnvs: Record = {} private remotePid = -1 private commandHandle: CommandHandle | undefined private outputTransportError: Error | undefined private outputDrainExpired = false private stateDirectoryCreated = false private preparing = true - private invalidHandleQuiescent = false - private provisionalHandleQuiescent = false private terminationStarted = false - private terminationFenced = false private quiescenceProven = false private terminationAttempt: Promise | undefined private terminationFailure: Error | undefined @@ -264,20 +265,16 @@ export class E2BSubprocessHandle implements SubprocessHandle { /** @inheritdoc */ terminate(): void { - if (this.terminationFenced || this.quiescenceProven || this.terminationAttempt !== undefined) return + if (this.quiescenceProven || this.terminationAttempt !== undefined) return this.terminationStarted = true - if (this.preparing) this.startupController.abort(new Error('subprocess-e2b: command terminated during startup')) - this.outputTermination.abort() + this.terminationController.abort(new Error('subprocess-e2b: command terminated')) this.stdout?.destroy() this.stderr?.destroy() this.terminationFailure = undefined const attempt = this.terminateRemote() this.terminationAttempt = attempt void attempt.then( - () => { - this.terminationFenced = true - this.terminationAttempt = undefined - }, + () => { this.terminationAttempt = undefined }, (error: unknown) => { if (!this.quiescenceProven) this.terminationFailure = asError(error) this.terminationAttempt = undefined @@ -301,11 +298,8 @@ export class E2BSubprocessHandle implements SubprocessHandle { const attempt = this.terminationAttempt if (attempt !== undefined && await waitWithSignal(attempt, signal) === WAIT_ABORTED) return false this.throwTerminationFailure() - /* v8 ignore else -- successful provisional cleanup always records one proof; failures throw above. */ - if (this.invalidHandleQuiescent || this.provisionalHandleQuiescent) { - this.markQuiescent() - return true - } + // Successful pre-publication termination records quiescence; its only other outcome is the failure above. + return true } } else { try { @@ -361,6 +355,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { { background: true, cwd: this.spec.cwd, + envs: e2bControlEnvs(this.controlEnvs), stdin: this.spec.stdio.stdin !== 'ignore', timeoutMs: 0, onStdout: async (data) => { await this.dispatchOutput('stdout', data) }, @@ -374,7 +369,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { const invalidPid = new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`) try { await handle.kill() - this.invalidHandleQuiescent = true + this.markQuiescent() this.commandHandle = undefined } catch (cleanupError: unknown) { this.terminationFailure = asError(cleanupError) @@ -412,7 +407,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } catch (error: unknown) { const canceledPreparation = this.preparing && this.terminationStarted - && this.startupController.signal.aborted + && this.terminationController.signal.aborted let failure = await this.rollbackPublishedFailure(error) if (sandbox !== undefined && this.stateDirectoryCreated) { try { @@ -437,11 +432,15 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async prepareState(sandbox: Sandbox): Promise { - const signal = this.startupController.signal + const signal = this.terminationController.signal const ambient = await readRemoteEnvironment(sandbox, signal) + this.controlEnvs = bootstrapEnvironment(ambient) await sandbox.files.makeDir(this.stateDir, { signal }) this.stateDirectoryCreated = true - await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, { signal }) + await sandbox.commands.run( + `chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, + commandOpts(this.controlEnvs, signal), + ) const files = [ { path: this.paths.pid, data: '' }, { path: this.paths.status, data: '' }, @@ -450,7 +449,10 @@ export class E2BSubprocessHandle implements SubprocessHandle { ...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []), ] await sandbox.files.write(files, { signal }) - await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`, { signal }) + await sandbox.commands.run( + `chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`, + commandOpts(this.controlEnvs, signal), + ) signal.throwIfAborted() } @@ -490,7 +492,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise { const target = pipe ?? inherited - if (target === undefined || data.length === 0 || this.outputTermination.signal.aborted) return + if (target === undefined || data.length === 0 || this.terminationController.signal.aborted) return if (target.destroyed) throw new Error('subprocess output stream is closed') if (target.write(data)) return await new Promise((resolve, reject) => { @@ -502,13 +504,13 @@ export class E2BSubprocessHandle implements SubprocessHandle { target.removeListener('drain', onDrain) target.removeListener('close', onClose) target.removeListener('error', onError) - this.outputTermination.signal.removeEventListener('abort', onTermination) + this.terminationController.signal.removeEventListener('abort', onTermination) } target.once('drain', onDrain) target.once('close', onClose) target.once('error', onError) - this.outputTermination.signal.addEventListener('abort', onTermination, { once: true }) - if (this.outputTermination.signal.aborted) onTermination() + this.terminationController.signal.addEventListener('abort', onTermination, { once: true }) + if (this.terminationController.signal.aborted) onTermination() }) } @@ -596,12 +598,8 @@ export class E2BSubprocessHandle implements SubprocessHandle { // `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 { - await handle.kill().catch(() => false) - } - while (await this.groupAlive(sandbox, handle.pid)) await waitTick() + await this.forceKillGroup(sandbox, handle, handle.pid) + this.markQuiescent() } private async terminateRemote(): Promise { @@ -618,91 +616,76 @@ export class E2BSubprocessHandle implements SubprocessHandle { private async terminateRemoteInSandbox(): Promise { const handle = await this.commandState.promise - if (handle === undefined) return + if (handle === undefined) { + this.markQuiescent() + return + } if (!isValidProcessId(handle.pid) && this.remotePid <= 0) { await handle.kill() - this.invalidHandleQuiescent = true + this.markQuiescent() this.commandHandle = undefined return } - if (this.remotePid <= 0) { - const sandbox = await this.runtime.getSandbox() - this.terminationSignal = 'SIGTERM' - try { - const delivered = await this.signalGroup(sandbox, handle.pid, 'TERM') - if (delivered) { - const deadline = Date.now() + this.spec.graceMs - while (Date.now() < deadline && await this.groupAlive(sandbox, handle.pid)) await waitTick() - if (!await this.groupAlive(sandbox, handle.pid)) { - this.provisionalHandleQuiescent = true - return - } - } - } catch (_gracefulTerminationFailure) { - // A missing or unobservable provisional group still has the SDK handle fallback. - } - this.terminationSignal = 'SIGKILL' - let groupDelivered = false - let groupFailure: unknown - try { - groupDelivered = await this.signalGroup(sandbox, handle.pid, 'KILL') - } catch (error: unknown) { - groupFailure = error - } - let handleFailure: unknown - try { - if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination') - } catch (error: unknown) { - handleFailure = error - } - if (!groupDelivered && await this.groupAlive(sandbox, handle.pid)) { - throw new AggregateError( - [ - ...(groupFailure === undefined ? [] : [groupFailure]), - ...(handleFailure === undefined - ? [new Error('E2B SDK kill did not quiesce the provisional process group')] - : [handleFailure]), - ], - 'subprocess-e2b: force termination failed through both process-group and SDK transports', - ) - } - while (await this.groupAlive(sandbox, handle.pid)) await waitTick() - this.provisionalHandleQuiescent = true - return - } const sandbox = await this.runtime.getSandbox() - const processGroupId = this.remotePid + const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid + await this.terminateGroup(sandbox, handle, processGroupId) + } + + private async terminateGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise { this.terminationSignal = 'SIGTERM' try { await this.signalGroup(sandbox, processGroupId, 'TERM') - const deadline = Date.now() + this.spec.graceMs - while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) { - await waitTick() + if (await this.waitForGroupExit(sandbox, processGroupId)) { + this.markQuiescent() + return } - if (!await this.groupAlive(sandbox, processGroupId)) return } catch (_gracefulTerminationFailure) { // Failed TERM delivery or observation cannot prove exit; force cleanup still owns the group. } this.terminationSignal = 'SIGKILL' + await this.forceKillGroup(sandbox, handle, processGroupId) + this.markQuiescent() + } + + private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise { let groupFailure: unknown - let groupDelivered = false try { - groupDelivered = await this.signalGroup(sandbox, processGroupId, 'KILL') + if (!await this.signalGroup(sandbox, processGroupId, 'KILL')) { + groupFailure = new Error('process-group KILL did not report delivery') + } } catch (error: unknown) { groupFailure = error } let handleFailure: unknown try { - await handle.kill() + if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination') } catch (error: unknown) { handleFailure = error } - if (!groupDelivered && handleFailure !== undefined && await this.groupAlive(sandbox, processGroupId)) { - throw new AggregateError( - [...(groupFailure === undefined ? [] : [groupFailure]), handleFailure], - 'subprocess-e2b: force termination failed through both process-group and SDK transports', - ) + let proofFailure: unknown + try { + if (await this.waitForGroupExit(sandbox, processGroupId)) return + proofFailure = new Error(`remote process group ${processGroupId} remained live after force termination`) + } catch (error: unknown) { + proofFailure = error } + throw new AggregateError( + [ + ...(groupFailure === undefined ? [] : [groupFailure]), + ...(handleFailure === undefined ? [] : [handleFailure]), + proofFailure, + ], + 'subprocess-e2b: force termination failed through both process-group and SDK transports', + ) + } + + private async waitForGroupExit(sandbox: Sandbox, processGroupId: number): Promise { + const deadline = Date.now() + this.spec.graceMs + while (await this.groupAlive(sandbox, processGroupId)) { + if (Date.now() >= deadline) return false + await waitTick() + } + return true } private throwTerminationFailure(): void { @@ -710,8 +693,13 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise { + // TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one; + // a userspace identity precheck cannot close the numeric-PGID reuse race. try { - await sandbox.commands.run(`kill -${signal} -- -${pid}`) + await sandbox.commands.run( + `kill -${signal} -- -${pid}`, + commandOpts(this.controlEnvs, undefined), + ) return true } catch (error: unknown) { if (error instanceof CommandExitError || error instanceof SandboxNotFoundError) return false @@ -722,7 +710,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise { const result = await sandbox.commands.run( `set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`, - signalOpts(signal), + commandOpts(this.controlEnvs, signal), ).catch((error: unknown) => { if (signal?.aborted === true) return undefined if (error instanceof SandboxNotFoundError) return { exitCode: 0, stdout: '', stderr: '' } diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 9e0258c10d..ff8af60da9 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -6,6 +6,7 @@ import { PassThrough } from 'node:stream' import { posix } from 'node:path' import { CommandExitError, + e2bControlEnvs, FileNotFoundError, SandboxNotFoundError, quoteE2BShellArg, @@ -20,7 +21,11 @@ import type { SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import type E2BSandboxService from '@deepseek-ai/dsh-e2b' -import { readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts' +import { + bootstrapEnvironment, + readRemoteEnvironment, + serializeRemoteEnvironment, +} from './environment.ts' const POLL_MS = 20 @@ -54,6 +59,13 @@ function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } { return signal === undefined ? {} : { signal } } +function commandOpts( + envs: Record, + signal?: AbortSignal, +): { envs: Record; signal?: AbortSignal } { + return { envs: e2bControlEnvs(envs), ...signalOpts(signal) } +} + function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } @@ -143,8 +155,13 @@ function serializeValues(values: readonly string[], kind: string): string { return values.map(value => `${value}\0`).join('') } -async function terminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise { - const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, signalOpts(signal)) +async function terminalSessionId( + sandbox: Sandbox, + pid: number, + envs: Record, + signal?: AbortSignal, +): Promise { + const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, commandOpts(envs, signal)) signal?.throwIfAborted() return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`) } @@ -169,11 +186,16 @@ async function waitUntilReady( } } -async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise { +async function sessionProcessGroups( + sandbox: Sandbox, + sessionId: number, + envs: Record, +): Promise { let result: CommandResult try { result = await sandbox.commands.run( `set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`, + commandOpts(envs), ) } catch (error: unknown) { if (error instanceof SandboxNotFoundError) return [] @@ -194,9 +216,17 @@ async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promis return [...groups] } -async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | 'KILL'): Promise { +async function signalGroups( + sandbox: Sandbox, + groups: number[], + signal: 'TERM' | 'KILL', + envs: Record, +): Promise { try { - await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`) + await sandbox.commands.run( + `kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`, + commandOpts(envs), + ) } catch (error: unknown) { if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error } @@ -205,14 +235,15 @@ async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | async function awaitSessionEmpty( sandbox: Sandbox, sessionId: number, + envs: Record, graceMs: number, kill = false, ): Promise { const deadline = Date.now() + graceMs for (;;) { - const groups = await sessionProcessGroups(sandbox, sessionId) + const groups = await sessionProcessGroups(sandbox, sessionId, envs) if (groups.length === 0 || Date.now() >= deadline) return groups - if (kill) await signalGroups(sandbox, groups, 'KILL') + if (kill) await signalGroups(sandbox, groups, 'KILL', envs) await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now()))) } } @@ -221,6 +252,7 @@ async function rollbackUnpublishedTerminal( sandbox: Sandbox, handle: CommandHandle, completion: Promise, + envs: Record, graceMs: number, ): Promise { let topLevelExited = false @@ -234,20 +266,20 @@ async function rollbackUnpublishedTerminal( if (validPid) { sessionId = handle.pid try { - sessionId = await terminalSessionId(sandbox, handle.pid) + sessionId = await terminalSessionId(sandbox, handle.pid, envs) } 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) + let groups = await sessionProcessGroups(sandbox, sessionId, envs) if (groups.length > 0) { - await signalGroups(sandbox, groups, 'TERM') - groups = await awaitSessionEmpty(sandbox, sessionId, graceMs) + await signalGroups(sandbox, groups, 'TERM', envs) + groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs) } if (groups.length > 0) { - await signalGroups(sandbox, groups, 'KILL') - await awaitSessionEmpty(sandbox, sessionId, graceMs, true) + await signalGroups(sandbox, groups, 'KILL', envs) + await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true) } } catch (error: unknown) { attemptFailures.push(asError(error)) @@ -279,7 +311,7 @@ async function rollbackUnpublishedTerminal( const proofFailures: Error[] = [] if (sessionId !== undefined) { try { - const groups = await awaitSessionEmpty(sandbox, sessionId, graceMs, true) + const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true) if (groups.length > 0) { proofFailures.push(new Error( `subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`, @@ -322,6 +354,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { readonly output: PassThrough, private readonly completion: Promise, private readonly sessionId: number, + private readonly controlEnvs: Record, private readonly stateDir: string, private readonly graceMs: number, signal?: AbortSignal, @@ -344,7 +377,10 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { /** @inheritdoc */ async inspectForeground(): Promise { try { - const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`) + const result = await this.sandbox.commands.run( + `ps -o tpgid= -p ${this.pid}`, + commandOpts(this.controlEnvs), + ) return { processGroupId: parsePositiveId( result.stdout, @@ -369,7 +405,10 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) { throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead') } - await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${foreground.processGroupId}`) + await this.sandbox.commands.run( + `kill -${signal.slice(3)} -- -${foreground.processGroupId}`, + commandOpts(this.controlEnvs), + ) return foreground.processGroupId } @@ -402,11 +441,11 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { } private async closeOnce(): Promise { - let groups = await sessionProcessGroups(this.sandbox, this.sessionId) + let groups = await sessionProcessGroups(this.sandbox, this.sessionId, this.controlEnvs) if (groups.length > 0) { this.terminationSignal = 'SIGTERM' - await signalGroups(this.sandbox, groups, 'TERM') - groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs) + await signalGroups(this.sandbox, groups, 'TERM', this.controlEnvs) + groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs) } if (groups.length === 0 && !this.topLevelExited) { await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)]) @@ -421,7 +460,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { throw error } } - groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true) + groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, true) if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)]) } if (groups.length > 0) { @@ -469,13 +508,18 @@ export async function spawnE2BTerminal( let handle: CommandHandle | undefined let completion: Promise | undefined let stateDirectoryCreated = false + let controlEnvs: Record = {} try { const ambient = await readRemoteEnvironment(sandbox, spec.signal) + controlEnvs = bootstrapEnvironment(ambient) const environment = serializeRemoteEnvironment(ambient, spec.env) const argv = serializeValues(spec.argv, 'argv') - await sandbox.files.makeDir(stateDir) stateDirectoryCreated = true - await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stateDir)}`, signalOpts(spec.signal)) + await sandbox.files.makeDir(stateDir, signalOpts(spec.signal)) + await sandbox.commands.run( + `chmod 700 -- ${quoteE2BShellArg(stateDir)}`, + commandOpts(controlEnvs, spec.signal), + ) await sandbox.files.write([ { path: paths.runner, data: TERMINAL_RUNNER_SOURCE }, { path: paths.environment, data: environment }, @@ -484,13 +528,13 @@ export async function spawnE2BTerminal( ], signalOpts(spec.signal)) await sandbox.commands.run( `chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)} ${quoteE2BShellArg(paths.outputMarker)}`, - signalOpts(spec.signal), + commandOpts(controlEnvs, spec.signal), ) handle = await sandbox.pty.create({ rows: spec.rows, cols: spec.cols, cwd: spec.cwd, - envs: { TERM: 'dumb' }, + envs: e2bControlEnvs(controlEnvs), timeoutMs: 0, onData: (data) => { outputFilter.push(data) }, }) @@ -504,13 +548,14 @@ export async function spawnE2BTerminal( await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal)) await waitUntilReady(sandbox, paths, completion, spec.signal) await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal) - const sessionId = await terminalSessionId(sandbox, handle.pid, spec.signal) + const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal) return new E2BTerminalHandle( sandbox, handle, output, completion, sessionId, + controlEnvs, stateDir, spec.graceMs, spec.signal, @@ -524,7 +569,7 @@ export async function spawnE2BTerminal( if (!terminalQuiescent && handle !== undefined) { try { if (completion === undefined) await handle.kill() - else await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs) + else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs) terminalQuiescent = true } catch (cleanupError: unknown) { if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index d3c96cf2fc..0e906b1cfb 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -111,6 +111,7 @@ class FakeSandbox { alive = true zombieOnly = false ambient = 'PATH=/ambient/bin\0KEEP=safe\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0' + environmentHome = '/home/user' environmentWire: string | undefined environmentRequest: ((signal: AbortSignal | undefined) => Promise) | undefined processGroupId = '4242\n' @@ -241,7 +242,9 @@ class FakeSandbox { if (this.envError !== undefined) throw this.envError return { exitCode: 0, - stdout: this.environmentWire ?? Buffer.from(this.ambient).toString('base64'), + stdout: this.environmentWire ?? [this.environmentHome, this.ambient] + .map(value => Buffer.from(value).toString('base64')) + .join('\n'), stderr: '', } } @@ -396,7 +399,14 @@ describe('E2BSubprocessHandle', () => { expect(handle.pid).toBe(4343) expect(fake.handle.sent.map(value => String(value))).toEqual(['hello']) expect(fake.handle.closes).toBe(1) - expect(fake.startOptions?.envs).toBeUndefined() + const controlEnvs = fake.startOptions?.envs + expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/) + expect(controlEnvs).toEqual({ + TERM: 'dumb', + NPM_TOKEN: '', + DSH_STALE: '', + HOME: controlEnvs?.HOME, + }) 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') @@ -405,7 +415,9 @@ describe('E2BSubprocessHandle', () => { expect(command).not.toContain('explicit-secret') expect(command).not.toContain('hyphen-value') expect(command).not.toContain('${!dsh_e2b_name}') - expect(fake.commandsSeen).toContain('set -o pipefail; env -0 | base64 -w 0') + expect(fake.commandsSeen).toContain( + 'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 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') @@ -421,7 +433,7 @@ describe('E2BSubprocessHandle', () => { '/workspace/.dsh-e2b/processes/one/stderr.log', ]) expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe( - 'PATH=/bin\0KEEP=safe\0UNICODE=你好\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0', + 'PATH=/bin\0KEEP=safe\0UNICODE=你好\0HOME=/home/user\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0', ) let piped = '' @@ -560,6 +572,7 @@ describe('E2BSubprocessHandle', () => { await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) expect(fake.handle.disconnects).toBe(1) fake.alive = false + handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) }) @@ -783,7 +796,7 @@ describe('E2BSubprocessHandle', () => { expect(fake.handle.kills).toBe(1) }) - it('does not treat successful termination transport as observed quiescence', async () => { + it('keeps force cleanup retryable until quiescence is proven', async () => { const fake = new FakeSandbox() fake.trapsTerm = true fake.delaysKill = true @@ -793,16 +806,15 @@ describe('E2BSubprocessHandle', () => { await flush() handle.terminate() await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + expect(fake.alive).toBe(true) - let quiescent = false - const waiting = handle.waitForExit().then((value) => { quiescent = value }) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(quiescent).toBe(false) - - fake.alive = false - fake.finish() - await waiting - expect(quiescent).toBe(true) + fake.delaysKill = false + fake.delaysKillCompletion = false + fake.sdkKillStops = true + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) }) it('honors termination requested before asynchronous startup finishes', async () => { @@ -870,14 +882,10 @@ describe('E2BSubprocessHandle', () => { handle.terminate() await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) }) - let quiescent = false - const waiting = handle.waitForExit().then((value) => { quiescent = value }) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(quiescent).toBe(false) - + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') fake.alive = false - await waiting - expect(quiescent).toBe(true) + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) fake.releaseProcessGroupRead() fake.finish() await handle.done @@ -1102,23 +1110,36 @@ describe('E2BSubprocessHandle', () => { await expect(envHandle.done).rejects.toThrow('ambient lookup failed') expect(envFailure.removed).toEqual([]) - const malformedEnvironment = new FakeSandbox() - malformedEnvironment.environmentWire = '%' - const malformedEnvironmentHandle = new E2BSubprocessHandle( - runtime(malformedEnvironment), - spec(), - '/runtime/malformed-environment', + const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise => { + const fake = new FakeSandbox() + fake.environmentWire = wire + const failed = new E2BSubprocessHandle(runtime(fake), spec(), `/runtime/${name}`) + await expect(failed.done).rejects.toThrow(message) + } + const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64') + const encodedHome = Buffer.from('/home/user').toString('base64') + await expectEnvironmentFailure('malformed-frame', '%', 'invalid base64') + await expectEnvironmentFailure('malformed-base64', `${encodedHome}\n%`, 'invalid base64') + await expectEnvironmentFailure( + 'invalid-utf8-home', + `${Buffer.from([0xff]).toString('base64')}\n${encodedEnvironment}`, + 'not valid UTF-8', ) - await expect(malformedEnvironmentHandle.done).rejects.toThrow('invalid base64') - - const invalidUtf8Environment = new FakeSandbox() - invalidUtf8Environment.environmentWire = Buffer.from([0xff]).toString('base64') - const invalidUtf8EnvironmentHandle = new E2BSubprocessHandle( - runtime(invalidUtf8Environment), - spec(), - '/runtime/invalid-utf8-environment', + await expectEnvironmentFailure( + 'invalid-utf8-environment', + `${encodedHome}\n${Buffer.from([0xff]).toString('base64')}`, + 'not valid UTF-8', + ) + await expectEnvironmentFailure( + 'relative-home', + `${Buffer.from('home/user').toString('base64')}\n${encodedEnvironment}`, + 'remote login home is invalid', + ) + await expectEnvironmentFailure( + 'nul-home', + `${Buffer.from('/home/user\0tail').toString('base64')}\n${encodedEnvironment}`, + 'remote login home is invalid', ) - await expect(invalidUtf8EnvironmentHandle.done).rejects.toThrow('not valid UTF-8') const cleanupFailure = new FakeSandbox() cleanupFailure.backgroundError = new Error('start failed before credential consumption') @@ -1318,10 +1339,11 @@ describe('E2BSubprocessHandle', () => { const failures = Array.from(failure.errors as Iterable) expect(failures).toHaveLength(2) expect(failures[0]).toBeInstanceOf(Error) - expect(failures[1]).toBeInstanceOf(Error) - if (!(failures[0] instanceof Error) || !(failures[1] instanceof Error)) throw new Error('expected nested errors') + expect(failures[1]).toBeInstanceOf(AggregateError) + if (!(failures[0] instanceof Error) || !(failures[1] instanceof AggregateError)) throw new Error('expected nested errors') expect(failures[0].message).toContain('invalid process-group id') - expect(failures[1].message).toBe('rollback signal failed') + expect(failures[1].message).toBe('subprocess-e2b: force termination failed through both process-group and SDK transports') + expect(Array.from(failures[1].errors as Iterable)).toContainEqual(new Error('rollback signal failed')) expect(fake.handle.kills).toBe(1) const bounded = new AbortController() const waiting = handle.waitForExit(bounded.signal) @@ -1330,6 +1352,15 @@ describe('E2BSubprocessHandle', () => { handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) expect(fake.commandsSeen).toContain('kill -TERM -- -4242') + + const naturallyGone = new FakeSandbox() + naturallyGone.processGroupId = 'not-a-pid\n' + naturallyGone.signalError = new Error('rollback signal failed') + naturallyGone.handle.killError = new Error('SDK kill failed') + const observed = new E2BSubprocessHandle(runtime(naturallyGone), spec(), '/runtime/failed-rollback-observed') + await expect(observed.done).rejects.toThrow('process-group publication failed') + naturallyGone.alive = false + await expect(observed.waitForExit()).resolves.toBe(true) }) it('waits for delayed process-group publication', async () => { @@ -1479,6 +1510,24 @@ describe('E2BSubprocessHandle', () => { raced.terminate() await expect(raced.waitForExit()).resolves.toBe(true) }) + + it('rejects an optimistic SDK kill while descendants survive a failed group KILL', async () => { + const fake = new FakeSandbox() + fake.trapsTerm = true + fake.sdkKillStops = false + fake.signalErrors.push(undefined, new Error('KILL transport failed')) + const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/optimistic-sdk-kill') + await flush() + + handle.terminate() + await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both') + expect(fake.alive).toBe(true) + + fake.sdkKillStops = true + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + }) }) describe('E2BSubprocessService', () => { diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index 00b9fc41fc..8ad0eb1dde 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -23,6 +23,7 @@ function commandError(exitCode: number): CommandExitError { interface CommandOptions { signal?: AbortSignal cwd?: string + envs?: Record } class FakeTerminalCommandHandle { @@ -101,6 +102,7 @@ class FakeTerminalSandbox { writeError: unknown sendError: unknown commandFailure: unknown + makeDirRequest: ((signal: AbortSignal | undefined) => Promise) | undefined sessionGroupsFailure: unknown foregroundFailure: unknown termFailure: unknown @@ -129,8 +131,10 @@ class FakeTerminalSandbox { readonly sandbox = { files: { - makeDir: async (path: string): Promise => { + makeDir: async (path: string, options?: CommandOptions): Promise => { this.directories.push(path) + await this.makeDirRequest?.(options?.signal) + options?.signal?.throwIfAborted() return true }, write: async (files: Array<{ path: string; data: string }>): Promise => { @@ -163,7 +167,11 @@ class FakeTerminalSandbox { throw error } if (command.includes('env -0 | base64')) { - return { exitCode: 0, stdout: Buffer.from(this.ambient).toString('base64'), stderr: '' } + return { + exitCode: 0, + stdout: ['/home/user', this.ambient].map(value => Buffer.from(value).toString('base64')).join('\n'), + stderr: '', + } } if (command.includes('command -v -- ')) { return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' } @@ -262,7 +270,15 @@ describe('E2B terminal allocation', () => { expect(output).toBe('requested-shell$ ') expect(output).not.toContain('buffered banner') expect(output).not.toContain('runner.bash') - expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0, envs: { TERM: 'dumb' } }) + expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0 }) + const controlEnvs = fake.createOptions?.envs + expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/) + expect(controlEnvs).toEqual({ + TERM: 'dumb', + NPM_TOKEN: '', + DSH_STALE: '', + HOME: controlEnvs?.HOME, + }) expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'") expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0') expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0') @@ -554,6 +570,7 @@ describe('E2B terminal lifecycle', () => { new PassThrough(), fake.handle.wait(), 123, + { TERM: 'dumb' }, '/runtime/pre-aborted', 1, controller.signal, @@ -807,7 +824,10 @@ describe('E2B subprocess terminal service', () => { fake.resolvedExecutable = 'tools/bin/node\n' await expect(ctx.subprocess.resolveExecutable('node', { PATH: 'tools/bin' })) .resolves.toBe('/workspace/tools/bin/node') - expect(fake.commandOptions.at(-1)).toMatchObject({ cwd: '/workspace' }) + const commandOptions = fake.commandOptions.at(-1) + expect(commandOptions).toMatchObject({ cwd: '/workspace' }) + expect(commandOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/) + expect(commandOptions?.envs).toEqual({ HOME: commandOptions?.envs?.HOME }) expect((ctx.e2b)).toBeDefined() }) @@ -867,6 +887,29 @@ describe('E2B subprocess terminal service', () => { expect(fake.handle.disconnects).toBe(1) }) + it('owns and cancels terminal state-directory creation during disposal', async () => { + const fake = new FakeTerminalSandbox() + fake.makeDirRequest = async (signal) => { + await new Promise((_resolve, reject) => { + const onAbort = (): void => { + const reason: unknown = signal?.reason + reject(reason instanceof Error ? reason : new Error(String(reason))) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted === true) onAbort() + }) + } + const { ctx, fiber } = await service(fake) + const spawning = ctx.subprocess.spawnTerminal(spec()) + const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup') + await vi.waitFor(() => { expect(fake.directories.some(path => path.includes('/terminals/'))).toBe(true) }) + + await fiber.dispose() + await rejected + expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true) + expect(fake.createOptions).toBeUndefined() + }) + it('retains failed terminal setup cleanup for disposal retry', async () => { const fake = new FakeTerminalSandbox() fake.sendError = new Error('bootstrap failed')