mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 11:04:58 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
32f3c6c3bc
commit
77c8f1e3f3
12 changed files with 416 additions and 13 deletions
193
sdks/typescript/tests/provider-lifecycle.test.ts
Normal file
193
sdks/typescript/tests/provider-lifecycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue