refactor(e2b): narrow the sandbox POC

This commit is contained in:
Tianyi Cui
2026-07-30 04:25:47 +08:00
parent bb0be75d85
commit de77310c6f
37 changed files with 176 additions and 881 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
README.md: 6881556dc18497956966aff74adc085d8e8620d3
README.zh.md: aea13f341f903e6c47b7484f477bf54cebf101c6
README.md: 6045b313a91edafc25361eba3f3c4dacf56b4eb0
README.zh.md: dc157d9b5167488530c7d63b59aed793ab6aa4c7

View File

@@ -12,8 +12,6 @@ Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapte
config:
cwd: /home/user/workspace
timeoutMs: 300000
onTimeout: pause
onDispose: kill
- id: 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'
```
`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` and `onTimeout` are creation-only and cannot accompany `sandboxId`. Omitting `template` uses E2B's default base template.
`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.
## 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.
`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.
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.
## Model Experience
@@ -45,6 +39,6 @@ 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.
- **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** — 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 retains the base image's policy.

View File

@@ -12,8 +12,6 @@
config:
cwd: /home/user/workspace
timeoutMs: 300000
onTimeout: pause
onDispose: kill
- id: subprocess-e2b
name: '@deepseek-ai/dsh-subprocess-e2b'
@@ -22,17 +20,13 @@
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` 和 `onTimeout` 仅用于创建,不能与 `sandboxId` 同时使用。省略 `template` 时使用 E2B 的默认基础模板。
`apiKey` 可省略;省略时读取 `E2B_API_KEY`。该密钥只配置宿主 SDK 连接,绝不会安装进沙箱。`cwd` 默认为 `/home/user/workspace`,并且必须是绝对 POSIX 路径。`timeoutMs` 默认为 5 分钟并控制沙箱生命周期;超时会删除沙箱。
## 生命周期与所有权
构造阶段会启动一次 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(资源释放)。
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时也会删除新建沙箱;如果该回滚失败,资源释放会在解除所有权前重试。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
## 模型体验
@@ -45,6 +39,6 @@
## 已知限制与延后工作
- **这不是完整的 harness 运行时**:Cordis 服务、agent(智能体)/会话状态、会话日志、LLM(大语言模型)请求、skill(技能)和 SDK 侧缓冲仍留在宿主进程中。
- **保留的沙箱不会恢复宿主句柄**:重新连接会保留远程文件和适配器产物,但无法重建进程管理句柄、流游标或变更锁;进程管理服务 dispose 时会终止受管子进程。
- **没有配置部署平台**:模板、卷、快照、网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
- **`cwd` 是解析约定,而不是包含边界**:适配器和命令可以访问沙箱中的其他路径;E2B 网络访问也继续采用模板的策略。
- **沙箱状态是短暂的**:资源释放和超时都会删除沙箱;重新连接、pause/leave 保留、模板、卷和快照均不在本 POC 范围内。
- **没有配置部署平台**:网络策略、宿主工作区同步和沙箱发现均不在本 POC 范围内。
- **`cwd` 是解析约定,而不是包含边界**:适配器和命令可以访问沙箱中的其他路径;E2B 网络访问也继续采用基础镜像的策略。

View File

@@ -27,7 +27,6 @@
],
"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"
},
@@ -36,7 +35,6 @@
"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"

View File

@@ -9,30 +9,15 @@ import { posix } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { FileType, Sandbox, SandboxNotFoundError } 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, ProcessInfo, PtyOutput } 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
}
export type { CommandHandle, CommandResult, EntryInfo } from 'e2b'
/**
* 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()}` }
}
/** 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. */
/** E2B sandbox lifetime in milliseconds; expiry always deletes the sandbox. */
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
onDispose: E2BDisposeMode
}
declare module 'cordis' {
@@ -101,31 +67,24 @@ declare module 'cordis' {
}
/**
* Owns one lazily consumable E2B SDK handle and its final kill/pause/leave
* decision. The connection begins at plugin construction; adapters await
* Creates one lazily consumable E2B SDK handle and deletes the sandbox at
* timeout or disposal. Creation 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),
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
/** 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 readonly created: boolean
private failedSetupSandbox: Sandbox | undefined
private disposed = false
@@ -138,79 +97,50 @@ export class E2BSandboxService extends Service {
apiKey: apiKey ?? '',
cwd: resolved.cwd,
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.runtimeRoot = posix.join(this.cwd, '.dsh-e2b')
this.created = this.config.sandboxId === undefined
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 {
const failedSetupSandbox = this.failedSetupSandbox
if (failedSetupSandbox === undefined) return
sandbox = failedSetupSandbox
let sandbox = this.failedSetupSandbox
if (sandbox === undefined) {
try {
await sandbox.kill()
this.failedSetupSandbox = undefined
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
this.failedSetupSandbox = undefined
sandbox = await this.ready
} catch {
sandbox = this.failedSetupSandbox
}
return
}
if (sandbox === undefined) return
try {
switch (this.config.onDispose) {
case 'kill':
await sandbox.kill()
return
case 'pause': {
await sandbox.pause()
return
}
case 'leave':
return
}
await sandbox.kill()
} catch (error: unknown) {
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')
}
/**
* 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.
* @returns the created sandbox after the configured cwd exists.
* @throws when E2B rejects creation or the service is disposing.
*/
async getSandbox(): Promise<Sandbox> {
if (this.disposed) throw new Error('E2B sandbox service is disposing')
const sandbox = await this.ready
// 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')
return sandbox
}
private validate(input: Config): void {
private validate(): void {
if (this.config.apiKey.length === 0) {
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) {
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> {
const connection = {
const sandbox = await Sandbox.create({
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)
secure: true,
lifecycle: { onTimeout: 'kill' },
})
try {
await sandbox.files.makeDir(this.cwd)
await sandbox.files.makeDir(this.runtimeRoot)
@@ -262,14 +172,12 @@ export class E2BSandboxService extends Service {
)
return sandbox
} catch (error: unknown) {
if (this.created) {
try {
await sandbox.kill()
} catch (_cleanupFailure) {
// Preserve the setup failure as the public error while retaining the
// created handle for the service disposer to retry this rollback.
this.failedSetupSandbox = sandbox
}
try {
await sandbox.kill()
} catch (_cleanupFailure) {
// Preserve the setup failure as the public error while retaining the
// created handle for the service disposer to retry this rollback.
this.failedSetupSandbox = sandbox
}
throw error
}

View File

@@ -5,8 +5,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import E2BSandboxService, {
e2bControlEnvs,
import {
FileNotFoundError,
Sandbox,
SandboxNotFoundError,
@@ -113,26 +112,6 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
await subprocessFiber.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 {
await sandbox.kill().catch(() => false)
}
@@ -160,23 +139,10 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
expect(stderr).toBe('')
const output = JSON.parse(stdout) as Record<string, unknown>
expect(output).toMatchObject({
bashRead: 'written-by-fs-versioned\n',
fsVersionGuard: true,
bashRead: 'versioned-by-fs\n',
fsRead: 'written-by-bash\n',
explicitEnvironment: true,
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: {
kind: 'hover',
hover: { contents: '**remote hover** 你好 café' },
@@ -185,24 +151,14 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
kind: 'locations',
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
},
lspDocumentBound: true,
terminal: {
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
signal: { delivered: true },
interrupted: { sessionStatus: { kind: 'running' } },
interruptIdentitySafe: 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: [] },
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,
})
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({
value: { doubled: 42, typed: true },
logs: ['remote-log 你好 42', 'post-mutation'],
logs: ['remote-log 你好 42'],
})
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)
await expect(Sandbox.list({ apiKey }).nextItems()).resolves.toEqual([])
}, 195_000)
})

View File

@@ -4,7 +4,6 @@ import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
e2bControlEnvs,
E2BSandboxId,
FileType,
SandboxNotFoundError,
quoteE2BShellArg,
@@ -14,21 +13,16 @@ 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
// oxlint-disable-next-line typescript/no-extraneous-class -- The SDK contract is a class with a static factory.
class FakeSandbox {
static create(...args: unknown[]): unknown {
return sdk.create(...args)
}
static connect(...args: unknown[]): unknown {
return sdk.connect(...args)
}
}
return { ...actual, Sandbox: FakeSandbox }
})
@@ -39,7 +33,6 @@ interface SandboxFixture {
getInfo: ReturnType<typeof vi.fn>
run: Mock<RunCommand>
kill: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
}
type RunCommand = (
@@ -52,20 +45,17 @@ function fakeSandbox(id = 'sandbox-1'): SandboxFixture {
const getInfo = vi.fn().mockResolvedValue({ type: FileType.DIR })
const run = vi.fn<RunCommand>().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
const kill = vi.fn().mockResolvedValue(undefined)
const pause = vi.fn().mockResolvedValue(true)
const sandbox = {
sandboxId: id,
files: { makeDir, getInfo },
commands: { run },
kill,
pause,
} as unknown as SandboxType
return { sandbox, makeDir, getInfo, run, kill, pause }
return { sandbox, makeDir, getInfo, run, kill }
}
beforeEach(() => {
sdk.create.mockReset()
sdk.connect.mockReset()
vi.unstubAllEnvs()
})
@@ -87,14 +77,13 @@ describe('E2BSandboxService', () => {
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(sdk.create).toHaveBeenCalledWith({
apiKey: 'test-key',
timeoutMs: 300_000,
secure: true,
lifecycle: { onTimeout: 'pause', autoResume: true },
lifecycle: { onTimeout: 'kill' },
})
expect(fixture.makeDir).toHaveBeenNthCalledWith(1, '/home/user/workspace')
expect(fixture.makeDir).toHaveBeenNthCalledWith(2, '/home/user/workspace/.dsh-e2b')
@@ -127,55 +116,26 @@ describe('E2BSandboxService', () => {
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')
const fixture = fakeSandbox('template-sandbox')
const fixture = fakeSandbox('configured-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', {
expect(sdk.create).toHaveBeenCalledWith({
apiKey: 'environment-key',
timeoutMs: 60_000,
secure: true,
lifecycle: { onTimeout: 'kill', autoResume: false },
lifecycle: { onTimeout: 'kill' },
})
expect(ctx.e2b.cwd).toBe('/workspace/project')
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('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()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
@@ -208,49 +168,6 @@ describe('E2BSandboxService', () => {
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 () => {
const fixture = fakeSandbox()
fixture.makeDir.mockRejectedValueOnce(new Error('setup failed'))
@@ -259,7 +176,6 @@ describe('E2BSandboxService', () => {
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()
})
@@ -294,44 +210,30 @@ describe('E2BSandboxService', () => {
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([
['symbolic link', { type: FileType.DIR, symlinkTarget: '/tmp/redirected' }],
['regular file', { type: FileType.FILE }],
])('rejects a reserved runtime root that is a %s', async (_label, info) => {
const fixture = fakeSandbox()
fixture.getInfo.mockResolvedValueOnce(info)
sdk.connect.mockResolvedValue(fixture.sandbox)
sdk.create.mockResolvedValue(fixture.sandbox)
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')
expect(fixture.run).not.toHaveBeenCalled()
expect(fixture.kill).not.toHaveBeenCalled()
expect(fixture.kill).toHaveBeenCalledOnce()
})
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/],
[{ apiKey: 'x', sandboxId: 'one', onTimeout: 'kill' }, /onTimeout 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 () => {