feat(e2b): add remote runtime providers

This commit is contained in:
Tianyi Cui
2026-07-28 10:05:30 +08:00
parent b9b25f81cb
commit e7b682f1f6
56 changed files with 3565 additions and 30 deletions

View 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

View 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.

View 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 网络访问也继续采用模板的策略。

View 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"
}
}

View 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

View 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 */

View 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)
})

View 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()
})
})

View 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"
}
]
}