fix(e2b): harden remote adapter boundaries
This commit is contained in:
@@ -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: a713cca3fd9e7ee11a2c487bf6ed2c3a205dc502
|
||||
2026-07-27-e2b-remote-runtime-poc.zh.md: 7afd54f72622d51a3d71719b3b4975ccd7f83e3a
|
||||
2026-07-27-e2b-remote-runtime-poc.md: efb91fc166f36dc8fb047d686e8cd82d93b9af6a
|
||||
2026-07-27-e2b-remote-runtime-poc.zh.md: eb14cccc284d38cdc5cc704512b6899621a9a840
|
||||
@@ -18,7 +18,7 @@ The E2B integration is an opt-in provider-composition POC. Its six E2B-specific
|
||||
- `@deepseek-ai/dsh-fs-e2b` implements `ctx.fs` over that sandbox's Filesystem API.
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands and remote Linux process groups.
|
||||
- `@deepseek-ai/dsh-pty-e2b` registers an E2B byte-PTY backend on `ctx.pty` while the existing registry retains exact-Agent ownership.
|
||||
- `@deepseek-ai/dsh-lsp-e2b` registers configured remote language servers on `ctx.lsp`, reads source through E2B Filesystem APIs, and runs servers through `dsh-subprocess-e2b`.
|
||||
- `@deepseek-ai/dsh-lsp-e2b` registers configured remote language servers on `ctx.lsp`, reads source through a bounded no-follow helper in E2B, and runs servers through `dsh-subprocess-e2b`.
|
||||
- `@deepseek-ai/dsh-code-runtime-e2b` registers `ctx.codeRuntime`, runs each model program in a fresh remote worker, and dispatches binding functions in the host process.
|
||||
- The existing `@deepseek-ai/dsh-bash-local` remains the Bash implementation because it delegates all process mechanics to `ctx.subprocess`.
|
||||
|
||||
@@ -28,11 +28,11 @@ The providers reuse the PTY, LSP, Code Runtime, and subprocess seams without cha
|
||||
|
||||
## POC boundary
|
||||
|
||||
E2B owns the mutable filesystem, command and Bash processes, PTY shell and foreground process groups, language-server processes and source reads, the Code Runtime runner and worker, and adapter-private files under `.dsh-e2b`.
|
||||
E2B owns the mutable filesystem, command and Bash processes, PTY shell and terminal-session process groups, language-server processes and source reads, the Code Runtime launcher, controller, and worker, and adapter-private files under `.dsh-e2b`.
|
||||
|
||||
The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority decisions, skills, subagent orchestration, PTY buffers and readiness state, LSP JSON-RPC ids/queues/protocol state, Code Runtime type stripping/output accounting/binding dispatch, and E2B SDK/network orchestration. The overlay does not upload, mount, or synchronize the host workspace; identical cwd strings name independent host and remote directories.
|
||||
|
||||
Byte-sensitive protocols use the narrowest adapter required by E2B's callback shapes. PTY consumes the SDK's byte callback directly. LSP and Code Runtime install dependency-free remote helpers that encode raw payloads as validated newline-delimited base64 JSON, keeping E2B's decoded command callbacks on an ASCII transport.
|
||||
Byte-sensitive protocols use the narrowest adapter required by E2B's callback shapes. PTY consumes the SDK's byte callback directly. LSP installs a bounded remote source reader, while Code Runtime keeps framed stdout in a launcher process isolated from the controller and worker descriptors. Their dependency-free helpers encode protocol payloads as validated newline-delimited base64 JSON, keeping E2B's decoded command callbacks on an ASCII transport.
|
||||
|
||||
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.
|
||||
|
||||
@@ -40,9 +40,9 @@ The POC has no session-persistence backend, template builder, volume, snapshot,
|
||||
|
||||
## Verification
|
||||
|
||||
Focused package suites pin owner lifecycle cleanup, filesystem semantics, subprocess process groups, configuration and publication rollback, byte framing and multibyte boundaries, PTY readiness/signals, LSP transport and source containment, Code Runtime bindings, hostile traffic, output limits, timeout/abort ordering, disposal to quiescence, and package-owned invariant registrations. Adjacent local-backend suites pin the shared PTY utilities and the LSP cross-namespace `processId` behavior.
|
||||
Focused package suites pin owner lifecycle cleanup, filesystem semantics and commit metadata, subprocess process groups, configuration and verified publication rollback, byte framing and multibyte boundaries, PTY readiness/signals/default-environment scrubbing/terminal-session cleanup, stable bounded LSP source reads, Code Runtime binding and descriptor isolation, worker-pipe draining, hostile traffic, output limits, timeout/abort ordering, disposal to quiescence, and package-owned invariant registrations. Adjacent local-backend suites pin the shared PTY utilities and the LSP cross-namespace `processId` behavior.
|
||||
|
||||
A credential-gated Loader composition creates one real E2B sandbox and exercises FS-to-Bash and Bash-to-FS visibility, multibyte PTY output and `SIGINT`, multibyte LSP hover and definition results, Code Runtime host bindings and typed rejection under mutation of adapter-captured intrinsics, wall timeout, abort, runner cleanup, host-workspace isolation, and final sandbox deletion. The same scenario runs through source imports and built package exports.
|
||||
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 and process-tree cleanup, stable bounded LSP source reads, Code Runtime host bindings and descriptor-isolated output accounting, 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
|
||||
|
||||
@@ -66,4 +66,4 @@ A credential-gated Loader composition creates one real E2B sandbox and exercises
|
||||
|
||||
The small composition demonstrates that existing capability seams can move an agent's mutable coding world off-host without changing the loop or model-facing tool packages. `sandboxId` plus pause/leave permits manual remote-file retention for experiments, while kill remains the demo's cleanup policy.
|
||||
|
||||
The providers are not interchangeable with local backends for every consumer: remote startup cannot synchronously expose a PID, E2B retains complete command output in SDK memory, ordinary command callbacks are not byte-faithful, signal attribution is partly inferred, and reconnect cannot restore handles or protocol state. PTY uses E2B's byte API; LSP and Code Runtime add validated ASCII framing where protocol bytes matter. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a captured remote process group does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
|
||||
The providers are not interchangeable with local backends for every consumer: remote startup cannot synchronously expose a PID, E2B retains complete command output in SDK memory, ordinary command callbacks are not byte-faithful, signal attribution is partly inferred, and reconnect cannot restore handles or protocol state. PTY uses E2B's byte API; LSP and Code Runtime add validated ASCII framing where protocol bytes matter. Remote process/spill artifacts accumulate in a retained sandbox, Code programs share a JavaScript realm with Node worker internals, and a process that deliberately escapes a managed process group or PTY session does not become reconnectable or owned. These gaps remain documented POC constraints rather than compatibility shims or new cross-cutting abstractions.
|
||||
@@ -18,7 +18,7 @@ E2B 集成是一个选择性启用的提供方组合 POC。它的 6 个 E2B 专
|
||||
- `@deepseek-ai/dsh-fs-e2b` 在该沙箱的 Filesystem API 之上实现 `ctx.fs`。
|
||||
- `@deepseek-ai/dsh-subprocess-e2b` 在 E2B Commands 和远程 Linux 进程组之上实现 `ctx.subprocess`。
|
||||
- `@deepseek-ai/dsh-pty-e2b` 在 `ctx.pty` 上注册 E2B 字节 PTY 后端,并把精确的 Agent 所有权保留在现有注册表中。
|
||||
- `@deepseek-ai/dsh-lsp-e2b` 在 `ctx.lsp` 上注册已配置的远程语言服务器,通过 E2B Filesystem API 读取源代码,并通过 `dsh-subprocess-e2b` 运行服务器。
|
||||
- `@deepseek-ai/dsh-lsp-e2b` 在 `ctx.lsp` 上注册已配置的远程语言服务器,通过 E2B 内有界且不跟随链接的辅助程序读取源代码,并通过 `dsh-subprocess-e2b` 运行服务器。
|
||||
- `@deepseek-ai/dsh-code-runtime-e2b` 注册 `ctx.codeRuntime`,在全新的远程 worker 中运行每个模型程序,并在宿主进程中分发绑定函数。
|
||||
- 现有的 `@deepseek-ai/dsh-bash-local` 继续作为 Bash 实现,因为它把所有进程机制委托给 `ctx.subprocess`。
|
||||
|
||||
@@ -28,11 +28,11 @@ E2B 集成是一个选择性启用的提供方组合 POC。它的 6 个 E2B 专
|
||||
|
||||
## POC 边界
|
||||
|
||||
E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与前台进程组、语言服务器进程及源码读取、Code Runtime 运行器和 worker,以及 `.dsh-e2b` 下的适配器私有文件。
|
||||
E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与终端会话进程组、语言服务器进程及源码读取、Code Runtime launcher、controller 和 worker,以及 `.dsh-e2b` 下的适配器私有文件。
|
||||
|
||||
宿主拥有 Cordis 与插件对象、agent loop、agent/会话/goal 状态、会话日志及持久化、LLM(大语言模型)调用、提示词与工具、权限决策、skill(技能)、subagent 编排、PTY 缓冲与就绪状态、LSP JSON-RPC id/队列/协议状态、Code Runtime 类型剥离/输出计量/绑定分发,以及 E2B SDK/网络编排。该 overlay 不会上传、挂载或同步宿主工作区;拼写相同的 cwd 字符串分别指向彼此独立的宿主与远程目录。
|
||||
|
||||
对字节敏感的协议只使用适配 E2B 回调形状所需的最窄适配器。PTY 直接消费 SDK 的字节回调。LSP 与 Code Runtime 会安装无依赖的远程辅助程序,把原始载荷编码为经过验证、以换行分隔的 base64 JSON,并通过 ASCII 传输承载 E2B 已解码的命令回调。
|
||||
对字节敏感的协议只使用适配 E2B 回调形状所需的最窄适配器。PTY 直接消费 SDK 的字节回调。LSP 会安装一个有界的远程源码读取器;Code Runtime 则把分帧 stdout 保留在与 controller 和 worker 描述符隔离的 launcher 进程内。它们的无依赖辅助程序会把协议载荷编码为经过验证、以换行分隔的 base64 JSON,并通过 ASCII 传输承载 E2B 已解码的命令回调。
|
||||
|
||||
保留沙箱只会保存远程文件与未受管的远程状态。重新连接不会重建宿主 PTY 会话、缓冲、进程句柄、LSP 连接或请求、代码 worker、绑定调用、定时器、输出游标或锁。受管进程组会在所属提供方 dispose(资源释放)时终止并等待退出,之后共享所有者才会暂停、脱离或终止沙箱。
|
||||
|
||||
@@ -40,9 +40,9 @@ E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与前台进程
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦包测试套件固定所有者生命周期清理、文件系统语义、进程管理的进程组、配置与发布回滚、字节分帧与多字节边界、PTY 就绪状态/信号、LSP 传输与源码路径约束、Code Runtime 绑定、恶意通信、输出上限、超时/中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。相邻本地后端测试套件固定共享 PTY 工具函数,以及 LSP 跨命名空间 `processId` 行为。
|
||||
聚焦包测试套件固定所有者生命周期清理、文件系统语义与提交元数据、进程管理的进程组、配置与经过验证的发布回滚、字节分帧与多字节边界、PTY 就绪状态/信号/默认环境清理/终端会话清理、稳定且有界的 LSP 源码读取、Code Runtime 绑定与描述符隔离、worker 管道排空、恶意通信、输出上限、超时/中止顺序、等待完全停稳的资源释放,以及包自有不变式注册。相邻本地后端测试套件固定共享 PTY 工具函数,以及 LSP 跨命名空间 `processId` 行为。
|
||||
|
||||
凭据门控的 Loader 组合会创建一个真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、多字节 PTY 输出和 `SIGINT`、多字节 LSP 悬停与定义结果、Code Runtime 宿主绑定,以及适配器已捕获 intrinsic 被修改时的类型化 reject、墙钟超时、中止、运行器清理、宿主工作区隔离,以及最终删除沙箱。同一场景分别通过源代码导入与已构建包导出运行。
|
||||
凭据门控的 Loader 组合会创建真实 E2B 沙箱,并演练 FS-to-Bash 与 Bash-to-FS 可见性、进程发布回滚、有界 spill 输出、PTY 默认秘密清理与进程树清理、稳定且有界的 LSP 源码读取、Code Runtime 宿主绑定与描述符隔离的输出记账、墙钟超时、中止、runner 清理、宿主工作区隔离,以及最终删除沙箱。同一组合分别通过源代码导入与已构建包导出运行。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -66,4 +66,4 @@ E2B 拥有可变文件系统、命令和 Bash 进程、PTY shell 与前台进程
|
||||
|
||||
这个小型组合证明,现有功能 seam 可以把 agent 的可变 coding 环境移出宿主,而无需改变循环或面向模型的工具包。`sandboxId` 与 `pause`/`leave` 允许实验手动保留远程文件,演示仍以 `kill` 作为清理策略。
|
||||
|
||||
这些提供方并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,普通命令回调并非字节保真,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 API;LSP 与 Code Runtime 则在必须保真处理协议字节之处增加经过验证的 ASCII 分帧。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离已捕获远程进程组的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
|
||||
这些提供方并不能对所有消费方与本地后端互换:远程启动无法同步公开 PID,E2B 会在 SDK 内存中保留完整命令输出,普通命令回调并非字节保真,信号归因部分依靠推断,重新连接也无法恢复句柄或协议状态。PTY 使用 E2B 的字节 API;LSP 与 Code Runtime 则在必须保真处理协议字节之处增加经过验证的 ASCII 分帧。保留沙箱后会累积远程进程/spill 产物,模型程序与 Node worker 内部机制共享一个 JavaScript realm,有意逃离受管理进程组或 PTY 会话的进程也不会因此变得可重新连接或由该组合管理。这些缺口作为 POC 约束明确记录,而不会引入兼容垫片或新的跨领域抽象。
|
||||
+126
-2
@@ -5,7 +5,7 @@ import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-e2b'
|
||||
import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-fs-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-bash-local'
|
||||
import type {} from '@deepseek-ai/dsh-lsp-e2b'
|
||||
@@ -34,6 +34,7 @@ const owner: Agent = {
|
||||
const unregisterOwner = ctx.agents.register(owner)
|
||||
let terminalId: Awaited<ReturnType<typeof ctx.pty.spawn>>['sessionId'] | undefined
|
||||
try {
|
||||
const sandbox = await ctx.e2b.getSandbox()
|
||||
const fromFs = await ctx.fs.resolve('from-fs.txt')
|
||||
await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' })
|
||||
const bashRead = await ctx.bash.run(ctx.bash.resolve({ command: 'cat from-fs.txt' }))
|
||||
@@ -72,6 +73,38 @@ try {
|
||||
].every(entry => environmentLines.has(entry))
|
||||
if (!explicitEnvironment) throw new Error(`E2B subprocess dropped an explicit environment entry: ${environmentText}`)
|
||||
|
||||
const remoteFiles = sandbox.files as unknown as {
|
||||
read(path: string, options?: unknown): Promise<unknown>
|
||||
}
|
||||
const readRemoteFile = remoteFiles.read.bind(sandbox.files)
|
||||
let publicationFaultInjected = false
|
||||
remoteFiles.read = async (path, options) => {
|
||||
if (!publicationFaultInjected && path.includes('/processes/') && path.endsWith('/pid')) {
|
||||
publicationFaultInjected = true
|
||||
throw new Error('injected process-group publication read failure')
|
||||
}
|
||||
return await readRemoteFile(path, options)
|
||||
}
|
||||
let publicationRollback = false
|
||||
try {
|
||||
const unpublished = ctx.subprocess.spawn({
|
||||
argv: ['bash', '-c', 'exec -a dsh-publication-survivor sleep 30 & wait'],
|
||||
cwd: process.cwd(),
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 4_096 }, stderr: { maxBytes: 4_096 } },
|
||||
graceMs: 500,
|
||||
env: {},
|
||||
})
|
||||
await unpublished.done
|
||||
throw new Error('E2B subprocess unexpectedly survived an injected publication failure')
|
||||
} catch (error: unknown) {
|
||||
if (!String(error).includes('injected process-group publication read failure')) throw error
|
||||
const processes = await sandbox.commands.run('ps -eo args=')
|
||||
publicationRollback = publicationFaultInjected && !processes.stdout.includes('dsh-publication-survivor')
|
||||
if (!publicationRollback) throw new Error('E2B subprocess publication rollback left its remote process group alive')
|
||||
} finally {
|
||||
remoteFiles.read = readRemoteFile
|
||||
}
|
||||
|
||||
const spillHandle = ctx.subprocess.spawn({
|
||||
argv: ['bash', '-c', "printf '0123456789'; sleep 30"],
|
||||
cwd: process.cwd(),
|
||||
@@ -81,7 +114,7 @@ try {
|
||||
})
|
||||
const spillReader = spillHandle.collected.stdout
|
||||
if (spillReader === undefined) throw new Error('E2B subprocess omitted its configured stdout collector')
|
||||
const spillDeadline = Date.now() + 5_000
|
||||
const spillDeadline = Date.now() + 15_000
|
||||
while (spillReader.readFrom(0).nextOffset < 10) {
|
||||
if (Date.now() >= spillDeadline) throw new Error('E2B subprocess did not stream the spill probe output')
|
||||
await new Promise(resolveDelay => setTimeout(resolveDelay, 20))
|
||||
@@ -114,6 +147,53 @@ try {
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
|
||||
const swappedSource = await ctx.fs.resolve('swapped-source.ts')
|
||||
const swappedSourcePath = posix.join(process.cwd(), 'swapped-source.ts')
|
||||
await ctx.fs.writeText(swappedSource, 'const safe = true\n', { kind: 'createIfAbsent' })
|
||||
const remoteCommands = sandbox.commands as unknown as {
|
||||
run(command: string, options?: unknown): Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
}
|
||||
const runRemoteCommand = remoteCommands.run.bind(sandbox.commands)
|
||||
let containmentFaultInjected = false
|
||||
remoteCommands.run = async (command, options) => {
|
||||
if (!containmentFaultInjected && command.includes('dsh-e2b-source-reader') && command.includes('swapped-source.ts')) {
|
||||
containmentFaultInjected = true
|
||||
await runRemoteCommand(`rm -f -- ${quoteE2BShellArg(swappedSourcePath)} && ln -s -- /etc/hosts ${quoteE2BShellArg(swappedSourcePath)}`)
|
||||
}
|
||||
return await runRemoteCommand(command, options)
|
||||
}
|
||||
let lspContainment = false
|
||||
try {
|
||||
await ctx.lsp.query({
|
||||
operation: 'hover',
|
||||
filePath: 'swapped-source.ts',
|
||||
position: { line: 0, character: 1 },
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
lspContainment = containmentFaultInjected && String(error).includes('opened safely')
|
||||
if (!lspContainment) throw error
|
||||
} finally {
|
||||
remoteCommands.run = runRemoteCommand
|
||||
}
|
||||
if (!lspContainment) throw new Error('E2B LSP source swap was not rejected')
|
||||
|
||||
const oversizedSourcePath = posix.join(process.cwd(), 'oversized-source.ts')
|
||||
await sandbox.commands.run(`head -c 4000001 /dev/zero > ${quoteE2BShellArg(oversizedSourcePath)}`)
|
||||
let lspDocumentBound = false
|
||||
try {
|
||||
await ctx.lsp.query({
|
||||
operation: 'hover',
|
||||
filePath: 'oversized-source.ts',
|
||||
position: { line: 0, character: 0 },
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
lspDocumentBound = String(error).includes('over the 4000000-byte limit')
|
||||
if (!lspDocumentBound) throw error
|
||||
}
|
||||
if (!lspDocumentBound) throw new Error('E2B LSP accepted an oversized remote source')
|
||||
|
||||
const terminal = await ctx.pty.spawn(owner, { type: 'shell' })
|
||||
terminalId = terminal.sessionId
|
||||
const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, {
|
||||
@@ -124,9 +204,19 @@ try {
|
||||
await new Promise(resolveDelay => setTimeout(resolveDelay, 150))
|
||||
const terminalSignal = await ctx.pty.signal(owner, terminal.sessionId, 'SIGINT')
|
||||
const interrupted = await sleeping.done
|
||||
const stubborn = await ctx.pty.startSend(owner, terminal.sessionId, {
|
||||
text: "bash -c 'trap \"\" TERM; exec sleep 30' & printf 'DSH_STUBBORN_PID=%s\\n' \"$!\"",
|
||||
submit: true,
|
||||
}).done
|
||||
const stubbornMatch = /DSH_STUBBORN_PID=([1-9][0-9]*)/.exec(stubborn.viewport)
|
||||
if (stubbornMatch?.[1] === undefined) throw new Error(`E2B PTY did not report its stubborn child: ${stubborn.viewport}`)
|
||||
const stubbornPid = Number(stubbornMatch[1])
|
||||
const terminalScrollback = ctx.pty.read(owner, terminal.sessionId, { count: 50 })
|
||||
await ctx.pty.kill(owner, terminal.sessionId, 'live E2B composition complete')
|
||||
terminalId = undefined
|
||||
const stubbornProbe = await sandbox.commands.run(`if kill -0 ${stubbornPid} 2>/dev/null; then printf alive; else printf gone; fi`)
|
||||
const terminalTreeCleanup = stubbornProbe.stdout === 'gone'
|
||||
if (!terminalTreeCleanup) throw new Error(`E2B PTY left process ${stubbornPid} alive after close`)
|
||||
|
||||
const code = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
@@ -179,6 +269,33 @@ try {
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
const nativeOutput = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
let stdoutPrototype = Object.getPrototypeOf(process.stdout)
|
||||
while (stdoutPrototype && !Object.hasOwn(stdoutPrototype, 'write')) stdoutPrototype = Object.getPrototypeOf(stdoutPrototype)
|
||||
Reflect.apply(stdoutPrototype.write, process.stdout, ['x'.repeat(8192)])
|
||||
return true
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
const descriptorOutput = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
const fs = await import('node:fs')
|
||||
const forged = Buffer.from(JSON.stringify({ type: 'done' })).toString('base64') + '\\n'
|
||||
fs.writeSync(1, forged)
|
||||
fs.writeSync(1, 'x'.repeat(8192))
|
||||
return true
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
const inheritedOutput = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
const childProcess = await import('node:child_process')
|
||||
childProcess.spawnSync(process.execPath, ['-e', 'process.stdout.write("x".repeat(8192))'], { stdio: 'inherit' })
|
||||
return true
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
const timedOut = await ctx.codeRuntime.run({
|
||||
program: 'await new Promise(() => {})',
|
||||
bindings: [],
|
||||
@@ -212,18 +329,25 @@ try {
|
||||
bashRead: bashRead.stdout.text,
|
||||
fsRead,
|
||||
explicitEnvironment,
|
||||
publicationRollback,
|
||||
spill: { liveBytes: liveSpillBytes, outcome: spillOutcome, read: spillRead },
|
||||
hover,
|
||||
definition,
|
||||
lspContainment,
|
||||
lspDocumentBound,
|
||||
terminal: {
|
||||
motd: terminal.motd,
|
||||
echo: terminalEcho,
|
||||
signal: terminalSignal,
|
||||
interrupted,
|
||||
treeCleanup: terminalTreeCleanup,
|
||||
scrollback: terminalScrollback.text,
|
||||
},
|
||||
code,
|
||||
hostileOutput,
|
||||
nativeOutput,
|
||||
descriptorOutput,
|
||||
inheritedOutput,
|
||||
timedOut,
|
||||
aborted,
|
||||
oversizedBoot,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: '@deepseek-ai/dsh-e2b'
|
||||
config:
|
||||
cwd: !!js process.cwd()
|
||||
timeoutMs: 120000
|
||||
timeoutMs: 180000
|
||||
onTimeout: kill
|
||||
onDispose: kill
|
||||
|
||||
|
||||
@@ -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/code-runtime-e2b/README.md
|
||||
README.md: 1007800c59725d116197a52f6d087ea5e2860686
|
||||
README.zh.md: a56026bce760879079627dd63a80dfe95c4d2049
|
||||
README.md: 171e63a8c1ca70deb63e9860f5088b83401a37d0
|
||||
README.zh.md: b70984e765d32985436c15dd543263cc06a25715
|
||||
@@ -19,7 +19,7 @@ Every value is a positive safe integer. `maxOutputBytes` is at least four bytes,
|
||||
|
||||
## Execution and bridge contract
|
||||
|
||||
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner creates a fresh worker thread with an empty environment and heap limit, measures active event-loop time, and destroys that worker after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
|
||||
Setup uploads one dependency-free runner under `ctx.e2b.runtimeRoot` and resolves remote Node. For each run, the host wraps and type-strips erasable TypeScript with Node's `stripTypeScriptTypes`, then starts the runner in `ctx.e2b.cwd`. The runner keeps the framed host protocol in a launcher process, forks a controller whose stdout and stderr are bounded data pipes, and creates a fresh worker thread with an empty environment and heap limit. Model writes to native descriptors and inherited child output therefore cannot enter the frame stream; worker and controller pipes drain before the terminal frame. The worker measures active event-loop time and is destroyed after one completion. The enclosing E2B subprocess group is terminated and awaited after every result, timeout, abort, or disposal, so ordinary child processes in that group stop with the run.
|
||||
|
||||
The bridge uses validated newline-delimited base64 JSON frames because E2B subprocess callbacks expose decoded text. Binding arguments and resolutions use the worker runtime's iterative lossless-JSON wire shape; binding functions execute on the host and typed rejection classes are materialized inside the remote worker. The worker captures the JavaScript intrinsics that its adapter boundary invokes before model code runs, hardening binding transport, output accounting, and completion validation against mutation of those references. The host repeats message validation, call-id deduplication, lossless-JSON checks, and the outer-output ledger.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 执行与桥接契约
|
||||
|
||||
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会创建一个具有空环境与堆上限的全新 worker 线程,测量事件循环活跃时间,并在一次运行结算后销毁该 worker。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
|
||||
设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个无依赖的 runner,并解析远程 Node。每次运行时,宿主会包装仅使用可擦除语法的 TypeScript,再用 Node 的 `stripTypeScriptTypes` 剥离类型,然后在 `ctx.e2b.cwd` 中启动 runner。runner 会把面向宿主的分帧协议保留在 launcher 进程内,派生一个以 stdout 和 stderr 作为有界数据管道的 controller,再创建一个具有空环境与堆上限的全新 worker 线程。因此,模型对原生描述符的写入和继承的子进程输出无法进入分帧流;worker 与 controller 管道会在发出终结帧前排空。worker 会测量事件循环活跃时间,并在一次运行结算后销毁。每当运行返回结果、超时、中止或因资源释放终止时,系统都会终止外围的 E2B 进程组并等待其退出,因此组内的普通子进程会随本次运行一同停止。
|
||||
|
||||
由于 E2B 进程管理回调公开的是已解码文本,桥接层使用经过验证、以换行分隔的 base64 JSON 帧。绑定参数与 resolve 值使用 worker 运行时的迭代式无损 JSON wire 形状;绑定函数在宿主执行,类型化的 reject 类则在远程 worker 内物化。worker 会在模型代码运行前捕获其适配器边界调用的 JavaScript intrinsic,从而增强绑定传输、输出记账与完成值验证对这些引用修改的抵御能力。宿主会再次执行消息验证、调用 id 去重和无损 JSON 检查,并用外层输出账本再次计量。
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
/** Node program that runs one model program in a fresh remote worker thread. */
|
||||
export const CODE_RUNNER_SOURCE = String.raw`import { Buffer } from 'node:buffer'
|
||||
import { fork } from 'node:child_process'
|
||||
import { inspect } from 'node:util'
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const emitFrame = message => {
|
||||
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
|
||||
@@ -12,23 +14,140 @@ const emitFrame = message => {
|
||||
|
||||
const parseFrame = line => JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
|
||||
|
||||
if (isMainThread) {
|
||||
const waitForPipeDrain = stream => {
|
||||
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
|
||||
return new Promise(resolve => {
|
||||
const done = () => {
|
||||
stream.off('end', done)
|
||||
stream.off('close', done)
|
||||
stream.off('error', done)
|
||||
resolve()
|
||||
}
|
||||
stream.once('end', done)
|
||||
stream.once('close', done)
|
||||
stream.once('error', done)
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
const waitForChildExit = child => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise(resolve => { child.once('exit', resolve) })
|
||||
}
|
||||
|
||||
const jsonStringBytes = text => Buffer.byteLength(JSON.stringify(text))
|
||||
|
||||
const truncateLog = (text, available) => {
|
||||
if (available < 2) return ''
|
||||
let result = ''
|
||||
let bytes = 2
|
||||
for (const character of text) {
|
||||
const cost = jsonStringBytes(character) - 2
|
||||
if (bytes + cost > available) break
|
||||
bytes += cost
|
||||
result += character
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const runLauncher = () => {
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
let controller
|
||||
let maxOutputBytes = 0
|
||||
let logBytes = 2
|
||||
let logEntries = 0
|
||||
let settling = false
|
||||
let closed = false
|
||||
let terminal
|
||||
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
emitFrame(message)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
Promise.resolve(current ? current.terminate() : undefined).finally(() => {
|
||||
if (settling) {
|
||||
if (message.type === 'output-limit') terminal = message
|
||||
return
|
||||
}
|
||||
settling = true
|
||||
terminal = message
|
||||
const current = controller
|
||||
controller = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
const exited = waitForChildExit(current)
|
||||
current.kill('SIGKILL')
|
||||
await Promise.all([exited, stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
process.stderr.write('code-runtime-e2b controller cleanup error: ' + String(error) + '\n')
|
||||
}).then(() => {
|
||||
closed = true
|
||||
emitFrame(terminal)
|
||||
input.close()
|
||||
process.stdin.destroy()
|
||||
})
|
||||
}
|
||||
|
||||
const forwardLog = text => {
|
||||
if (closed || terminal?.type === 'output-limit') return
|
||||
const separator = logEntries > 0 ? 1 : 0
|
||||
const available = maxOutputBytes - logBytes - separator
|
||||
const cost = jsonStringBytes(text)
|
||||
if (cost > available) {
|
||||
const prefix = truncateLog(text, available)
|
||||
if (prefix) {
|
||||
logBytes += jsonStringBytes(prefix) + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text: prefix })
|
||||
}
|
||||
finish({ type: 'output-limit' })
|
||||
return
|
||||
}
|
||||
logBytes += cost + separator
|
||||
logEntries += 1
|
||||
emitFrame({ type: 'log', text })
|
||||
}
|
||||
|
||||
const startController = message => {
|
||||
maxOutputBytes = message.maxOutputBytes
|
||||
controller = fork(fileURLToPath(import.meta.url), [], {
|
||||
env: { DSH_CODE_RUNTIME_CONTROLLER: '1' },
|
||||
execArgv: [],
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
const current = controller
|
||||
current.stdout.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.stderr.on('data', data => { forwardLog(data.toString('utf8')) })
|
||||
current.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
forwardLog(raw.text)
|
||||
return
|
||||
}
|
||||
if (settling) return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'output-limit') {
|
||||
finish({ type: 'output-limit' })
|
||||
} else if (raw.type === 'done') {
|
||||
if (raw.error && typeof raw.error === 'object' && typeof raw.error.kind === 'string' && typeof raw.error.message === 'string') {
|
||||
finish({ type: 'done', error: { kind: raw.error.kind, message: raw.error.message } })
|
||||
} else if (raw.value === undefined || Array.isArray(raw.value)) {
|
||||
finish({ type: 'done', ...(raw.value === undefined ? {} : { value: raw.value }) })
|
||||
}
|
||||
}
|
||||
})
|
||||
current.on('error', error => {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller error: ' + error.message } })
|
||||
})
|
||||
current.on('exit', code => {
|
||||
if (!settling) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller exited with code ' + code + ' before completing' } })
|
||||
})
|
||||
current.send(message, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller boot failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
|
||||
input.on('line', line => {
|
||||
let message
|
||||
try {
|
||||
@@ -38,26 +157,77 @@ if (isMainThread) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received a malformed frame' } })
|
||||
return
|
||||
}
|
||||
if (!controller) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces) || !Number.isSafeInteger(message.maxOutputBytes) || message.maxOutputBytes < 4) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
startController(message)
|
||||
return
|
||||
}
|
||||
if (message && message.type === 'reply' && typeof message.id === 'number' && typeof message.ok === 'boolean') {
|
||||
controller.send(message.ok
|
||||
? { type: 'reply', id: message.id, ok: true, value: message.value }
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) }, error => {
|
||||
if (error) finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller reply failed: ' + error.message } })
|
||||
})
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (controller && !settling) controller.kill('SIGKILL') })
|
||||
}
|
||||
|
||||
const runController = () => {
|
||||
let worker
|
||||
let finished = false
|
||||
let computeTimer
|
||||
const send = message => {
|
||||
if (process.send) process.send(message)
|
||||
}
|
||||
const finish = message => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearInterval(computeTimer)
|
||||
const current = worker
|
||||
worker = undefined
|
||||
const drain = current
|
||||
? new Promise(resolve => { setImmediate(resolve) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(current.stdout)
|
||||
const stderrDrained = waitForPipeDrain(current.stderr)
|
||||
await Promise.all([current.terminate(), stdoutDrained, stderrDrained])
|
||||
})
|
||||
: Promise.resolve()
|
||||
void drain.catch(error => {
|
||||
send({ type: 'log', text: 'code-runtime-e2b worker cleanup error: ' + String(error) + '\n' })
|
||||
}).then(() => {
|
||||
if (!process.send) {
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
process.send(message, () => { if (process.connected) process.disconnect() })
|
||||
})
|
||||
}
|
||||
process.on('message', message => {
|
||||
if (!worker) {
|
||||
if (!message || message.type !== 'boot' || typeof message.code !== 'string' || !Array.isArray(message.namespaces)) {
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote runner received an invalid boot frame' } })
|
||||
finish({ type: 'done', error: { kind: 'worker-exit', message: 'remote controller received an invalid boot frame' } })
|
||||
return
|
||||
}
|
||||
worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: message,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
resourceLimits: { maxOldGenerationSizeMb: message.maxOldGenerationSizeMb },
|
||||
})
|
||||
worker.stdout.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stderr.on('data', data => { emitFrame({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stdout.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.stderr.on('data', data => { send({ type: 'log', text: data.toString('utf8') }) })
|
||||
worker.on('message', raw => {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
if (raw.type === 'call' && typeof raw.id === 'number' && typeof raw.global === 'string' && typeof raw.name === 'string' && Array.isArray(raw.args)) {
|
||||
emitFrame({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
send({ type: 'call', id: raw.id, global: raw.global, name: raw.name, args: raw.args })
|
||||
} else if (raw.type === 'log' && typeof raw.text === 'string') {
|
||||
emitFrame({ type: 'log', text: raw.text })
|
||||
send({ type: 'log', text: raw.text })
|
||||
} else if (raw.type === 'output-limit') {
|
||||
finish({ type: 'output-limit' })
|
||||
} else if (raw.type === 'done') {
|
||||
@@ -88,8 +258,10 @@ if (isMainThread) {
|
||||
: { type: 'reply', id: message.id, ok: false, message: String(message.message) })
|
||||
}
|
||||
})
|
||||
input.on('close', () => { if (worker && !finished) void worker.terminate() })
|
||||
} else {
|
||||
process.on('disconnect', () => { if (worker && !finished) void worker.terminate() })
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
const port = parentPort
|
||||
if (!port) throw new Error('remote worker requires parentPort')
|
||||
|
||||
@@ -456,5 +628,9 @@ if (isMainThread) {
|
||||
process.stdout.write = originalStdout
|
||||
process.stderr.write = originalStderr
|
||||
}
|
||||
} else if (process.env.DSH_CODE_RUNTIME_CONTROLLER === '1') {
|
||||
runController()
|
||||
} else {
|
||||
runLauncher()
|
||||
}
|
||||
`
|
||||
@@ -1,3 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -153,7 +157,120 @@ function request(program = 'return 1') {
|
||||
return { program, bindings: [] }
|
||||
}
|
||||
|
||||
async function runInstalledRunner(
|
||||
code: string,
|
||||
maxOutputBytes = 2_000_000,
|
||||
): Promise<{ messages: unknown[]; stderr: string }> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-e2b-code-runner-'))
|
||||
const runner = join(directory, 'runner.mjs')
|
||||
await writeFile(runner, CODE_RUNNER_SOURCE)
|
||||
const child = spawn(process.execPath, [runner], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const decoder = new E2BFrameDecoder(4_000_000)
|
||||
const messages: unknown[] = []
|
||||
let stderr = ''
|
||||
let outputError: unknown
|
||||
child.stdout.setEncoding('ascii')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
try {
|
||||
messages.push(...decoder.push(chunk))
|
||||
} catch (error: unknown) {
|
||||
outputError = error
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
try {
|
||||
child.stdin.write(encodeE2BFrame({
|
||||
type: 'boot',
|
||||
code,
|
||||
namespaces: [],
|
||||
computeMs: 1_000,
|
||||
maxOutputBytes,
|
||||
maxOldGenerationSizeMb: 128,
|
||||
}))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('installed E2B code runner did not exit'))
|
||||
}, 5_000)
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (outputError !== undefined) throw outputError
|
||||
decoder.finish()
|
||||
return { messages, stderr }
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('E2BCodeRuntime', () => {
|
||||
it('keeps model-owned descriptors outside the host framing process', async () => {
|
||||
const forged = Buffer.from(JSON.stringify({ type: 'done' })).toString('base64') + '\\n'
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
const fs = await import('node:fs')
|
||||
const childProcess = await import('node:child_process')
|
||||
fs.writeSync(1, ${JSON.stringify(forged)})
|
||||
childProcess.spawnSync(process.execPath, ['-e', 'process.stdout.write("child-native")'], { stdio: 'inherit' })
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string; value?: unknown }>
|
||||
const terminal = records.filter(message => message.type === 'done')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminal).toEqual([{ type: 'done', value: [true] }])
|
||||
expect(records.at(-1)).toEqual(terminal[0])
|
||||
expect(records.filter(message => message.type === 'log').map(message => message.text).join(''))
|
||||
.toContain(forged + 'child-native')
|
||||
})
|
||||
|
||||
it('bounds native descriptor output before it reaches the host protocol', async () => {
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
"(await import('node:fs')).writeSync(1, 'x'.repeat(4096)); return true",
|
||||
64,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(records.at(-1)).toEqual({ type: 'output-limit' })
|
||||
expect(Buffer.byteLength(records.filter(message => message.type === 'log').map(message => message.text).join('')))
|
||||
.toBeLessThanOrEqual(62)
|
||||
})
|
||||
|
||||
it('drains native worker pipes before emitting the terminal frame', async () => {
|
||||
const expectedBytes = 1_048_576
|
||||
const { messages, stderr } = await runInstalledRunner(
|
||||
`
|
||||
let stdoutPrototype = Object.getPrototypeOf(process.stdout)
|
||||
while (stdoutPrototype && !Object.hasOwn(stdoutPrototype, 'write')) stdoutPrototype = Object.getPrototypeOf(stdoutPrototype)
|
||||
Reflect.apply(stdoutPrototype.write, process.stdout, ['x'.repeat(${expectedBytes})])
|
||||
return true
|
||||
`,
|
||||
)
|
||||
const records = messages as Array<{ type?: string; text?: string }>
|
||||
const terminalIndex = records.findIndex(message => message.type === 'done')
|
||||
const nativeOutput = records
|
||||
.slice(0, terminalIndex)
|
||||
.filter(message => message.type === 'log')
|
||||
.map(message => message.text ?? '')
|
||||
.join('')
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(terminalIndex).toBe(records.length - 1)
|
||||
expect(Buffer.byteLength(nativeOutput)).toBe(expectedBytes)
|
||||
})
|
||||
|
||||
it('prepares the remote runner and returns logs and a lossless completion', async () => {
|
||||
const handle = new FakeHandle((message, current) => {
|
||||
if ((message as { type?: string }).type !== 'boot') return
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
|
||||
import { E2BPtyBackend } from '@deepseek-ai/dsh-pty-e2b'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
|
||||
const binScript = join(fixtureRoot, 'bin.ts')
|
||||
@@ -11,6 +17,53 @@ 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 () => {
|
||||
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({
|
||||
apiKey,
|
||||
envs: { NPM_TOKEN: 'sentinel-secret', DSH_STALE: 'sentinel-stale', KEEP: 'visible' },
|
||||
timeoutMs: 60_000,
|
||||
secure: true,
|
||||
lifecycle: { onTimeout: 'kill' },
|
||||
})
|
||||
try {
|
||||
const ctx = new Context()
|
||||
ctx.provide('e2b', { cwd: '/home/user', getSandbox: async () => sandbox } as never)
|
||||
const ownerId = SessionId('e2b-pty-env-owner')
|
||||
const owner: Agent = {
|
||||
id: ownerId,
|
||||
options: {},
|
||||
session: new Session(ownerId),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx,
|
||||
followup: () => AgentMessageId('unused'),
|
||||
steer: () => AgentMessageId('unused'),
|
||||
inject: () => AgentMessageId('unused'),
|
||||
send: () => AgentMessageId('unused'),
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const backend = new E2BPtyBackend(ctx, {
|
||||
backendType: 'shell', rows: 24, cols: 80,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
|
||||
pollIntervalMs: 25, idleSilenceMs: 1_000, timeoutMs: 5_000, disposeGraceMs: 1_000,
|
||||
})
|
||||
const session = await backend.spawn({ sessionId: PtySessionId('env'), owner, type: 'shell' })
|
||||
const result = await session.startSend({
|
||||
text: "printf 'NPM=<%s> DSH=<%s> KEEP=<%s>\\n' \"$NPM_TOKEN\" \"$DSH_STALE\" \"$KEEP\"",
|
||||
submit: true,
|
||||
}).done
|
||||
expect(result.viewport).toContain('NPM=<> DSH=<> KEEP=<visible>')
|
||||
expect(result.viewport).not.toContain('sentinel-secret')
|
||||
expect(result.viewport).not.toContain('sentinel-stale')
|
||||
await session.close('environment test complete')
|
||||
} finally {
|
||||
await sandbox.kill().catch(() => false)
|
||||
}
|
||||
}, 70_000)
|
||||
|
||||
it('runs FS, Bash, PTY, LSP, and Code Runtime in one sandbox and deletes it', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'E2B composition',
|
||||
@@ -22,7 +75,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
env: {
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
||||
},
|
||||
processTimeoutMs: 120_000,
|
||||
processTimeoutMs: 180_000,
|
||||
inspect: async (cwd) => {
|
||||
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte # file.ts', 'fixture-lsp.mjs']) {
|
||||
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
@@ -36,6 +89,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
bashRead: 'written-by-fs\n',
|
||||
fsRead: 'written-by-bash\n',
|
||||
explicitEnvironment: true,
|
||||
publicationRollback: true,
|
||||
spill: {
|
||||
liveBytes: 6,
|
||||
outcome: { exitCode: null, signal: 'SIGTERM' },
|
||||
@@ -49,12 +103,18 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
kind: 'locations',
|
||||
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
|
||||
},
|
||||
lspContainment: true,
|
||||
lspDocumentBound: true,
|
||||
terminal: {
|
||||
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
|
||||
signal: { delivered: true },
|
||||
interrupted: { sessionStatus: { kind: 'running' } },
|
||||
treeCleanup: true,
|
||||
},
|
||||
hostileOutput: { error: { kind: 'output-limit' } },
|
||||
nativeOutput: { error: { kind: 'output-limit' } },
|
||||
descriptorOutput: { error: { kind: 'output-limit' } },
|
||||
inheritedOutput: { error: { kind: 'output-limit' } },
|
||||
timedOut: { error: { kind: 'timeout' } },
|
||||
aborted: { error: { kind: 'abort', message: 'live abort' } },
|
||||
oversizedBoot: { error: { kind: 'worker-exit' } },
|
||||
@@ -75,5 +135,5 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
const apiKey = process.env.E2B_API_KEY
|
||||
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
|
||||
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
|
||||
}, 135_000)
|
||||
}, 195_000)
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md
|
||||
README.md: d8f3915fa281e8b9c062e3412c92bc6358616284
|
||||
README.zh.md: 93d27bcdd514ccbf87e07d0eff5958edf3c726c1
|
||||
README.md: a505703fc764f8441fa54d2b1d922762eb3fafdf
|
||||
README.zh.md: 626fdb52979d29f12d1bc1b67b0ea13730003af7
|
||||
@@ -8,7 +8,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
|
||||
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; `realpath -m` supplies canonical target identity without requiring the final file to exist. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
|
||||
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
|
||||
- **Atomic mutations** — writes upload a mode-`0600` temporary sibling, preserve an existing file's POSIX mode, and publish through same-directory Linux `mv -f`. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
|
||||
- **Atomic mutations** — writes upload a mode-`0600` temporary sibling, preserve an existing file's POSIX mode, and publish through E2B's same-directory atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
|
||||
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at SDK request boundaries; a successful rename is the commit point.
|
||||
|
||||
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;`realpath -m` 提供规范化目标身份,且不要求最终文件存在。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **原子变更**:写入会上传 mode 为 `0600` 的同级临时文件,保留现有文件的 POSIX mode,并通过同目录 Linux `mv -f` 发布。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **原子变更**:写入会上传 mode 为 `0600` 的同级临时文件,保留现有文件的 POSIX mode,并通过 E2B 的同目录原子重命名发布。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在 SDK 请求边界上采用尽力而为语义;成功 rename 是提交点。
|
||||
|
||||
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
|
||||
|
||||
@@ -412,11 +412,7 @@ export class E2BFileSystem extends FileSystem {
|
||||
signalOpts(signal),
|
||||
)
|
||||
assertNotAborted(signal, 'write')
|
||||
await sandbox.commands.run(
|
||||
`mv -f -- ${quoteE2BShellArg(temporary)} ${quoteE2BShellArg(targetPath)}`,
|
||||
signalOpts(signal),
|
||||
)
|
||||
const committed = await sandbox.files.getInfo(targetPath)
|
||||
const committed = await sandbox.files.rename(temporary, targetPath, signalOpts(signal))
|
||||
return entryVersion(committed)
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
|
||||
@@ -453,6 +453,17 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('returns committed rename metadata without a fallible post-commit lookup', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
|
||||
.resolves.toMatchObject({ operation: 'create' })
|
||||
expect(getInfo).toHaveBeenCalledTimes(1)
|
||||
expect(remote.renames).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cleans staging files and maps command, permission, and abort failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
@@ -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/lsp-e2b/README.md
|
||||
README.md: ded928ec073e6c0943d574d86359eeec586bb862
|
||||
README.zh.md: 91eb7cecb49adcf73f4be948e9a5da400981e6f1
|
||||
README.md: 6cc118767adc5d2df855b009f48faab596928fb7
|
||||
README.zh.md: b384e9efd0e5c62b0ee6e69f73755e1bbe0192df
|
||||
@@ -27,7 +27,7 @@ Provider ids and commands are non-empty; numeric bounds are positive safe intege
|
||||
|
||||
E2B command callbacks are text, while LSP is byte-framed. The installed proxy therefore base64-frames raw server stdout, stderr, and stdin as newline-delimited ASCII JSON; the host validates and decodes every frame before handing bytes to the shared `LspInstance` protocol engine. `initialize.processId` is `null` because host and server do not share a process namespace.
|
||||
|
||||
One language-server process is pooled per provider and canonical remote workspace. Queries serialize per workspace but different workspaces run concurrently. Each query canonicalizes the remote workspace and source with `realpath`, rejects paths outside that workspace, requires a regular file, enforces the size bound before and after reading, decodes strict UTF-8, and uses the ordinary transient `didOpen` / request / `didClose` lifecycle. A transport failure disposes the instance and retries the read-only query once on a fresh remote process.
|
||||
One language-server process is pooled per provider and canonical remote workspace. Queries serialize per workspace but different workspaces run concurrently. Each query canonicalizes the remote workspace and source with `realpath`, rejects paths outside that workspace, then uses a remote helper to open the canonical source without following the final symlink and to verify and read one stable descriptor. The helper requires a regular file and reads at most `maxDocumentBytes + 1` bytes before strict UTF-8 decoding. Queries use the ordinary transient `didOpen` / request / `didClose` lifecycle. A transport failure disposes the instance and retries the read-only query once on a fresh remote process.
|
||||
|
||||
The subprocess adapter owns process groups and escalation, so cancellation and disposal await remote server quiescence. The host owns LSP request ids, pending requests, provider queues, and normalized results.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
E2B 命令回调交付的是文本,而 LSP 按字节分帧。因此,已安装的代理会把服务器 stdout、stderr 和 stdin 的原始字节进行 base64 编码,封装为以换行分隔的 ASCII JSON 帧;宿主会验证并解码每一帧,再把字节交给共享的 `LspInstance` 协议引擎。`initialize.processId` 为 `null`,因为宿主与服务器不共享进程命名空间。
|
||||
|
||||
每个提供方与规范化远程工作区的组合共享一个池化语言服务器进程。同一工作区的查询串行执行,不同工作区的查询并发运行。每项查询都会使用 `realpath` 规范化远程工作区与源文件,拒绝工作区外的路径,要求源文件为普通文件,在读取前后都检查大小上限,使用严格的 UTF-8 解码,并采用常规的临时 `didOpen`/请求/`didClose` 生命周期。传输失败会 dispose(资源释放)该实例,并在全新的远程进程上重试一次只读查询。
|
||||
每个提供方与规范化远程工作区的组合共享一个池化语言服务器进程。同一工作区的查询串行执行,不同工作区的查询并发运行。每项查询都会使用 `realpath` 规范化远程工作区与源文件,拒绝工作区外的路径,再由远程 helper 以不跟随最终符号链接的方式打开规范化源文件,并在同一个稳定描述符上完成验证与读取。该 helper 要求目标为普通文件,最多读取 `maxDocumentBytes + 1` 字节,随后执行严格的 UTF-8 解码。查询采用常规的临时 `didOpen`/请求/`didClose` 生命周期。传输失败会 dispose(资源释放)该实例,并在全新的远程进程上重试一次只读查询。
|
||||
|
||||
进程管理适配器负责进程组和终止升级,因此取消与资源释放都会等待远程服务器完全停稳。宿主负责 LSP 请求 id、待完成请求、提供方队列和规范化结果。
|
||||
|
||||
|
||||
@@ -92,6 +92,46 @@ interface RemoteSource {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface RemoteSourceReadResponse {
|
||||
kind: 'ok' | 'not-file' | 'oversize' | 'grew' | 'open-error'
|
||||
data?: string
|
||||
size?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
const SOURCE_READER_SOURCE = String.raw`
|
||||
/* dsh-e2b-source-reader */
|
||||
const fs = require('node:fs')
|
||||
const path = process.argv[1]
|
||||
const maxBytes = Number(process.argv[2])
|
||||
let descriptor
|
||||
let response
|
||||
try {
|
||||
descriptor = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
|
||||
const info = fs.fstatSync(descriptor)
|
||||
if (!info.isFile()) response = { kind: 'not-file' }
|
||||
else if (info.size > maxBytes) response = { kind: 'oversize', size: info.size }
|
||||
else {
|
||||
const chunks = []
|
||||
let total = 0
|
||||
while (total <= maxBytes) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(65536, maxBytes - total + 1))
|
||||
const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)
|
||||
if (bytesRead === 0) break
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
total += bytesRead
|
||||
}
|
||||
response = total > maxBytes
|
||||
? { kind: 'grew' }
|
||||
: { kind: 'ok', data: Buffer.concat(chunks, total).toString('base64') }
|
||||
}
|
||||
} catch (error) {
|
||||
response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||
process.stdout.write(JSON.stringify(response))
|
||||
`
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
@@ -164,7 +204,8 @@ export async function canonicalizeE2BWorkspace(
|
||||
* @param sandbox - Shared sandbox that owns the source.
|
||||
* @param filePath - Absolute path or path relative to the canonical workspace.
|
||||
* @param workspace - Canonical remote workspace directory.
|
||||
* @param maxDocumentBytes - Maximum source size before and after reading.
|
||||
* @param maxDocumentBytes - Maximum bytes read through the stable remote handle.
|
||||
* @param nodeExecutable - Resolved remote Node executable used by the bounded reader.
|
||||
* @param signal - Optional query cancellation signal.
|
||||
* @returns The canonical source path and decoded text.
|
||||
*/
|
||||
@@ -173,6 +214,7 @@ export async function readE2BSource(
|
||||
filePath: string,
|
||||
workspace: string,
|
||||
maxDocumentBytes: number,
|
||||
nodeExecutable: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSource> {
|
||||
const requested = posix.isAbsolute(filePath) ? filePath : posix.resolve(workspace, filePath)
|
||||
@@ -181,16 +223,39 @@ export async function readE2BSource(
|
||||
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} resolves outside the workspace`)
|
||||
}
|
||||
const info = await sandbox.files.getInfo(canonicalPath, signal === undefined ? {} : { signal })
|
||||
if (info.type !== FileType.FILE) throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const bytes = await sandbox.files.read(canonicalPath, { format: 'bytes', ...signal === undefined ? {} : { signal } })
|
||||
const command = [
|
||||
quoteE2BShellArg(nodeExecutable),
|
||||
'--input-type=commonjs',
|
||||
'-e',
|
||||
quoteE2BShellArg(SOURCE_READER_SOURCE),
|
||||
quoteE2BShellArg(canonicalPath),
|
||||
String(maxDocumentBytes),
|
||||
].join(' ')
|
||||
const result = await sandbox.commands.run(command, signal === undefined ? {} : { signal })
|
||||
signal?.throwIfAborted()
|
||||
if (bytes.length > maxDocumentBytes) {
|
||||
let response: RemoteSourceReadResponse
|
||||
try {
|
||||
response = JSON.parse(result.stdout) as RemoteSourceReadResponse
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} reader returned an invalid response`, { cause: error })
|
||||
}
|
||||
if (response.kind === 'not-file') throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
|
||||
if (response.kind === 'oversize' && Number.isSafeInteger(response.size)) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} is ${response.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
if (response.kind === 'grew') {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} grew past the ${maxDocumentBytes}-byte limit while reading`)
|
||||
}
|
||||
if (response.kind === 'open-error' && typeof response.message === 'string') {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} could not be opened safely: ${response.message}`)
|
||||
}
|
||||
if (response.kind !== 'ok' || typeof response.data !== 'string') {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} reader returned an invalid response`)
|
||||
}
|
||||
const bytes = Buffer.from(response.data, 'base64')
|
||||
if (bytes.toString('base64') !== response.data || bytes.length > maxDocumentBytes) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} reader returned invalid bounded bytes`)
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
@@ -239,7 +304,14 @@ export class E2BLspProvider implements LspProvider {
|
||||
this.assertActive(signal)
|
||||
return this.enqueue(workspace, signal, async () => {
|
||||
this.assertActive(signal)
|
||||
const source = await readE2BSource(this.sandbox, request.filePath, workspace, this.config.maxDocumentBytes, signal)
|
||||
const source = await readE2BSource(
|
||||
this.sandbox,
|
||||
request.filePath,
|
||||
workspace,
|
||||
this.config.maxDocumentBytes,
|
||||
this.nodeExecutable,
|
||||
signal,
|
||||
)
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
try {
|
||||
|
||||
@@ -94,11 +94,14 @@ class FakeRemote {
|
||||
readonly contents = new Map<string, Uint8Array>()
|
||||
readonly realpaths = new Map<string, string>()
|
||||
forcedRealpath: string | undefined
|
||||
readerResponse: unknown
|
||||
readerOutput: string | undefined
|
||||
|
||||
constructor() {
|
||||
this.infos.set('/workspace', { type: FileType.DIR, size: 0 })
|
||||
this.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 12 })
|
||||
this.contents.set('/workspace/file.ts', Buffer.from('const x = 1'))
|
||||
this.readerResponse = { kind: 'ok', data: Buffer.from('const x = 1').toString('base64') }
|
||||
}
|
||||
|
||||
readonly sandbox = {
|
||||
@@ -110,6 +113,9 @@ class FakeRemote {
|
||||
const requested = match?.[1] ?? ''
|
||||
return { exitCode: 0, stdout: `${this.forcedRealpath ?? this.realpaths.get(requested) ?? requested}\n`, stderr: '' }
|
||||
}
|
||||
if (command.includes('dsh-e2b-source-reader')) {
|
||||
return { exitCode: 0, stdout: this.readerOutput ?? JSON.stringify(this.readerResponse), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('command -v')) return { exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' }
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
@@ -172,16 +178,16 @@ describe('E2B LSP filesystem boundary', () => {
|
||||
it('canonicalizes a directory and reads a contained UTF-8 source', async () => {
|
||||
const remote = new FakeRemote()
|
||||
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace')).resolves.toBe('/workspace')
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024)).resolves.toEqual({
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, '/usr/bin/node')).resolves.toEqual({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
text: 'const x = 1',
|
||||
})
|
||||
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024)).resolves.toMatchObject({
|
||||
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024, '/usr/bin/node')).resolves.toMatchObject({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace', signal)).resolves.toBe('/workspace')
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, signal)).resolves.toMatchObject({
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, '/usr/bin/node', signal)).resolves.toMatchObject({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
})
|
||||
})
|
||||
@@ -199,25 +205,35 @@ describe('E2B LSP filesystem boundary', () => {
|
||||
|
||||
const outside = new FakeRemote()
|
||||
outside.realpaths.set('/workspace/file.ts', '/outside/file.ts')
|
||||
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('outside the workspace')
|
||||
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('outside the workspace')
|
||||
|
||||
const notFile = new FakeRemote()
|
||||
notFile.infos.set('/workspace/file.ts', { type: FileType.DIR, size: 0 })
|
||||
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not a regular file')
|
||||
notFile.readerResponse = { kind: 'not-file' }
|
||||
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('not a regular file')
|
||||
|
||||
const tooLarge = new FakeRemote()
|
||||
tooLarge.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 21 })
|
||||
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('over the 20-byte limit')
|
||||
tooLarge.readerResponse = { kind: 'oversize', size: 21 }
|
||||
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('over the 20-byte limit')
|
||||
|
||||
const grew = new FakeRemote()
|
||||
grew.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
||||
grew.contents.set('/workspace/file.ts', Buffer.alloc(21))
|
||||
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('grew past')
|
||||
grew.readerResponse = { kind: 'grew' }
|
||||
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('grew past')
|
||||
|
||||
const invalid = new FakeRemote()
|
||||
invalid.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
||||
invalid.contents.set('/workspace/file.ts', Uint8Array.from([0xff]))
|
||||
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not valid UTF-8')
|
||||
invalid.readerResponse = { kind: 'ok', data: Buffer.from([0xff]).toString('base64') }
|
||||
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('not valid UTF-8')
|
||||
|
||||
const swapped = new FakeRemote()
|
||||
swapped.readerResponse = { kind: 'open-error', message: 'ELOOP: symbolic link encountered' }
|
||||
await expect(readE2BSource(swapped.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('opened safely')
|
||||
|
||||
const malformedReader = new FakeRemote()
|
||||
malformedReader.readerResponse = { kind: 'ok', data: '*' }
|
||||
await expect(readE2BSource(malformedReader.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('invalid bounded bytes')
|
||||
malformedReader.readerResponse = { kind: 'oversize', size: 'large' }
|
||||
await expect(readE2BSource(malformedReader.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('invalid response')
|
||||
malformedReader.readerOutput = '{'
|
||||
await expect(readE2BSource(malformedReader.sandbox, 'file.ts', '/workspace', 20, '/usr/bin/node')).rejects.toThrow('invalid response')
|
||||
|
||||
await expect(canonicalizeE2BWorkspace(new FakeRemote().sandbox, '/workspace', AbortSignal.abort('stop')))
|
||||
.rejects.toBe('stop')
|
||||
|
||||
@@ -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/pty-e2b/README.md
|
||||
README.md: 3d363309f6bd1c4def5fbff668735958ca2de52d
|
||||
README.zh.md: 78d61cc654bf6eb46a3bec7124b3cc06cc7e34e9
|
||||
README.md: d6d8e05f13701b85cee9a9646bfdd82ac07769da
|
||||
README.zh.md: 265257c72e7bcbd2dfd805b343db5bab7173ab51
|
||||
@@ -20,13 +20,13 @@ The `pty-e2b` plugin injects `e2b` and `pty`, then registers one backend under `
|
||||
| `timeoutMs` | `30000` | Absolute startup and send wait bound. |
|
||||
| `disposeGraceMs` | `3000` | TERM-to-KILL cleanup grace. |
|
||||
|
||||
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute.
|
||||
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute. Before launch, the backend enumerates sandbox-default environment names, blanks `DSH_*` and credential-shaped names, then overlays its controlled terminal values and explicit `spec.env` entries.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
The backend uses E2B's byte-oriented PTY callback with a streaming fatal UTF-8 decoder, then the backend-neutral line sanitizer and bounded buffers from `dsh-pty`. It installs a controlled Bash prompt marker and waits for printable prompt text; when that marker is unavailable, observed output plus the configured silence bound yields `inferred_idle`. Startup with no output reaches the absolute timeout and fails instead of publishing an empty session.
|
||||
|
||||
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. Close sends `SIGTERM` to the PTY process group, waits, escalates through E2B's PTY kill, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
|
||||
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. The backend records the terminal's POSIX session id at startup. Close sends `SIGTERM` to every process group still in that session, escalates survivors to `SIGKILL`, verifies that the session is empty, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
|
||||
|
||||
The remote PTY process and its child processes live in E2B. Prompt/readiness state, scrollback, operation handles, owner authority, and SDK event delivery remain in host memory.
|
||||
|
||||
@@ -51,4 +51,5 @@ No direct invalidation; the consumer owns prompts, schemas, and appended results
|
||||
- **Line-oriented terminal model** — CSI/OSC control sequences are removed; alternate-screen and full terminal emulation remain unsupported.
|
||||
- **Readiness is marker-or-silence based** — E2B exposes foreground process groups but not the local backend's Linux syscall inspection, so `inferred_idle` is deliberately possible.
|
||||
- **UTF-8 only** — invalid byte sequences fail the session instead of returning lossy text.
|
||||
- **Deliberate session escape is unmanaged** — a process that calls `setsid` leaves the terminal session and is outside this backend's cleanup identity.
|
||||
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.
|
||||
@@ -20,13 +20,13 @@
|
||||
| `timeoutMs` | `30000` | 启动与发送等待的绝对上限。 |
|
||||
| `disposeGraceMs` | `3000` | TERM 到 KILL 的清理宽限期。 |
|
||||
|
||||
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。
|
||||
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。启动前,后端会枚举沙箱默认环境变量名,清空 `DSH_*` 和形似凭据的名称,再覆盖其受控终端值与显式 `spec.env` 条目。
|
||||
|
||||
## 运行时契约
|
||||
|
||||
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
|
||||
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。关闭操作向 PTY 进程组发送 `SIGTERM`,等待后通过 E2B 的 PTY kill 操作升级,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。后端会在启动时记录终端的 POSIX 会话 id。关闭操作会向该会话内仍存在的每个进程组发送 `SIGTERM`,对存活者升级为 `SIGKILL`,验证会话已经清空,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
|
||||
|
||||
远程 PTY 进程及其子进程位于 E2B。提示符/就绪状态、scrollback、操作句柄、所有者权限和 SDK 事件交付仍保留在宿主内存中。
|
||||
|
||||
@@ -51,4 +51,5 @@
|
||||
- **面向行的终端模型**:CSI/OSC 控制序列会被移除;备用屏幕与完整终端仿真仍不受支持。
|
||||
- **就绪判断基于标记或静默**:E2B 会公开前台进程组,但不提供本地后端使用的 Linux syscall 检查,因此系统有意保留返回 `inferred_idle` 的可能性。
|
||||
- **仅支持 UTF-8**:无效字节序列会使会话失败,而不是返回有损文本。
|
||||
- **主动逃离会话的进程不受管理**:调用 `setsid` 的进程会离开终端会话,因而不属于本后端的清理身份。
|
||||
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。
|
||||
@@ -17,8 +17,22 @@ export const name = 'pty-e2b'
|
||||
/** Required shared sandbox owner and PTY registry. */
|
||||
export const inject = ['e2b', 'pty']
|
||||
|
||||
function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
|
||||
const SENSITIVE_ENV_NAME = /KEY|SECRET|TOKEN/i
|
||||
|
||||
async function terminalEnvironment(
|
||||
sandbox: Sandbox,
|
||||
spec: PtyBackendSpawnSpec,
|
||||
): Promise<Record<string, string>> {
|
||||
const discovered = await sandbox.commands.run(
|
||||
'env -0 | cut -z -d= -f1',
|
||||
spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
)
|
||||
spec.signal?.throwIfAborted()
|
||||
const scrubbed = Object.fromEntries(discovered.stdout.split('\0')
|
||||
.filter(name => name.startsWith('DSH_') || SENSITIVE_ENV_NAME.test(name))
|
||||
.map(name => [name, '']))
|
||||
return {
|
||||
...scrubbed,
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
@@ -31,6 +45,20 @@ function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTerminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<number> {
|
||||
const result = await sandbox.commands.run(
|
||||
`ps -o sid= -p ${pid}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const raw = result.stdout.trim()
|
||||
const sessionId = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(sessionId)) {
|
||||
throw new Error(`pty-e2b: cannot resolve process session for E2B PTY ${pid}`)
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/** E2B backend registered under the configured terminal type. */
|
||||
export class E2BPtyBackend implements PtyBackend {
|
||||
readonly type: string
|
||||
@@ -57,7 +85,7 @@ export class E2BPtyBackend implements PtyBackend {
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
cwd: posix.resolve(this.ctx.e2b.cwd, spec.cwd ?? this.ctx.e2b.cwd),
|
||||
envs: terminalEnvironment(spec),
|
||||
envs: await terminalEnvironment(sandbox, spec),
|
||||
timeoutMs: 0,
|
||||
...spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
onData: (data) => {
|
||||
@@ -69,7 +97,15 @@ export class E2BPtyBackend implements PtyBackend {
|
||||
await handle.kill().catch(() => false)
|
||||
throw new Error(`pty-e2b: E2B returned invalid PTY pid ${handle.pid}`)
|
||||
}
|
||||
const session = new E2BPtySession(sandbox, handle, this.config)
|
||||
let terminalSessionId: number
|
||||
try {
|
||||
terminalSessionId = await resolveTerminalSessionId(sandbox, handle.pid, spec.signal)
|
||||
} catch (error: unknown) {
|
||||
await handle.kill().catch(() => false)
|
||||
await Promise.allSettled([handle.wait()])
|
||||
throw error
|
||||
}
|
||||
const session = new E2BPtySession(sandbox, handle, terminalSessionId, this.config)
|
||||
created.session = session
|
||||
try {
|
||||
const initializing = session.initialize(spec.signal)
|
||||
|
||||
@@ -105,6 +105,7 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
constructor(
|
||||
private readonly sandbox: Sandbox,
|
||||
private readonly handle: CommandHandle,
|
||||
private readonly terminalSessionId: number,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = handle.pid
|
||||
@@ -314,6 +315,40 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
return pgid
|
||||
}
|
||||
|
||||
private async sessionProcessGroups(): Promise<number[]> {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${this.terminalSessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const pgid = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid) || pgid <= 1) {
|
||||
throw new Error(`pty-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.terminalSessionId}`)
|
||||
}
|
||||
groups.add(pgid)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
private async signalProcessGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(pgid => `-${pgid}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitSessionEmpty(timeoutMs: number, signal?: 'KILL'): Promise<number[]> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
const groups = await this.sessionProcessGroups()
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (signal !== undefined) await this.signalProcessGroups(groups, signal)
|
||||
await delay(Math.min(this.config.pollIntervalMs, deadline - Date.now()))
|
||||
}
|
||||
}
|
||||
|
||||
private onExit(exitCode: number): void {
|
||||
this.remoteExited = true
|
||||
let tail = ''
|
||||
@@ -343,19 +378,20 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
if (!this.remoteExited) {
|
||||
let survivingGroups = await this.sessionProcessGroups()
|
||||
if (survivingGroups.length > 0) {
|
||||
this.closeSignal = 'SIGTERM'
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -TERM -- -${this.pid}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
await this.signalProcessGroups(survivingGroups, 'TERM')
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs)
|
||||
}
|
||||
if (!this.remoteExited) {
|
||||
if (survivingGroups.length > 0 || !this.remoteExited) {
|
||||
this.closeSignal = 'SIGKILL'
|
||||
await this.sandbox.pty.kill(this.pid)
|
||||
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
if (!this.remoteExited) await this.sandbox.pty.kill(this.pid)
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs, 'KILL')
|
||||
if (!this.remoteExited) await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (survivingGroups.length > 0) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving process groups: ${survivingGroups.join(', ')}`)
|
||||
}
|
||||
if (!this.remoteExited) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving pid: ${this.pid}`)
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
it('creates a remote PTY with isolated environment and initializes the session', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const sandbox = {} as Sandbox
|
||||
const run = vi.fn(async (command: string) => command.startsWith('env -0')
|
||||
? { exitCode: 0, stdout: 'NPM_TOKEN\0DSH_STALE\0KEEP\0', stderr: '' }
|
||||
: { exitCode: 0, stdout: '123\n', stderr: '' })
|
||||
const sandbox = { commands: { run } } as unknown as Sandbox
|
||||
ctx.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => sandbox,
|
||||
@@ -65,9 +68,11 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
expect(session.motd).toBe('banner\ndsh> ')
|
||||
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
|
||||
expect(options?.envs).toMatchObject({
|
||||
NPM_TOKEN: '', DSH_STALE: '',
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
})
|
||||
expect(options?.envs).not.toHaveProperty('KEEP')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -79,7 +84,10 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
|
||||
return created
|
||||
})
|
||||
const sandbox = { pty: { create } } as unknown as Sandbox
|
||||
const sandbox = {
|
||||
commands: { run: async (command: string) => ({ exitCode: 0, stdout: command.startsWith('env -0') ? '' : '123\n', stderr: '' }) },
|
||||
pty: { create },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
|
||||
const backend = new E2BPtyBackend(ctx, config())
|
||||
const pending = backend.spawn({ sessionId: PtySessionId('default'), owner: owner(ctx), type: 'shell' })
|
||||
@@ -91,7 +99,9 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
|
||||
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
|
||||
const ctx = new Context()
|
||||
const sandbox = {} as Sandbox
|
||||
const sandbox = {
|
||||
commands: { run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
|
||||
const create = vi.fn().mockResolvedValue(handle(0))
|
||||
const backend = new E2BPtyBackend(ctx, config(), create)
|
||||
@@ -109,13 +119,47 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
const killFailure = handle(0, killFailureKill)
|
||||
const raced = new E2BPtyBackend(ctx, config(), async () => killFailure)
|
||||
await expect(raced.spawn({ sessionId: PtySessionId('three'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
|
||||
|
||||
const invalidSessionKill = vi.fn().mockRejectedValue(new Error('kill raced'))
|
||||
const invalidSessionHandle = {
|
||||
pid: 123,
|
||||
wait: vi.fn().mockRejectedValue(new Error('already exited')),
|
||||
kill: invalidSessionKill,
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CommandHandle
|
||||
const invalidSessionSandbox = {
|
||||
commands: {
|
||||
run: async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('env -0') ? '' : '9007199254740992\n',
|
||||
stderr: '',
|
||||
}),
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
const invalidSessionContext = new Context()
|
||||
invalidSessionContext.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => invalidSessionSandbox,
|
||||
} as E2BSandboxService)
|
||||
const invalidSession = new E2BPtyBackend(invalidSessionContext, config(), async () => invalidSessionHandle)
|
||||
await expect(invalidSession.spawn({
|
||||
sessionId: PtySessionId('four'), owner: owner(invalidSessionContext), type: 'shell',
|
||||
}))
|
||||
.rejects.toThrow('cannot resolve process session')
|
||||
expect(invalidSessionKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans failed startup and aggregates a cleanup failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const sandbox = {
|
||||
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) },
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('ps -o sid=') || command.startsWith('ps -eo sid=') ? '123\n' : '',
|
||||
stderr: '',
|
||||
})),
|
||||
},
|
||||
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
|
||||
@@ -141,10 +185,19 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
wait: () => completion.promise,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as CommandHandle
|
||||
let sessionRunning = true
|
||||
const sandbox = {
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => {
|
||||
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
|
||||
if (command.startsWith('env -0')) return { exitCode: 0, stdout: '', stderr: '' }
|
||||
if (command.startsWith('ps -o sid=')) return { exitCode: 0, stdout: '123\n', stderr: '' }
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: sessionRunning ? '123\n' : '', stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) {
|
||||
sessionRunning = false
|
||||
completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -64,10 +64,12 @@ class FakeSandbox {
|
||||
readonly commands: string[] = []
|
||||
readonly killed: number[] = []
|
||||
pgid = '456\n'
|
||||
sessionGroups = [123]
|
||||
sendError: unknown
|
||||
commandError: unknown
|
||||
signalError: unknown
|
||||
killError: unknown
|
||||
onTerm: (() => void) | undefined
|
||||
onGroupKill: (() => void) | undefined
|
||||
onKill: (() => void) | undefined
|
||||
|
||||
readonly sandbox = {
|
||||
@@ -86,13 +88,19 @@ class FakeSandbox {
|
||||
commands: {
|
||||
run: async (command: string): Promise<CommandResult> => {
|
||||
this.commands.push(command)
|
||||
if (this.commandError !== undefined) {
|
||||
const error = this.commandError
|
||||
this.commandError = undefined
|
||||
throw error
|
||||
if (command.startsWith('ps -o tpgid')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: this.sessionGroups.map(value => `${value}\n`).join(''), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -')) {
|
||||
if (this.signalError !== undefined) {
|
||||
const error = this.signalError
|
||||
this.signalError = undefined
|
||||
throw error
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
if (command.startsWith('kill -KILL')) this.onGroupKill?.()
|
||||
}
|
||||
if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
@@ -122,7 +130,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config({ maxReadBytes: 12 }))
|
||||
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
|
||||
await initialize(session)
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
@@ -152,7 +160,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await initialize(session)
|
||||
|
||||
const inferred = session.startSend({ text: '', submit: false })
|
||||
@@ -176,7 +184,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
|
||||
const startupHandle = new FakePtyHandle()
|
||||
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
|
||||
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), 123, config())
|
||||
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
@@ -186,7 +194,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const initializing = session.initialize()
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07'))
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
@@ -230,7 +238,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await initialize(session)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -268,7 +276,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const abortHandle = new FakePtyHandle()
|
||||
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config())
|
||||
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), 123, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
const initializing = abortSession.initialize(controller.signal)
|
||||
@@ -277,20 +285,20 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
await rejected
|
||||
|
||||
const invalidHandle = new FakePtyHandle()
|
||||
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config())
|
||||
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), 123, config())
|
||||
const pending = invalid.startSend({ text: '', submit: false })
|
||||
invalid.onData(Uint8Array.from([0xff]))
|
||||
await expect(pending.done).rejects.toThrow('invalid UTF-8')
|
||||
expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
|
||||
const crashHandle = new FakePtyHandle()
|
||||
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config())
|
||||
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), 123, config())
|
||||
const active = crashed.startSend({ text: '', submit: false })
|
||||
crashHandle.crash('transport gone')
|
||||
await expect(active.done).rejects.toEqual(new Error('transport gone'))
|
||||
|
||||
const startupExitHandle = new FakePtyHandle()
|
||||
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config())
|
||||
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), 123, config())
|
||||
const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
|
||||
startupExitHandle.exit(7)
|
||||
await exitedDuringStartup
|
||||
@@ -300,12 +308,12 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const tinyHandle = new FakePtyHandle()
|
||||
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 }))
|
||||
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), 123, config({ maxReadBytes: 1 }))
|
||||
tiny.onData(Buffer.from('你'))
|
||||
expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 })
|
||||
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
const internal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
@@ -324,8 +332,8 @@ describe('E2BPtySession teardown', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
fake.onTerm = () => { fake.sessionGroups = []; handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const first = session.close('done')
|
||||
expect(session.close('again')).toBe(first)
|
||||
await first
|
||||
@@ -334,63 +342,100 @@ describe('E2BPtySession teardown', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('escalates every job-control group that survives shell exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
fake.sessionGroups = [123, 456]
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { fake.sessionGroups = [456]; handle.failExit(143) }
|
||||
fake.onGroupKill = () => { fake.sessionGroups = [] }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
|
||||
const closing = session.close('tree cleanup')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await closing
|
||||
|
||||
expect(fake.commands).toContain('kill -TERM -- -123 -456')
|
||||
expect(fake.commands).toContain('kill -KILL -- -456')
|
||||
})
|
||||
|
||||
it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gone = new FakeSandbox()
|
||||
const goneHandle = new FakePtyHandle()
|
||||
gone.commandError = commandError(1)
|
||||
gone.signalError = commandError(1)
|
||||
gone.onGroupKill = () => { gone.sessionGroups = [] }
|
||||
gone.onKill = () => { goneHandle.failExit(137) }
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), 123, config())
|
||||
const closingGone = goneSession.close('gone')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await closingGone
|
||||
expect(gone.killed).toEqual([123])
|
||||
expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' })
|
||||
|
||||
const survivor = new FakeSandbox()
|
||||
const survivorHandle = new FakePtyHandle()
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config())
|
||||
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), 123, config())
|
||||
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving process groups: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
survivorHandle.exit()
|
||||
survivor.sessionGroups = []
|
||||
await expect(survivorSession.close('retry')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates cleanup transport failures and lets close retry', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
fake.commandError = new Error('TERM transport failed')
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
fake.signalError = new Error('TERM transport failed')
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
|
||||
handle.exit()
|
||||
fake.sessionGroups = []
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
|
||||
const invalidTailHandle = new FakePtyHandle()
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config())
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), 123, config())
|
||||
invalidTail.onData(Uint8Array.from([0xe2]))
|
||||
invalidTailHandle.exit()
|
||||
await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8')
|
||||
|
||||
const normalHandle = new FakePtyHandle()
|
||||
normalHandle.disconnectError = new Error('disconnect raced')
|
||||
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config())
|
||||
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), 123, config())
|
||||
normalHandle.exit(7)
|
||||
await Promise.resolve()
|
||||
expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null })
|
||||
await expect(normal.close('already exited')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid session groups and a shell handle that survives SDK kill', async () => {
|
||||
const invalid = new FakeSandbox()
|
||||
invalid.sessionGroups = [1]
|
||||
const invalidSession = new E2BPtySession(invalid.sandbox, new FakePtyHandle().asHandle(), 123, config())
|
||||
await expect(invalidSession.close('invalid group')).rejects.toThrow('invalid process group')
|
||||
|
||||
vi.useFakeTimers()
|
||||
const survivor = new FakeSandbox()
|
||||
survivor.sessionGroups = []
|
||||
const survivorHandle = new FakePtyHandle()
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), 123, config())
|
||||
const failed = expect(survivorSession.close('shell survived')).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
expect(survivor.killed).toEqual([123])
|
||||
})
|
||||
|
||||
it('kills a remotely live PTY after its host transport fails', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const active = session.startSend({ text: '', submit: false })
|
||||
session.onData(Uint8Array.from([0xff]))
|
||||
await expect(active.done).rejects.toThrow('invalid UTF-8')
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
|
||||
fake.onTerm = () => { handle.failExit(143) }
|
||||
fake.onTerm = () => { fake.sessionGroups = []; handle.failExit(143) }
|
||||
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
|
||||
expect(fake.commands).toContain('kill -TERM -- -123')
|
||||
expect(handle.disconnects).toBe(1)
|
||||
|
||||
@@ -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: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
|
||||
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c
|
||||
README.md: 066d35a099f560fe40fe629ede632c37535129f7
|
||||
README.zh.md: d3af14e8cc58c7d86331156358f78a0abbb24c39
|
||||
@@ -6,8 +6,8 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the SDK returns the command PID; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
|
||||
- **Linux process groups** — a quoted wrapper starts each argv under `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 assuming the SDK command PID is the group id. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback. Service disposal terminates and joins every retained handle before the sandbox owner disposes.
|
||||
- **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; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
## 行为
|
||||
|
||||
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。SDK 返回命令 PID 之前,`pid` 为 `-1`;`done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
|
||||
- **Linux 进程组**:带引号保护的包装层会在 `setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会假设 SDK 命令 PID 就是进程组 ID。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。服务 dispose(资源释放)会在沙箱所有者释放前终止并等待每个保留句柄退出。
|
||||
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;`done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
|
||||
- **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 句柄。
|
||||
|
||||
|
||||
@@ -253,7 +253,14 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
try {
|
||||
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
|
||||
} catch (error: unknown) {
|
||||
await Promise.allSettled([handle.kill()])
|
||||
try {
|
||||
await this.rollbackUnpublishedGroup(sandbox, handle)
|
||||
} catch (cleanupError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'subprocess-e2b: process-group publication failed and rollback did not reach quiescence',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.readyState.resolve(handle)
|
||||
@@ -361,6 +368,19 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
|
||||
// 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.
|
||||
try {
|
||||
await this.signalGroup(sandbox, handle.pid, 'KILL')
|
||||
} finally {
|
||||
await handle.kill().catch(() => false)
|
||||
}
|
||||
while (await this.groupAlive(sandbox, handle.pid)) await waitTick()
|
||||
}
|
||||
|
||||
private async terminateRemote(): Promise<void> {
|
||||
let handle: CommandHandle
|
||||
try {
|
||||
|
||||
@@ -89,6 +89,7 @@ class FakeSandbox {
|
||||
probeError: unknown
|
||||
signalError: unknown
|
||||
trapsTerm = false
|
||||
delaysKill = false
|
||||
alive = true
|
||||
processGroupId = '4242\n'
|
||||
readonly processGroupReads: string[] = []
|
||||
@@ -176,7 +177,7 @@ class FakeSandbox {
|
||||
this.signalError = undefined
|
||||
throw error
|
||||
}
|
||||
this.alive = false
|
||||
if (!this.delaysKill) this.alive = false
|
||||
this.handle.fail(137)
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -596,14 +597,13 @@ describe('E2BSubprocessHandle', () => {
|
||||
it('rejects invalid or absent process-group publication', async () => {
|
||||
const invalidGroup = new FakeSandbox()
|
||||
invalidGroup.processGroupId = 'not-a-pid\n'
|
||||
vi.spyOn(invalidGroup.handle, 'kill').mockImplementation(async () => {
|
||||
invalidGroup.handle.kills += 1
|
||||
invalidGroup.finish()
|
||||
return true
|
||||
})
|
||||
invalidGroup.delaysKill = true
|
||||
invalidGroup.afterProbe = () => { invalidGroup.alive = false }
|
||||
const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
|
||||
await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
|
||||
expect(invalidGroup.handle.kills).toBe(1)
|
||||
expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242')
|
||||
await expect(invalid.waitForExit()).resolves.toBe(true)
|
||||
|
||||
const absentGroup = new FakeSandbox()
|
||||
absentGroup.processGroupId = ''
|
||||
@@ -612,6 +612,35 @@ describe('E2BSubprocessHandle', () => {
|
||||
absentGroup.finish()
|
||||
await expect(absent.done).rejects.toThrow(/exited before publishing/)
|
||||
expect(absentGroup.handle.kills).toBe(1)
|
||||
expect(absentGroup.commandsSeen).toContain('kill -KILL -- -4242')
|
||||
await expect(absent.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('preserves publication and rollback failures when cleanup cannot be verified', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
fake.processGroupId = 'not-a-pid\n'
|
||||
fake.signalError = new Error('rollback signal failed')
|
||||
fake.handle.killError = new Error('SDK kill failed')
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/failed-rollback')
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await handle.done
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError')
|
||||
expect(failure.message).toBe('subprocess-e2b: process-group publication failed and rollback did not reach quiescence')
|
||||
const failures = Array.from(failure.errors as Iterable<unknown>)
|
||||
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[0].message).toContain('invalid process-group id')
|
||||
expect(failures[1].message).toBe('rollback signal failed')
|
||||
expect(fake.handle.kills).toBe(1)
|
||||
fake.finish()
|
||||
})
|
||||
|
||||
it('waits for delayed process-group publication', async () => {
|
||||
|
||||
Reference in New Issue
Block a user