Merge pull request #269 from rivet-dev/e2b-base-image-support

feat(providers): add base image support and improve forward compatibility
This commit is contained in:
ABC 2026-03-25 00:42:49 -04:00 committed by GitHub
commit 32dd5914ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 240 additions and 38 deletions

View file

@ -27,7 +27,11 @@ if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY
const sdk = await SandboxAgent.start({ const sdk = await SandboxAgent.start({
sandbox: computesdk({ 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 `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: Before calling `SandboxAgent.start()`, configure ComputeSDK with your provider:

View file

@ -21,9 +21,11 @@ import { e2b } from "sandbox-agent/e2b";
const envs: 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.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; 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({ const sdk = await SandboxAgent.start({
sandbox: e2b({ sandbox: e2b({
template,
create: { envs }, 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. 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 ## Faster cold starts
For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. 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).

View file

@ -21,9 +21,11 @@ import { modal } from "sandbox-agent/modal";
const secrets: Record<string, string> = {}; const secrets: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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; 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({ const sdk = await SandboxAgent.start({
sandbox: modal({ sandbox: modal({
image: baseImage,
create: { secrets }, 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. 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 ## Faster cold starts

View file

@ -17,8 +17,10 @@ export async function setupE2BSandboxAgent(): Promise<{
token?: string; token?: string;
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
}> { }> {
const template = process.env.E2B_TEMPLATE;
const client = await SandboxAgent.start({ const client = await SandboxAgent.start({
sandbox: e2b({ sandbox: e2b({
template,
create: { envs: collectEnvVars() }, create: { envs: collectEnvVars() },
}), }),
}); });

View file

@ -5,10 +5,11 @@ import { detectAgent } from "@sandbox-agent/example-shared";
const envs: 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.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; 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({ const client = await SandboxAgent.start({
// ✨ NEW ✨ // ✨ NEW ✨
sandbox: e2b({ create: { envs } }), sandbox: e2b({ template, create: { envs } }),
}); });
const session = await client.createSession({ const session = await client.createSession({

View file

@ -1,25 +1,31 @@
import { compute } from "computesdk"; import { compute, type CreateSandboxOptions } from "computesdk";
import type { SandboxProvider } from "./types.ts"; import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000; const DEFAULT_AGENT_PORT = 3000;
type ComputeCreateOverrides = Partial<CreateSandboxOptions>;
export interface ComputeSdkProviderOptions { export interface ComputeSdkProviderOptions {
create?: { create?: ComputeCreateOverrides | (() => ComputeCreateOverrides | Promise<ComputeCreateOverrides>);
envs?: Record<string, string>;
};
agentPort?: number; agentPort?: number;
} }
async function resolveCreateOptions(value: ComputeSdkProviderOptions["create"]): Promise<ComputeCreateOverrides> {
if (!value) return {};
return typeof value === "function" ? await value() : value;
}
export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProvider { export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
return { return {
name: "computesdk", name: "computesdk",
async create(): Promise<string> { async create(): Promise<string> {
const envs = options.create?.envs; const createOpts = await resolveCreateOptions(options.create);
const sandbox = await compute.sandbox.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 }) => { const run = async (cmd: string, runOptions?: { background?: boolean }) => {

View file

@ -11,7 +11,7 @@ type DaytonaCreateOverrides = Partial<DaytonaCreateParams>;
export interface DaytonaProviderOptions { export interface DaytonaProviderOptions {
create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>); create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>);
image?: string; image?: DaytonaCreateParams["image"];
agentPort?: number; agentPort?: number;
previewTtlSeconds?: number; previewTtlSeconds?: number;
deleteTimeoutSeconds?: number; deleteTimeoutSeconds?: number;

View file

@ -8,10 +8,12 @@ const DEFAULT_TIMEOUT_MS = 3_600_000;
type E2BCreateOverrides = Omit<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">; type E2BCreateOverrides = Omit<Partial<SandboxBetaCreateOpts>, "timeoutMs" | "autoPause">;
type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">; type E2BConnectOverrides = Omit<Partial<SandboxConnectOpts>, "timeoutMs">;
type E2BTemplateOverride = string | (() => string | Promise<string>);
export interface E2BProviderOptions { export interface E2BProviderOptions {
create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise<E2BCreateOverrides>); create?: E2BCreateOverrides | (() => E2BCreateOverrides | Promise<E2BCreateOverrides>);
connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise<E2BConnectOverrides>); connect?: E2BConnectOverrides | ((sandboxId: string) => E2BConnectOverrides | Promise<E2BConnectOverrides>);
template?: E2BTemplateOverride;
agentPort?: number; agentPort?: number;
timeoutMs?: number; timeoutMs?: number;
autoPause?: boolean; autoPause?: boolean;
@ -28,6 +30,11 @@ async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderO
return value; return value;
} }
async function resolveTemplate(value: E2BTemplateOverride | undefined): Promise<string | undefined> {
if (!value) return undefined;
return typeof value === "function" ? await value() : value;
}
export function e2b(options: E2BProviderOptions = {}): SandboxProvider { export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@ -37,8 +44,16 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
name: "e2b", name: "e2b",
async create(): Promise<string> { async create(): Promise<string> {
const createOpts = await resolveOptions(options.create); 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 // 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) => { 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}`); if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`);

View file

@ -1,49 +1,58 @@
import { ModalClient } from "modal"; import { ModalClient, type Image, type SandboxCreateParams } from "modal";
import type { SandboxProvider } from "./types.ts"; import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; import { DEFAULT_SANDBOX_AGENT_IMAGE } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000; const DEFAULT_AGENT_PORT = 3000;
const DEFAULT_APP_NAME = "sandbox-agent"; const DEFAULT_APP_NAME = "sandbox-agent";
const DEFAULT_MEMORY_MIB = 2048; const DEFAULT_MEMORY_MIB = 2048;
type ModalCreateOverrides = Omit<Partial<SandboxCreateParams>, "secrets" | "encryptedPorts"> & {
secrets?: Record<string, string>;
encryptedPorts?: number[];
appName?: string;
};
export interface ModalProviderOptions { export interface ModalProviderOptions {
create?: { create?: ModalCreateOverrides | (() => ModalCreateOverrides | Promise<ModalCreateOverrides>);
secrets?: Record<string, string>; image?: string | Image;
appName?: string;
memoryMiB?: number;
};
agentPort?: number; agentPort?: number;
} }
async function resolveCreateOptions(value: ModalProviderOptions["create"]): Promise<ModalCreateOverrides> {
if (!value) return {};
return typeof value === "function" ? await value() : value;
}
export function modal(options: ModalProviderOptions = {}): SandboxProvider { export function modal(options: ModalProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; 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(); const client = new ModalClient();
return { return {
name: "modal", name: "modal",
async create(): Promise<string> { async create(): Promise<string> {
const createOpts = await resolveCreateOptions(options.create);
const appName = createOpts.appName ?? DEFAULT_APP_NAME;
const baseImage = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
const app = await client.apps.fromName(appName, { createIfMissing: true }); const app = await client.apps.fromName(appName, { createIfMissing: true });
// Pre-install sandbox-agent and agents in the image so they are cached // The default `-full` base image already includes sandbox-agent and all
// across sandbox creates and don't need to be installed at runtime. // agents pre-installed, so no additional dockerfile commands are needed.
const installAgentCmds = DEFAULT_AGENTS.map((agent) => `RUN sandbox-agent install-agent ${agent}`); const image = typeof baseImage === "string" ? client.images.fromRegistry(baseImage) : baseImage;
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 envVars = options.create?.secrets ?? {}; const envVars = createOpts.secrets ?? {};
const secrets = Object.keys(envVars).length > 0 ? [await client.secrets.fromObject(envVars)] : []; 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, { const sb = await client.sandboxes.create(app, image, {
encryptedPorts: [agentPort], ...sandboxCreateOpts,
encryptedPorts: [agentPort, ...extraPorts],
secrets, secrets,
memoryMiB, memoryMiB: sandboxCreateOpts.memoryMiB ?? DEFAULT_MEMORY_MIB,
}); });
// Start the server as a long-running exec process. We intentionally // Start the server as a long-running exec process. We intentionally

View file

@ -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", () => ({ vi.mock("@e2b/code-interpreter", () => ({
NotFoundError: e2bMocks.MockNotFoundError, NotFoundError: e2bMocks.MockNotFoundError,
Sandbox: { 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 { e2b } from "../src/providers/e2b.ts";
import { modal } from "../src/providers/modal.ts";
import { computesdk } from "../src/providers/computesdk.ts";
function createFetch(): typeof fetch { function createFetch(): typeof fetch {
return async () => new Response(null, { status: 200 }); 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", () => { describe("SandboxAgent provider lifecycle", () => {
it("reconnects an existing sandbox before ensureServer", async () => { it("reconnects an existing sandbox before ensureServer", async () => {
const order: string[] = []; const order: string[] = [];
@ -124,11 +180,6 @@ describe("SandboxAgent provider lifecycle", () => {
}); });
describe("e2b provider", () => { describe("e2b provider", () => {
beforeEach(() => {
e2bMocks.betaCreate.mockReset();
e2bMocks.connect.mockReset();
});
it("creates sandboxes with betaCreate, autoPause, and the default timeout", async () => { it("creates sandboxes with betaCreate, autoPause, and the default timeout", async () => {
const sandbox = createMockSandbox(); const sandbox = createMockSandbox();
e2bMocks.betaCreate.mockResolvedValue(sandbox); e2bMocks.betaCreate.mockResolvedValue(sandbox);
@ -190,4 +241,109 @@ describe("e2b provider", () => {
await expect(provider.reconnect?.("missing-sandbox")).rejects.toBeInstanceOf(SandboxDestroyedError); 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",
}),
);
});
}); });