refactor(e2b): narrow the sandbox POC
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
||||||
2026-07-28-portable-execution-world-consumers.md: 250445cfed6a4fe2dd188562813d586df29992c1
|
2026-07-28-portable-execution-world-consumers.md: 57c3a8a8b211279593359b02def865644052f549
|
||||||
2026-07-28-portable-execution-world-consumers.zh.md: 361c4de4e813aa7f57a7eb5acc75087b2630db43
|
2026-07-28-portable-execution-world-consumers.zh.md: 8231913c26f75361add7a28bc40f7723c9b18728
|
||||||
+4
-4
@@ -28,17 +28,17 @@ Generic consumers use that execution world:
|
|||||||
|
|
||||||
## E2B POC boundary
|
## E2B POC boundary
|
||||||
|
|
||||||
The opt-in E2B realization has exactly three provider-specific packages under `packages/e2b/`: `dsh-e2b` creates or reconnects one sandbox and owns kill/pause/leave disposal, `dsh-fs-e2b` implements `ctx.fs`, and `dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands, PTYs, and remote Linux process groups. The two adapters obtain the sole sandbox identity from the owner and never create private sandboxes.
|
The opt-in E2B realization has exactly three provider-specific packages under `packages/e2b/`: `dsh-e2b` creates one sandbox and deletes it on timeout or disposal, `dsh-fs-e2b` implements `ctx.fs`, and `dsh-subprocess-e2b` implements `ctx.subprocess` over E2B Commands, PTYs, and remote Linux process groups. The two adapters obtain the sole SDK handle from the owner and never create private sandboxes.
|
||||||
|
|
||||||
E2B owns the mutable filesystem, managed command and Bash processes, terminal allocation and terminal-session groups, language-server processes and source reads, subprocess Code Runtime processes, and adapter-private files under `.dsh-e2b`. The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority, skills, subagent orchestration, PTY buffers and readiness, LSP protocol state, Code Runtime program/binding/output policy, and E2B SDK/network buffers. The overlay neither uploads nor synchronizes the host workspace.
|
E2B owns the mutable filesystem, managed command and Bash processes, terminal allocation and terminal-session groups, language-server processes and source reads, subprocess Code Runtime processes, and adapter-private files under `.dsh-e2b`. The host owns Cordis and plugin objects, the agent loop, agent/session/goal state, session logs and persistence, LLM calls, prompts and tools, authority, skills, subagent orchestration, PTY buffers and readiness, LSP protocol state, Code Runtime program/binding/output policy, and E2B SDK/network buffers. The overlay neither uploads nor synchronizes the host workspace.
|
||||||
|
|
||||||
The adapters retain only substrate mechanics. Filesystem canonicalization crosses the SDK's decoded command transport as strict base64-encoded NUL framing; streamed reads leave byte ceilings with consumers. Subprocess command output and environment snapshots use ASCII/base64 where SDK chunk decoding would otherwise lose bytes, while private control shells isolate profiles and later launches blank discovered credential-shaped names. Process and terminal cleanup uses remote groups and proves quiescence before settlement.
|
The adapters retain only substrate mechanics. Filesystem canonicalization crosses the SDK's decoded command transport as strict base64-encoded NUL framing; streamed reads leave byte ceilings with consumers. Subprocess command output and environment snapshots use ASCII/base64 where SDK chunk decoding would otherwise lose bytes, while private control shells isolate profiles and later launches blank discovered credential-shaped names. Process and terminal cleanup uses remote groups and proves quiescence before settlement.
|
||||||
|
|
||||||
Retaining a sandbox preserves remote files and unmanaged state only. Reconnect does not reconstruct host process or terminal handles, protocol connections, pending calls, output cursors, timers, or locks. The POC adds no session-persistence backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, workspace synchronization, durable remote handles, or whole-harness execution.
|
Sandbox state is deliberately ephemeral: timeout and disposal delete the remote files and unmanaged state. The POC adds no reconnect or pause/leave retention, session-persistence backend, template builder, volume, snapshot, network-policy layer, sandbox catalog, workspace synchronization, durable remote handles, or whole-harness execution.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Focused package suites pin sandbox lifecycle, canonical path framing, filesystem metadata and atomic versions, subprocess publication/rollback, terminal text I/O and session cleanup, output limits, cancellation, disposal, and invariant registration. A credential-gated Loader composition exercises the same three-package provider through source imports and built exports, including FS/Bash visibility, post-rename version reread plus guarded edit, hostile login profiles, byte-split UTF-8 output, process and terminal cleanup, LSP document bounds, Code Runtime bindings/limits/cleanup, host-workspace isolation, and final sandbox deletion.
|
Focused package suites pin sandbox lifecycle, canonical path framing, filesystem metadata and atomic versions, subprocess publication/rollback, terminal text I/O and session cleanup, output limits, cancellation, disposal, and invariant registration. A credential-gated Loader composition exercises the same three-package provider through source imports and built exports, including FS/Bash visibility, hostile login profiles, byte-split UTF-8 output, process and terminal cleanup, LSP queries, Code Runtime bindings and cleanup, host-workspace isolation, and final sandbox deletion.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -70,4 +70,4 @@ The fundamental interfaces are wider, and a filesystem/subprocess pair must agre
|
|||||||
|
|
||||||
The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer.
|
The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer.
|
||||||
|
|
||||||
The E2B composition demonstrates that a shared sandbox owner plus filesystem and subprocess adapters are sufficient to move the mutable coding world off-host while leaving higher capabilities provider-neutral. Its POC limits remain explicit: the SDK retains complete command transport in host memory, remote startup cannot publish a PID synchronously, exact terminal stdin-wait and independent signal facts are unavailable, numeric PID/PGID operations are not identity-fenced, the initial environment probe cannot hide unknown sandbox-default secrets from already-running same-UID processes, retained artifacts accumulate, and escaped processes or reconnect state are not recovered. These are provider constraints, not justification for compatibility shims or more E2B packages.
|
The E2B composition demonstrates that a shared sandbox owner plus filesystem and subprocess adapters are sufficient to move the mutable coding world off-host while leaving higher capabilities provider-neutral. Its POC limits remain explicit: the SDK retains complete command transport in host memory, remote startup cannot publish a PID synchronously, exact terminal stdin-wait and independent signal facts are unavailable, numeric PID/PGID operations are not identity-fenced, the initial environment probe cannot hide unknown sandbox-default secrets from already-running same-UID processes, and adapter artifacts remain until sandbox deletion. These are provider constraints, not justification for compatibility shims or more E2B packages.
|
||||||
+4
-4
@@ -31,17 +31,17 @@ Status: implemented
|
|||||||
|
|
||||||
## E2B POC 边界
|
## E2B POC 边界
|
||||||
|
|
||||||
可选启用的 E2B 实现在 `packages/e2b/` 下恰好只有三个提供方专用包:`dsh-e2b` 创建或重新连接一个沙箱,并负责 kill、pause 或 leave 资源释放;`dsh-fs-e2b` 实现 `ctx.fs`;`dsh-subprocess-e2b` 基于 E2B Commands、PTY 和远程 Linux 进程组实现 `ctx.subprocess`。两个适配器都从所有者取得唯一的沙箱身份,绝不创建私有沙箱。
|
可选启用的 E2B 实现在 `packages/e2b/` 下恰好只有三个提供方专用包:`dsh-e2b` 创建一个沙箱,并在超时或资源释放时将其删除;`dsh-fs-e2b` 实现 `ctx.fs`;`dsh-subprocess-e2b` 基于 E2B Commands、PTY 和远程 Linux 进程组实现 `ctx.subprocess`。两个适配器都从所有者取得唯一的 SDK 句柄,绝不创建私有沙箱。
|
||||||
|
|
||||||
E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终端会话组、语言服务器进程与源文件读取、子进程代码运行时进程,以及 `.dsh-e2b` 下的适配器私有文件。宿主负责 Cordis 与插件对象、agent loop(智能体循环)、agent(智能体)状态、会话状态与目标状态、会话日志与持久化、LLM(大语言模型)调用、提示词与工具、权限、skill(技能)、subagent 编排、PTY 缓冲区与就绪状态、LSP 协议状态、代码运行时程序/绑定/输出策略,以及 E2B SDK/网络缓冲区。该叠加层既不上传,也不同步宿主工作区。
|
E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终端会话组、语言服务器进程与源文件读取、子进程代码运行时进程,以及 `.dsh-e2b` 下的适配器私有文件。宿主负责 Cordis 与插件对象、agent loop(智能体循环)、agent(智能体)状态、会话状态与目标状态、会话日志与持久化、LLM(大语言模型)调用、提示词与工具、权限、skill(技能)、subagent 编排、PTY 缓冲区与就绪状态、LSP 协议状态、代码运行时程序/绑定/输出策略,以及 E2B SDK/网络缓冲区。该叠加层既不上传,也不同步宿主工作区。
|
||||||
|
|
||||||
适配器只保留执行基底机制。文件系统规范化以严格的 base64 加 NUL 分帧穿过 SDK 已解码的命令传输;流式读取把字节上限留给消费方执行。进程管理命令输出与环境快照采用 ASCII/base64,避免 SDK 分片解码丢失字节;私有控制 shell 隔离 profile,后续启动会把已发现且名称呈凭据特征的环境变量置空。进程与终端清理使用远程进程组,并在结算前证明完全停稳。
|
适配器只保留执行基底机制。文件系统规范化以严格的 base64 加 NUL 分帧穿过 SDK 已解码的命令传输;流式读取把字节上限留给消费方执行。进程管理命令输出与环境快照采用 ASCII/base64,避免 SDK 分片解码丢失字节;私有控制 shell 隔离 profile,后续启动会把已发现且名称呈凭据特征的环境变量置空。进程与终端清理使用远程进程组,并在结算前证明完全停稳。
|
||||||
|
|
||||||
保留沙箱只会保留远程文件与非托管状态。重新连接不会重建宿主进程或终端句柄、协议连接、待处理调用、输出游标、计时器或锁。该 POC 不会新增会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录、工作区同步、持久远程句柄,也不会在其中运行整个 harness。
|
沙箱状态有意保持短暂:超时与资源释放会删除远程文件和非托管状态。该 POC 不提供重新连接、pause/leave 保留、会话持久化后端、模板构建器、卷、快照、网络策略层、沙箱目录、工作区同步、持久远程句柄,也不会在其中运行整个 harness。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
聚焦的包测试套件锁定了沙箱生命周期、规范化路径分帧、文件系统元数据与原子版本、进程管理发布/回滚、终端文本 I/O 与会话清理、输出上限、取消、资源释放和不变式注册。一项受凭据门控的 Loader 组合通过源代码导入与构建后导出运行同一套三包提供方组合,其中包括 FS/Bash 可见性、重命名后的版本重读与带保护编辑、恶意登录 profile、跨字节边界拆分的 UTF-8 输出、进程与终端清理、LSP 文档上限、代码运行时绑定/上限/清理、宿主工作区隔离,以及最终沙箱删除。
|
聚焦的包测试套件锁定了沙箱生命周期、规范化路径分帧、文件系统元数据与原子版本、进程管理发布/回滚、终端文本 I/O 与会话清理、输出上限、取消、资源释放和不变式注册。一项受凭据门控的 Loader 组合通过源代码导入与构建后导出运行同一套三包提供方组合,其中包括 FS/Bash 可见性、恶意登录 profile、跨字节边界拆分的 UTF-8 输出、进程与终端清理、LSP 查询、代码运行时绑定与清理、宿主工作区隔离,以及最终沙箱删除。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|
||||||
@@ -75,4 +75,4 @@ E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终
|
|||||||
|
|
||||||
本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。
|
本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。
|
||||||
|
|
||||||
E2B 组合证明,共享沙箱所有者加上文件系统与进程管理适配器,就足以在保持上层能力与提供方无关的同时,把可变编码世界移出宿主。其 POC 限制仍明确在案:SDK 会把完整命令传输内容保留在宿主内存中;远程启动无法同步发布 PID;无法获得精确的终端 stdin 等待状态与独立信号事实;基于数值 PID/PGID 的操作没有身份围栏;初始环境探测无法向已在运行的同 UID 进程隐藏未知的沙箱默认 secret;保留的产物会累积;也不会恢复逃逸进程或重连状态。这些是提供方限制,不是引入兼容性 shim 或更多 E2B 包的理由。
|
E2B 组合证明,共享沙箱所有者加上文件系统与进程管理适配器,就足以在保持上层能力与提供方无关的同时,把可变编码世界移出宿主。其 POC 限制仍明确在案:SDK 会把完整命令传输内容保留在宿主内存中;远程启动无法同步发布 PID;无法获得精确的终端 stdin 等待状态与独立信号事实;基于数值 PID/PGID 的操作没有身份围栏;初始环境探测无法向已在运行的同 UID 进程隐藏未知的沙箱默认 secret;适配器产物会一直保留到沙箱删除。这些是提供方限制,不是引入兼容性 shim 或更多 E2B 包的理由。
|
||||||
@@ -12,8 +12,9 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every
|
|||||||
vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md
|
vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md
|
||||||
packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||||
core/ product API spine: session, system-prompt, tools, agent, agent-loop
|
core/ product API spine: session, system-prompt, tools, agent, agent-loop
|
||||||
|
typert/ type graph generator, loader, and runtime registry
|
||||||
llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
|
llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
|
||||||
e2b/ remote-runtime POC
|
e2b/ E2B POC: sandbox owner + FS/subprocess adapters
|
||||||
bash/ bash executor seam + local impl + model-facing bash tools
|
bash/ bash executor seam + local impl + model-facing bash tools
|
||||||
subprocess/ subprocess seam + local process-tree impl
|
subprocess/ subprocess seam + local process-tree impl
|
||||||
pty/ persistent PTY seam/backend/tools
|
pty/ persistent PTY seam/backend/tools
|
||||||
@@ -90,7 +91,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
|
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
|
||||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)).
|
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
|
||||||
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
||||||
- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
|
- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
|
||||||
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
|
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
|
||||||
@@ -114,7 +115,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
|||||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||||
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||||
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
|
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
|
||||||
- **Use incremental merge commits.** Split independent changes; never squash, rebase, or rewrite pushed history. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
|
- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
|
||||||
- **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible.
|
- **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible.
|
||||||
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
||||||
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
|
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
|
||||||
|
|||||||
@@ -580,18 +580,18 @@ Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/
|
|||||||
|
|
||||||
## `ctx.e2b` — `E2BSandboxService`
|
## `ctx.e2b` — `E2BSandboxService`
|
||||||
|
|
||||||
Owns one lazily consumable E2B SDK handle and its final kill/pause/leave decision. The connection begins at plugin construction; adapters await getSandbox before their first operation.
|
Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout or disposal. Creation begins at plugin construction; adapters await getSandbox before their first operation.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
/**
|
/**
|
||||||
* Return the shared live SDK handle.
|
* Return the shared live SDK handle.
|
||||||
* @returns the created or reconnected sandbox after the configured cwd exists.
|
* @returns the created sandbox after the configured cwd exists.
|
||||||
* @throws when E2B rejects creation/reconnection or the service is disposing.
|
* @throws when E2B rejects creation or the service is disposing.
|
||||||
*/
|
*/
|
||||||
async getSandbox(): Promise<Sandbox>
|
async getSandbox(): Promise<Sandbox>
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/e2b/e2b/src/index.ts:108`](../../packages/e2b/e2b/src/index.ts)
|
Source: [`packages/e2b/e2b/src/index.ts:76`](../../packages/e2b/e2b/src/index.ts)
|
||||||
|
|
||||||
## `ctx.fs` — `FileSystem` (abstract seam)
|
## `ctx.fs` — `FileSystem` (abstract seam)
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Keyless counterpart to e2b.cordis.yml. It keeps the same generic PTY, LSP,
|
|
||||||
# and subprocess Code Runtime consumers, but supplies their fundamental
|
|
||||||
# filesystem/process world locally and replaces the live model with replay.
|
|
||||||
- id: base
|
|
||||||
name: '@cordisjs/plugin-include'
|
|
||||||
config:
|
|
||||||
path: ./cordis.yml
|
|
||||||
patches:
|
|
||||||
- id: llm-deepseek
|
|
||||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
|
||||||
disabled: true
|
|
||||||
- id: cli-agent
|
|
||||||
name: '@deepseek-ai/dsh-cli-demo'
|
|
||||||
config:
|
|
||||||
provider: deepseek
|
|
||||||
model: deepseek-v4-pro
|
|
||||||
persistenceRoot: './.sessions'
|
|
||||||
persistenceCompression: none
|
|
||||||
workspaceContext:
|
|
||||||
maxBytes: 65536
|
|
||||||
tools:
|
|
||||||
mode: both
|
|
||||||
persona: |
|
|
||||||
You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
|
||||||
|
|
||||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
|
||||||
- insert:
|
|
||||||
- id: code-runtime-subprocess
|
|
||||||
name: '@deepseek-ai/dsh-code-runtime-subprocess'
|
|
||||||
- id: tool-cordis
|
|
||||||
name: '@deepseek-ai/dsh-tool-cordis'
|
|
||||||
- id: sandbox-policy
|
|
||||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
|
||||||
config:
|
|
||||||
mode: danger-full-access
|
|
||||||
workspaceRoot: !!js process.cwd()
|
|
||||||
- id: pty
|
|
||||||
name: '@deepseek-ai/dsh-pty'
|
|
||||||
- id: pty-local
|
|
||||||
name: '@deepseek-ai/dsh-pty-local'
|
|
||||||
- id: tool-pty
|
|
||||||
name: '@deepseek-ai/dsh-tool-pty'
|
|
||||||
- id: lsp
|
|
||||||
name: '@deepseek-ai/dsh-lsp'
|
|
||||||
- id: lsp-local
|
|
||||||
name: '@deepseek-ai/dsh-lsp-local'
|
|
||||||
config:
|
|
||||||
servers:
|
|
||||||
typescript:
|
|
||||||
command: node
|
|
||||||
extensionToLanguage:
|
|
||||||
.ts: typescript
|
|
||||||
- id: tool-lsp
|
|
||||||
name: '@deepseek-ai/dsh-tool-lsp'
|
|
||||||
- id: llm-replay
|
|
||||||
name: '@deepseek-ai/dsh-llm-replay'
|
|
||||||
@@ -15,19 +15,12 @@
|
|||||||
- id: code-runtime
|
- id: code-runtime
|
||||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||||
disabled: true
|
disabled: true
|
||||||
- id: bash
|
|
||||||
name: '@deepseek-ai/dsh-bash-local'
|
|
||||||
config:
|
|
||||||
cwd: !!js process.cwd()
|
|
||||||
timeoutMs: 60000
|
|
||||||
- insert:
|
- insert:
|
||||||
- id: e2b
|
- id: e2b
|
||||||
name: '@deepseek-ai/dsh-e2b'
|
name: '@deepseek-ai/dsh-e2b'
|
||||||
config:
|
config:
|
||||||
cwd: !!js process.cwd()
|
cwd: !!js process.cwd()
|
||||||
timeoutMs: 300000
|
timeoutMs: 300000
|
||||||
onTimeout: kill
|
|
||||||
onDispose: kill
|
|
||||||
- id: subprocess-e2b
|
- id: subprocess-e2b
|
||||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||||
- id: fs-e2b
|
- id: fs-e2b
|
||||||
|
|||||||
+9
-206
@@ -1,10 +1,9 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { posix, resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { boot } from '@deepseek-ai/dsh-app-boot'
|
import { boot } from '@deepseek-ai/dsh-app-boot'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type {} from '@deepseek-ai/dsh-code-runtime-subprocess'
|
import type {} from '@deepseek-ai/dsh-code-runtime-subprocess'
|
||||||
import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
|
|
||||||
import type {} from '@deepseek-ai/dsh-fs-e2b'
|
import type {} from '@deepseek-ai/dsh-fs-e2b'
|
||||||
import type {} from '@deepseek-ai/dsh-bash-local'
|
import type {} from '@deepseek-ai/dsh-bash-local'
|
||||||
import type {} from '@deepseek-ai/dsh-lsp-local'
|
import type {} from '@deepseek-ai/dsh-lsp-local'
|
||||||
@@ -36,18 +35,17 @@ try {
|
|||||||
const sandbox = await ctx.e2b.getSandbox()
|
const sandbox = await ctx.e2b.getSandbox()
|
||||||
const fromFs = await ctx.fs.resolve('from-fs.txt')
|
const fromFs = await ctx.fs.resolve('from-fs.txt')
|
||||||
const written = await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' })
|
const written = await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' })
|
||||||
const reread = await ctx.fs.stat(fromFs)
|
const observed = await ctx.fs.stat(fromFs)
|
||||||
if (reread?.version !== written.version) {
|
if (observed?.version !== written.version) {
|
||||||
throw new Error(`E2B rename did not preserve version metadata: ${JSON.stringify({ written, reread })}`)
|
throw new Error(`E2B rename did not preserve version metadata: ${JSON.stringify({ written, observed })}`)
|
||||||
}
|
}
|
||||||
await ctx.fs.editText(
|
await ctx.fs.editText(
|
||||||
fromFs,
|
fromFs,
|
||||||
{ oldString: 'written-by-fs', newString: 'written-by-fs-versioned', replaceAll: false },
|
{ oldString: 'written-by-fs', newString: 'versioned-by-fs', replaceAll: false },
|
||||||
{ version: reread.version },
|
{ version: observed.version },
|
||||||
)
|
)
|
||||||
const fsVersionGuard = true
|
|
||||||
const bashRead = await ctx.bash.run(ctx.bash.resolve({ command: 'cat from-fs.txt' }))
|
const bashRead = await ctx.bash.run(ctx.bash.resolve({ command: 'cat from-fs.txt' }))
|
||||||
if (bashRead.exitCode !== 0 || bashRead.stdout.text !== 'written-by-fs-versioned\n') {
|
if (bashRead.exitCode !== 0 || bashRead.stdout.text !== 'versioned-by-fs\n') {
|
||||||
throw new Error(`E2B Bash could not read the FS write: ${JSON.stringify(bashRead)}`)
|
throw new Error(`E2B Bash could not read the FS write: ${JSON.stringify(bashRead)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,67 +112,11 @@ try {
|
|||||||
)
|
)
|
||||||
if (outputDrainOutcome.exitCode !== 0 || outputDrainText !== 'leader-done\n'
|
if (outputDrainOutcome.exitCode !== 0 || outputDrainText !== 'leader-done\n'
|
||||||
|| outputDrainElapsedMs >= 10_000 || !outputDrainExited || !outputDrainClean) {
|
|| outputDrainElapsedMs >= 10_000 || !outputDrainExited || !outputDrainClean) {
|
||||||
throw new Error(`E2B subprocess output drain was not bounded: ${JSON.stringify({
|
throw new Error(`E2B subprocess did not bound descendant-held output: ${JSON.stringify({
|
||||||
outputDrainOutcome, outputDrainText, outputDrainElapsedMs, outputDrainExited, outputDrainClean,
|
outputDrainOutcome, outputDrainText, outputDrainElapsedMs, outputDrainExited, outputDrainClean,
|
||||||
})}`)
|
})}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
|
||||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 4, spill: { maxBytes: 6 } }, stderr: { maxBytes: 4_096 } },
|
|
||||||
graceMs: 500,
|
|
||||||
env: {},
|
|
||||||
})
|
|
||||||
const spillReader = spillHandle.collected.stdout
|
|
||||||
if (spillReader === undefined) throw new Error('E2B subprocess omitted its configured stdout collector')
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
const spillPath = posix.join((spillHandle as unknown as { stateDir: string }).stateDir, 'stdout.log')
|
|
||||||
const liveSpillBytes = (await (await ctx.e2b.getSandbox()).files.getInfo(spillPath)).size
|
|
||||||
spillHandle.terminate()
|
|
||||||
const spillOutcome = await spillHandle.done
|
|
||||||
const spillExited = await spillHandle.waitForExit(AbortSignal.timeout(5_000))
|
|
||||||
const spillRead = spillReader.readFrom(0)
|
|
||||||
if (liveSpillBytes !== 6 || !spillExited || spillRead.spillPath !== undefined) {
|
|
||||||
throw new Error(`E2B subprocess spill bound failed: ${JSON.stringify({ liveSpillBytes, spillExited, spillRead })}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lspFixture = await readFile(new URL('./fixture-lsp.mjs', import.meta.url), 'utf8')
|
const lspFixture = await readFile(new URL('./fixture-lsp.mjs', import.meta.url), 'utf8')
|
||||||
const remoteLspFixture = await ctx.fs.resolve('fixture-lsp.mjs')
|
const remoteLspFixture = await ctx.fs.resolve('fixture-lsp.mjs')
|
||||||
await ctx.fs.writeText(remoteLspFixture, lspFixture, { kind: 'createIfAbsent' })
|
await ctx.fs.writeText(remoteLspFixture, lspFixture, { kind: 'createIfAbsent' })
|
||||||
@@ -193,53 +135,12 @@ try {
|
|||||||
workspaceRoot: process.cwd(),
|
workspaceRoot: process.cwd(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const oversizedSourcePath = posix.join(process.cwd(), 'oversized-source.ts')
|
|
||||||
await sandbox.commands.run(`head -c 4000001 /dev/zero | tr '\\0' x > ${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('exceeds the 4000000-byte limit')
|
|
||||||
if (!lspDocumentBound) throw error
|
|
||||||
}
|
|
||||||
if (!lspDocumentBound) throw new Error('E2B LSP accepted an oversized remote source')
|
|
||||||
|
|
||||||
const remoteCommands = sandbox.commands as unknown as {
|
|
||||||
run(command: string, options?: unknown): Promise<{ exitCode: number; stdout: string; stderr: string }>
|
|
||||||
}
|
|
||||||
const runRemoteCommand = remoteCommands.run.bind(sandbox.commands)
|
|
||||||
const terminal = await ctx.pty.spawn(owner, { type: 'shell' })
|
const terminal = await ctx.pty.spawn(owner, { type: 'shell' })
|
||||||
terminalId = terminal.sessionId
|
terminalId = terminal.sessionId
|
||||||
const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, {
|
const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, {
|
||||||
text: "printf 'PTY-你好\\n'",
|
text: "printf 'PTY-你好\\n'",
|
||||||
submit: true,
|
submit: true,
|
||||||
}).done
|
}).done
|
||||||
const foregroundLookup = Promise.withResolvers<undefined>()
|
|
||||||
let delayedForegroundLookup = false
|
|
||||||
remoteCommands.run = async (command, options) => {
|
|
||||||
if (!delayedForegroundLookup && command.startsWith('ps -o tpgid=')) {
|
|
||||||
delayedForegroundLookup = true
|
|
||||||
await foregroundLookup.promise
|
|
||||||
}
|
|
||||||
return await runRemoteCommand(command, options)
|
|
||||||
}
|
|
||||||
const staleInterrupt = ctx.pty.startSend(owner, terminal.sessionId, { text: 'sleep 0.2', submit: true })
|
|
||||||
if (!staleInterrupt.cancel()) throw new Error('E2B PTY refused the stale-interrupt probe cancellation')
|
|
||||||
let canceledSendRetained = false
|
|
||||||
try {
|
|
||||||
ctx.pty.startSend(owner, terminal.sessionId, { text: 'sleep 30', submit: true })
|
|
||||||
} catch (error: unknown) {
|
|
||||||
canceledSendRetained = String(error).includes('active send')
|
|
||||||
}
|
|
||||||
foregroundLookup.resolve(undefined)
|
|
||||||
await staleInterrupt.done
|
|
||||||
remoteCommands.run = runRemoteCommand
|
|
||||||
if (!canceledSendRetained) throw new Error('E2B PTY released a canceled send before foreground signalling settled')
|
|
||||||
const sleeping = ctx.pty.startSend(owner, terminal.sessionId, {
|
const sleeping = ctx.pty.startSend(owner, terminal.sessionId, {
|
||||||
text: "printf 'DSH_SLEEP_%s\\n' READY; sleep 30",
|
text: "printf 'DSH_SLEEP_%s\\n' READY; sleep 30",
|
||||||
submit: true,
|
submit: true,
|
||||||
@@ -258,13 +159,6 @@ try {
|
|||||||
}
|
}
|
||||||
if (Date.now() >= sleepReadyDeadline) throw new Error(`E2B PTY successor did not execute: ${sleepReadyOutput}`)
|
if (Date.now() >= sleepReadyDeadline) throw new Error(`E2B PTY successor did not execute: ${sleepReadyOutput}`)
|
||||||
}
|
}
|
||||||
const interruptIdentitySafe = await Promise.race([
|
|
||||||
sleeping.done.then(() => false),
|
|
||||||
new Promise<true>(resolveDelay => setTimeout(() => { resolveDelay(true) }, 300)),
|
|
||||||
])
|
|
||||||
if (!delayedForegroundLookup || !interruptIdentitySafe) {
|
|
||||||
throw new Error('E2B PTY stale interrupt affected its successor send')
|
|
||||||
}
|
|
||||||
const terminalSignal = await ctx.pty.signal(owner, terminal.sessionId, 'SIGINT')
|
const terminalSignal = await ctx.pty.signal(owner, terminal.sessionId, 'SIGINT')
|
||||||
const interrupted = await sleeping.done
|
const interrupted = await sleeping.done
|
||||||
const stubborn = await ctx.pty.startSend(owner, terminal.sessionId, {
|
const stubborn = await ctx.pty.startSend(owner, terminal.sessionId, {
|
||||||
@@ -284,24 +178,6 @@ try {
|
|||||||
const code = await ctx.codeRuntime.run({
|
const code = await ctx.codeRuntime.run({
|
||||||
program: `
|
program: `
|
||||||
console.log('remote-log 你好', 42)
|
console.log('remote-log 你好', 42)
|
||||||
const arrayPrototype = Array.prototype
|
|
||||||
const objectPrototype = Object.prototype
|
|
||||||
const setPrototype = Set.prototype
|
|
||||||
const stringPrototype = String.prototype
|
|
||||||
Array.isArray = () => false
|
|
||||||
Object.defineProperty = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') }
|
|
||||||
Object.hasOwn = () => false
|
|
||||||
Object.is = () => true
|
|
||||||
objectPrototype.propertyIsEnumerable = () => false
|
|
||||||
Number.isFinite = Number.isSafeInteger = () => false
|
|
||||||
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') }
|
|
||||||
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') }
|
|
||||||
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') }
|
|
||||||
Buffer.byteLength = () => 0
|
|
||||||
Function.prototype.toString = () => 'mutated'
|
|
||||||
objectPrototype.constructor = arrayPrototype.constructor = null
|
|
||||||
globalThis.Array = globalThis.Buffer = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Promise = globalThis.Reflect = globalThis.Set = globalThis.String = undefined
|
|
||||||
process.stdout.write('post-mutation', () => {})
|
|
||||||
const doubled: number = await bridge.double({ value: 21 })
|
const doubled: number = await bridge.double({ value: 21 })
|
||||||
let typed = false
|
let typed = false
|
||||||
try {
|
try {
|
||||||
@@ -323,42 +199,6 @@ try {
|
|||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
const hostileOutput = await ctx.codeRuntime.run({
|
|
||||||
program: `
|
|
||||||
const payload = '🙂'.repeat(4096)
|
|
||||||
String.prototype[Symbol.iterator] = () => { throw new Error('mutated string iterator') }
|
|
||||||
console.log(payload)
|
|
||||||
return true
|
|
||||||
`,
|
|
||||||
bindings: [],
|
|
||||||
})
|
|
||||||
const 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 descendantPipe = await ctx.codeRuntime.run({
|
const descendantPipe = await ctx.codeRuntime.run({
|
||||||
program: `
|
program: `
|
||||||
const childProcess = await import('node:child_process')
|
const childProcess = await import('node:child_process')
|
||||||
@@ -376,67 +216,30 @@ try {
|
|||||||
JSON.stringify([processInfo.cmd, processInfo.args]).includes('dsh-code-runtime-descendant'),
|
JSON.stringify([processInfo.cmd, processInfo.args]).includes('dsh-code-runtime-descendant'),
|
||||||
)
|
)
|
||||||
if (!descendantCleanup) throw new Error('E2B Code Runtime left a pipe-holding descendant alive')
|
if (!descendantCleanup) throw new Error('E2B Code Runtime left a pipe-holding descendant alive')
|
||||||
const timedOut = await ctx.codeRuntime.run({
|
|
||||||
program: 'await new Promise(() => {})',
|
|
||||||
bindings: [],
|
|
||||||
})
|
|
||||||
const abortController = new AbortController()
|
|
||||||
const aborting = ctx.codeRuntime.run({
|
|
||||||
program: 'await new Promise(() => {})',
|
|
||||||
bindings: [],
|
|
||||||
signal: abortController.signal,
|
|
||||||
})
|
|
||||||
setTimeout(() => { abortController.abort('live abort') }, 50)
|
|
||||||
const aborted = await aborting
|
|
||||||
const oversizedBoot = await ctx.codeRuntime.run({
|
|
||||||
program: `return ${JSON.stringify('x'.repeat(40_000))}`,
|
|
||||||
bindings: [],
|
|
||||||
})
|
|
||||||
const oversizedReply = await ctx.codeRuntime.run({
|
|
||||||
program: 'return await bridge.large(null)',
|
|
||||||
bindings: [{
|
|
||||||
global: 'bridge',
|
|
||||||
functions: { large: async () => 'x'.repeat(40_000) },
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
const remoteProcesses = await (await ctx.e2b.getSandbox()).commands.list()
|
const remoteProcesses = await (await ctx.e2b.getSandbox()).commands.list()
|
||||||
const lingeringCodeRunners = remoteProcesses.filter(processInfo =>
|
const lingeringCodeRunners = remoteProcesses.filter(processInfo =>
|
||||||
JSON.stringify([processInfo.cmd, processInfo.args]).includes('code-runtime-runner.mjs'),
|
JSON.stringify([processInfo.cmd, processInfo.args]).includes('code-runtime-runner.mjs'),
|
||||||
)
|
)
|
||||||
|
|
||||||
process.stdout.write(`${JSON.stringify({
|
process.stdout.write(`${JSON.stringify({
|
||||||
sandboxId: await ctx.e2b.sandboxId,
|
sandboxId: (await ctx.e2b.getSandbox()).sandboxId,
|
||||||
bashRead: bashRead.stdout.text,
|
bashRead: bashRead.stdout.text,
|
||||||
fsVersionGuard,
|
|
||||||
fsRead,
|
fsRead,
|
||||||
explicitEnvironment,
|
explicitEnvironment,
|
||||||
splitUtf8Output,
|
splitUtf8Output,
|
||||||
outputDrain: { outcome: outputDrainOutcome, text: outputDrainText, exited: outputDrainExited, clean: outputDrainClean },
|
|
||||||
publicationRollback,
|
|
||||||
spill: { liveBytes: liveSpillBytes, outcome: spillOutcome, read: spillRead },
|
|
||||||
hover,
|
hover,
|
||||||
definition,
|
definition,
|
||||||
lspDocumentBound,
|
|
||||||
terminal: {
|
terminal: {
|
||||||
motd: terminal.motd,
|
motd: terminal.motd,
|
||||||
echo: terminalEcho,
|
echo: terminalEcho,
|
||||||
signal: terminalSignal,
|
signal: terminalSignal,
|
||||||
interrupted,
|
interrupted,
|
||||||
interruptIdentitySafe,
|
|
||||||
treeCleanup: terminalTreeCleanup,
|
treeCleanup: terminalTreeCleanup,
|
||||||
scrollback: terminalScrollback.text,
|
scrollback: terminalScrollback.text,
|
||||||
},
|
},
|
||||||
code,
|
code,
|
||||||
hostileOutput,
|
|
||||||
nativeOutput,
|
|
||||||
descriptorOutput,
|
|
||||||
inheritedOutput,
|
|
||||||
descendantPipe,
|
descendantPipe,
|
||||||
descendantCleanup,
|
descendantCleanup,
|
||||||
timedOut,
|
|
||||||
aborted,
|
|
||||||
oversizedBoot,
|
|
||||||
oversizedReply,
|
|
||||||
lingeringCodeRunners: lingeringCodeRunners.length,
|
lingeringCodeRunners: lingeringCodeRunners.length,
|
||||||
})}\n`)
|
})}\n`)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
config:
|
config:
|
||||||
cwd: !!js process.cwd()
|
cwd: !!js process.cwd()
|
||||||
timeoutMs: 180000
|
timeoutMs: 180000
|
||||||
onTimeout: kill
|
|
||||||
onDispose: kill
|
|
||||||
|
|
||||||
- id: subprocess-e2b
|
- id: subprocess-e2b
|
||||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||||
@@ -12,7 +10,6 @@
|
|||||||
- id: bash
|
- id: bash
|
||||||
name: '@deepseek-ai/dsh-bash-local'
|
name: '@deepseek-ai/dsh-bash-local'
|
||||||
config:
|
config:
|
||||||
cwd: !!js process.cwd()
|
|
||||||
timeoutMs: 30000
|
timeoutMs: 30000
|
||||||
|
|
||||||
- id: fs-e2b
|
- id: fs-e2b
|
||||||
@@ -63,5 +60,4 @@
|
|||||||
maxWallMs: 15000
|
maxWallMs: 15000
|
||||||
maxOutputBytes: 4096
|
maxOutputBytes: 4096
|
||||||
maxOldGenerationSizeMb: 128
|
maxOldGenerationSizeMb: 128
|
||||||
maxFrameBytes: 32768
|
|
||||||
killGraceMs: 500
|
killGraceMs: 500
|
||||||
@@ -25,8 +25,6 @@ const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain')
|
|||||||
const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl')
|
const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl')
|
||||||
const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl')
|
const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl')
|
||||||
const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
|
const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
|
||||||
const e2bScenarioDir = join(snapshotsDir, 'e2b-overlay')
|
|
||||||
const e2bConfigPath = fileURLToPath(new URL('../e2b.cordis.snapshot.yml', import.meta.url))
|
|
||||||
const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
|
const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
|
||||||
const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
|
const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
|
||||||
const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
|
const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
|
||||||
@@ -610,60 +608,6 @@ describe('headless stream-json snapshots', () => {
|
|||||||
expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8'))
|
expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8'))
|
||||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||||
|
|
||||||
it('pins the E2B overlay provider-neutral tool surface', async () => {
|
|
||||||
const prompt = await scenarioPrompt(e2bScenarioDir, 'e2b-overlay')
|
|
||||||
const streamExpected = join(e2bScenarioDir, 'stream-json.expected.jsonl')
|
|
||||||
let runCwd = ''
|
|
||||||
const result = await runLoaderSmoke({
|
|
||||||
label: 'E2B overlay headless stream-json snapshot',
|
|
||||||
tempDirPrefix: 'headless-snapshot-e2b-overlay-',
|
|
||||||
binScript,
|
|
||||||
configPath: e2bConfigPath,
|
|
||||||
binArgs: ['--config', e2bConfigPath, '--output-format', 'stream-json', prompt],
|
|
||||||
tsconfigPath,
|
|
||||||
env: {
|
|
||||||
DSH_SNAPSHOT: 'replay',
|
|
||||||
DSH_SNAPSHOT_FILE: join(e2bScenarioDir, 'session.jsonl'),
|
|
||||||
DSH_SNAPSHOT_OVERRIDE: join(e2bScenarioDir, 'replay.override.json'),
|
|
||||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
|
||||||
},
|
|
||||||
prepare: (cwd) => { runCwd = cwd },
|
|
||||||
inspect: async (cwd) => {
|
|
||||||
const logs = await persistedLogs(cwd)
|
|
||||||
expect(logs).toHaveLength(1)
|
|
||||||
const headers = parseJsonl(logs[0]?.content ?? '').filter(record => record.type === 'request/header')
|
|
||||||
expect(headers).toHaveLength(1)
|
|
||||||
const data = headers[0]?.data as JsonObject | undefined
|
|
||||||
const header = data?.header as JsonObject | undefined
|
|
||||||
if (!Array.isArray(header?.tools)) throw new Error('E2B overlay snapshot request has no tool schemas')
|
|
||||||
const toolNames = header.tools.map((tool, index) => {
|
|
||||||
if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) {
|
|
||||||
throw new Error(`E2B overlay snapshot tool schema ${index} is not an object`)
|
|
||||||
}
|
|
||||||
const name = (tool as JsonObject).name
|
|
||||||
if (typeof name !== 'string') throw new Error(`E2B overlay snapshot tool schema ${index} has no name`)
|
|
||||||
return name
|
|
||||||
})
|
|
||||||
expect(toolNames.filter(name => name === 'lsp' || name === 'run_code' || name.startsWith('terminal_')).sort())
|
|
||||||
.toEqual([
|
|
||||||
'lsp',
|
|
||||||
'run_code',
|
|
||||||
'terminal_close',
|
|
||||||
'terminal_list',
|
|
||||||
'terminal_open',
|
|
||||||
'terminal_read',
|
|
||||||
'terminal_send',
|
|
||||||
'terminal_signal',
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.stderr).toBe('')
|
|
||||||
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
|
|
||||||
if (refreshing) await writeFile(streamExpected, normalized)
|
|
||||||
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
|
|
||||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
|
||||||
|
|
||||||
it('replays persisted goal tools through the one-shot app', async () => {
|
it('replays persisted goal tools through the one-shot app', async () => {
|
||||||
const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools')
|
const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools')
|
||||||
const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl')
|
const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl')
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"op": "prompt",
|
|
||||||
"text": "Report the assembled E2B overlay tool surface."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"kind": "chunks",
|
|
||||||
"chunks": [
|
|
||||||
{ "type": "block-start", "index": 0, "blockType": "text" },
|
|
||||||
{ "type": "text-delta", "index": 0, "text": "E2B_SURFACE_OK" },
|
|
||||||
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "E2B_SURFACE_OK" } },
|
|
||||||
{ "type": "usage", "usage": { "inputTokens": 8, "outputTokens": 3 } },
|
|
||||||
{ "type": "finish", "reason": { "kind": "stop" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Report the assembled E2B overlay tool surface."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Report the assembled E2B overlay","messageSeqs":[1],"source":{"kind":"fallback"}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"E2B_SURFACE_OK"}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"E2B_SURFACE_OK"}}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8,"outputTokens":3}}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"E2B_SURFACE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"{{sessionId}}"},"usage":{"inputTokens":8,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}}
|
|
||||||
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
|
|
||||||
{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"E2B_SURFACE_OK","reason":{"kind":"completed"},"usage":{"inputTokens":8,"outputTokens":3}}
|
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/e2b/README.md
|
# pnpm run verify-translation-pairing --write packages/e2b/README.md
|
||||||
README.md: cb733ba00d21070773ff9b381e4a72d9d3b9a8df
|
README.md: 3c85b2790d3414af73625e65732df58ff9fec566
|
||||||
README.zh.md: 29f8e58a0cdc59098cf8d74120cb360d11cfbcb3
|
README.zh.md: a759539fc054dd68e75f3c7660ccc3b762985af5
|
||||||
@@ -6,7 +6,7 @@ An experimental provider-composition POC that places one filesystem/process exec
|
|||||||
|
|
||||||
| Package | ctx key | Role |
|
| Package | ctx key | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create or reconnect one sandbox, create its working/runtime directories, expose the shared SDK handle, and apply the configured kill/pause/leave disposition |
|
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create one sandbox, prepare its working/runtime directories, expose the shared SDK handle, and delete it on timeout or disposal |
|
||||||
| [`fs-e2b`](fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
|
| [`fs-e2b`](fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
|
||||||
| [`subprocess-e2b`](subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement executable lookup, managed process groups and stdio, remote spill files, and terminal sessions over E2B Commands and PTY APIs |
|
| [`subprocess-e2b`](subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement executable lookup, managed process groups and stdio, remote spill files, and terminal sessions over E2B Commands and PTY APIs |
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
| 包(package) | ctx 键 | 职责 |
|
| 包(package) | ctx 键 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`e2b`](e2b/README.md)(`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
|
| [`e2b`](e2b/README.md)(`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | 创建一个沙箱,准备其工作目录与运行时目录,公开共享 SDK 句柄,并在超时或资源释放时将其删除 |
|
||||||
| [`fs-e2b`](fs-e2b/README.md)(`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
|
| [`fs-e2b`](fs-e2b/README.md)(`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
|
||||||
| [`subprocess-e2b`](subprocess-e2b/README.md)(`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | 通过 E2B Commands 与 PTY API 实现可执行文件查找、受管进程组与 stdio、远程 spill 文件及终端会话 |
|
| [`subprocess-e2b`](subprocess-e2b/README.md)(`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | 通过 E2B Commands 与 PTY API 实现可执行文件查找、受管进程组与 stdio、远程 spill 文件及终端会话 |
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
|
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
|
||||||
README.md: 6881556dc18497956966aff74adc085d8e8620d3
|
README.md: 6045b313a91edafc25361eba3f3c4dacf56b4eb0
|
||||||
README.zh.md: aea13f341f903e6c47b7484f477bf54cebf101c6
|
README.zh.md: dc157d9b5167488530c7d63b59aed793ab6aa4c7
|
||||||
@@ -12,8 +12,6 @@ Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapte
|
|||||||
config:
|
config:
|
||||||
cwd: /home/user/workspace
|
cwd: /home/user/workspace
|
||||||
timeoutMs: 300000
|
timeoutMs: 300000
|
||||||
onTimeout: pause
|
|
||||||
onDispose: kill
|
|
||||||
|
|
||||||
- id: subprocess-e2b
|
- id: subprocess-e2b
|
||||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||||
@@ -22,17 +20,13 @@ Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapte
|
|||||||
name: '@deepseek-ai/dsh-fs-e2b'
|
name: '@deepseek-ai/dsh-fs-e2b'
|
||||||
```
|
```
|
||||||
|
|
||||||
`apiKey` is optional and otherwise reads `E2B_API_KEY`; the key configures the host SDK connection and is never installed in the sandbox. `cwd` defaults to `/home/user/workspace` and must be an absolute POSIX path. `timeoutMs` defaults to five minutes. `onTimeout` is `pause` by default and accepts `pause | kill`; it applies only when this service creates a sandbox. Pause-on-timeout enables E2B auto-resume so the shared SDK handle wakes on its next operation. `onDispose` defaults to `kill` and accepts `kill | pause | leave`.
|
`apiKey` is optional and otherwise reads `E2B_API_KEY`; the key configures the host SDK connection and is never installed in the sandbox. `cwd` defaults to `/home/user/workspace` and must be an absolute POSIX path. `timeoutMs` defaults to five minutes and controls the sandbox lifetime; expiry deletes the sandbox.
|
||||||
|
|
||||||
Set `sandboxId` to reconnect a running or paused sandbox instead of creating one. E2B resumes a paused sandbox during connect; `template` and `onTimeout` are creation-only and cannot accompany `sandboxId`. Omitting `template` uses E2B's default base template.
|
|
||||||
|
|
||||||
## Lifecycle and ownership
|
## Lifecycle and ownership
|
||||||
|
|
||||||
Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
|
Construction starts one sandbox creation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command.
|
||||||
|
|
||||||
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` is accepted when disposal requests `kill`, or when this service created a sandbox with `onTimeout: kill`; otherwise, a not-found error from a requested `pause` rejects teardown because retention was not proved. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
|
Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure also deletes the newly created sandbox; if that rollback fails, disposal retries it before releasing ownership. Provider plugins must load after this owner and dispose before it.
|
||||||
|
|
||||||
`pause` and `leave` retain remote filesystem and adapter artifacts for a later `sandboxId` connection, but a later harness process receives only a new SDK handle. The subprocess service still fulfills its seam contract by terminating managed groups before owner disposal; neither disposition recovers prior process objects, output cursors, or in-memory adapter locks.
|
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
@@ -45,6 +39,6 @@ No direct invalidation; this package does not contribute request tokens.
|
|||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **This is not a whole-harness runtime** — Cordis services, agent/session state, session logs, LLM requests, skills, and SDK-side buffers stay in the host process.
|
- **This is not a whole-harness runtime** — Cordis services, agent/session state, session logs, LLM requests, skills, and SDK-side buffers stay in the host process.
|
||||||
- **Retained sandboxes do not restore host handles** — reconnect preserves remote files and adapter artifacts, but cannot reconstruct subprocess handles, stream cursors, or mutation locks; managed subprocesses terminate when their service disposes.
|
- **Sandbox state is ephemeral** — disposal and timeout delete the sandbox; reconnect, pause/leave retention, templates, volumes, and snapshots are outside this POC.
|
||||||
- **No deployment platform is configured** — templates, volumes, snapshots, network policy, host-workspace synchronization, and sandbox discovery are outside this POC.
|
- **No deployment platform is configured** — network policy, host-workspace synchronization, and sandbox discovery are outside this POC.
|
||||||
- **`cwd` is a resolution convention, not containment** — adapters and commands can address other sandbox paths; E2B network access also retains the template's policy.
|
- **`cwd` is a resolution convention, not containment** — adapters and commands can address other sandbox paths; E2B network access retains the base image's policy.
|
||||||
@@ -12,8 +12,6 @@
|
|||||||
config:
|
config:
|
||||||
cwd: /home/user/workspace
|
cwd: /home/user/workspace
|
||||||
timeoutMs: 300000
|
timeoutMs: 300000
|
||||||
onTimeout: pause
|
|
||||||
onDispose: kill
|
|
||||||
|
|
||||||
- id: subprocess-e2b
|
- id: subprocess-e2b
|
||||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||||
@@ -22,17 +20,13 @@
|
|||||||
name: '@deepseek-ai/dsh-fs-e2b'
|
name: '@deepseek-ai/dsh-fs-e2b'
|
||||||
```
|
```
|
||||||
|
|
||||||
`apiKey` 可省略;省略时读取 `E2B_API_KEY`。该密钥只配置宿主 SDK 连接,绝不会安装进沙箱。`cwd` 默认为 `/home/user/workspace`,并且必须是绝对 POSIX 路径。`timeoutMs` 默认为 5 分钟。`onTimeout` 默认为 `pause`,接受 `pause | kill`;它只在本服务创建沙箱时生效。超时时 pause 会启用 E2B 自动恢复,使共享 SDK 句柄在下一次操作时唤醒。`onDispose` 默认为 `kill`,接受 `kill | pause | leave`。
|
`apiKey` 可省略;省略时读取 `E2B_API_KEY`。该密钥只配置宿主 SDK 连接,绝不会安装进沙箱。`cwd` 默认为 `/home/user/workspace`,并且必须是绝对 POSIX 路径。`timeoutMs` 默认为 5 分钟并控制沙箱生命周期;超时会删除沙箱。
|
||||||
|
|
||||||
设置 `sandboxId` 可重新连接正在运行或已经暂停的沙箱,而不是创建新沙箱。连接时,E2B 会恢复已经暂停的沙箱;`template` 和 `onTimeout` 仅用于创建,不能与 `sandboxId` 同时使用。省略 `template` 时使用 E2B 的默认基础模板。
|
|
||||||
|
|
||||||
## 生命周期与所有权
|
## 生命周期与所有权
|
||||||
|
|
||||||
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
|
构造阶段会启动一次沙箱创建。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。
|
||||||
|
|
||||||
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 仅在资源释放请求 `kill`,或本服务创建了配置为 `onTimeout: kill` 的沙箱时才可接受;否则,`pause` 请求返回的未找到错误会导致 teardown 拒绝,因为无法证明保留成功。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
|
资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时也会删除新建沙箱;如果该回滚失败,资源释放会在解除所有权前重试。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
|
||||||
|
|
||||||
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
|
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
@@ -45,6 +39,6 @@
|
|||||||
## 已知限制与延后工作
|
## 已知限制与延后工作
|
||||||
|
|
||||||
- **这不是完整的 harness 运行时**:Cordis 服务、agent(智能体)/会话状态、会话日志、LLM(大语言模型)请求、skill(技能)和 SDK 侧缓冲仍留在宿主进程中。
|
- **这不是完整的 harness 运行时**:Cordis 服务、agent(智能体)/会话状态、会话日志、LLM(大语言模型)请求、skill(技能)和 SDK 侧缓冲仍留在宿主进程中。
|
||||||
- **保留的沙箱不会恢复宿主句柄**:重新连接会保留远程文件和适配器产物,但无法重建进程管理句柄、流游标或变更锁;进程管理服务 dispose 时会终止受管子进程。
|
- **沙箱状态是短暂的**:资源释放和超时都会删除沙箱;重新连接、pause/leave 保留、模板、卷和快照均不在本 POC 范围内。
|
||||||
- **没有配置部署平台**:模板、卷、快照、网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
|
- **没有配置部署平台**:网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
|
||||||
- **`cwd` 是解析约定,而不是包含边界**:适配器和命令可以访问沙箱中的其他路径;E2B 网络访问也继续采用模板的策略。
|
- **`cwd` 是解析约定,而不是包含边界**:适配器和命令可以访问沙箱中的其他路径;E2B 网络访问也继续采用基础镜像的策略。
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
|
||||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
},
|
},
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
"schemastery": "^3.18.0"
|
"schemastery": "^3.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
|
||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
|
|||||||
+27
-119
@@ -9,30 +9,15 @@ import { posix } from 'node:path'
|
|||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { FileType, Sandbox, SandboxNotFoundError } from 'e2b'
|
import { FileType, Sandbox, SandboxNotFoundError } from 'e2b'
|
||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CommandExitError,
|
CommandExitError,
|
||||||
FileNotFoundError,
|
FileNotFoundError,
|
||||||
FileType,
|
FileType,
|
||||||
Sandbox,
|
Sandbox,
|
||||||
SandboxError,
|
|
||||||
SandboxNotFoundError,
|
SandboxNotFoundError,
|
||||||
TimeoutError,
|
|
||||||
} from 'e2b'
|
} from 'e2b'
|
||||||
export type { CommandHandle, CommandResult, EntryInfo, ProcessInfo, PtyOutput } from 'e2b'
|
export type { CommandHandle, CommandResult, EntryInfo } from 'e2b'
|
||||||
|
|
||||||
/** Opaque E2B sandbox identity used for reconnecting a later harness process. */
|
|
||||||
export type E2BSandboxId = Branded<'E2BSandboxId'>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brand an SDK sandbox id after E2B has created or resolved it.
|
|
||||||
* @param value - E2B's opaque sandbox id.
|
|
||||||
* @returns the same string with the harness brand.
|
|
||||||
*/
|
|
||||||
export function E2BSandboxId(value: string): E2BSandboxId {
|
|
||||||
return value as E2BSandboxId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quote one opaque argument for the SDK's unavoidable `/bin/bash -l -c` layer.
|
* Quote one opaque argument for the SDK's unavoidable `/bin/bash -l -c` layer.
|
||||||
@@ -54,44 +39,25 @@ export function e2bControlEnvs(
|
|||||||
return { ...overrides, HOME: `/.dsh-e2b-control-${randomUUID()}` }
|
return { ...overrides, HOME: `/.dsh-e2b-control-${randomUUID()}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action taken on the owned sandbox when the Cordis service is disposed. */
|
|
||||||
export type E2BDisposeMode = 'kill' | 'pause' | 'leave'
|
|
||||||
|
|
||||||
/** Action E2B takes when a newly created sandbox reaches its lifetime. */
|
|
||||||
export type E2BTimeoutMode = 'kill' | 'pause'
|
|
||||||
|
|
||||||
/** Configuration for the shared E2B sandbox owner. */
|
/** Configuration for the shared E2B sandbox owner. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** API key; omission reads `E2B_API_KEY`. It is never forwarded into the sandbox. */
|
/** API key; omission reads `E2B_API_KEY`. It is never forwarded into the sandbox. */
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
/** Existing sandbox to reconnect instead of creating a new one. */
|
|
||||||
sandboxId?: string
|
|
||||||
/** Template name or id for a newly created sandbox. */
|
|
||||||
template?: string
|
|
||||||
/** Shared remote working directory, created before adapters receive the sandbox. */
|
/** Shared remote working directory, created before adapters receive the sandbox. */
|
||||||
cwd?: string
|
cwd?: string
|
||||||
/** E2B sandbox lifetime in milliseconds. */
|
/** E2B sandbox lifetime in milliseconds; expiry always deletes the sandbox. */
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
/** E2B action when a newly created sandbox reaches `timeoutMs`. */
|
|
||||||
onTimeout?: E2BTimeoutMode
|
|
||||||
/** Disposal policy; `pause` and `leave` retain remote state for reconnect. */
|
|
||||||
onDispose?: E2BDisposeMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedConfig {
|
interface ResolvedConfig {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
cwd: string
|
cwd: string
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
onTimeout: E2BTimeoutMode
|
|
||||||
onDispose: E2BDisposeMode
|
|
||||||
sandboxId?: string
|
|
||||||
template?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SchemaResolvedConfig extends Config {
|
interface SchemaResolvedConfig extends Config {
|
||||||
cwd: string
|
cwd: string
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
onDispose: E2BDisposeMode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
@@ -101,31 +67,24 @@ declare module 'cordis' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns one lazily consumable E2B SDK handle and its final kill/pause/leave
|
* Creates one lazily consumable E2B SDK handle and deletes the sandbox at
|
||||||
* decision. The connection begins at plugin construction; adapters await
|
* timeout or disposal. Creation begins at plugin construction; adapters await
|
||||||
* {@link getSandbox} before their first operation.
|
* {@link getSandbox} before their first operation.
|
||||||
*/
|
*/
|
||||||
export class E2BSandboxService extends Service {
|
export class E2BSandboxService extends Service {
|
||||||
static Config: z<Config> = z.object({
|
static Config: z<Config> = z.object({
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
sandboxId: z.string(),
|
|
||||||
template: z.string(),
|
|
||||||
cwd: z.string().default('/home/user/workspace'),
|
cwd: z.string().default('/home/user/workspace'),
|
||||||
timeoutMs: z.number().default(300_000),
|
timeoutMs: z.number().default(300_000),
|
||||||
onTimeout: z.union(['kill', 'pause'] as const),
|
|
||||||
onDispose: z.union(['kill', 'pause', 'leave'] as const).default('kill'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Validated remote working directory shared by provider adapters. */
|
/** Validated remote working directory shared by provider adapters. */
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
/** Remote directory reserved for adapter-owned process and terminal state. */
|
/** Remote directory reserved for adapter-owned process and terminal state. */
|
||||||
readonly runtimeRoot: string
|
readonly runtimeRoot: string
|
||||||
/** Sandbox id once E2B has created or resolved the remote runtime. */
|
|
||||||
readonly sandboxId: Promise<E2BSandboxId>
|
|
||||||
|
|
||||||
private readonly config: ResolvedConfig
|
private readonly config: ResolvedConfig
|
||||||
private readonly ready: Promise<Sandbox>
|
private readonly ready: Promise<Sandbox>
|
||||||
private readonly created: boolean
|
|
||||||
private failedSetupSandbox: Sandbox | undefined
|
private failedSetupSandbox: Sandbox | undefined
|
||||||
private disposed = false
|
private disposed = false
|
||||||
|
|
||||||
@@ -138,79 +97,50 @@ export class E2BSandboxService extends Service {
|
|||||||
apiKey: apiKey ?? '',
|
apiKey: apiKey ?? '',
|
||||||
cwd: resolved.cwd,
|
cwd: resolved.cwd,
|
||||||
timeoutMs: resolved.timeoutMs,
|
timeoutMs: resolved.timeoutMs,
|
||||||
onTimeout: config.onTimeout ?? 'pause',
|
|
||||||
onDispose: resolved.onDispose,
|
|
||||||
...(config.sandboxId !== undefined ? { sandboxId: config.sandboxId } : {}),
|
|
||||||
...(config.template !== undefined ? { template: config.template } : {}),
|
|
||||||
}
|
}
|
||||||
this.validate(config)
|
this.validate()
|
||||||
this.cwd = this.config.cwd
|
this.cwd = this.config.cwd
|
||||||
this.runtimeRoot = posix.join(this.cwd, '.dsh-e2b')
|
this.runtimeRoot = posix.join(this.cwd, '.dsh-e2b')
|
||||||
this.created = this.config.sandboxId === undefined
|
|
||||||
this.ready = this.open()
|
this.ready = this.open()
|
||||||
// A deployment may load the owner before any adapter uses it. Keep a
|
// A deployment may load the owner before any adapter uses it. Keep a
|
||||||
// failed eager connection observed; getSandbox() still returns the error.
|
// failed eager connection observed; getSandbox() still returns the error.
|
||||||
void this.ready.catch(() => {})
|
void this.ready.catch(() => {})
|
||||||
this.sandboxId = this.ready.then(sandbox => E2BSandboxId(sandbox.sandboxId))
|
|
||||||
void this.sandboxId.catch(() => {})
|
|
||||||
|
|
||||||
ctx.effect(() => async () => {
|
ctx.effect(() => async () => {
|
||||||
this.disposed = true
|
this.disposed = true
|
||||||
let sandbox: Sandbox
|
let sandbox = this.failedSetupSandbox
|
||||||
try {
|
if (sandbox === undefined) {
|
||||||
sandbox = await this.ready
|
|
||||||
} catch {
|
|
||||||
const failedSetupSandbox = this.failedSetupSandbox
|
|
||||||
if (failedSetupSandbox === undefined) return
|
|
||||||
sandbox = failedSetupSandbox
|
|
||||||
try {
|
try {
|
||||||
await sandbox.kill()
|
sandbox = await this.ready
|
||||||
this.failedSetupSandbox = undefined
|
} catch {
|
||||||
} catch (error: unknown) {
|
sandbox = this.failedSetupSandbox
|
||||||
if (!(error instanceof SandboxNotFoundError)) throw error
|
|
||||||
this.failedSetupSandbox = undefined
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (sandbox === undefined) return
|
||||||
try {
|
try {
|
||||||
switch (this.config.onDispose) {
|
await sandbox.kill()
|
||||||
case 'kill':
|
|
||||||
await sandbox.kill()
|
|
||||||
return
|
|
||||||
case 'pause': {
|
|
||||||
await sandbox.pause()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'leave':
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!(error instanceof SandboxNotFoundError)) throw error
|
if (!(error instanceof SandboxNotFoundError)) throw error
|
||||||
// Missing proves the requested disposition only when this owner asked
|
|
||||||
// for deletion or created the sandbox with timeout deletion. A
|
|
||||||
// reconnected sandbox's creation lifecycle is unknown.
|
|
||||||
if (this.config.onDispose === 'kill') return
|
|
||||||
if (this.created && this.config.onTimeout === 'kill') return
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
this.failedSetupSandbox = undefined
|
||||||
}, 'e2b sandbox teardown')
|
}, 'e2b sandbox teardown')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the shared live SDK handle.
|
* Return the shared live SDK handle.
|
||||||
* @returns the created or reconnected sandbox after the configured cwd exists.
|
* @returns the created sandbox after the configured cwd exists.
|
||||||
* @throws when E2B rejects creation/reconnection or the service is disposing.
|
* @throws when E2B rejects creation or the service is disposing.
|
||||||
*/
|
*/
|
||||||
async getSandbox(): Promise<Sandbox> {
|
async getSandbox(): Promise<Sandbox> {
|
||||||
if (this.disposed) throw new Error('E2B sandbox service is disposing')
|
if (this.disposed) throw new Error('E2B sandbox service is disposing')
|
||||||
const sandbox = await this.ready
|
const sandbox = await this.ready
|
||||||
// Disposal can race the awaited sandbox readiness despite the synchronous precheck.
|
// Disposal can race the awaited sandbox readiness despite the synchronous precheck.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Awaiting readiness yields to disposal.
|
||||||
if (this.disposed) throw new Error('E2B sandbox service is disposing')
|
if (this.disposed) throw new Error('E2B sandbox service is disposing')
|
||||||
return sandbox
|
return sandbox
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(input: Config): void {
|
private validate(): void {
|
||||||
if (this.config.apiKey.length === 0) {
|
if (this.config.apiKey.length === 0) {
|
||||||
throw new Error('dsh-e2b: configure apiKey or set E2B_API_KEY')
|
throw new Error('dsh-e2b: configure apiKey or set E2B_API_KEY')
|
||||||
}
|
}
|
||||||
@@ -220,35 +150,15 @@ export class E2BSandboxService extends Service {
|
|||||||
if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs <= 0) {
|
if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs <= 0) {
|
||||||
throw new Error('dsh-e2b: timeoutMs must be a positive finite number')
|
throw new Error('dsh-e2b: timeoutMs must be a positive finite number')
|
||||||
}
|
}
|
||||||
if (this.config.sandboxId !== undefined && this.config.sandboxId.length === 0) {
|
|
||||||
throw new Error('dsh-e2b: sandboxId must be non-empty when provided')
|
|
||||||
}
|
|
||||||
if (this.config.sandboxId !== undefined && this.config.template !== undefined) {
|
|
||||||
throw new Error('dsh-e2b: template applies only when creating; omit it when sandboxId reconnects')
|
|
||||||
}
|
|
||||||
if (this.config.sandboxId !== undefined && input.onTimeout !== undefined) {
|
|
||||||
throw new Error('dsh-e2b: onTimeout applies only when creating; omit it when sandboxId reconnects')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async open(): Promise<Sandbox> {
|
private async open(): Promise<Sandbox> {
|
||||||
const connection = {
|
const sandbox = await Sandbox.create({
|
||||||
apiKey: this.config.apiKey,
|
apiKey: this.config.apiKey,
|
||||||
timeoutMs: this.config.timeoutMs,
|
timeoutMs: this.config.timeoutMs,
|
||||||
}
|
secure: true,
|
||||||
const sandbox = this.config.sandboxId === undefined
|
lifecycle: { onTimeout: 'kill' },
|
||||||
? this.config.template === undefined
|
})
|
||||||
? await Sandbox.create({
|
|
||||||
...connection,
|
|
||||||
secure: true,
|
|
||||||
lifecycle: { onTimeout: this.config.onTimeout, autoResume: this.config.onTimeout === 'pause' },
|
|
||||||
})
|
|
||||||
: await Sandbox.create(this.config.template, {
|
|
||||||
...connection,
|
|
||||||
secure: true,
|
|
||||||
lifecycle: { onTimeout: this.config.onTimeout, autoResume: this.config.onTimeout === 'pause' },
|
|
||||||
})
|
|
||||||
: await Sandbox.connect(this.config.sandboxId, connection)
|
|
||||||
try {
|
try {
|
||||||
await sandbox.files.makeDir(this.cwd)
|
await sandbox.files.makeDir(this.cwd)
|
||||||
await sandbox.files.makeDir(this.runtimeRoot)
|
await sandbox.files.makeDir(this.runtimeRoot)
|
||||||
@@ -262,14 +172,12 @@ export class E2BSandboxService extends Service {
|
|||||||
)
|
)
|
||||||
return sandbox
|
return sandbox
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (this.created) {
|
try {
|
||||||
try {
|
await sandbox.kill()
|
||||||
await sandbox.kill()
|
} catch (_cleanupFailure) {
|
||||||
} catch (_cleanupFailure) {
|
// Preserve the setup failure as the public error while retaining the
|
||||||
// Preserve the setup failure as the public error while retaining the
|
// created handle for the service disposer to retry this rollback.
|
||||||
// created handle for the service disposer to retry this rollback.
|
this.failedSetupSandbox = sandbox
|
||||||
this.failedSetupSandbox = sandbox
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Context } from 'cordis'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||||
import E2BSandboxService, {
|
import {
|
||||||
e2bControlEnvs,
|
|
||||||
FileNotFoundError,
|
FileNotFoundError,
|
||||||
Sandbox,
|
Sandbox,
|
||||||
SandboxNotFoundError,
|
SandboxNotFoundError,
|
||||||
@@ -113,26 +112,6 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
|||||||
await subprocessFiber.dispose()
|
await subprocessFiber.dispose()
|
||||||
await ptyFiber.dispose()
|
await ptyFiber.dispose()
|
||||||
|
|
||||||
await sandbox.commands.run([
|
|
||||||
'rm -rf -- /home/user/.dsh-e2b /home/user/dsh-e2b-runtime-target',
|
|
||||||
'mkdir -p -- /home/user/dsh-e2b-runtime-target',
|
|
||||||
'chmod 755 -- /home/user/dsh-e2b-runtime-target',
|
|
||||||
'ln -s -- /home/user/dsh-e2b-runtime-target /home/user/.dsh-e2b',
|
|
||||||
].join('\n'), { envs: e2bControlEnvs({ NPM_TOKEN: '' }) })
|
|
||||||
const linkedCtx = new Context()
|
|
||||||
const linkedFiber = await linkedCtx.plugin(E2BSandboxService, {
|
|
||||||
apiKey,
|
|
||||||
sandboxId: sandbox.sandboxId,
|
|
||||||
cwd: '/home/user',
|
|
||||||
onDispose: 'leave',
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await expect(linkedCtx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
|
|
||||||
const target = await sandbox.files.getInfo('/home/user/dsh-e2b-runtime-target')
|
|
||||||
expect(target.mode & 0o777).toBe(0o755)
|
|
||||||
} finally {
|
|
||||||
await linkedFiber.dispose()
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await sandbox.kill().catch(() => false)
|
await sandbox.kill().catch(() => false)
|
||||||
}
|
}
|
||||||
@@ -160,23 +139,10 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
|||||||
expect(stderr).toBe('')
|
expect(stderr).toBe('')
|
||||||
const output = JSON.parse(stdout) as Record<string, unknown>
|
const output = JSON.parse(stdout) as Record<string, unknown>
|
||||||
expect(output).toMatchObject({
|
expect(output).toMatchObject({
|
||||||
bashRead: 'written-by-fs-versioned\n',
|
bashRead: 'versioned-by-fs\n',
|
||||||
fsVersionGuard: true,
|
|
||||||
fsRead: 'written-by-bash\n',
|
fsRead: 'written-by-bash\n',
|
||||||
explicitEnvironment: true,
|
explicitEnvironment: true,
|
||||||
splitUtf8Output: '你好',
|
splitUtf8Output: '你好',
|
||||||
outputDrain: {
|
|
||||||
outcome: { exitCode: 0, signal: null },
|
|
||||||
text: 'leader-done\n',
|
|
||||||
exited: true,
|
|
||||||
clean: true,
|
|
||||||
},
|
|
||||||
publicationRollback: true,
|
|
||||||
spill: {
|
|
||||||
liveBytes: 6,
|
|
||||||
outcome: { exitCode: null, signal: 'SIGTERM' },
|
|
||||||
read: { text: '6789', nextOffset: 10, lossy: true },
|
|
||||||
},
|
|
||||||
hover: {
|
hover: {
|
||||||
kind: 'hover',
|
kind: 'hover',
|
||||||
hover: { contents: '**remote hover** 你好 café' },
|
hover: { contents: '**remote hover** 你好 café' },
|
||||||
@@ -185,24 +151,14 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
|||||||
kind: 'locations',
|
kind: 'locations',
|
||||||
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
|
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
|
||||||
},
|
},
|
||||||
lspDocumentBound: true,
|
|
||||||
terminal: {
|
terminal: {
|
||||||
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
|
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
|
||||||
signal: { delivered: true },
|
signal: { delivered: true },
|
||||||
interrupted: { sessionStatus: { kind: 'running' } },
|
interrupted: { sessionStatus: { kind: 'running' } },
|
||||||
interruptIdentitySafe: true,
|
|
||||||
treeCleanup: true,
|
treeCleanup: true,
|
||||||
},
|
},
|
||||||
hostileOutput: { error: { kind: 'output-limit' } },
|
|
||||||
nativeOutput: { error: { kind: 'output-limit' } },
|
|
||||||
descriptorOutput: { error: { kind: 'output-limit' } },
|
|
||||||
inheritedOutput: { error: { kind: 'output-limit' } },
|
|
||||||
descendantPipe: { value: true, logs: [] },
|
descendantPipe: { value: true, logs: [] },
|
||||||
descendantCleanup: true,
|
descendantCleanup: true,
|
||||||
timedOut: { error: { kind: 'timeout' } },
|
|
||||||
aborted: { error: { kind: 'abort', message: 'live abort' } },
|
|
||||||
oversizedBoot: { error: { kind: 'worker-exit' } },
|
|
||||||
oversizedReply: { error: { kind: 'worker-exit' } },
|
|
||||||
lingeringCodeRunners: 0,
|
lingeringCodeRunners: 0,
|
||||||
})
|
})
|
||||||
const terminalMotd = (output.terminal as { motd: string }).motd
|
const terminalMotd = (output.terminal as { motd: string }).motd
|
||||||
@@ -217,10 +173,11 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
|||||||
)
|
)
|
||||||
expect(output.code).toEqual({
|
expect(output.code).toEqual({
|
||||||
value: { doubled: 42, typed: true },
|
value: { doubled: 42, typed: true },
|
||||||
logs: ['remote-log 你好 42', 'post-mutation'],
|
logs: ['remote-log 你好 42'],
|
||||||
})
|
})
|
||||||
const apiKey = process.env.E2B_API_KEY
|
const apiKey = process.env.E2B_API_KEY
|
||||||
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
|
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)
|
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
|
||||||
|
await expect(Sandbox.list({ apiKey }).nextItems()).resolves.toEqual([])
|
||||||
}, 195_000)
|
}, 195_000)
|
||||||
})
|
})
|
||||||
@@ -4,7 +4,6 @@ import { Context } from 'cordis'
|
|||||||
import type { Sandbox as SandboxType } from 'e2b'
|
import type { Sandbox as SandboxType } from 'e2b'
|
||||||
import E2BSandboxService, {
|
import E2BSandboxService, {
|
||||||
e2bControlEnvs,
|
e2bControlEnvs,
|
||||||
E2BSandboxId,
|
|
||||||
FileType,
|
FileType,
|
||||||
SandboxNotFoundError,
|
SandboxNotFoundError,
|
||||||
quoteE2BShellArg,
|
quoteE2BShellArg,
|
||||||
@@ -14,21 +13,16 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
|||||||
|
|
||||||
const sdk = vi.hoisted(() => ({
|
const sdk = vi.hoisted(() => ({
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
connect: vi.fn(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('e2b', async (importOriginal) => {
|
vi.mock('e2b', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('e2b')>()
|
const actual = await importOriginal<typeof import('e2b')>()
|
||||||
// The mock replaces only the SDK's static factory surface and is never constructed.
|
// The mock replaces only the SDK's static factory surface and is never constructed.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
// oxlint-disable-next-line typescript/no-extraneous-class -- The SDK contract is a class with a static factory.
|
||||||
class FakeSandbox {
|
class FakeSandbox {
|
||||||
static create(...args: unknown[]): unknown {
|
static create(...args: unknown[]): unknown {
|
||||||
return sdk.create(...args)
|
return sdk.create(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
static connect(...args: unknown[]): unknown {
|
|
||||||
return sdk.connect(...args)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { ...actual, Sandbox: FakeSandbox }
|
return { ...actual, Sandbox: FakeSandbox }
|
||||||
})
|
})
|
||||||
@@ -39,7 +33,6 @@ interface SandboxFixture {
|
|||||||
getInfo: ReturnType<typeof vi.fn>
|
getInfo: ReturnType<typeof vi.fn>
|
||||||
run: Mock<RunCommand>
|
run: Mock<RunCommand>
|
||||||
kill: ReturnType<typeof vi.fn>
|
kill: ReturnType<typeof vi.fn>
|
||||||
pause: ReturnType<typeof vi.fn>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunCommand = (
|
type RunCommand = (
|
||||||
@@ -52,20 +45,17 @@ function fakeSandbox(id = 'sandbox-1'): SandboxFixture {
|
|||||||
const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR })
|
const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR })
|
||||||
const run = vi.fn<RunCommand>().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
const run = vi.fn<RunCommand>().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||||
const kill = vi.fn().mockResolvedValue(undefined)
|
const kill = vi.fn().mockResolvedValue(undefined)
|
||||||
const pause = vi.fn().mockResolvedValue(true)
|
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
sandboxId: id,
|
sandboxId: id,
|
||||||
files: { makeDir, getInfo },
|
files: { makeDir, getInfo },
|
||||||
commands: { run },
|
commands: { run },
|
||||||
kill,
|
kill,
|
||||||
pause,
|
|
||||||
} as unknown as SandboxType
|
} as unknown as SandboxType
|
||||||
return { sandbox, makeDir, getInfo, run, kill, pause }
|
return { sandbox, makeDir, getInfo, run, kill }
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sdk.create.mockReset()
|
sdk.create.mockReset()
|
||||||
sdk.connect.mockReset()
|
|
||||||
vi.unstubAllEnvs()
|
vi.unstubAllEnvs()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,14 +77,13 @@ describe('E2BSandboxService', () => {
|
|||||||
|
|
||||||
const service = ctx.e2b
|
const service = ctx.e2b
|
||||||
await expect(service.getSandbox()).resolves.toBe(fixture.sandbox)
|
await expect(service.getSandbox()).resolves.toBe(fixture.sandbox)
|
||||||
await expect(service.sandboxId).resolves.toBe(E2BSandboxId('sandbox-1'))
|
|
||||||
expect(service.cwd).toBe('/home/user/workspace')
|
expect(service.cwd).toBe('/home/user/workspace')
|
||||||
expect(service.runtimeRoot).toBe('/home/user/workspace/.dsh-e2b')
|
expect(service.runtimeRoot).toBe('/home/user/workspace/.dsh-e2b')
|
||||||
expect(sdk.create).toHaveBeenCalledWith({
|
expect(sdk.create).toHaveBeenCalledWith({
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
timeoutMs: 300_000,
|
timeoutMs: 300_000,
|
||||||
secure: true,
|
secure: true,
|
||||||
lifecycle: { onTimeout: 'pause', autoResume: true },
|
lifecycle: { onTimeout: 'kill' },
|
||||||
})
|
})
|
||||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
|
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
|
||||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
|
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
|
||||||
@@ -127,55 +116,26 @@ describe('E2BSandboxService', () => {
|
|||||||
expect(fixture.kill).toHaveBeenCalledOnce()
|
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates from a template, honors timeout and pause policies, and reads the key from the environment', async () => {
|
it('reads the key from the environment and honors the configured cwd and lifetime', async () => {
|
||||||
vi.stubEnv('E2B_API_KEY', 'environment-key')
|
vi.stubEnv('E2B_API_KEY', 'environment-key')
|
||||||
const fixture = fakeSandbox('template-sandbox')
|
const fixture = fakeSandbox('configured-sandbox')
|
||||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const fiber = await ctx.plugin(E2BSandboxService, {
|
const fiber = await ctx.plugin(E2BSandboxService, {
|
||||||
template: 'agent-template',
|
|
||||||
cwd: '/workspace/project',
|
cwd: '/workspace/project',
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
onTimeout: 'kill',
|
|
||||||
onDispose: 'pause',
|
|
||||||
})
|
})
|
||||||
await ctx.e2b.getSandbox()
|
await ctx.e2b.getSandbox()
|
||||||
|
|
||||||
expect(sdk.create).toHaveBeenCalledWith('agent-template', {
|
expect(sdk.create).toHaveBeenCalledWith({
|
||||||
apiKey: 'environment-key',
|
apiKey: 'environment-key',
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
secure: true,
|
secure: true,
|
||||||
lifecycle: { onTimeout: 'kill', autoResume: false },
|
lifecycle: { onTimeout: 'kill' },
|
||||||
})
|
})
|
||||||
|
expect(ctx.e2b.cwd).toBe('/workspace/project')
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
expect(fixture.pause).toHaveBeenCalledOnce()
|
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||||
expect(fixture.kill).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts an already-paused result during configured pause disposal', async () => {
|
|
||||||
const fixture = fakeSandbox()
|
|
||||||
fixture.pause.mockResolvedValue(false)
|
|
||||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
|
||||||
const ctx = new Context()
|
|
||||||
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key', onDispose: 'pause' })
|
|
||||||
await ctx.e2b.getSandbox()
|
|
||||||
await fiber.dispose()
|
|
||||||
expect(fixture.pause).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats a timeout-killed sandbox as already quiescent during disposal', async () => {
|
|
||||||
const fixture = fakeSandbox()
|
|
||||||
fixture.pause.mockRejectedValue(new SandboxNotFoundError('sandbox expired'))
|
|
||||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
|
||||||
const ctx = new Context()
|
|
||||||
const fiber = await ctx.plugin(E2BSandboxService, {
|
|
||||||
apiKey: 'test-key',
|
|
||||||
onTimeout: 'kill',
|
|
||||||
onDispose: 'pause',
|
|
||||||
})
|
|
||||||
await ctx.e2b.getSandbox()
|
|
||||||
await expect(fiber.dispose()).resolves.toBeUndefined()
|
|
||||||
expect(fixture.pause).toHaveBeenCalledOnce()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
|
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
|
||||||
@@ -208,49 +168,6 @@ describe('E2BSandboxService', () => {
|
|||||||
expect(errors).toContain(failure)
|
expect(errors).toContain(failure)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a created pause-on-timeout sandbox', false],
|
|
||||||
['a reconnected sandbox with unknown creation policy', true],
|
|
||||||
] as const)('reports missing during pause disposal for %s', async (_label, reconnect) => {
|
|
||||||
const fixture = fakeSandbox()
|
|
||||||
const failure = new SandboxNotFoundError('sandbox unexpectedly missing')
|
|
||||||
fixture.pause.mockRejectedValue(failure)
|
|
||||||
if (reconnect) sdk.connect.mockResolvedValue(fixture.sandbox)
|
|
||||||
else sdk.create.mockResolvedValue(fixture.sandbox)
|
|
||||||
const ctx = new Context()
|
|
||||||
const errors: unknown[] = []
|
|
||||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
|
||||||
const fiber = await ctx.plugin(E2BSandboxService, {
|
|
||||||
apiKey: 'test-key',
|
|
||||||
onDispose: 'pause',
|
|
||||||
...(reconnect ? { sandboxId: 'existing' } : {}),
|
|
||||||
})
|
|
||||||
await ctx.e2b.getSandbox()
|
|
||||||
|
|
||||||
await expect(fiber.dispose()).resolves.toBeUndefined()
|
|
||||||
expect(fixture.pause).toHaveBeenCalledOnce()
|
|
||||||
expect(errors).toContain(failure)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reconnects without applying creation lifecycle options and can leave state running', async () => {
|
|
||||||
const fixture = fakeSandbox('existing')
|
|
||||||
sdk.connect.mockResolvedValue(fixture.sandbox)
|
|
||||||
const ctx = new Context()
|
|
||||||
const fiber = await ctx.plugin(E2BSandboxService, {
|
|
||||||
apiKey: 'test-key',
|
|
||||||
sandboxId: 'existing',
|
|
||||||
timeoutMs: 90_000,
|
|
||||||
onDispose: 'leave',
|
|
||||||
})
|
|
||||||
await ctx.e2b.getSandbox()
|
|
||||||
|
|
||||||
expect(sdk.connect).toHaveBeenCalledWith('existing', { apiKey: 'test-key', timeoutMs: 90_000 })
|
|
||||||
expect(sdk.create).not.toHaveBeenCalled()
|
|
||||||
await fiber.dispose()
|
|
||||||
expect(fixture.kill).not.toHaveBeenCalled()
|
|
||||||
expect(fixture.pause).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('kills a newly created sandbox when remote directory setup fails', async () => {
|
it('kills a newly created sandbox when remote directory setup fails', async () => {
|
||||||
const fixture = fakeSandbox()
|
const fixture = fakeSandbox()
|
||||||
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
|
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
|
||||||
@@ -259,7 +176,6 @@ describe('E2BSandboxService', () => {
|
|||||||
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
||||||
|
|
||||||
await expect(ctx.e2b.getSandbox()).rejects.toThrow('setup failed')
|
await expect(ctx.e2b.getSandbox()).rejects.toThrow('setup failed')
|
||||||
await expect(ctx.e2b.sandboxId).rejects.toThrow('setup failed')
|
|
||||||
expect(fixture.kill).toHaveBeenCalledOnce()
|
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
})
|
})
|
||||||
@@ -294,44 +210,30 @@ describe('E2BSandboxService', () => {
|
|||||||
expect(fixture.kill).toHaveBeenCalledTimes(2)
|
expect(fixture.kill).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not kill a reconnected sandbox when setup fails', async () => {
|
|
||||||
const fixture = fakeSandbox()
|
|
||||||
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
|
|
||||||
sdk.connect.mockResolvedValue(fixture.sandbox)
|
|
||||||
const ctx = new Context()
|
|
||||||
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key', sandboxId: 'existing' })
|
|
||||||
await expect(ctx.e2b.getSandbox()).rejects.toThrow('setup failed')
|
|
||||||
expect(fixture.kill).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }],
|
['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }],
|
||||||
['regular file', { type: FileType.FILE }],
|
['regular file', { type: FileType.FILE }],
|
||||||
])('rejects a reserved runtime root that is a %s', async (_label, info) => {
|
])('rejects a reserved runtime root that is a %s', async (_label, info) => {
|
||||||
const fixture = fakeSandbox()
|
const fixture = fakeSandbox()
|
||||||
fixture.getInfo.mockResolvedValueOnce(info)
|
fixture.getInfo.mockResolvedValueOnce(info)
|
||||||
sdk.connect.mockResolvedValue(fixture.sandbox)
|
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key', sandboxId: 'existing' })
|
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
||||||
|
|
||||||
await expect(ctx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
|
await expect(ctx.e2b.getSandbox()).rejects.toThrow('runtime root must be a real directory')
|
||||||
expect(fixture.run).not.toHaveBeenCalled()
|
expect(fixture.run).not.toHaveBeenCalled()
|
||||||
expect(fixture.kill).not.toHaveBeenCalled()
|
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{ apiKey: '' }, /configure apiKey/],
|
[{ apiKey: '' }, /configure apiKey/],
|
||||||
[{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/],
|
[{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/],
|
||||||
[{ apiKey: 'x', timeoutMs: 0 }, /positive finite/],
|
[{ apiKey: 'x', timeoutMs: 0 }, /positive finite/],
|
||||||
[{ apiKey: 'x', sandboxId: '' }, /sandboxId must be non-empty/],
|
|
||||||
[{ apiKey: 'x', sandboxId: 'one', template: 'two' }, /template applies only/],
|
|
||||||
[{ apiKey: 'x', sandboxId: 'one', onTimeout: 'kill' }, /onTimeout applies only/],
|
|
||||||
] as const)('fails self-contained configuration before opening E2B: %j', async (config, message) => {
|
] as const)('fails self-contained configuration before opening E2B: %j', async (config, message) => {
|
||||||
vi.stubEnv('E2B_API_KEY', '')
|
vi.stubEnv('E2B_API_KEY', '')
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await expect(ctx.plugin(E2BSandboxService, config)).rejects.toThrow(message)
|
await expect(ctx.plugin(E2BSandboxService, config)).rejects.toThrow(message)
|
||||||
expect(sdk.create).not.toHaveBeenCalled()
|
expect(sdk.create).not.toHaveBeenCalled()
|
||||||
expect(sdk.connect).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a key when both config and the environment omit it', async () => {
|
it('requires a key when both config and the environment omit it', async () => {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md
|
# pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md
|
||||||
README.md: 6827e16e9c45532590ee1aa986c18a353d175fdc
|
README.md: cd170bcbe2831b0856b51791a2045382d93c1148
|
||||||
README.zh.md: 21b067829ea95c4581d7e89f3d225f9c90e630ef
|
README.zh.md: f97790cf1d4ef90f042df8ed564723e186327f00
|
||||||
@@ -24,8 +24,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, template, or external process populates it; local files are neither uploaded nor reflected back.
|
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, or external process populates it; local files are neither uploaded nor reflected back.
|
||||||
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
|
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
|
||||||
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
|
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
|
||||||
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
|
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
|
||||||
- **Custom templates must support the used Linux/GNU and E2B filesystem features** — `realpath -mz`, `base64 -w0`, `chmod`, same-filesystem rename, streaming reads, and file metadata extended attributes are required; unsupported templates fail rather than degrade silently.
|
- **The POC targets E2B's default Linux image** — it relies on GNU `realpath`/`base64`/`chmod`, same-filesystem rename, streaming reads, and metadata extended attributes; custom templates are outside this POC.
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
|
|
||||||
## 已知限制与延后工作
|
## 已知限制与延后工作
|
||||||
|
|
||||||
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令、模板或外部进程填充它;本地文件既不会上传,也不会同步回本地。
|
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令或外部进程填充它;本地文件既不会上传,也不会同步回本地。
|
||||||
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
||||||
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
|
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
|
||||||
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
|
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
|
||||||
- **自定义模板必须支持所用的 Linux/GNU 与 E2B 文件系统功能**:必须支持 `realpath -mz`、`base64 -w0`、`chmod`、同一文件系统内的 rename、流式读取和文件元数据扩展属性;不支持的模板会失败,而不会静默降级。
|
- **该 POC 面向 E2B 默认 Linux 镜像**:它依赖 GNU `realpath`/`base64`/`chmod`、同一文件系统内的 rename、流式读取和元数据扩展属性;自定义模板不在该 POC 范围内。
|
||||||
@@ -275,7 +275,6 @@ async function setup(remote = new FakeRemote()): Promise<{ ctx: Context; fs: E2B
|
|||||||
const runtime = {
|
const runtime = {
|
||||||
cwd: '/workspace',
|
cwd: '/workspace',
|
||||||
runtimeRoot: '/workspace/.dsh-e2b',
|
runtimeRoot: '/workspace/.dsh-e2b',
|
||||||
disposeMode: 'kill',
|
|
||||||
getSandbox: async () => remote.sandbox,
|
getSandbox: async () => remote.sandbox,
|
||||||
} as unknown as E2BSandboxService
|
} as unknown as E2BSandboxService
|
||||||
ctx.provide('e2b', runtime)
|
ctx.provide('e2b', runtime)
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md
|
# pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md
|
||||||
README.md: 9d563c91c149d28718159af1b1f8ddbd6652dc44
|
README.md: 4e47566d55cf9400459993185f03482761c92096
|
||||||
README.zh.md: be189f41818c66aeff2bed7b1851b82ca08f08f4
|
README.zh.md: 21ef231af700f72c2c5ec1fc651297ebdd17d49b
|
||||||
@@ -9,12 +9,12 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
|
|||||||
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
|
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
|
||||||
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides.
|
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides.
|
||||||
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
|
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
|
||||||
- **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the template's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
|
- **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
|
||||||
- **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle.
|
- **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle.
|
||||||
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
|
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
|
||||||
- **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable.
|
- **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable.
|
||||||
|
|
||||||
The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, `kill`, `id`, and `getent`. A custom template must retain compatible commands and E2B PTY support.
|
The default E2B base image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, `kill`, `id`, and `getent`.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
@@ -28,10 +28,9 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
|||||||
|
|
||||||
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
|
- **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
|
||||||
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
|
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
|
||||||
- **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them.
|
- **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep.
|
||||||
- **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep.
|
|
||||||
- **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.
|
- **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.
|
||||||
- **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. In a reconnected sandbox, a same-UID untrusted process could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive or a hardened template to close the gap.
|
- **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. A same-UID untrusted process already in the sandbox could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive to close the gap.
|
||||||
- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`.
|
- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`.
|
||||||
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
|
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
|
||||||
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, arbitrary-template, escaped-session recovery, or network-partition fidelity layer.
|
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, escaped-session recovery, or network-partition fidelity layer.
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
|
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
|
||||||
- **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
|
- **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
|
||||||
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
|
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
|
||||||
- **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变模板 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
|
- **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
|
||||||
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。
|
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。
|
||||||
- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
|
- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
|
||||||
- **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。
|
- **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。
|
||||||
|
|
||||||
基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`base64`、`chmod`、`tee`、`head`、`rm`、`kill`、`id` 和 `getent`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
|
E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`base64`、`chmod`、`tee`、`head`、`rm`、`kill`、`id` 和 `getent`。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
@@ -28,10 +28,9 @@
|
|||||||
|
|
||||||
- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。
|
- **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。
|
||||||
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
|
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
|
||||||
- **重新连接不会重建句柄**:保留沙箱后,远程 PID/状态/spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。
|
- **私有状态随沙箱生命周期存在**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。
|
||||||
- **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。
|
|
||||||
- **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。
|
- **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。
|
||||||
- **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。在重新连接的沙箱中,一个同 UID 的不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语或经加固的模板才能弥合该缺口。
|
- **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。一个已在沙箱内运行的同 UID 不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语才能弥合该缺口。
|
||||||
- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。
|
- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。
|
||||||
- **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
|
- **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
|
||||||
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。
|
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、逃逸会话恢复或网络分区的保真层。
|
||||||
@@ -106,7 +106,7 @@ export class E2BSubprocessService extends SubprocessService {
|
|||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
|
||||||
if (this.isDisposing()) throw new Error('subprocess-e2b: service is disposing')
|
if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
|
||||||
const program = spec.argv[0]
|
const program = spec.argv[0]
|
||||||
if (program === undefined || program.length === 0) {
|
if (program === undefined || program.length === 0) {
|
||||||
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
|
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
|
||||||
@@ -115,7 +115,7 @@ export class E2BSubprocessService extends SubprocessService {
|
|||||||
throw new Error('subprocess-e2b: graceMs must be a positive finite number')
|
throw new Error('subprocess-e2b: graceMs must be a positive finite number')
|
||||||
}
|
}
|
||||||
if (spec.signal?.aborted === true) {
|
if (spec.signal?.aborted === true) {
|
||||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`)
|
||||||
}
|
}
|
||||||
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
|
const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
|
||||||
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir)
|
const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir)
|
||||||
@@ -132,7 +132,7 @@ export class E2BSubprocessService extends SubprocessService {
|
|||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
||||||
if (this.isDisposing()) throw new Error('subprocess-e2b: service is disposing')
|
if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
|
||||||
const program = spec.argv[0]
|
const program = spec.argv[0]
|
||||||
if (program === undefined || program.length === 0) {
|
if (program === undefined || program.length === 0) {
|
||||||
throw new Error('subprocess-e2b: terminal argv must contain a program')
|
throw new Error('subprocess-e2b: terminal argv must contain a program')
|
||||||
@@ -158,7 +158,8 @@ export class E2BSubprocessService extends SubprocessService {
|
|||||||
(cleanup) => { this.failedTerminalSetupCleanups.add(cleanup) },
|
(cleanup) => { this.failedTerminalSetupCleanups.add(cleanup) },
|
||||||
)
|
)
|
||||||
this.terminals.add(terminal)
|
this.terminals.add(terminal)
|
||||||
if (this.isDisposing()) {
|
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote allocation yields to disposal.
|
||||||
|
if (this.disposing) {
|
||||||
await terminal.terminate()
|
await terminal.terminate()
|
||||||
this.terminals.delete(terminal)
|
this.terminals.delete(terminal)
|
||||||
throw new Error('subprocess-e2b: service disposed during terminal setup')
|
throw new Error('subprocess-e2b: service disposed during terminal setup')
|
||||||
@@ -176,10 +177,6 @@ export class E2BSubprocessService extends SubprocessService {
|
|||||||
setup.resolve()
|
setup.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isDisposing(): boolean {
|
|
||||||
return this.disposing
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default E2BSubprocessService
|
export default E2BSubprocessService
|
||||||
@@ -115,9 +115,6 @@ export class E2BOutputReader implements SubprocessOutputReader {
|
|||||||
|
|
||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
readFrom(fromByte: number): SubprocessOutputRead {
|
readFrom(fromByte: number): SubprocessOutputRead {
|
||||||
if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
|
|
||||||
throw new Error('subprocess output offset must be a non-negative safe integer')
|
|
||||||
}
|
|
||||||
const retained = Buffer.concat(this.chunks, this.retainedBytes)
|
const retained = Buffer.concat(this.chunks, this.retainedBytes)
|
||||||
const firstRetained = this.totalBytes - this.retainedBytes
|
const firstRetained = this.totalBytes - this.retainedBytes
|
||||||
const lossy = fromByte < firstRetained
|
const lossy = fromByte < firstRetained
|
||||||
|
|||||||
@@ -149,10 +149,6 @@ function commandOpts(
|
|||||||
return { envs: e2bControlEnvs(envs), ...(signal === undefined ? {} : { signal }) }
|
return { envs: e2bControlEnvs(envs), ...(signal === undefined ? {} : { signal }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
|
||||||
return signal?.aborted === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitTick(signal?: AbortSignal): Promise<boolean> {
|
function waitTick(signal?: AbortSignal): Promise<boolean> {
|
||||||
if (signal?.aborted === true) return Promise.resolve(false)
|
if (signal?.aborted === true) return Promise.resolve(false)
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
@@ -173,7 +169,7 @@ const WAIT_ABORTED = Symbol('wait aborted')
|
|||||||
function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof WAIT_ABORTED> {
|
function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof WAIT_ABORTED> {
|
||||||
if (signal === undefined) return promise
|
if (signal === undefined) return promise
|
||||||
if (signal.aborted) return Promise.resolve(WAIT_ABORTED)
|
if (signal.aborted) return Promise.resolve(WAIT_ABORTED)
|
||||||
return new Promise<T | typeof WAIT_ABORTED>((resolve, reject) => {
|
return new Promise<T | typeof WAIT_ABORTED>((resolve) => {
|
||||||
const onAbort = (): void => { cleanup(); resolve(WAIT_ABORTED) }
|
const onAbort = (): void => { cleanup(); resolve(WAIT_ABORTED) }
|
||||||
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
||||||
signal.addEventListener('abort', onAbort, { once: true })
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
@@ -181,10 +177,7 @@ function waitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined)
|
|||||||
onAbort()
|
onAbort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void promise.then(
|
void promise.then((value) => { cleanup(); resolve(value) })
|
||||||
(value) => { cleanup(); resolve(value) },
|
|
||||||
(error: unknown) => { cleanup(); reject(asError(error)) },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +199,9 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
private readonly paths: RemotePaths
|
private readonly paths: RemotePaths
|
||||||
private controlEnvs: Record<string, string> = {}
|
private controlEnvs: Record<string, string> = {}
|
||||||
private remotePid = -1
|
private remotePid = -1
|
||||||
private commandHandle: CommandHandle | undefined
|
|
||||||
private outputTransportError: Error | undefined
|
private outputTransportError: Error | undefined
|
||||||
private outputDrainExpired = false
|
private outputDrainExpired = false
|
||||||
private stateDirectoryCreated = false
|
private stateDirectoryCreated = false
|
||||||
private preparing = true
|
|
||||||
private terminationStarted = false
|
|
||||||
private quiescenceProven = false
|
private quiescenceProven = false
|
||||||
private terminationAttempt: Promise<void> | undefined
|
private terminationAttempt: Promise<void> | undefined
|
||||||
private terminationFailure: Error | undefined
|
private terminationFailure: Error | undefined
|
||||||
@@ -265,7 +255,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
terminate(): void {
|
terminate(): void {
|
||||||
if (this.quiescenceProven || this.terminationAttempt !== undefined) return
|
if (this.quiescenceProven || this.terminationAttempt !== undefined) return
|
||||||
this.terminationStarted = true
|
|
||||||
this.terminationController.abort(new Error('subprocess-e2b: command terminated'))
|
this.terminationController.abort(new Error('subprocess-e2b: command terminated'))
|
||||||
this.stdout?.destroy()
|
this.stdout?.destroy()
|
||||||
this.stderr?.destroy()
|
this.stderr?.destroy()
|
||||||
@@ -285,7 +274,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||||
if (this.quiescenceProven) return true
|
if (this.quiescenceProven) return true
|
||||||
let handle: CommandHandle | undefined
|
let handle: CommandHandle | undefined
|
||||||
if (this.terminationStarted) {
|
if (this.terminationController.signal.aborted) {
|
||||||
const observed = await waitWithSignal(this.commandState.promise, signal)
|
const observed = await waitWithSignal(this.commandState.promise, signal)
|
||||||
if (observed === WAIT_ABORTED) return false
|
if (observed === WAIT_ABORTED) return false
|
||||||
handle = observed
|
handle = observed
|
||||||
@@ -295,22 +284,23 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
}
|
}
|
||||||
if (this.remotePid <= 0) {
|
if (this.remotePid <= 0) {
|
||||||
const attempt = this.terminationAttempt
|
const attempt = this.terminationAttempt
|
||||||
if (attempt !== undefined && await waitWithSignal(attempt, signal) === WAIT_ABORTED) return false
|
if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
this.throwTerminationFailure()
|
this.throwTerminationFailure()
|
||||||
// Successful pre-publication termination records quiescence; its only other outcome is the failure above.
|
// Successful pre-publication termination records quiescence; its only other outcome is the failure above.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
const observed = await waitWithSignal(
|
||||||
const observed = await waitWithSignal(this.readyState.promise, signal)
|
this.readyState.promise.catch(() => this.commandState.promise),
|
||||||
if (observed === WAIT_ABORTED) return false
|
signal,
|
||||||
handle = observed
|
)
|
||||||
} catch {
|
if (observed === WAIT_ABORTED) return false
|
||||||
handle = this.commandHandle
|
handle = observed
|
||||||
if (handle === undefined) {
|
if (handle === undefined) {
|
||||||
this.markQuiescent()
|
this.markQuiescent()
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.throwTerminationFailure()
|
this.throwTerminationFailure()
|
||||||
@@ -318,7 +308,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
try {
|
try {
|
||||||
sandbox = await this.runtime.getSandbox()
|
sandbox = await this.runtime.getSandbox()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isAborted(signal)) return false
|
if (signal?.aborted === true) return false
|
||||||
if (error instanceof SandboxNotFoundError) {
|
if (error instanceof SandboxNotFoundError) {
|
||||||
this.markQuiescent()
|
this.markQuiescent()
|
||||||
return true
|
return true
|
||||||
@@ -331,7 +321,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
if (!await waitTick(signal)) return false
|
if (!await waitTick(signal)) return false
|
||||||
}
|
}
|
||||||
this.throwTerminationFailure()
|
this.throwTerminationFailure()
|
||||||
if (isAborted(signal)) return false
|
if (signal?.aborted === true) return false
|
||||||
this.markQuiescent()
|
this.markQuiescent()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -345,10 +335,11 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
|
|
||||||
private async run(): Promise<SubprocessOutcome> {
|
private async run(): Promise<SubprocessOutcome> {
|
||||||
let sandbox: Sandbox | undefined
|
let sandbox: Sandbox | undefined
|
||||||
|
let preparing = true
|
||||||
try {
|
try {
|
||||||
sandbox = await this.runtime.getSandbox()
|
sandbox = await this.runtime.getSandbox()
|
||||||
await this.prepareState(sandbox)
|
await this.prepareState(sandbox)
|
||||||
this.preparing = false
|
preparing = false
|
||||||
const handle = await sandbox.commands.run(
|
const handle = await sandbox.commands.run(
|
||||||
commandText(this.spec, this.paths),
|
commandText(this.spec, this.paths),
|
||||||
{
|
{
|
||||||
@@ -361,7 +352,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
|
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
this.commandHandle = handle
|
|
||||||
const completion = handle.wait()
|
const completion = handle.wait()
|
||||||
void completion.catch(() => {})
|
void completion.catch(() => {})
|
||||||
if (!isValidProcessId(handle.pid)) {
|
if (!isValidProcessId(handle.pid)) {
|
||||||
@@ -369,7 +359,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
try {
|
try {
|
||||||
await handle.kill()
|
await handle.kill()
|
||||||
this.markQuiescent()
|
this.markQuiescent()
|
||||||
this.commandHandle = undefined
|
|
||||||
} catch (cleanupError: unknown) {
|
} catch (cleanupError: unknown) {
|
||||||
this.terminationFailure = asError(cleanupError)
|
this.terminationFailure = asError(cleanupError)
|
||||||
this.commandState.resolve(handle)
|
this.commandState.resolve(handle)
|
||||||
@@ -404,9 +393,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
await this.finalizeSpills(sandbox)
|
await this.finalizeSpills(sandbox)
|
||||||
return outcome
|
return outcome
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const canceledPreparation = this.preparing
|
const canceledPreparation = preparing && this.terminationController.signal.aborted
|
||||||
&& this.terminationStarted
|
|
||||||
&& this.terminationController.signal.aborted
|
|
||||||
let failure = await this.rollbackPublishedFailure(error)
|
let failure = await this.rollbackPublishedFailure(error)
|
||||||
if (sandbox !== undefined && this.stateDirectoryCreated) {
|
if (sandbox !== undefined && this.stateDirectoryCreated) {
|
||||||
try {
|
try {
|
||||||
@@ -423,7 +410,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
if (canceledPreparation && failure === error) return { exitCode: null, signal: 'SIGTERM' }
|
if (canceledPreparation && failure === error) return { exitCode: null, signal: 'SIGTERM' }
|
||||||
throw failure
|
throw failure
|
||||||
} finally {
|
} finally {
|
||||||
this.preparing = false
|
|
||||||
this.spec.signal?.removeEventListener('abort', this.onAbort)
|
this.spec.signal?.removeEventListener('abort', this.onAbort)
|
||||||
this.stdout?.end()
|
this.stdout?.end()
|
||||||
this.stderr?.end()
|
this.stderr?.end()
|
||||||
@@ -583,7 +569,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
|
private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
|
||||||
if (this.remotePid <= 0 || this.commandHandle === undefined || this.quiescenceProven) return error
|
if (this.remotePid <= 0 || this.quiescenceProven) return error
|
||||||
this.terminate()
|
this.terminate()
|
||||||
try {
|
try {
|
||||||
await this.waitForExit()
|
await this.waitForExit()
|
||||||
@@ -626,7 +612,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
|
if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
|
||||||
await handle.kill()
|
await handle.kill()
|
||||||
this.markQuiescent()
|
this.markQuiescent()
|
||||||
this.commandHandle = undefined
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const sandbox = await this.runtime.getSandbox()
|
const sandbox = await this.runtime.getSandbox()
|
||||||
@@ -730,7 +715,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
|||||||
const size = (reader as E2BOutputReader).size
|
const size = (reader as E2BOutputReader).size
|
||||||
if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) {
|
if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) {
|
||||||
removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => {
|
removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => {
|
||||||
// The command outcome is authoritative; a retained sandbox tolerates private residue.
|
// The command outcome is authoritative; owner teardown bounds private residue.
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const TERMINAL_RUNNER_SOURCE = [
|
|||||||
' exit 125',
|
' exit 125',
|
||||||
'fi',
|
'fi',
|
||||||
'printf \'%s\' "$dsh_output_marker"',
|
'printf \'%s\' "$dsh_output_marker"',
|
||||||
"printf 'ready\\n' > \"$dsh_state/ready\"",
|
|
||||||
'exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"',
|
'exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"',
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -51,7 +50,6 @@ interface TerminalPaths {
|
|||||||
environment: string
|
environment: string
|
||||||
argv: string
|
argv: string
|
||||||
outputMarker: string
|
outputMarker: string
|
||||||
ready: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
||||||
@@ -165,26 +163,6 @@ async function terminalSessionId(
|
|||||||
return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
|
return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitUntilReady(
|
|
||||||
sandbox: Sandbox,
|
|
||||||
paths: TerminalPaths,
|
|
||||||
completion: Promise<CommandResult>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
const settled = completion.then(() => true, () => true)
|
|
||||||
for (;;) {
|
|
||||||
signal?.throwIfAborted()
|
|
||||||
try {
|
|
||||||
if ((await sandbox.files.read(paths.ready, signalOpts(signal))).trim() === 'ready') return
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!(error instanceof FileNotFoundError)) throw error
|
|
||||||
}
|
|
||||||
if (await Promise.race([settled, delay(POLL_MS).then(() => false)])) {
|
|
||||||
throw new Error('subprocess-e2b: terminal exited before publishing readiness')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sessionProcessGroups(
|
async function sessionProcessGroups(
|
||||||
sandbox: Sandbox,
|
sandbox: Sandbox,
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
@@ -241,8 +219,13 @@ async function awaitSessionEmpty(
|
|||||||
const deadline = Date.now() + graceMs
|
const deadline = Date.now() + graceMs
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const groups = await sessionProcessGroups(sandbox, sessionId, envs)
|
const groups = await sessionProcessGroups(sandbox, sessionId, envs)
|
||||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
if (groups.length === 0) return groups
|
||||||
if (kill) await signalGroups(sandbox, groups, 'KILL', envs)
|
if (kill) {
|
||||||
|
await signalGroups(sandbox, groups, 'KILL', envs)
|
||||||
|
if (Date.now() >= deadline) return await sessionProcessGroups(sandbox, sessionId, envs)
|
||||||
|
} else if (Date.now() >= deadline) {
|
||||||
|
return groups
|
||||||
|
}
|
||||||
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
|
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +260,6 @@ async function rollbackUnpublishedTerminal(
|
|||||||
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs)
|
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs)
|
||||||
}
|
}
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
await signalGroups(sandbox, groups, 'KILL', envs)
|
|
||||||
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
|
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -285,25 +267,13 @@ async function rollbackUnpublishedTerminal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Completion can settle while any awaited provider cleanup above is running.
|
// Completion can settle while any awaited provider cleanup above is running.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Provider cleanup yields to completion.
|
||||||
if (!topLevelExited) {
|
if (!topLevelExited) {
|
||||||
if (validPid) {
|
try {
|
||||||
try {
|
await handle.kill()
|
||||||
await sandbox.pty.kill(handle.pid)
|
} catch (error: unknown) {
|
||||||
} catch (error: unknown) {
|
if (error instanceof SandboxNotFoundError) return
|
||||||
if (error instanceof SandboxNotFoundError) return
|
attemptFailures.push(asError(error))
|
||||||
attemptFailures.push(asError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The awaited PTY fallback can settle completion before the SDK fallback.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
if (!topLevelExited) {
|
|
||||||
try {
|
|
||||||
await handle.kill()
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof SandboxNotFoundError) return
|
|
||||||
attemptFailures.push(asError(error))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await Promise.race([completion.catch(() => undefined), delay(graceMs)])
|
await Promise.race([completion.catch(() => undefined), delay(graceMs)])
|
||||||
}
|
}
|
||||||
@@ -321,7 +291,7 @@ async function rollbackUnpublishedTerminal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The bounded completion race above updates this callback-owned state.
|
// The bounded completion race above updates this callback-owned state.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// oxlint-disable-next-line typescript/no-unnecessary-condition -- The callback mutates this after a race.
|
||||||
if (!topLevelExited) {
|
if (!topLevelExited) {
|
||||||
proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
|
proofFailures.push(new Error(`subprocess-e2b: terminal setup rollback failed; surviving pid: ${handle.pid}`))
|
||||||
}
|
}
|
||||||
@@ -347,7 +317,6 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
private cleanup: Promise<void> | undefined
|
private cleanup: Promise<void> | undefined
|
||||||
private readonly operationController = new AbortController()
|
private readonly operationController = new AbortController()
|
||||||
private readonly operations = new Set<Promise<unknown>>()
|
private readonly operations = new Set<Promise<unknown>>()
|
||||||
private terminating = false
|
|
||||||
private terminationSignal: NodeJS.Signals | null = null
|
private terminationSignal: NodeJS.Signals | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -400,7 +369,6 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
/** @inheritdoc */
|
/** @inheritdoc */
|
||||||
terminate(): Promise<void> {
|
terminate(): Promise<void> {
|
||||||
if (this.cleanup !== undefined) return this.cleanup
|
if (this.cleanup !== undefined) return this.cleanup
|
||||||
this.terminating = true
|
|
||||||
this.operationController.abort(new Error('subprocess-e2b: terminal is terminating'))
|
this.operationController.abort(new Error('subprocess-e2b: terminal is terminating'))
|
||||||
const cleanup = this.closeAfterOperations()
|
const cleanup = this.closeAfterOperations()
|
||||||
this.cleanup = cleanup
|
this.cleanup = cleanup
|
||||||
@@ -434,7 +402,9 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private trackOperation<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
private trackOperation<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||||
if (this.terminating) return Promise.reject(new Error('subprocess-e2b: terminal is terminating'))
|
if (this.operationController.signal.aborted) {
|
||||||
|
return Promise.reject(new Error('subprocess-e2b: terminal is terminating'))
|
||||||
|
}
|
||||||
const pending = operation(this.operationController.signal)
|
const pending = operation(this.operationController.signal)
|
||||||
this.operations.add(pending)
|
this.operations.add(pending)
|
||||||
void pending.then(
|
void pending.then(
|
||||||
@@ -445,7 +415,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async closeAfterOperations(): Promise<void> {
|
private async closeAfterOperations(): Promise<void> {
|
||||||
if (this.operations.size > 0) await Promise.allSettled(this.operations)
|
await Promise.allSettled(this.operations)
|
||||||
await this.closeOnce()
|
await this.closeOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +451,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
this.terminationSignal = 'SIGKILL'
|
this.terminationSignal = 'SIGKILL'
|
||||||
if (!this.topLevelExited) {
|
if (!this.topLevelExited) {
|
||||||
try {
|
try {
|
||||||
await this.sandbox.pty.kill(this.pid)
|
await this.handle.kill()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof SandboxNotFoundError) return
|
if (error instanceof SandboxNotFoundError) return
|
||||||
throw error
|
throw error
|
||||||
@@ -504,7 +474,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
|||||||
try {
|
try {
|
||||||
await this.sandbox.files.remove(this.stateDir)
|
await this.sandbox.files.remove(this.stateDir)
|
||||||
} catch (_adapterPrivateStateRemovalFailure) {
|
} catch (_adapterPrivateStateRemovalFailure) {
|
||||||
// The terminal is quiescent; a retained sandbox tolerates private residue.
|
// The terminal is quiescent; owner teardown bounds private residue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,7 +501,6 @@ export async function spawnE2BTerminal(
|
|||||||
environment: posix.join(stateDir, 'environment'),
|
environment: posix.join(stateDir, 'environment'),
|
||||||
argv: posix.join(stateDir, 'argv'),
|
argv: posix.join(stateDir, 'argv'),
|
||||||
outputMarker: posix.join(stateDir, 'output-marker'),
|
outputMarker: posix.join(stateDir, 'output-marker'),
|
||||||
ready: posix.join(stateDir, 'ready'),
|
|
||||||
}
|
}
|
||||||
const outputMarker = Buffer.from(`dsh-e2b-bootstrap:${randomUUID()}`)
|
const outputMarker = Buffer.from(`dsh-e2b-bootstrap:${randomUUID()}`)
|
||||||
const output = new PassThrough()
|
const output = new PassThrough()
|
||||||
@@ -577,7 +546,6 @@ export async function spawnE2BTerminal(
|
|||||||
}
|
}
|
||||||
const command = `exec /bin/bash ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(stateDir)}\r`
|
const command = `exec /bin/bash ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(stateDir)}\r`
|
||||||
await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
|
await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
|
||||||
await waitUntilReady(sandbox, paths, completion, spec.signal)
|
|
||||||
await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal)
|
await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal)
|
||||||
const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal)
|
const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal)
|
||||||
return new E2BTerminalHandle(
|
return new E2BTerminalHandle(
|
||||||
|
|||||||
@@ -316,7 +316,6 @@ function runtime(fake: FakeSandbox, getSandbox: () => Promise<Sandbox> = async (
|
|||||||
return {
|
return {
|
||||||
cwd: '/workspace',
|
cwd: '/workspace',
|
||||||
runtimeRoot: '/workspace/.dsh-e2b',
|
runtimeRoot: '/workspace/.dsh-e2b',
|
||||||
disposeMode: 'kill',
|
|
||||||
getSandbox,
|
getSandbox,
|
||||||
} as unknown as E2BSandboxService
|
} as unknown as E2BSandboxService
|
||||||
}
|
}
|
||||||
@@ -372,8 +371,6 @@ describe('E2BOutputReader', () => {
|
|||||||
const overCap = new E2BOutputReader(2, 3, '/too-small')
|
const overCap = new E2BOutputReader(2, 3, '/too-small')
|
||||||
overCap.push(Buffer.from('abcd'))
|
overCap.push(Buffer.from('abcd'))
|
||||||
expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
|
expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
|
||||||
expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/)
|
|
||||||
expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1673,7 +1670,6 @@ describe('E2BSubprocessService', () => {
|
|||||||
expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
|
expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
|
||||||
expect(() => ctx.subprocess.spawn(spec({ graceMs: 0 }))).toThrow(/positive finite/)
|
expect(() => ctx.subprocess.spawn(spec({ graceMs: 0 }))).toThrow(/positive finite/)
|
||||||
expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
|
expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
|
||||||
expect(() => ctx.subprocess.spawn(spec({ signal: { aborted: true, reason: undefined } as AbortSignal }))).toThrow(/aborted$/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers the package-owned empty invariant installer', async () => {
|
it('registers the package-owned empty invariant installer', async () => {
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ class FakeTerminalSandbox {
|
|||||||
readonly writes = new Map<string, string>()
|
readonly writes = new Map<string, string>()
|
||||||
createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
|
createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
|
||||||
ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
|
ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
|
||||||
ready: string | Error = 'ready\n'
|
|
||||||
readyMisses = 0
|
|
||||||
readyReads = 0
|
|
||||||
sessionId = '123\n'
|
sessionId = '123\n'
|
||||||
foreground = '456\n'
|
foreground = '456\n'
|
||||||
groups = [123]
|
groups = [123]
|
||||||
@@ -108,12 +105,9 @@ class FakeTerminalSandbox {
|
|||||||
sessionGroupsFailure: unknown
|
sessionGroupsFailure: unknown
|
||||||
foregroundFailure: unknown
|
foregroundFailure: unknown
|
||||||
termFailure: unknown
|
termFailure: unknown
|
||||||
ptyKillError: unknown
|
|
||||||
removeError: unknown
|
removeError: unknown
|
||||||
clearOnTerm = true
|
clearOnTerm = true
|
||||||
clearOnKill = true
|
clearOnKill = true
|
||||||
settleOnPtyKill = true
|
|
||||||
ptyKills = 0
|
|
||||||
resolvedExecutable = '/usr/bin/node\n'
|
resolvedExecutable = '/usr/bin/node\n'
|
||||||
requestedOutput = 'requested-shell$ '
|
requestedOutput = 'requested-shell$ '
|
||||||
emitOutputMarker = true
|
emitOutputMarker = true
|
||||||
@@ -144,15 +138,6 @@ class FakeTerminalSandbox {
|
|||||||
if (this.writeError !== undefined) throw this.writeError
|
if (this.writeError !== undefined) throw this.writeError
|
||||||
return files.map(() => ({}))
|
return files.map(() => ({}))
|
||||||
},
|
},
|
||||||
read: async (): Promise<string> => {
|
|
||||||
this.readyReads += 1
|
|
||||||
if (this.readyMisses > 0) {
|
|
||||||
this.readyMisses -= 1
|
|
||||||
throw new FileNotFoundError('not ready')
|
|
||||||
}
|
|
||||||
if (this.ready instanceof Error) throw this.ready
|
|
||||||
return this.ready
|
|
||||||
},
|
|
||||||
remove: async (path: string): Promise<void> => {
|
remove: async (path: string): Promise<void> => {
|
||||||
this.removed.push(path)
|
this.removed.push(path)
|
||||||
if (this.removeError !== undefined) throw this.removeError
|
if (this.removeError !== undefined) throw this.removeError
|
||||||
@@ -237,12 +222,6 @@ class FakeTerminalSandbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
kill: async (pid: number): Promise<boolean> => {
|
|
||||||
this.ptyKills += 1
|
|
||||||
if (this.ptyKillError !== undefined) throw this.ptyKillError
|
|
||||||
if (this.settleOnPtyKill) this.handle.fail(137)
|
|
||||||
return pid === this.handle.pid
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as unknown as Sandbox
|
} as unknown as Sandbox
|
||||||
}
|
}
|
||||||
@@ -251,7 +230,6 @@ function runtime(fake: FakeTerminalSandbox): E2BSandboxService {
|
|||||||
return {
|
return {
|
||||||
cwd: '/workspace',
|
cwd: '/workspace',
|
||||||
runtimeRoot: '/workspace/.dsh-e2b',
|
runtimeRoot: '/workspace/.dsh-e2b',
|
||||||
disposeMode: 'kill',
|
|
||||||
getSandbox: async () => fake.sandbox,
|
getSandbox: async () => fake.sandbox,
|
||||||
} as unknown as E2BSandboxService
|
} as unknown as E2BSandboxService
|
||||||
}
|
}
|
||||||
@@ -284,7 +262,6 @@ function holdRequestUntilAbort(started: PromiseWithResolvers<AbortSignal>) {
|
|||||||
describe('E2B terminal allocation', () => {
|
describe('E2B terminal allocation', () => {
|
||||||
it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
|
it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.readyMisses = 1
|
|
||||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/terminal-one')
|
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/terminal-one')
|
||||||
let output = ''
|
let output = ''
|
||||||
terminal.output.on('data', (chunk) => { output += String(chunk) })
|
terminal.output.on('data', (chunk) => { output += String(chunk) })
|
||||||
@@ -417,12 +394,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
expect(failedInput.commands).toContain('kill -TERM -- -123')
|
expect(failedInput.commands).toContain('kill -TERM -- -123')
|
||||||
expect(failedInput.groups).toEqual([])
|
expect(failedInput.groups).toEqual([])
|
||||||
|
|
||||||
const exited = new FakeTerminalSandbox()
|
|
||||||
exited.ready = new FileNotFoundError('not ready')
|
|
||||||
queueMicrotask(() => { exited.handle.succeed(0) })
|
|
||||||
await expect(spawnE2BTerminal(runtime(exited), spec(), '/runtime/exited'))
|
|
||||||
.rejects.toThrow('exited before publishing readiness')
|
|
||||||
|
|
||||||
const invalidSession = new FakeTerminalSandbox()
|
const invalidSession = new FakeTerminalSandbox()
|
||||||
invalidSession.sessionId = 'not-a-session\n'
|
invalidSession.sessionId = 'not-a-session\n'
|
||||||
invalidSession.clearOnTerm = false
|
invalidSession.clearOnTerm = false
|
||||||
@@ -431,7 +402,7 @@ describe('E2B terminal allocation', () => {
|
|||||||
expect(invalidSession.commands).toContain('kill -TERM -- -123')
|
expect(invalidSession.commands).toContain('kill -TERM -- -123')
|
||||||
expect(invalidSession.commands).toContain('kill -KILL -- -123')
|
expect(invalidSession.commands).toContain('kill -KILL -- -123')
|
||||||
expect(invalidSession.groups).toEqual([])
|
expect(invalidSession.groups).toEqual([])
|
||||||
expect(invalidSession.ptyKills).toBe(1)
|
expect(invalidSession.handle.sdkKills).toBe(1)
|
||||||
const lateData = invalidSession.createOptions?.onData
|
const lateData = invalidSession.createOptions?.onData
|
||||||
if (lateData === undefined) throw new Error('missing captured terminal callback')
|
if (lateData === undefined) throw new Error('missing captured terminal callback')
|
||||||
expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
|
expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
|
||||||
@@ -442,12 +413,12 @@ describe('E2B terminal allocation', () => {
|
|||||||
await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed'))
|
await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed'))
|
||||||
.rejects.toThrow('bootstrap failed')
|
.rejects.toThrow('bootstrap failed')
|
||||||
expect(termFailed.commands).toContain('kill -KILL -- -123')
|
expect(termFailed.commands).toContain('kill -KILL -- -123')
|
||||||
expect(termFailed.ptyKills).toBe(1)
|
expect(termFailed.handle.sdkKills).toBe(1)
|
||||||
|
|
||||||
const uninspectable = new FakeTerminalSandbox()
|
const uninspectable = new FakeTerminalSandbox()
|
||||||
uninspectable.sendError = new Error('bootstrap failed')
|
uninspectable.sendError = new Error('bootstrap failed')
|
||||||
uninspectable.sessionGroupsFailure = 'session enumeration failed'
|
uninspectable.sessionGroupsFailure = 'session enumeration failed'
|
||||||
uninspectable.ptyKillError = new Error('PTY kill failed')
|
uninspectable.handle.sdkKillError = new Error('PTY kill failed')
|
||||||
let uninspectableFailure: unknown
|
let uninspectableFailure: unknown
|
||||||
try {
|
try {
|
||||||
await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable')
|
await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable')
|
||||||
@@ -455,7 +426,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
uninspectableFailure = error
|
uninspectableFailure = error
|
||||||
}
|
}
|
||||||
expect(uninspectableFailure).toBeInstanceOf(AggregateError)
|
expect(uninspectableFailure).toBeInstanceOf(AggregateError)
|
||||||
expect(uninspectable.ptyKills).toBe(1)
|
|
||||||
expect(uninspectable.handle.sdkKills).toBe(1)
|
expect(uninspectable.handle.sdkKills).toBe(1)
|
||||||
|
|
||||||
const survivingGroups = new FakeTerminalSandbox()
|
const survivingGroups = new FakeTerminalSandbox()
|
||||||
@@ -468,7 +438,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
const survivingPid = new FakeTerminalSandbox()
|
const survivingPid = new FakeTerminalSandbox()
|
||||||
survivingPid.sendError = new Error('bootstrap failed')
|
survivingPid.sendError = new Error('bootstrap failed')
|
||||||
survivingPid.groups = []
|
survivingPid.groups = []
|
||||||
survivingPid.settleOnPtyKill = false
|
|
||||||
survivingPid.handle.settleOnSdkKill = false
|
survivingPid.handle.settleOnSdkKill = false
|
||||||
await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
|
await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
|
||||||
.rejects.toThrow('bootstrap failed')
|
.rejects.toThrow('bootstrap failed')
|
||||||
@@ -491,12 +460,12 @@ describe('E2B terminal allocation', () => {
|
|||||||
const expiredDuringRollback = new FakeTerminalSandbox()
|
const expiredDuringRollback = new FakeTerminalSandbox()
|
||||||
expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
|
expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
|
||||||
expiredDuringRollback.groups = []
|
expiredDuringRollback.groups = []
|
||||||
expiredDuringRollback.settleOnPtyKill = false
|
expiredDuringRollback.handle.settleOnSdkKill = false
|
||||||
expiredDuringRollback.ptyKillError = new SandboxNotFoundError('sandbox expired')
|
expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
|
||||||
expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
|
expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
|
||||||
await expect(spawnE2BTerminal(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
|
await expect(spawnE2BTerminal(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
|
||||||
.rejects.toThrow('bootstrap failed before timeout')
|
.rejects.toThrow('bootstrap failed before timeout')
|
||||||
expect(expiredDuringRollback.ptyKills).toBe(1)
|
expect(expiredDuringRollback.handle.sdkKills).toBe(1)
|
||||||
|
|
||||||
const expiredBeforeSdkRollback = new FakeTerminalSandbox()
|
const expiredBeforeSdkRollback = new FakeTerminalSandbox()
|
||||||
expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
|
expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
|
||||||
@@ -505,15 +474,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
await expect(spawnE2BTerminal(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
|
await expect(spawnE2BTerminal(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
|
||||||
.rejects.toThrow('wait failed after timeout')
|
.rejects.toThrow('wait failed after timeout')
|
||||||
|
|
||||||
const expiredDuringSdkFallback = new FakeTerminalSandbox()
|
|
||||||
expiredDuringSdkFallback.sendError = new Error('bootstrap failed before SDK fallback')
|
|
||||||
expiredDuringSdkFallback.groups = []
|
|
||||||
expiredDuringSdkFallback.settleOnPtyKill = false
|
|
||||||
expiredDuringSdkFallback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
|
|
||||||
expiredDuringSdkFallback.handle.settleOnSdkKill = false
|
|
||||||
await expect(spawnE2BTerminal(runtime(expiredDuringSdkFallback), spec(), '/runtime/expired-sdk-fallback'))
|
|
||||||
.rejects.toThrow('bootstrap failed before SDK fallback')
|
|
||||||
|
|
||||||
const missingDuringDisconnect = new FakeTerminalSandbox()
|
const missingDuringDisconnect = new FakeTerminalSandbox()
|
||||||
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
|
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
|
||||||
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
|
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
|
||||||
@@ -537,10 +497,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
await expect(spawnE2BTerminal(runtime(createFailed), spec(), '/runtime/create'))
|
await expect(spawnE2BTerminal(runtime(createFailed), spec(), '/runtime/create'))
|
||||||
.rejects.toThrow('create failed')
|
.rejects.toThrow('create failed')
|
||||||
|
|
||||||
const readFailed = new FakeTerminalSandbox()
|
|
||||||
readFailed.ready = new Error('ready transport failed')
|
|
||||||
await expect(spawnE2BTerminal(runtime(readFailed), spec(), '/runtime/read'))
|
|
||||||
.rejects.toThrow('ready transport failed')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
|
it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
|
||||||
@@ -560,7 +516,6 @@ describe('E2B terminal allocation', () => {
|
|||||||
'/runtime/cancel-output-boundary',
|
'/runtime/cancel-output-boundary',
|
||||||
)
|
)
|
||||||
await vi.waitFor(() => { expect(cancelled.inputs).toHaveLength(1) })
|
await vi.waitFor(() => { expect(cancelled.inputs).toHaveLength(1) })
|
||||||
await vi.waitFor(() => { expect(cancelled.readyReads).toBeGreaterThan(0) })
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
controller.abort(new Error('cancel output boundary'))
|
controller.abort(new Error('cancel output boundary'))
|
||||||
await expect(cancelling).rejects.toThrow('cancel output boundary')
|
await expect(cancelling).rejects.toThrow('cancel output boundary')
|
||||||
@@ -661,23 +616,23 @@ describe('E2B terminal lifecycle', () => {
|
|||||||
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
|
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.groups = []
|
fake.groups = []
|
||||||
fake.settleOnPtyKill = false
|
fake.handle.settleOnSdkKill = false
|
||||||
fake.ptyKillError = new SandboxNotFoundError('sandbox expired')
|
fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
|
||||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
|
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
|
||||||
|
|
||||||
await terminal.terminate()
|
await terminal.terminate()
|
||||||
expect(fake.ptyKills).toBe(1)
|
expect(fake.handle.sdkKills).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('propagates a non-missing PTY kill failure', async () => {
|
it('propagates a non-missing PTY kill failure', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.groups = []
|
fake.groups = []
|
||||||
fake.settleOnPtyKill = false
|
fake.handle.settleOnSdkKill = false
|
||||||
fake.ptyKillError = new Error('PTY kill transport failed')
|
fake.handle.sdkKillError = new Error('PTY kill transport failed')
|
||||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
|
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
|
||||||
|
|
||||||
await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
|
await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
|
||||||
fake.ptyKillError = undefined
|
fake.handle.sdkKillError = undefined
|
||||||
fake.handle.succeed(0)
|
fake.handle.succeed(0)
|
||||||
await terminal.done
|
await terminal.done
|
||||||
await terminal.terminate()
|
await terminal.terminate()
|
||||||
@@ -712,11 +667,11 @@ describe('E2B terminal lifecycle', () => {
|
|||||||
await terminal.terminate()
|
await terminal.terminate()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('escalates surviving process groups', async () => {
|
it('sends KILL before checking an expired force-cleanup deadline', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.groups = [123, 456]
|
fake.groups = [123, 456]
|
||||||
fake.clearOnTerm = false
|
fake.clearOnTerm = false
|
||||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/escalate')
|
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate')
|
||||||
const terminating = terminal.terminate()
|
const terminating = terminal.terminate()
|
||||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||||
await terminating
|
await terminating
|
||||||
@@ -771,11 +726,11 @@ describe('E2B terminal lifecycle', () => {
|
|||||||
it('keeps a late command rejection authoritative after PTY kill', async () => {
|
it('keeps a late command rejection authoritative after PTY kill', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.groups = []
|
fake.groups = []
|
||||||
fake.settleOnPtyKill = false
|
fake.handle.settleOnSdkKill = false
|
||||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
|
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
|
||||||
terminal.output.on('error', () => {})
|
terminal.output.on('error', () => {})
|
||||||
const cleanup = terminal.terminate()
|
const cleanup = terminal.terminate()
|
||||||
while (fake.ptyKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
fake.handle.crash(new Error('late command transport failed'))
|
fake.handle.crash(new Error('late command transport failed'))
|
||||||
await expect(terminal.done).rejects.toThrow('late command transport failed')
|
await expect(terminal.done).rejects.toThrow('late command transport failed')
|
||||||
@@ -791,7 +746,7 @@ describe('E2B terminal lifecycle', () => {
|
|||||||
|
|
||||||
const livePid = new FakeTerminalSandbox()
|
const livePid = new FakeTerminalSandbox()
|
||||||
livePid.groups = []
|
livePid.groups = []
|
||||||
livePid.settleOnPtyKill = false
|
livePid.handle.settleOnSdkKill = false
|
||||||
const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
|
const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
|
||||||
await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
|
await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
|
||||||
livePid.handle.succeed(0)
|
livePid.handle.succeed(0)
|
||||||
@@ -879,13 +834,13 @@ describe('E2B subprocess terminal service', () => {
|
|||||||
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
|
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aborts and rolls back terminal setup that cannot publish readiness during disposal', async () => {
|
it('aborts and rolls back terminal setup that cannot publish its output boundary during disposal', async () => {
|
||||||
const fake = new FakeTerminalSandbox()
|
const fake = new FakeTerminalSandbox()
|
||||||
fake.ready = new FileNotFoundError('not ready')
|
fake.emitOutputMarker = false
|
||||||
const { ctx, fiber } = await service(fake)
|
const { ctx, fiber } = await service(fake)
|
||||||
const spawning = ctx.subprocess.spawnTerminal(spec())
|
const spawning = ctx.subprocess.spawnTerminal(spec())
|
||||||
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
|
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
|
||||||
await vi.waitFor(() => { expect(fake.readyReads).toBeGreaterThan(0) })
|
await vi.waitFor(() => { expect(fake.inputs).toHaveLength(1) })
|
||||||
|
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
await rejected
|
await rejected
|
||||||
|
|||||||
Generated
+3
-3
@@ -716,6 +716,9 @@ importers:
|
|||||||
|
|
||||||
packages/bash/bash:
|
packages/bash/bash:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@deepseek-ai/dsh-brand':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../util/brand
|
||||||
'@deepseek-ai/dsh-invariants':
|
'@deepseek-ai/dsh-invariants':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../support/invariants
|
version: link:../../support/invariants
|
||||||
@@ -3091,9 +3094,6 @@ importers:
|
|||||||
|
|
||||||
packages/credentials/credentials:
|
packages/credentials/credentials:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@deepseek-ai/dsh-brand':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../../util/brand
|
|
||||||
'@deepseek-ai/dsh-invariants':
|
'@deepseek-ai/dsh-invariants':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../support/invariants
|
version: link:../../support/invariants
|
||||||
|
|||||||
Reference in New Issue
Block a user