diff --git a/docs/deploy/computesdk.mdx b/docs/deploy/computesdk.mdx index 1adfffe..65000a6 100644 --- a/docs/deploy/computesdk.mdx +++ b/docs/deploy/computesdk.mdx @@ -27,7 +27,11 @@ if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY const sdk = await SandboxAgent.start({ sandbox: computesdk({ - create: { envs }, + create: { + envs, + image: process.env.COMPUTESDK_IMAGE, + templateId: process.env.COMPUTESDK_TEMPLATE_ID, + }, }), }); @@ -43,6 +47,7 @@ try { ``` The `computesdk` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. ComputeSDK routes to your configured provider behind the scenes. +The `create` option now forwards the full ComputeSDK sandbox-create payload, including provider-specific fields such as `image` and `templateId` when the selected provider supports them. Before calling `SandboxAgent.start()`, configure ComputeSDK with your provider: diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index e6465f2..30354a0 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -21,9 +21,11 @@ import { e2b } from "sandbox-agent/e2b"; 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; +const template = process.env.E2B_TEMPLATE; const sdk = await SandboxAgent.start({ sandbox: e2b({ + template, create: { envs }, }), }); @@ -41,7 +43,10 @@ try { 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. +Pass `template` when you want to start from a custom E2B template alias or template ID. E2B base-image selection happens when you build the template, then `sandbox-agent/e2b` uses that template at sandbox creation time. + ## Faster cold starts For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. -See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template). +Build System 2.0 also lets you choose the template's base image in code. +See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template) and [E2B Base Images](https://e2b.dev/docs/template/base-image). diff --git a/docs/deploy/modal.mdx b/docs/deploy/modal.mdx index 02a3828..dffd622 100644 --- a/docs/deploy/modal.mdx +++ b/docs/deploy/modal.mdx @@ -21,9 +21,11 @@ import { modal } from "sandbox-agent/modal"; const secrets: Record = {}; if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) secrets.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const baseImage = process.env.MODAL_BASE_IMAGE ?? "node:22-slim"; const sdk = await SandboxAgent.start({ sandbox: modal({ + image: baseImage, create: { secrets }, }), }); @@ -40,6 +42,7 @@ try { ``` The `modal` provider handles app creation, image building, sandbox provisioning, agent installation, server startup, and tunnel networking automatically. +Set `image` to change the base Docker image before Sandbox Agent and its agent binaries are layered on top. You can also pass a prebuilt Modal `Image` object. ## Faster cold starts diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/e2b.ts index bfd5bda..17762a2 100644 --- a/examples/e2b/src/e2b.ts +++ b/examples/e2b/src/e2b.ts @@ -17,8 +17,10 @@ export async function setupE2BSandboxAgent(): Promise<{ token?: string; cleanup: () => Promise; }> { + const template = process.env.E2B_TEMPLATE; const client = await SandboxAgent.start({ sandbox: e2b({ + template, create: { envs: collectEnvVars() }, }), }); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index c20ebaa..6eb79b7 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -5,10 +5,11 @@ import { detectAgent } from "@sandbox-agent/example-shared"; 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; +const template = process.env.E2B_TEMPLATE; const client = await SandboxAgent.start({ // ✨ NEW ✨ - sandbox: e2b({ create: { envs } }), + sandbox: e2b({ template, create: { envs } }), }); const session = await client.createSession({ diff --git a/sdks/typescript/src/providers/computesdk.ts b/sdks/typescript/src/providers/computesdk.ts index 7bca7ca..8cbef53 100644 --- a/sdks/typescript/src/providers/computesdk.ts +++ b/sdks/typescript/src/providers/computesdk.ts @@ -1,25 +1,31 @@ -import { compute } from "computesdk"; +import { compute, type CreateSandboxOptions } from "computesdk"; import type { SandboxProvider } from "./types.ts"; import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; const DEFAULT_AGENT_PORT = 3000; +type ComputeCreateOverrides = Partial; + export interface ComputeSdkProviderOptions { - create?: { - envs?: Record; - }; + create?: ComputeCreateOverrides | (() => ComputeCreateOverrides | Promise); agentPort?: number; } +async function resolveCreateOptions(value: ComputeSdkProviderOptions["create"]): Promise { + if (!value) return {}; + return typeof value === "function" ? await value() : value; +} + export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProvider { const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; return { name: "computesdk", async create(): Promise { - const envs = options.create?.envs; + const createOpts = await resolveCreateOptions(options.create); const sandbox = await compute.sandbox.create({ - envs: envs && Object.keys(envs).length > 0 ? envs : undefined, + ...createOpts, + envs: createOpts.envs && Object.keys(createOpts.envs).length > 0 ? createOpts.envs : undefined, }); const run = async (cmd: string, runOptions?: { background?: boolean }) => { diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts index 19026de..f614faf 100644 --- a/sdks/typescript/src/providers/daytona.ts +++ b/sdks/typescript/src/providers/daytona.ts @@ -11,7 +11,7 @@ type DaytonaCreateOverrides = Partial; export interface DaytonaProviderOptions { create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise); - image?: string; + image?: DaytonaCreateParams["image"]; agentPort?: number; previewTtlSeconds?: number; deleteTimeoutSeconds?: number; diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts index 8e99c64..54d2e28 100644 --- a/sdks/typescript/src/providers/e2b.ts +++ b/sdks/typescript/src/providers/e2b.ts @@ -8,10 +8,12 @@ const DEFAULT_TIMEOUT_MS = 3_600_000; type E2BCreateOverrides = Omit, "timeoutMs" | "autoPause">; type E2BConnectOverrides = Omit, "timeoutMs">; +type E2BTemplateOverride = string | (() => string | Promise); export interface E2BProviderOptions { create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise); connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise); + template?: E2BTemplateOverride; agentPort?: number; timeoutMs?: number; autoPause?: boolean; @@ -28,6 +30,11 @@ async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderO return value; } +async function resolveTemplate(value: E2BTemplateOverride | undefined): Promise { + if (!value) return undefined; + return typeof value === "function" ? await value() : value; +} + export function e2b(options: E2BProviderOptions = {}): SandboxProvider { const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -37,8 +44,16 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider { name: "e2b", async create(): Promise { const createOpts = await resolveOptions(options.create); + const rawTemplate = typeof createOpts.template === "string" ? createOpts.template : undefined; + const restCreateOpts = { ...createOpts }; + delete restCreateOpts.template; + const template = (await resolveTemplate(options.template)) ?? rawTemplate; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sandbox = await Sandbox.betaCreate({ allowInternetAccess: true, ...createOpts, timeoutMs, autoPause } as any); + const sandbox = template + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Sandbox.betaCreate(template, { allowInternetAccess: true, ...restCreateOpts, timeoutMs, autoPause } as any) + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Sandbox.betaCreate({ allowInternetAccess: true, ...restCreateOpts, 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}`); diff --git a/sdks/typescript/src/providers/modal.ts b/sdks/typescript/src/providers/modal.ts index 394272b..4e193c8 100644 --- a/sdks/typescript/src/providers/modal.ts +++ b/sdks/typescript/src/providers/modal.ts @@ -1,49 +1,64 @@ -import { ModalClient } from "modal"; +import { ModalClient, type Image, type SandboxCreateParams } from "modal"; import type { SandboxProvider } from "./types.ts"; import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; const DEFAULT_AGENT_PORT = 3000; const DEFAULT_APP_NAME = "sandbox-agent"; const DEFAULT_MEMORY_MIB = 2048; +const DEFAULT_BASE_IMAGE = "node:22-slim"; + +type ModalCreateOverrides = Omit, "secrets" | "encryptedPorts"> & { + secrets?: Record; + encryptedPorts?: number[]; + appName?: string; +}; export interface ModalProviderOptions { - create?: { - secrets?: Record; - appName?: string; - memoryMiB?: number; - }; + create?: ModalCreateOverrides | (() => ModalCreateOverrides | Promise); + image?: string | Image; agentPort?: number; } +async function resolveCreateOptions(value: ModalProviderOptions["create"]): Promise { + if (!value) return {}; + return typeof value === "function" ? await value() : value; +} + export function modal(options: ModalProviderOptions = {}): SandboxProvider { const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; - const appName = options.create?.appName ?? DEFAULT_APP_NAME; - const memoryMiB = options.create?.memoryMiB ?? DEFAULT_MEMORY_MIB; const client = new ModalClient(); return { name: "modal", async create(): Promise { + const createOpts = await resolveCreateOptions(options.create); + const appName = createOpts.appName ?? DEFAULT_APP_NAME; + const baseImage = options.image ?? DEFAULT_BASE_IMAGE; const app = await client.apps.fromName(appName, { createIfMissing: true }); // Pre-install sandbox-agent and agents in the image so they are cached // across sandbox creates and don't need to be installed at runtime. const installAgentCmds = DEFAULT_AGENTS.map((agent) => `RUN sandbox-agent install-agent ${agent}`); - const image = client.images - .fromRegistry("node:22-slim") - .dockerfileCommands([ - "RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*", - `RUN curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`, - ...installAgentCmds, - ]); + const image = (typeof baseImage === "string" ? client.images.fromRegistry(baseImage) : baseImage).dockerfileCommands([ + "RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*", + `RUN curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`, + ...installAgentCmds, + ]); - const envVars = options.create?.secrets ?? {}; + const envVars = createOpts.secrets ?? {}; const secrets = Object.keys(envVars).length > 0 ? [await client.secrets.fromObject(envVars)] : []; + const sandboxCreateOpts = { ...createOpts }; + delete sandboxCreateOpts.appName; + delete sandboxCreateOpts.secrets; + + const extraPorts = createOpts.encryptedPorts ?? []; + delete sandboxCreateOpts.encryptedPorts; const sb = await client.sandboxes.create(app, image, { - encryptedPorts: [agentPort], + ...sandboxCreateOpts, + encryptedPorts: [agentPort, ...extraPorts], secrets, - memoryMiB, + memoryMiB: sandboxCreateOpts.memoryMiB ?? DEFAULT_MEMORY_MIB, }); // Start the server as a long-running exec process. We intentionally diff --git a/sdks/typescript/tests/provider-lifecycle.test.ts b/sdks/typescript/tests/provider-lifecycle.test.ts index 06c85f5..8c3d397 100644 --- a/sdks/typescript/tests/provider-lifecycle.test.ts +++ b/sdks/typescript/tests/provider-lifecycle.test.ts @@ -16,6 +16,19 @@ const e2bMocks = vi.hoisted(() => { }; }); +const modalMocks = vi.hoisted(() => ({ + appsFromName: vi.fn(), + imageFromRegistry: vi.fn(), + secretFromObject: vi.fn(), + sandboxCreate: vi.fn(), + sandboxFromId: vi.fn(), +})); + +const computeSdkMocks = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + vi.mock("@e2b/code-interpreter", () => ({ NotFoundError: e2bMocks.MockNotFoundError, Sandbox: { @@ -24,7 +37,30 @@ vi.mock("@e2b/code-interpreter", () => ({ }, })); +vi.mock("modal", () => ({ + ModalClient: class MockModalClient { + apps = { fromName: modalMocks.appsFromName }; + images = { fromRegistry: modalMocks.imageFromRegistry }; + secrets = { fromObject: modalMocks.secretFromObject }; + sandboxes = { + create: modalMocks.sandboxCreate, + fromId: modalMocks.sandboxFromId, + }; + }, +})); + +vi.mock("computesdk", () => ({ + compute: { + sandbox: { + create: computeSdkMocks.create, + getById: computeSdkMocks.getById, + }, + }, +})); + import { e2b } from "../src/providers/e2b.ts"; +import { modal } from "../src/providers/modal.ts"; +import { computesdk } from "../src/providers/computesdk.ts"; function createFetch(): typeof fetch { return async () => new Response(null, { status: 200 }); @@ -56,6 +92,26 @@ function createMockSandbox() { }; } +function createMockModalImage() { + return { + dockerfileCommands: vi.fn(function dockerfileCommands() { + return this; + }), + }; +} + +beforeEach(() => { + e2bMocks.betaCreate.mockReset(); + e2bMocks.connect.mockReset(); + modalMocks.appsFromName.mockReset(); + modalMocks.imageFromRegistry.mockReset(); + modalMocks.secretFromObject.mockReset(); + modalMocks.sandboxCreate.mockReset(); + modalMocks.sandboxFromId.mockReset(); + computeSdkMocks.create.mockReset(); + computeSdkMocks.getById.mockReset(); +}); + describe("SandboxAgent provider lifecycle", () => { it("reconnects an existing sandbox before ensureServer", async () => { const order: string[] = []; @@ -124,11 +180,6 @@ describe("SandboxAgent provider lifecycle", () => { }); 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); @@ -190,4 +241,109 @@ describe("e2b provider", () => { await expect(provider.reconnect?.("missing-sandbox")).rejects.toBeInstanceOf(SandboxDestroyedError); }); + + it("passes a configured template to betaCreate", async () => { + const sandbox = createMockSandbox(); + e2bMocks.betaCreate.mockResolvedValue(sandbox); + + const provider = e2b({ + template: "my-template", + create: { envs: { ANTHROPIC_API_KEY: "test" } }, + }); + + await provider.create(); + + expect(e2bMocks.betaCreate).toHaveBeenCalledWith( + "my-template", + expect.objectContaining({ + allowInternetAccess: true, + envs: { ANTHROPIC_API_KEY: "test" }, + timeoutMs: 3_600_000, + }), + ); + }); + + it("accepts legacy create.template values from plain JavaScript", async () => { + const sandbox = createMockSandbox(); + e2bMocks.betaCreate.mockResolvedValue(sandbox); + + const provider = e2b({ + create: { template: "legacy-template" } as never, + }); + + await provider.create(); + + expect(e2bMocks.betaCreate).toHaveBeenCalledWith( + "legacy-template", + expect.objectContaining({ + allowInternetAccess: true, + timeoutMs: 3_600_000, + }), + ); + }); +}); + +describe("modal provider", () => { + it("uses the configured base image when building the sandbox image", async () => { + const app = { appId: "app-123" }; + const image = createMockModalImage(); + const sandbox = { + sandboxId: "sbx-modal", + exec: vi.fn(), + }; + + modalMocks.appsFromName.mockResolvedValue(app); + modalMocks.imageFromRegistry.mockReturnValue(image); + modalMocks.sandboxCreate.mockResolvedValue(sandbox); + + const provider = modal({ + image: "python:3.12-slim", + create: { + appName: "custom-app", + secrets: { OPENAI_API_KEY: "test" }, + }, + }); + + await expect(provider.create()).resolves.toBe("sbx-modal"); + + expect(modalMocks.appsFromName).toHaveBeenCalledWith("custom-app", { createIfMissing: true }); + expect(modalMocks.imageFromRegistry).toHaveBeenCalledWith("python:3.12-slim"); + expect(image.dockerfileCommands).toHaveBeenCalled(); + expect(modalMocks.sandboxCreate).toHaveBeenCalledWith( + app, + image, + expect.objectContaining({ + encryptedPorts: [3000], + memoryMiB: 2048, + }), + ); + }); +}); + +describe("computesdk provider", () => { + it("passes image and template options through to compute.sandbox.create", async () => { + const sandbox = { + sandboxId: "sbx-compute", + runCommand: vi.fn(async () => ({ exitCode: 0, stderr: "" })), + }; + computeSdkMocks.create.mockResolvedValue(sandbox); + + const provider = computesdk({ + create: { + envs: { ANTHROPIC_API_KEY: "test" }, + image: "ghcr.io/example/sandbox-agent:latest", + templateId: "tmpl-123", + }, + }); + + await expect(provider.create()).resolves.toBe("sbx-compute"); + + expect(computeSdkMocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + envs: { ANTHROPIC_API_KEY: "test" }, + image: "ghcr.io/example/sandbox-agent:latest", + templateId: "tmpl-123", + }), + ); + }); });