feat(e2b): add remote runtime providers
This commit is contained in:
6
packages/e2b/README.i18n.yaml
Normal file
6
packages/e2b/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/README.md
|
||||
README.md: 4b28ec7c0452189a579a6d7e0cd22a0b8561784d
|
||||
README.zh.md: ab254eecad82c05cd6a6021e7f8bc4eda6cf3076
|
||||
13
packages/e2b/README.md
Normal file
13
packages/e2b/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# e2b/ — E2B remote runtime family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
An experimental provider-composition POC that places the filesystem and managed subprocess world in one E2B Linux sandbox. The shared owner is separate from the capability adapters so every remote provider awaits the same sandbox identity and lifecycle.
|
||||
|
||||
| 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 |
|
||||
| [`fs-e2b`](../fs/fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
|
||||
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement managed process groups, stdio projection, and remote spill files over E2B Commands |
|
||||
|
||||
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it already delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world as `ctx.fs`. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, or E2B SDK buffers. The [decision record](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary and rejected expansion.
|
||||
13
packages/e2b/README.zh.md
Normal file
13
packages/e2b/README.zh.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# e2b/ — E2B 远程运行时家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是一个实验性提供方组合 POC,把文件系统和受管子进程环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器彼此分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
|
||||
|
||||
| 包(package) | ctx 键 | 职责 |
|
||||
|---|---|---|
|
||||
| [`e2b`](e2b/README.md)(`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
|
||||
| [`fs-e2b`](../fs/fs-e2b/README.md)(`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
|
||||
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md)(`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | 通过 E2B Commands 实现受管进程组、stdio 投影与远程 spill 文件 |
|
||||
|
||||
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork:它已经把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 与 `ctx.fs` 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)或 E2B SDK 缓冲。[决策记录](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)负责说明 POC 边界及未采纳的扩展方案。
|
||||
6
packages/e2b/e2b/README.i18n.yaml
Normal file
6
packages/e2b/e2b/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
|
||||
README.md: bf62cb7d4811ca92f263bd3d337fff9fe41ce223
|
||||
README.zh.md: 8c7603aa501a2481aba6dab1f22750056e43ad26
|
||||
50
packages/e2b/e2b/README.md
Normal file
50
packages/e2b/e2b/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @deepseek-ai/dsh-e2b
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared lifecycle owner for one E2B sandbox. Filesystem and subprocess adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
- id: e2b
|
||||
name: '@deepseek-ai/dsh-e2b'
|
||||
config:
|
||||
cwd: /home/user/workspace
|
||||
timeoutMs: 300000
|
||||
onTimeout: pause
|
||||
onDispose: kill
|
||||
|
||||
- id: subprocess-e2b
|
||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||
|
||||
- id: 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`.
|
||||
|
||||
Set `sandboxId` to reconnect a running or paused sandbox instead of creating one. E2B resumes a paused sandbox during connect; `template` is creation-only and cannot accompany `sandboxId`. Omitting `template` uses E2B's default base template.
|
||||
|
||||
## 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, then sets that directory to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
|
||||
|
||||
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A newly created sandbox is killed when initial directory setup fails; a reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
|
||||
|
||||
`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
|
||||
|
||||
None, as this shared runtime owner registers no model-visible context; provider adapters and their consumers own any rendered effects.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; this package does not contribute request tokens.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **No deployment platform is configured** — templates, volumes, snapshots, 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.
|
||||
50
packages/e2b/e2b/README.zh.md
Normal file
50
packages/e2b/e2b/README.zh.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @deepseek-ai/dsh-e2b
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个 E2B 沙箱的共享生命周期所有者。文件系统与进程管理适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: e2b
|
||||
name: '@deepseek-ai/dsh-e2b'
|
||||
config:
|
||||
cwd: /home/user/workspace
|
||||
timeoutMs: 300000
|
||||
onTimeout: pause
|
||||
onDispose: kill
|
||||
|
||||
- id: subprocess-e2b
|
||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||
|
||||
- id: 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`。
|
||||
|
||||
设置 `sandboxId` 可重新连接正在运行或已经暂停的沙箱,而不是创建新沙箱。连接时,E2B 会恢复已经暂停的沙箱;`template` 仅用于创建,不能与 `sandboxId` 同时使用。省略 `template` 时使用 E2B 的默认基础模板。
|
||||
|
||||
## 生命周期与所有权
|
||||
|
||||
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
|
||||
|
||||
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
|
||||
|
||||
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。本共享运行时所有者不注册模型可见上下文;提供方适配器及其消费方拥有所有渲染效果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;本包不会贡献请求 token。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **这不是完整的 harness 运行时**:Cordis 服务、agent(智能体)/会话状态、会话日志、LLM(大语言模型)请求、skill(技能)和 SDK 侧缓冲仍留在宿主进程中。
|
||||
- **保留的沙箱不会恢复宿主句柄**:重新连接会保留远程文件和适配器产物,但无法重建进程管理句柄、流游标或变更锁;进程管理服务 dispose 时会终止受管子进程。
|
||||
- **没有配置部署平台**:模板、卷、快照、网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
|
||||
- **`cwd` 是解析约定,而不是包含边界**:适配器和命令可以访问沙箱中的其他路径;E2B 网络访问也继续采用模板的策略。
|
||||
44
packages/e2b/e2b/package.json
Normal file
44
packages/e2b/e2b/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-e2b",
|
||||
"description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"e2b": "2.29.1",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
240
packages/e2b/e2b/src/index.ts
Normal file
240
packages/e2b/e2b/src/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Shared ownership of one E2B sandbox. Capability adapters await the same SDK
|
||||
* handle, so filesystem and process operations inhabit one remote Linux world.
|
||||
* @module @deepseek-ai/dsh-e2b
|
||||
*/
|
||||
|
||||
import { posix } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Sandbox } from 'e2b'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
export {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
FileType,
|
||||
Sandbox,
|
||||
SandboxError,
|
||||
SandboxNotFoundError,
|
||||
TimeoutError,
|
||||
} 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.
|
||||
* @param value - Exact argument value to preserve.
|
||||
* @returns A single shell word with no interpolation.
|
||||
*/
|
||||
export function quoteE2BShellArg(value: string): string {
|
||||
return `'${value.replaceAll('\'', "'\"'\"'")}'`
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export interface Config {
|
||||
/** API key; omission reads `E2B_API_KEY`. It is never forwarded into the sandbox. */
|
||||
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. */
|
||||
cwd?: string
|
||||
/** E2B sandbox lifetime in milliseconds. */
|
||||
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 {
|
||||
apiKey: string
|
||||
cwd: string
|
||||
timeoutMs: number
|
||||
onTimeout: E2BTimeoutMode
|
||||
onDispose: E2BDisposeMode
|
||||
sandboxId?: string
|
||||
template?: string
|
||||
}
|
||||
|
||||
interface SchemaResolvedConfig extends Config {
|
||||
cwd: string
|
||||
timeoutMs: number
|
||||
onTimeout: E2BTimeoutMode
|
||||
onDispose: E2BDisposeMode
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
e2b: E2BSandboxService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one lazily consumable E2B SDK handle and its final kill/pause/leave
|
||||
* decision. The connection begins at plugin construction; adapters await
|
||||
* {@link getSandbox} before their first operation.
|
||||
*/
|
||||
export class E2BSandboxService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
sandboxId: z.string(),
|
||||
template: z.string(),
|
||||
cwd: z.string().default('/home/user/workspace'),
|
||||
timeoutMs: z.number().default(300_000),
|
||||
onTimeout: z.union(['kill', 'pause'] as const).default('pause'),
|
||||
onDispose: z.union(['kill', 'pause', 'leave'] as const).default('kill'),
|
||||
})
|
||||
|
||||
/** Validated remote working directory shared by provider adapters. */
|
||||
readonly cwd: string
|
||||
/** Remote directory reserved for adapter-owned process and terminal state. */
|
||||
readonly runtimeRoot: string
|
||||
/** Whether this service creates a sandbox rather than reconnecting one. */
|
||||
readonly created: boolean
|
||||
/** Configured action when a newly created sandbox reaches its lifetime. */
|
||||
readonly timeoutMode: E2BTimeoutMode
|
||||
/** Configured final sandbox disposition. */
|
||||
readonly disposeMode: E2BDisposeMode
|
||||
/** Sandbox id once E2B has created or resolved the remote runtime. */
|
||||
readonly sandboxId: Promise<E2BSandboxId>
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
private readonly ready: Promise<Sandbox>
|
||||
private disposed = false
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'e2b')
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
const resolved = config as SchemaResolvedConfig
|
||||
const apiKey = config.apiKey ?? process.env.E2B_API_KEY
|
||||
this.config = {
|
||||
apiKey: apiKey ?? '',
|
||||
cwd: resolved.cwd,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
onTimeout: resolved.onTimeout,
|
||||
onDispose: resolved.onDispose,
|
||||
...(config.sandboxId !== undefined ? { sandboxId: config.sandboxId } : {}),
|
||||
...(config.template !== undefined ? { template: config.template } : {}),
|
||||
}
|
||||
this.validate()
|
||||
this.cwd = this.config.cwd
|
||||
this.runtimeRoot = posix.join(this.cwd, '.dsh-e2b')
|
||||
this.created = this.config.sandboxId === undefined
|
||||
this.timeoutMode = this.config.onTimeout
|
||||
this.disposeMode = this.config.onDispose
|
||||
this.ready = this.open()
|
||||
// A deployment may load the owner before any adapter uses it. Keep a
|
||||
// failed eager connection observed; getSandbox() still returns the error.
|
||||
void this.ready.catch(() => {})
|
||||
this.sandboxId = this.ready.then(sandbox => E2BSandboxId(sandbox.sandboxId))
|
||||
void this.sandboxId.catch(() => {})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
let sandbox: Sandbox
|
||||
try {
|
||||
sandbox = await this.ready
|
||||
} catch {
|
||||
// Connection creation already failed and is exposed by getSandbox();
|
||||
// there is no remote resource for teardown to own.
|
||||
return
|
||||
}
|
||||
switch (this.config.onDispose) {
|
||||
case 'kill':
|
||||
await sandbox.kill()
|
||||
return
|
||||
case 'pause': {
|
||||
await sandbox.pause()
|
||||
return
|
||||
}
|
||||
case 'leave':
|
||||
return
|
||||
}
|
||||
}, 'e2b sandbox teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shared live SDK handle.
|
||||
* @returns the created or reconnected sandbox after the configured cwd exists.
|
||||
* @throws when E2B rejects creation/reconnection or the service is disposing.
|
||||
*/
|
||||
async getSandbox(): Promise<Sandbox> {
|
||||
if (this.disposed) throw new Error('E2B sandbox service is disposing')
|
||||
return await this.ready
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
if (this.config.apiKey.length === 0) {
|
||||
throw new Error('dsh-e2b: configure apiKey or set E2B_API_KEY')
|
||||
}
|
||||
if (!posix.isAbsolute(this.config.cwd)) {
|
||||
throw new Error(`dsh-e2b: cwd must be an absolute Linux path: ${this.config.cwd}`)
|
||||
}
|
||||
if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs <= 0) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
private async open(): Promise<Sandbox> {
|
||||
const connection = {
|
||||
apiKey: this.config.apiKey,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
}
|
||||
const sandbox = this.config.sandboxId === undefined
|
||||
? 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 {
|
||||
await sandbox.files.makeDir(this.cwd)
|
||||
await sandbox.files.makeDir(this.runtimeRoot)
|
||||
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.runtimeRoot)}`)
|
||||
return sandbox
|
||||
} catch (error: unknown) {
|
||||
if (this.created) {
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch (_cleanupFailure) {
|
||||
// The setup failure remains authoritative; E2B will still apply the configured lifetime.
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default E2BSandboxService
|
||||
30
packages/e2b/e2b/src/invariant.ts
Normal file
30
packages/e2b/e2b/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-e2b`.
|
||||
* @module @deepseek-ai/dsh-e2b/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-e2b'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'e2b-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: sandbox creation and teardown have one SDK promise and
|
||||
* no independent event or mutable-data relationship to cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
39
packages/e2b/e2b/tests/composition.e2e.ts
Normal file
39
packages/e2b/e2b/tests/composition.e2e.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
|
||||
|
||||
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
|
||||
const binScript = join(fixtureRoot, 'bin.ts')
|
||||
const configPath = join(fixtureRoot, 'cordis.yml')
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
it('shares remote state across FS and Bash without creating host workspace files', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'E2B composition',
|
||||
tempDirPrefix: 'dsh-e2b-composition-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
processTimeoutMs: 90_000,
|
||||
inspect: async (cwd) => {
|
||||
await expect(access(join(cwd, 'from-fs.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(access(join(cwd, 'from-bash.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
},
|
||||
})
|
||||
|
||||
expect(stderr).toBe('')
|
||||
const output = JSON.parse(stdout) as Record<string, unknown>
|
||||
expect(output).toMatchObject({
|
||||
bashRead: 'written-by-fs\n',
|
||||
fsRead: 'written-by-bash\n',
|
||||
})
|
||||
const apiKey = process.env.E2B_API_KEY
|
||||
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
|
||||
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
|
||||
}, 105_000)
|
||||
})
|
||||
218
packages/e2b/e2b/tests/e2b.spec.ts
Normal file
218
packages/e2b/e2b/tests/e2b.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Sandbox as SandboxType } from 'e2b'
|
||||
import E2BSandboxService, {
|
||||
E2BSandboxId,
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import * as E2BInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const sdk = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('e2b', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('e2b')>()
|
||||
// The mock replaces only the SDK's static factory surface and is never constructed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
class FakeSandbox {
|
||||
static create(...args: unknown[]): unknown {
|
||||
return sdk.create(...args)
|
||||
}
|
||||
|
||||
static connect(...args: unknown[]): unknown {
|
||||
return sdk.connect(...args)
|
||||
}
|
||||
}
|
||||
return { ...actual, Sandbox: FakeSandbox }
|
||||
})
|
||||
|
||||
interface SandboxFixture {
|
||||
sandbox: SandboxType
|
||||
makeDir: ReturnType<typeof vi.fn>
|
||||
run: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakeSandbox(id = 'sandbox-1'): SandboxFixture {
|
||||
const makeDir = vi.fn().mockResolvedValue(true)
|
||||
const run = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||
const kill = vi.fn().mockResolvedValue(undefined)
|
||||
const pause = vi.fn().mockResolvedValue(true)
|
||||
const sandbox = {
|
||||
sandboxId: id,
|
||||
files: { makeDir },
|
||||
commands: { run },
|
||||
kill,
|
||||
pause,
|
||||
} as unknown as SandboxType
|
||||
return { sandbox, makeDir, run, kill, pause }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sdk.create.mockReset()
|
||||
sdk.connect.mockReset()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('E2BSandboxService', () => {
|
||||
it('creates one protected shared sandbox and kills it on default disposal', async () => {
|
||||
const fixture = fakeSandbox()
|
||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
||||
|
||||
const service = ctx.e2b
|
||||
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.runtimeRoot).toBe('/home/user/workspace/.dsh-e2b')
|
||||
expect(service.created).toBe(true)
|
||||
expect(service.timeoutMode).toBe('pause')
|
||||
expect(service.disposeMode).toBe('kill')
|
||||
expect(sdk.create).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
timeoutMs: 300_000,
|
||||
secure: true,
|
||||
lifecycle: { onTimeout: 'pause', autoResume: true },
|
||||
})
|
||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
|
||||
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
|
||||
expect(fixture.run).toHaveBeenCalledWith("chmod 700 -- '/home/user/workspace/.dsh-e2b'")
|
||||
|
||||
await fiber.dispose()
|
||||
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||
await expect(service.getSandbox()).rejects.toThrow(/disposing/)
|
||||
})
|
||||
|
||||
it('creates from a template, honors timeout and pause policies, and reads the key from the environment', async () => {
|
||||
vi.stubEnv('E2B_API_KEY', 'environment-key')
|
||||
const fixture = fakeSandbox('template-sandbox')
|
||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(E2BSandboxService, {
|
||||
template: 'agent-template',
|
||||
cwd: '/workspace/project',
|
||||
timeoutMs: 60_000,
|
||||
onTimeout: 'kill',
|
||||
onDispose: 'pause',
|
||||
})
|
||||
await ctx.e2b.getSandbox()
|
||||
|
||||
expect(sdk.create).toHaveBeenCalledWith('agent-template', {
|
||||
apiKey: 'environment-key',
|
||||
timeoutMs: 60_000,
|
||||
secure: true,
|
||||
lifecycle: { onTimeout: 'kill', autoResume: false },
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(fixture.pause).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('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(ctx.e2b.created).toBe(false)
|
||||
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 () => {
|
||||
const fixture = fakeSandbox()
|
||||
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
|
||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
||||
|
||||
await expect(ctx.e2b.getSandbox()).rejects.toThrow('setup failed')
|
||||
await expect(ctx.e2b.sandboxId).rejects.toThrow('setup failed')
|
||||
expect(fixture.kill).toHaveBeenCalledOnce()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('preserves the setup failure even when cleanup also fails', async () => {
|
||||
const fixture = fakeSandbox()
|
||||
fixture.run.mockRejectedValueOnce(new Error('chmod failed'))
|
||||
fixture.kill.mockRejectedValueOnce(new Error('cleanup failed'))
|
||||
sdk.create.mockResolvedValue(fixture.sandbox)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
|
||||
await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed')
|
||||
})
|
||||
|
||||
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([
|
||||
[{ apiKey: '' }, /configure apiKey/],
|
||||
[{ apiKey: 'x', cwd: 'relative' }, /absolute Linux path/],
|
||||
[{ apiKey: 'x', timeoutMs: 0 }, /positive finite/],
|
||||
[{ apiKey: 'x', sandboxId: '' }, /sandboxId must be non-empty/],
|
||||
[{ apiKey: 'x', sandboxId: 'one', template: 'two' }, /template applies only/],
|
||||
] as const)('fails self-contained configuration before opening E2B: %j', async (config, message) => {
|
||||
vi.stubEnv('E2B_API_KEY', '')
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(E2BSandboxService, config)).rejects.toThrow(message)
|
||||
expect(sdk.create).not.toHaveBeenCalled()
|
||||
expect(sdk.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires a key when both config and the environment omit it', async () => {
|
||||
const original = process.env.E2B_API_KEY
|
||||
delete process.env.E2B_API_KEY
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(E2BSandboxService, {})).rejects.toThrow(/configure apiKey/)
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.E2B_API_KEY
|
||||
else process.env.E2B_API_KEY = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B helpers and invariant companion', () => {
|
||||
it('quotes opaque shell arguments without interpolation', () => {
|
||||
expect(quoteE2BShellArg("a'b $HOME")).toBe("'a'\"'\"'b $HOME'")
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = await ctx.plugin(E2BInvariant).await()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
25
packages/e2b/e2b/tsconfig.json
Normal file
25
packages/e2b/e2b/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user