refactor(e2b): narrow the sandbox POC
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 网络访问也继续采用基础镜像的策略。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user