refactor(e2b): narrow the sandbox POC
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user