From 77c8f1e3f3f0ccf6db0b4d052cca8b580b932a4f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 16 Mar 2026 14:57:49 -0700 Subject: [PATCH] feat: add E2B auto-pause support with pause/kill/reconnect provider lifecycle Add `pause()`, `kill()`, and `reconnect()` methods to the SandboxProvider interface so providers can support graceful suspension and permanent deletion as distinct operations. The E2B provider now uses `betaCreate` with `autoPause: true` by default, `betaPause()` for suspension, and surfaces `SandboxDestroyedError` on reconnect to a deleted sandbox. SDK exposes `pauseSandbox()` and `killSandbox()` alongside the existing `destroySandbox()`. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/deploy/e2b.mdx | 2 +- docs/quickstart.mdx | 4 +- docs/sdk-overview.mdx | 4 +- examples/daytona/src/daytona.ts | 33 +++ examples/e2b/src/e2b.ts | 32 +++ examples/vercel/src/vercel.ts | 35 ++++ sdks/typescript/src/client.ts | 57 ++++++ sdks/typescript/src/index.ts | 1 + sdks/typescript/src/providers/e2b.ts | 42 +++- sdks/typescript/src/providers/types.ts | 19 ++ .../tests/provider-lifecycle.test.ts | 193 ++++++++++++++++++ sdks/typescript/tests/providers.test.ts | 7 +- 12 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 examples/daytona/src/daytona.ts create mode 100644 examples/e2b/src/e2b.ts create mode 100644 examples/vercel/src/vercel.ts create mode 100644 sdks/typescript/tests/provider-lifecycle.test.ts diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index 4e056ee..e6465f2 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -39,7 +39,7 @@ try { } ``` -The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. +The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. Sandboxes pause by default instead of being deleted, and reconnecting with the same `sandboxId` resumes them automatically. ## Faster cold starts diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 5c299c3..3701c74 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -357,10 +357,10 @@ icon: "rocket" ```typescript - await client.destroySandbox(); // tears down the sandbox and disconnects + await client.destroySandbox(); // provider-defined cleanup and disconnect ``` - Use `client.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later). + Use `client.dispose()` instead to disconnect without changing sandbox state. On E2B, `client.pauseSandbox()` pauses the sandbox and `client.killSandbox()` deletes it permanently. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index a0f9b84..8e7c8f6 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -87,7 +87,7 @@ const sdk = await SandboxAgent.start({ // sdk.sandboxId — prefixed provider ID (e.g. "local/127.0.0.1:2468") -await sdk.destroySandbox(); // tears down sandbox + disposes client +await sdk.destroySandbox(); // provider-defined cleanup + disposes client ``` `SandboxAgent.start(...)` requires a `sandbox` provider. Built-in providers: @@ -101,7 +101,7 @@ await sdk.destroySandbox(); // tears down sandbox + disposes client | `sandbox-agent/vercel` | Vercel Sandbox | | `sandbox-agent/cloudflare` | Cloudflare Sandbox | -Use `sdk.dispose()` to disconnect without destroying the sandbox, or `sdk.destroySandbox()` to tear down both. +Use `sdk.dispose()` to disconnect without changing sandbox state, `sdk.pauseSandbox()` for graceful suspension when supported, or `sdk.killSandbox()` for permanent deletion. ## Session flow diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/daytona.ts new file mode 100644 index 0000000..ccffc94 --- /dev/null +++ b/examples/daytona/src/daytona.ts @@ -0,0 +1,33 @@ +import { SandboxAgent } from "sandbox-agent"; +import { daytona } from "sandbox-agent/daytona"; + +function collectEnvVars(): Record { + const envVars: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return envVars; +} + +function inspectorUrlToBaseUrl(inspectorUrl: string): string { + return inspectorUrl.replace(/\/ui\/$/, ""); +} + +export async function setupDaytonaSandboxAgent(): Promise<{ + baseUrl: string; + token?: string; + extraHeaders?: Record; + cleanup: () => Promise; +}> { + const client = await SandboxAgent.start({ + sandbox: daytona({ + create: { envVars: collectEnvVars() }, + }), + }); + + return { + baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl), + cleanup: async () => { + await client.killSandbox(); + }, + }; +} diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/e2b.ts new file mode 100644 index 0000000..bfd5bda --- /dev/null +++ b/examples/e2b/src/e2b.ts @@ -0,0 +1,32 @@ +import { SandboxAgent } from "sandbox-agent"; +import { e2b } from "sandbox-agent/e2b"; + +function collectEnvVars(): Record { + const envs: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return envs; +} + +function inspectorUrlToBaseUrl(inspectorUrl: string): string { + return inspectorUrl.replace(/\/ui\/$/, ""); +} + +export async function setupE2BSandboxAgent(): Promise<{ + baseUrl: string; + token?: string; + cleanup: () => Promise; +}> { + const client = await SandboxAgent.start({ + sandbox: e2b({ + create: { envs: collectEnvVars() }, + }), + }); + + return { + baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl), + cleanup: async () => { + await client.killSandbox(); + }, + }; +} diff --git a/examples/vercel/src/vercel.ts b/examples/vercel/src/vercel.ts new file mode 100644 index 0000000..742cd5a --- /dev/null +++ b/examples/vercel/src/vercel.ts @@ -0,0 +1,35 @@ +import { SandboxAgent } from "sandbox-agent"; +import { vercel } from "sandbox-agent/vercel"; + +function collectEnvVars(): Record { + const env: Record = {}; + if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return env; +} + +function inspectorUrlToBaseUrl(inspectorUrl: string): string { + return inspectorUrl.replace(/\/ui\/$/, ""); +} + +export async function setupVercelSandboxAgent(): Promise<{ + baseUrl: string; + token?: string; + cleanup: () => Promise; +}> { + const client = await SandboxAgent.start({ + sandbox: vercel({ + create: { + runtime: "node24", + env: collectEnvVars(), + }, + }), + }); + + return { + baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl), + cleanup: async () => { + await client.killSandbox(); + }, + }; +} diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 4752c0a..10200bc 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -216,6 +216,18 @@ export class SandboxAgentError extends Error { } } +export class SandboxDestroyedError extends Error { + readonly sandboxId: string; + readonly provider: string; + + constructor(sandboxId: string, provider: string, options?: { cause?: unknown }) { + super(`Sandbox '${provider}/${sandboxId}' no longer exists and cannot be reconnected.`, options); + this.name = "SandboxDestroyedError"; + this.sandboxId = sandboxId; + this.provider = provider; + } +} + export class UnsupportedSessionCategoryError extends Error { readonly sessionId: string; readonly category: string; @@ -904,6 +916,7 @@ export class SandboxAgent { const createdSandbox = !existingSandbox; if (existingSandbox) { + await provider.reconnect?.(rawSandboxId); await provider.ensureServer?.(rawSandboxId); } @@ -1007,6 +1020,50 @@ export class SandboxAgent { } } + async pauseSandbox(): Promise { + const provider = this.sandboxProvider; + const rawSandboxId = this.sandboxProviderRawId; + + try { + if (provider && rawSandboxId) { + if (provider.pause) { + await provider.pause(rawSandboxId); + } else { + await provider.destroy(rawSandboxId); + } + } else if (!provider || !rawSandboxId) { + throw new Error("SandboxAgent is not attached to a provisioned sandbox."); + } + } finally { + await this.dispose(); + this.sandboxProvider = undefined; + this.sandboxProviderId = undefined; + this.sandboxProviderRawId = undefined; + } + } + + async killSandbox(): Promise { + const provider = this.sandboxProvider; + const rawSandboxId = this.sandboxProviderRawId; + + try { + if (provider && rawSandboxId) { + if (provider.kill) { + await provider.kill(rawSandboxId); + } else { + await provider.destroy(rawSandboxId); + } + } else if (!provider || !rawSandboxId) { + throw new Error("SandboxAgent is not attached to a provisioned sandbox."); + } + } finally { + await this.dispose(); + this.sandboxProvider = undefined; + this.sandboxProviderId = undefined; + this.sandboxProviderRawId = undefined; + } + } + async listSessions(request: ListPageRequest = {}): Promise> { const page = await this.persist.listSessions(request); return { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index f0ebe2e..15537dd 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -3,6 +3,7 @@ export { ProcessTerminalSession, SandboxAgent, SandboxAgentError, + SandboxDestroyedError, Session, UnsupportedPermissionReplyError, UnsupportedSessionCategoryError, diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts index 84d767c..8e99c64 100644 --- a/sdks/typescript/src/providers/e2b.ts +++ b/sdks/typescript/src/providers/e2b.ts @@ -1,13 +1,20 @@ -import { Sandbox } from "@e2b/code-interpreter"; +import { NotFoundError, Sandbox, type SandboxBetaCreateOpts, type SandboxConnectOpts } from "@e2b/code-interpreter"; +import { SandboxDestroyedError } from "../client.ts"; import type { SandboxProvider } from "./types.ts"; import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; const DEFAULT_AGENT_PORT = 3000; +const DEFAULT_TIMEOUT_MS = 3_600_000; + +type E2BCreateOverrides = Omit, "timeoutMs" | "autoPause">; +type E2BConnectOverrides = Omit, "timeoutMs">; export interface E2BProviderOptions { - create?: Record | (() => Record | Promise>); - connect?: Record | ((sandboxId: string) => Record | Promise>); + create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise); + connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise); agentPort?: number; + timeoutMs?: number; + autoPause?: boolean; } async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise> { @@ -23,13 +30,15 @@ async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderO export function e2b(options: E2BProviderOptions = {}): SandboxProvider { const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const autoPause = options.autoPause ?? true; return { name: "e2b", async create(): Promise { const createOpts = await resolveOptions(options.create); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sandbox = await Sandbox.create({ allowInternetAccess: true, ...createOpts } as any); + const sandbox = await Sandbox.betaCreate({ allowInternetAccess: true, ...createOpts, timeoutMs, autoPause } as any); await sandbox.commands.run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`).then((r) => { if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`); @@ -44,18 +53,37 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider { return sandbox.sandboxId; }, async destroy(sandboxId: string): Promise { + await this.pause?.(sandboxId); + }, + async reconnect(sandboxId: string): Promise { const connectOpts = await resolveOptions(options.connect, sandboxId); - const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + try { + await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts); + } catch (error) { + if (error instanceof NotFoundError) { + throw new SandboxDestroyedError(sandboxId, "e2b", { cause: error }); + } + throw error; + } + }, + async pause(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts); + await sandbox.betaPause(); + }, + async kill(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts); await sandbox.kill(); }, async getUrl(sandboxId: string): Promise { const connectOpts = await resolveOptions(options.connect, sandboxId); - const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts); return `https://${sandbox.getHost(agentPort)}`; }, async ensureServer(sandboxId: string): Promise { const connectOpts = await resolveOptions(options.connect, sandboxId); - const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + const sandbox = await Sandbox.connect(sandboxId, { ...connectOpts, timeoutMs } as SandboxConnectOpts); await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 }); }, }; diff --git a/sdks/typescript/src/providers/types.ts b/sdks/typescript/src/providers/types.ts index ea778de..ab996e1 100644 --- a/sdks/typescript/src/providers/types.ts +++ b/sdks/typescript/src/providers/types.ts @@ -8,6 +8,25 @@ export interface SandboxProvider { /** Permanently tear down a sandbox. */ destroy(sandboxId: string): Promise; + /** + * Reconnect to an existing sandbox before the SDK attempts health checks. + * Providers can use this to resume paused sandboxes or surface provider-specific + * reconnect errors. + */ + reconnect?(sandboxId: string): Promise; + + /** + * Gracefully stop or pause a sandbox without permanently deleting it. + * When omitted, callers should fall back to `destroy()`. + */ + pause?(sandboxId: string): Promise; + + /** + * Permanently delete a sandbox. When omitted, callers should fall back to + * `destroy()`. + */ + kill?(sandboxId: string): Promise; + /** * Return the sandbox-agent base URL for this sandbox. * Providers that cannot expose a URL should implement `getFetch()` instead. diff --git a/sdks/typescript/tests/provider-lifecycle.test.ts b/sdks/typescript/tests/provider-lifecycle.test.ts new file mode 100644 index 0000000..06c85f5 --- /dev/null +++ b/sdks/typescript/tests/provider-lifecycle.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SandboxAgent, SandboxDestroyedError, type SandboxProvider } from "../src/index.ts"; + +const e2bMocks = vi.hoisted(() => { + class MockNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } + } + + return { + MockNotFoundError, + betaCreate: vi.fn(), + connect: vi.fn(), + }; +}); + +vi.mock("@e2b/code-interpreter", () => ({ + NotFoundError: e2bMocks.MockNotFoundError, + Sandbox: { + betaCreate: e2bMocks.betaCreate, + connect: e2bMocks.connect, + }, +})); + +import { e2b } from "../src/providers/e2b.ts"; + +function createFetch(): typeof fetch { + return async () => new Response(null, { status: 200 }); +} + +function createBaseProvider(overrides: Partial = {}): SandboxProvider { + return { + name: "mock", + async create(): Promise { + return "created"; + }, + async destroy(): Promise {}, + async getUrl(): Promise { + return "http://127.0.0.1:3000"; + }, + ...overrides, + }; +} + +function createMockSandbox() { + return { + sandboxId: "sbx-123", + getHost: vi.fn(() => "sandbox.example"), + betaPause: vi.fn(async () => true), + kill: vi.fn(async () => undefined), + commands: { + run: vi.fn(async () => ({ exitCode: 0, stderr: "" })), + }, + }; +} + +describe("SandboxAgent provider lifecycle", () => { + it("reconnects an existing sandbox before ensureServer", async () => { + const order: string[] = []; + const provider = createBaseProvider({ + reconnect: vi.fn(async () => { + order.push("reconnect"); + }), + ensureServer: vi.fn(async () => { + order.push("ensureServer"); + }), + }); + + const sdk = await SandboxAgent.start({ + sandbox: provider, + sandboxId: "mock/existing", + skipHealthCheck: true, + fetch: createFetch(), + }); + + expect(order).toEqual(["reconnect", "ensureServer"]); + + await sdk.killSandbox(); + }); + + it("surfaces SandboxDestroyedError from reconnect", async () => { + const provider = createBaseProvider({ + reconnect: vi.fn(async () => { + throw new SandboxDestroyedError("existing", "mock"); + }), + ensureServer: vi.fn(async () => undefined), + }); + + await expect( + SandboxAgent.start({ + sandbox: provider, + sandboxId: "mock/existing", + skipHealthCheck: true, + fetch: createFetch(), + }), + ).rejects.toBeInstanceOf(SandboxDestroyedError); + + expect(provider.ensureServer).not.toHaveBeenCalled(); + }); + + it("uses provider pause and kill hooks for explicit lifecycle control", async () => { + const pause = vi.fn(async () => undefined); + const kill = vi.fn(async () => undefined); + const provider = createBaseProvider({ pause, kill }); + + const paused = await SandboxAgent.start({ + sandbox: provider, + skipHealthCheck: true, + fetch: createFetch(), + }); + await paused.pauseSandbox(); + expect(pause).toHaveBeenCalledWith("created"); + + const killed = await SandboxAgent.start({ + sandbox: provider, + skipHealthCheck: true, + fetch: createFetch(), + }); + await killed.killSandbox(); + expect(kill).toHaveBeenCalledWith("created"); + }); +}); + +describe("e2b provider", () => { + beforeEach(() => { + e2bMocks.betaCreate.mockReset(); + e2bMocks.connect.mockReset(); + }); + + it("creates sandboxes with betaCreate, autoPause, and the default timeout", async () => { + const sandbox = createMockSandbox(); + e2bMocks.betaCreate.mockResolvedValue(sandbox); + + const provider = e2b({ + create: { + envs: { ANTHROPIC_API_KEY: "test" }, + }, + }); + + await expect(provider.create()).resolves.toBe("sbx-123"); + + expect(e2bMocks.betaCreate).toHaveBeenCalledWith( + expect.objectContaining({ + allowInternetAccess: true, + autoPause: true, + timeoutMs: 3_600_000, + envs: { ANTHROPIC_API_KEY: "test" }, + }), + ); + }); + + it("allows timeoutMs and autoPause to be overridden", async () => { + const sandbox = createMockSandbox(); + e2bMocks.betaCreate.mockResolvedValue(sandbox); + + const provider = e2b({ + timeoutMs: 123_456, + autoPause: false, + }); + + await provider.create(); + + expect(e2bMocks.betaCreate).toHaveBeenCalledWith( + expect.objectContaining({ + autoPause: false, + timeoutMs: 123_456, + }), + ); + }); + + it("pauses by default in destroy and uses kill for permanent deletion", async () => { + const sandbox = createMockSandbox(); + e2bMocks.connect.mockResolvedValue(sandbox); + const provider = e2b(); + + await provider.destroy("sbx-123"); + expect(e2bMocks.connect).toHaveBeenLastCalledWith("sbx-123", { timeoutMs: 3_600_000 }); + expect(sandbox.betaPause).toHaveBeenCalledTimes(1); + expect(sandbox.kill).not.toHaveBeenCalled(); + + await provider.kill?.("sbx-123"); + expect(sandbox.kill).toHaveBeenCalledTimes(1); + }); + + it("maps missing reconnect targets to SandboxDestroyedError", async () => { + e2bMocks.connect.mockRejectedValue(new e2bMocks.MockNotFoundError("gone")); + const provider = e2b(); + + await expect(provider.reconnect?.("missing-sandbox")).rejects.toBeInstanceOf(SandboxDestroyedError); + }); +}); diff --git a/sdks/typescript/tests/providers.test.ts b/sdks/typescript/tests/providers.test.ts index 3376026..d98672d 100644 --- a/sdks/typescript/tests/providers.test.ts +++ b/sdks/typescript/tests/providers.test.ts @@ -291,7 +291,7 @@ function providerSuite(entry: ProviderEntry) { afterEach(async () => { if (!sdk) return; - await sdk.destroySandbox().catch(async () => { + await sdk.killSandbox().catch(async () => { await sdk?.dispose().catch(() => {}); }); sdk = undefined; @@ -364,6 +364,11 @@ function providerSuite(entry: ProviderEntry) { }); await expect(reconnected.listAgents()).rejects.toThrow(); } + + if (entry.name === "e2b") { + const rawSandboxId = sandboxId?.slice(sandboxId.indexOf("/") + 1); + await entry.createProvider().kill?.(rawSandboxId!); + } }, entry.startTimeoutMs, );