feat(e2b): add remote runtime providers
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 网络访问也继续采用模板的策略。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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