Merge pull request #261 from rivet-dev/e2b-autopause-provider

feat: add E2B auto-pause provider lifecycle support
This commit is contained in:
Nathan Flurry 2026-03-16 15:39:45 -07:00 committed by GitHub
commit 9ce71c03c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 416 additions and 13 deletions

View file

@ -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

View file

@ -357,10 +357,10 @@ icon: "rocket"
<Step title="Clean up">
```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.
</Step>
<Step title="Inspect with the UI">

View file

@ -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

View file

@ -0,0 +1,33 @@
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
function collectEnvVars(): Record<string, string> {
const envVars: Record<string, string> = {};
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<string, string>;
cleanup: () => Promise<void>;
}> {
const client = await SandboxAgent.start({
sandbox: daytona({
create: { envVars: collectEnvVars() },
}),
});
return {
baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
cleanup: async () => {
await client.killSandbox();
},
};
}

32
examples/e2b/src/e2b.ts Normal file
View file

@ -0,0 +1,32 @@
import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
function collectEnvVars(): Record<string, string> {
const envs: Record<string, string> = {};
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<void>;
}> {
const client = await SandboxAgent.start({
sandbox: e2b({
create: { envs: collectEnvVars() },
}),
});
return {
baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
cleanup: async () => {
await client.killSandbox();
},
};
}

View file

@ -0,0 +1,35 @@
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
function collectEnvVars(): Record<string, string> {
const env: Record<string, string> = {};
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<void>;
}> {
const client = await SandboxAgent.start({
sandbox: vercel({
create: {
runtime: "node24",
env: collectEnvVars(),
},
}),
});
return {
baseUrl: inspectorUrlToBaseUrl(client.inspectorUrl),
cleanup: async () => {
await client.killSandbox();
},
};
}

View file

@ -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<void> {
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<void> {
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<ListPage<Session>> {
const page = await this.persist.listSessions(request);
return {

View file

@ -3,6 +3,7 @@ export {
ProcessTerminalSession,
SandboxAgent,
SandboxAgentError,
SandboxDestroyedError,
Session,
UnsupportedPermissionReplyError,
UnsupportedSessionCategoryError,

View file

@ -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<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">;
type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">;
export interface E2BProviderOptions {
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
connect?: Record<string, unknown> | ((sandboxId: string) => Record<string, unknown> | Promise<Record<string, unknown>>);
create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise<E2BCreateOverrides>);
connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise<E2BConnectOverrides>);
agentPort?: number;
timeoutMs?: number;
autoPause?: boolean;
}
async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise<Record<string, unknown>> {
@ -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<string> {
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<void> {
await this.pause?.(sandboxId);
},
async reconnect(sandboxId: string): Promise<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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 });
},
};

View file

@ -8,6 +8,25 @@ export interface SandboxProvider {
/** Permanently tear down a sandbox. */
destroy(sandboxId: string): Promise<void>;
/**
* 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<void>;
/**
* Gracefully stop or pause a sandbox without permanently deleting it.
* When omitted, callers should fall back to `destroy()`.
*/
pause?(sandboxId: string): Promise<void>;
/**
* Permanently delete a sandbox. When omitted, callers should fall back to
* `destroy()`.
*/
kill?(sandboxId: string): Promise<void>;
/**
* Return the sandbox-agent base URL for this sandbox.
* Providers that cannot expose a URL should implement `getFetch()` instead.

View file

@ -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> = {}): SandboxProvider {
return {
name: "mock",
async create(): Promise<string> {
return "created";
},
async destroy(): Promise<void> {},
async getUrl(): Promise<string> {
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);
});
});

View file

@ -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,
);