SDK: Add ensureServer() for automatic server recovery (#260)

* SDK sandbox provisioning: built-in providers, docs restructure, and quickstart overhaul

- Add built-in sandbox providers (local, docker, e2b, daytona, vercel, cloudflare) to the TypeScript SDK so users import directly instead of passing client instances
- Restructure docs: rename architecture to orchestration-architecture, add new architecture page for server overview, improve getting started flow
- Rewrite quickstart to be TypeScript-first with provider CodeGroup and custom provider accordion
- Update all examples to use new provider APIs
- Update persist drivers and foundry for new SDK surface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix SDK typecheck errors and update persist drivers for insertEvent signature

- Fix insertEvent call in client.ts to pass sessionId as first argument
- Update Daytona provider create options to use Partial type (image has default)
- Update StrictUniqueSessionPersistDriver in tests to match new insertEvent signature
- Sync persist packages, openapi spec, and docs with upstream changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Modal and ComputeSDK built-in providers, update examples and docs

- Add `sandbox-agent/modal` provider using Modal SDK with node:22-slim image
- Add `sandbox-agent/computesdk` provider using ComputeSDK's unified sandbox API
- Update Modal and ComputeSDK examples to use new SDK providers
- Update Modal and ComputeSDK deploy docs with provider-based examples
- Add Modal to quickstart CodeGroup and docs.json navigation
- Add provider test entries for Modal and ComputeSDK
- Remove old standalone example files (modal.ts, computesdk.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Modal provider: pre-install agents in image, fire-and-forget exec for server

- Pre-install agents in Dockerfile commands so they are cached across creates
- Use fire-and-forget exec (no wait) to keep server alive in Modal sandbox
- Add memoryMiB option (default 2GB) to avoid OOM during agent install

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Sync upstream changes: multiplayer docs, logos, openapi spec, foundry config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* SDK: Add ensureServer() for automatic server recovery

Add ensureServer() to SandboxProvider interface to handle cases where the
sandbox-agent server stops or goes to sleep. The SDK now calls this method
after 3 consecutive health-check failures, allowing providers to restart the
server if needed. Most built-in providers (E2B, Daytona, Vercel, Modal,
ComputeSDK) implement this. Docker and Cloudflare manage server lifecycle
differently, and Local uses managed child processes.

Also update docs for quickstart, architecture, multiplayer, and session
persistence; mark persist-* packages as deprecated; and add ensureServer
implementations to all applicable providers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* wip

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-15 20:29:28 -07:00 committed by GitHub
parent 3426cbc6ec
commit cf7e2a92c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 3739 additions and 3537 deletions

View file

@ -14,6 +14,74 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./local": {
"types": "./dist/providers/local.d.ts",
"import": "./dist/providers/local.js"
},
"./e2b": {
"types": "./dist/providers/e2b.d.ts",
"import": "./dist/providers/e2b.js"
},
"./daytona": {
"types": "./dist/providers/daytona.d.ts",
"import": "./dist/providers/daytona.js"
},
"./docker": {
"types": "./dist/providers/docker.d.ts",
"import": "./dist/providers/docker.js"
},
"./vercel": {
"types": "./dist/providers/vercel.d.ts",
"import": "./dist/providers/vercel.js"
},
"./cloudflare": {
"types": "./dist/providers/cloudflare.d.ts",
"import": "./dist/providers/cloudflare.js"
},
"./modal": {
"types": "./dist/providers/modal.d.ts",
"import": "./dist/providers/modal.js"
},
"./computesdk": {
"types": "./dist/providers/computesdk.d.ts",
"import": "./dist/providers/computesdk.js"
}
},
"peerDependencies": {
"@cloudflare/sandbox": ">=0.1.0",
"@daytonaio/sdk": ">=0.12.0",
"@e2b/code-interpreter": ">=1.0.0",
"@vercel/sandbox": ">=0.1.0",
"dockerode": ">=4.0.0",
"get-port": ">=7.0.0",
"modal": ">=0.1.0",
"computesdk": ">=0.1.0"
},
"peerDependenciesMeta": {
"@cloudflare/sandbox": {
"optional": true
},
"@daytonaio/sdk": {
"optional": true
},
"@e2b/code-interpreter": {
"optional": true
},
"@vercel/sandbox": {
"optional": true
},
"dockerode": {
"optional": true
},
"get-port": {
"optional": true
},
"modal": {
"optional": true
},
"computesdk": {
"optional": true
}
},
"dependencies": {
@ -33,8 +101,17 @@
"test:watch": "vitest"
},
"devDependencies": {
"@cloudflare/sandbox": ">=0.1.0",
"@daytonaio/sdk": ">=0.12.0",
"@e2b/code-interpreter": ">=1.0.0",
"@types/dockerode": "^4.0.0",
"@types/node": "^22.0.0",
"@types/ws": "^8.18.1",
"@vercel/sandbox": ">=0.1.0",
"dockerode": ">=4.0.0",
"get-port": ">=7.0.0",
"modal": ">=0.1.0",
"computesdk": ">=0.1.0",
"openapi-typescript": "^6.7.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",

View file

@ -22,7 +22,7 @@ import {
type SetSessionModeResponse,
type SetSessionModeRequest,
} from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
import type { SandboxProvider } from "./providers/types.ts";
import {
type AcpServerListResponse,
type AgentInfo,
@ -89,6 +89,7 @@ const HEALTH_WAIT_MIN_DELAY_MS = 500;
const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
const HEALTH_WAIT_LOG_EVERY_MS = 10_000;
const HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES = 3;
export interface SandboxAgentHealthWaitOptions {
timeoutMs?: number;
@ -101,6 +102,8 @@ interface SandboxAgentConnectCommonOptions {
replayMaxChars?: number;
signal?: AbortSignal;
token?: string;
skipHealthCheck?: boolean;
/** @deprecated Use skipHealthCheck instead. */
waitForHealth?: boolean | SandboxAgentHealthWaitOptions;
}
@ -115,17 +118,24 @@ export type SandboxAgentConnectOptions =
});
export interface SandboxAgentStartOptions {
sandbox: SandboxProvider;
sandboxId?: string;
skipHealthCheck?: boolean;
fetch?: typeof fetch;
headers?: HeadersInit;
persist?: SessionPersistDriver;
replayMaxEvents?: number;
replayMaxChars?: number;
spawn?: SandboxAgentSpawnOptions | boolean;
signal?: AbortSignal;
token?: string;
}
export interface SessionCreateRequest {
id?: string;
agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
@ -135,6 +145,9 @@ export interface SessionCreateRequest {
export interface SessionResumeOrCreateRequest {
id: string;
agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
@ -824,12 +837,14 @@ export class SandboxAgent {
private readonly defaultHeaders?: HeadersInit;
private readonly healthWait: NormalizedHealthWaitOptions;
private readonly healthWaitAbortController = new AbortController();
private sandboxProvider?: SandboxProvider;
private sandboxProviderId?: string;
private sandboxProviderRawId?: string;
private readonly persist: SessionPersistDriver;
private readonly replayMaxEvents: number;
private readonly replayMaxChars: number;
private spawnHandle?: SandboxAgentSpawnHandle;
private healthPromise?: Promise<void>;
private healthError?: Error;
private disposed = false;
@ -857,7 +872,7 @@ export class SandboxAgent {
}
this.fetcher = resolvedFetch;
this.defaultHeaders = options.headers;
this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal);
this.healthWait = normalizeHealthWaitOptions(options.skipHealthCheck, options.waitForHealth, options.signal);
this.persist = options.persist ?? new InMemorySessionPersistDriver();
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
@ -870,29 +885,79 @@ export class SandboxAgent {
return new SandboxAgent(options);
}
static async start(options: SandboxAgentStartOptions = {}): Promise<SandboxAgent> {
const spawnOptions = normalizeSpawnOptions(options.spawn, true);
if (!spawnOptions.enabled) {
throw new Error("SandboxAgent.start requires spawn to be enabled.");
static async start(options: SandboxAgentStartOptions): Promise<SandboxAgent> {
const provider = options.sandbox;
if (!provider.getUrl && !provider.getFetch) {
throw new Error(`Sandbox provider '${provider.name}' must implement getUrl() or getFetch().`);
}
const { spawnSandboxAgent } = await import("./spawn.js");
const resolvedFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
const handle = await spawnSandboxAgent(spawnOptions, resolvedFetch);
const existingSandbox = options.sandboxId ? parseSandboxProviderId(options.sandboxId) : null;
const client = new SandboxAgent({
baseUrl: handle.baseUrl,
token: handle.token,
fetch: options.fetch,
headers: options.headers,
waitForHealth: false,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
});
if (existingSandbox && existingSandbox.provider !== provider.name) {
throw new Error(
`SandboxAgent.start received sandboxId '${options.sandboxId}' for provider '${existingSandbox.provider}', but the configured provider is '${provider.name}'.`,
);
}
client.spawnHandle = handle;
return client;
const rawSandboxId = existingSandbox?.rawId ?? (await provider.create());
const prefixedSandboxId = `${provider.name}/${rawSandboxId}`;
const createdSandbox = !existingSandbox;
if (existingSandbox) {
await provider.ensureServer?.(rawSandboxId);
}
try {
const fetcher = await resolveProviderFetch(provider, rawSandboxId);
const baseUrl = provider.getUrl ? await provider.getUrl(rawSandboxId) : undefined;
const providerFetch = options.fetch ?? fetcher;
const commonConnectOptions = {
headers: options.headers,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
signal: options.signal,
skipHealthCheck: options.skipHealthCheck,
token: options.token ?? (await resolveProviderToken(provider, rawSandboxId)),
};
const client = providerFetch
? new SandboxAgent({
...commonConnectOptions,
baseUrl,
fetch: providerFetch,
})
: new SandboxAgent({
...commonConnectOptions,
baseUrl: requireSandboxBaseUrl(baseUrl, provider.name),
});
client.sandboxProvider = provider;
client.sandboxProviderId = prefixedSandboxId;
client.sandboxProviderRawId = rawSandboxId;
return client;
} catch (error) {
if (createdSandbox) {
try {
await provider.destroy(rawSandboxId);
} catch {
// Best-effort cleanup if connect fails after provisioning.
}
}
throw error;
}
}
get sandboxId(): string | undefined {
return this.sandboxProviderId;
}
get sandbox(): SandboxProvider | undefined {
return this.sandboxProvider;
}
get inspectorUrl(): string {
return `${this.baseUrl.replace(/\/+$/, "")}/ui/`;
}
async dispose(): Promise<void> {
@ -922,10 +987,23 @@ export class SandboxAgent {
await connection.close();
}),
);
}
if (this.spawnHandle) {
await this.spawnHandle.dispose();
this.spawnHandle = undefined;
async destroySandbox(): Promise<void> {
const provider = this.sandboxProvider;
const rawSandboxId = this.sandboxProviderRawId;
try {
if (provider && rawSandboxId) {
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;
}
}
@ -956,7 +1034,7 @@ export class SandboxAgent {
const localSessionId = request.id?.trim() || randomId();
const live = await this.getLiveConnection(request.agent.trim());
const sessionInit = normalizeSessionInit(request.sessionInit);
const sessionInit = normalizeSessionInit(request.sessionInit, request.cwd);
const response = await live.createRemoteSession(localSessionId, sessionInit);
@ -966,6 +1044,7 @@ export class SandboxAgent {
agentSessionId: response.sessionId,
lastConnectionId: live.connectionId,
createdAt: nowMs(),
sandboxId: this.sandboxProviderId,
sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
@ -1692,7 +1771,7 @@ export class SandboxAgent {
};
try {
await this.persist.insertEvent(event);
await this.persist.insertEvent(localSessionId, event);
break;
} catch (error) {
if (!isSessionEventIndexConflict(error) || attempt === MAX_EVENT_INDEX_INSERT_RETRIES - 1) {
@ -2040,6 +2119,7 @@ export class SandboxAgent {
let delayMs = HEALTH_WAIT_MIN_DELAY_MS;
let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS;
let lastError: unknown;
let consecutiveFailures = 0;
while (!this.disposed && (deadline === undefined || Date.now() < deadline)) {
throwIfAborted(signal);
@ -2050,11 +2130,22 @@ export class SandboxAgent {
return;
}
lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`);
consecutiveFailures++;
} catch (error) {
if (isAbortError(error)) {
throw error;
}
lastError = error;
consecutiveFailures++;
}
if (consecutiveFailures >= HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES && this.sandboxProvider?.ensureServer && this.sandboxProviderRawId) {
try {
await this.sandboxProvider.ensureServer(this.sandboxProviderRawId);
} catch {
// Best-effort; the next health check will determine if it worked.
}
consecutiveFailures = 0;
}
const now = Date.now();
@ -2255,17 +2346,17 @@ function toAgentQuery(options: AgentQueryOptions | undefined): Record<string, Qu
};
}
function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined): Omit<NewSessionRequest, "_meta"> {
function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined, cwdShorthand?: string): Omit<NewSessionRequest, "_meta"> {
if (!value) {
return {
cwd: defaultCwd(),
cwd: cwdShorthand ?? defaultCwd(),
mcpServers: [],
};
}
return {
...value,
cwd: value.cwd ?? defaultCwd(),
cwd: value.cwd ?? cwdShorthand ?? defaultCwd(),
mcpServers: value.mcpServers ?? [],
};
}
@ -2405,16 +2496,23 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb
return Math.floor(value as number);
}
function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptions | undefined, signal: AbortSignal | undefined): NormalizedHealthWaitOptions {
if (value === false) {
function normalizeHealthWaitOptions(
skipHealthCheck: boolean | undefined,
waitForHealth: boolean | SandboxAgentHealthWaitOptions | undefined,
signal: AbortSignal | undefined,
): NormalizedHealthWaitOptions {
if (skipHealthCheck === true || waitForHealth === false) {
return { enabled: false };
}
if (value === true || value === undefined) {
if (waitForHealth === true || waitForHealth === undefined) {
return { enabled: true, signal };
}
const timeoutMs = typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 ? Math.floor(value.timeoutMs) : undefined;
const timeoutMs =
typeof waitForHealth.timeoutMs === "number" && Number.isFinite(waitForHealth.timeoutMs) && waitForHealth.timeoutMs > 0
? Math.floor(waitForHealth.timeoutMs)
: undefined;
return {
enabled: true,
@ -2423,24 +2521,47 @@ function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptio
};
}
function normalizeSpawnOptions(
spawn: SandboxAgentSpawnOptions | boolean | undefined,
defaultEnabled: boolean,
): SandboxAgentSpawnOptions & { enabled: boolean } {
if (spawn === false) {
return { enabled: false };
}
if (spawn === true || spawn === undefined) {
return { enabled: defaultEnabled };
function parseSandboxProviderId(sandboxId: string): { provider: string; rawId: string } {
const slashIndex = sandboxId.indexOf("/");
if (slashIndex < 1 || slashIndex === sandboxId.length - 1) {
throw new Error(`Sandbox IDs must be prefixed as "{provider}/{id}". Received '${sandboxId}'.`);
}
return {
...spawn,
enabled: spawn.enabled ?? defaultEnabled,
provider: sandboxId.slice(0, slashIndex),
rawId: sandboxId.slice(slashIndex + 1),
};
}
function requireSandboxBaseUrl(baseUrl: string | undefined, providerName: string): string {
if (!baseUrl) {
throw new Error(`Sandbox provider '${providerName}' did not return a base URL.`);
}
return baseUrl;
}
async function resolveProviderFetch(provider: SandboxProvider, rawSandboxId: string): Promise<typeof globalThis.fetch | undefined> {
if (provider.getFetch) {
return await provider.getFetch(rawSandboxId);
}
return undefined;
}
async function resolveProviderToken(provider: SandboxProvider, rawSandboxId: string): Promise<string | undefined> {
const maybeGetToken = (
provider as SandboxProvider & {
getToken?: (sandboxId: string) => string | undefined | Promise<string | undefined>;
}
).getToken;
if (typeof maybeGetToken !== "function") {
return undefined;
}
const token = await maybeGetToken.call(provider, rawSandboxId);
return typeof token === "string" && token ? token : undefined;
}
async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
try {
const text = await response.clone().text();

View file

@ -38,6 +38,7 @@ export type {
export type { InspectorUrlOptions } from "./inspector.ts";
export { InMemorySessionPersistDriver } from "./types.ts";
export type { SandboxProvider } from "./providers/types.ts";
export type {
AcpEnvelope,

View file

@ -0,0 +1,79 @@
import type { SandboxProvider } from "./types.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface CloudflareSandboxClient {
create?(options?: Record<string, unknown>): Promise<{ id?: string; sandboxId?: string }>;
connect?(
sandboxId: string,
options?: Record<string, unknown>,
): Promise<{
close?(): Promise<void>;
stop?(): Promise<void>;
containerFetch(input: RequestInfo | URL, init?: RequestInit, port?: number): Promise<Response>;
}>;
}
export interface CloudflareProviderOptions {
sdk: CloudflareSandboxClient;
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
agentPort?: number;
}
async function resolveCreateOptions(value: CloudflareProviderOptions["create"]): Promise<Record<string, unknown>> {
if (!value) {
return {};
}
if (typeof value === "function") {
return await value();
}
return value;
}
export function cloudflare(options: CloudflareProviderOptions): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const sdk = options.sdk;
return {
name: "cloudflare",
async create(): Promise<string> {
if (typeof sdk.create !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `create()` method.');
}
const sandbox = await sdk.create(await resolveCreateOptions(options.create));
const sandboxId = sandbox.sandboxId ?? sandbox.id;
if (!sandboxId) {
throw new Error("cloudflare sandbox did not return an id");
}
return sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
if (typeof sdk.connect !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.');
}
const sandbox = await sdk.connect(sandboxId);
if (typeof sandbox.close === "function") {
await sandbox.close();
return;
}
if (typeof sandbox.stop === "function") {
await sandbox.stop();
}
},
async getFetch(sandboxId: string): Promise<typeof globalThis.fetch> {
if (typeof sdk.connect !== "function") {
throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.');
}
const sandbox = await sdk.connect(sandboxId);
return async (input, init) =>
sandbox.containerFetch(
input,
{
...(init ?? {}),
signal: undefined,
},
agentPort,
);
},
};
}

View file

@ -0,0 +1,60 @@
import { compute } from "computesdk";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface ComputeSdkProviderOptions {
create?: {
envs?: Record<string, string>;
};
agentPort?: number;
}
export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
return {
name: "computesdk",
async create(): Promise<string> {
const envs = options.create?.envs;
const sandbox = await compute.sandbox.create({
envs: envs && Object.keys(envs).length > 0 ? envs : undefined,
});
const run = async (cmd: string, runOptions?: { background?: boolean }) => {
const result = await sandbox.runCommand(cmd, runOptions);
if (typeof result?.exitCode === "number" && result.exitCode !== 0) {
throw new Error(`computesdk command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`);
}
return result;
};
await run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`);
for (const agent of DEFAULT_AGENTS) {
await run(`sandbox-agent install-agent ${agent}`);
}
await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, {
background: true,
});
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const sandbox = await compute.sandbox.getById(sandboxId);
if (sandbox) await sandbox.destroy();
},
async getUrl(sandboxId: string): Promise<string> {
const sandbox = await compute.sandbox.getById(sandboxId);
if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`);
return sandbox.getUrl({ port: agentPort });
},
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await compute.sandbox.getById(sandboxId);
if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`);
await sandbox.runCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, {
background: true,
});
},
};
}

View file

@ -0,0 +1,67 @@
import { Daytona } from "@daytonaio/sdk";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
type DaytonaCreateParams = NonNullable<Parameters<Daytona["create"]>[0]>;
type DaytonaCreateOverrides = Partial<DaytonaCreateParams>;
export interface DaytonaProviderOptions {
create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise<DaytonaCreateOverrides>);
image?: string;
agentPort?: number;
previewTtlSeconds?: number;
deleteTimeoutSeconds?: number;
}
async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise<DaytonaCreateOverrides | undefined> {
if (!value) return undefined;
if (typeof value === "function") return await value();
return value;
}
export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
const client = new Daytona();
return {
name: "daytona",
async create(): Promise<string> {
const createOpts = await resolveCreateOptions(options.create);
const sandbox = await client.create({
image,
autoStopInterval: 0,
...createOpts,
} as DaytonaCreateParams);
await sandbox.process.executeCommand(buildServerStartCommand(agentPort));
return sandbox.id;
},
async destroy(sandboxId: string): Promise<void> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
return;
}
await sandbox.delete(options.deleteTimeoutSeconds);
},
async getUrl(sandboxId: string): Promise<string> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
throw new Error(`daytona sandbox not found: ${sandboxId}`);
}
const preview = await sandbox.getSignedPreviewUrl(agentPort, previewTtlSeconds);
return typeof preview === "string" ? preview : preview.url;
},
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await client.get(sandboxId);
if (!sandbox) {
throw new Error(`daytona sandbox not found: ${sandboxId}`);
}
await sandbox.process.executeCommand(buildServerStartCommand(agentPort));
},
};
}

View file

@ -0,0 +1,85 @@
import Docker from "dockerode";
import getPort from "get-port";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_SANDBOX_AGENT_IMAGE } from "./shared.ts";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_AGENT_PORT = 3000;
export interface DockerProviderOptions {
image?: string;
host?: string;
agentPort?: number;
env?: string[] | (() => string[] | Promise<string[]>);
binds?: string[] | (() => string[] | Promise<string[]>);
createContainerOptions?: Record<string, unknown>;
}
async function resolveValue<T>(value: T | (() => T | Promise<T>) | undefined, fallback: T): Promise<T> {
if (value === undefined) {
return fallback;
}
if (typeof value === "function") {
return await (value as () => T | Promise<T>)();
}
return value;
}
function extractMappedPort(
inspect: { NetworkSettings?: { Ports?: Record<string, Array<{ HostPort?: string }> | null | undefined> } },
containerPort: number,
): number {
const hostPort = inspect.NetworkSettings?.Ports?.[`${containerPort}/tcp`]?.[0]?.HostPort;
if (!hostPort) {
throw new Error(`docker sandbox-agent port ${containerPort} is not published`);
}
return Number(hostPort);
}
export function docker(options: DockerProviderOptions = {}): SandboxProvider {
const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE;
const host = options.host ?? DEFAULT_HOST;
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
const client = new Docker({ socketPath: "/var/run/docker.sock" });
return {
name: "docker",
async create(): Promise<string> {
const hostPort = await getPort();
const env = await resolveValue(options.env, []);
const binds = await resolveValue(options.binds, []);
const container = await client.createContainer({
Image: image,
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
Env: env,
ExposedPorts: { [`${agentPort}/tcp`]: {} },
HostConfig: {
AutoRemove: true,
Binds: binds,
PortBindings: {
[`${agentPort}/tcp`]: [{ HostPort: String(hostPort) }],
},
},
...(options.createContainerOptions ?? {}),
});
await container.start();
return container.id;
},
async destroy(sandboxId: string): Promise<void> {
const container = client.getContainer(sandboxId);
try {
await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
},
async getUrl(sandboxId: string): Promise<string> {
const container = client.getContainer(sandboxId);
const hostPort = extractMappedPort(await container.inspect(), agentPort);
return `http://${host}:${hostPort}`;
},
};
}

View file

@ -0,0 +1,62 @@
import { Sandbox } from "@e2b/code-interpreter";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
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>>);
agentPort?: number;
}
async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise<Record<string, unknown>> {
if (!value) return {};
if (typeof value === "function") {
if (sandboxId) {
return await (value as (id: string) => Record<string, unknown> | Promise<Record<string, unknown>>)(sandboxId);
}
return await (value as () => Record<string, unknown> | Promise<Record<string, unknown>>)();
}
return value;
}
export function e2b(options: E2BProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
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);
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}`);
});
for (const agent of DEFAULT_AGENTS) {
await sandbox.commands.run(`sandbox-agent install-agent ${agent}`).then((r) => {
if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`);
});
}
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const connectOpts = await resolveOptions(options.connect, sandboxId);
const sandbox = await Sandbox.connect(sandboxId, connectOpts as any);
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);
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);
await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 });
},
};
}

View file

@ -0,0 +1,84 @@
import { spawnSandboxAgent, type SandboxAgentSpawnHandle, type SandboxAgentSpawnLogMode, type SandboxAgentSpawnOptions } from "../spawn.ts";
import type { SandboxProvider } from "./types.ts";
export interface LocalProviderOptions {
host?: string;
port?: number;
token?: string;
binaryPath?: string;
log?: SandboxAgentSpawnLogMode;
env?: Record<string, string>;
}
const localSandboxes = new Map<string, SandboxAgentSpawnHandle>();
type LocalSandboxProvider = SandboxProvider & {
getToken(sandboxId: string): Promise<string | undefined>;
};
export function local(options: LocalProviderOptions = {}): SandboxProvider {
const provider: LocalSandboxProvider = {
name: "local",
async create(): Promise<string> {
const handle = await spawnSandboxAgent(
{
host: options.host,
port: options.port,
token: options.token,
binaryPath: options.binaryPath,
log: options.log,
env: options.env,
} satisfies SandboxAgentSpawnOptions,
globalThis.fetch?.bind(globalThis),
);
const rawSandboxId = baseUrlToSandboxId(handle.baseUrl);
localSandboxes.set(rawSandboxId, handle);
return rawSandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const handle = localSandboxes.get(sandboxId);
if (!handle) {
return;
}
localSandboxes.delete(sandboxId);
await handle.dispose();
},
async getUrl(sandboxId: string): Promise<string> {
return `http://${sandboxId}`;
},
async getFetch(sandboxId: string): Promise<typeof globalThis.fetch> {
const handle = localSandboxes.get(sandboxId);
const token = options.token ?? handle?.token;
const fetcher = globalThis.fetch?.bind(globalThis);
if (!fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
}
if (!token) {
return fetcher;
}
return async (input, init) => {
const request = new Request(input, init);
const targetUrl = new URL(request.url);
targetUrl.protocol = "http:";
targetUrl.host = sandboxId;
const headers = new Headers(request.headers);
if (!headers.has("authorization")) {
headers.set("authorization", `Bearer ${token}`);
}
const forwarded = new Request(targetUrl.toString(), request);
return fetcher(new Request(forwarded, { headers }));
};
},
async getToken(sandboxId: string): Promise<string | undefined> {
return options.token ?? localSandboxes.get(sandboxId)?.token;
},
};
return provider;
}
function baseUrlToSandboxId(baseUrl: string): string {
return new URL(baseUrl).host;
}

View file

@ -0,0 +1,74 @@
import { ModalClient } 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;
export interface ModalProviderOptions {
create?: {
secrets?: Record<string, string>;
appName?: string;
memoryMiB?: number;
};
agentPort?: number;
}
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<string> {
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 envVars = options.create?.secrets ?? {};
const secrets = Object.keys(envVars).length > 0 ? [await client.secrets.fromObject(envVars)] : [];
const sb = await client.sandboxes.create(app, image, {
encryptedPorts: [agentPort],
secrets,
memoryMiB,
});
// Start the server as a long-running exec process. We intentionally
// do NOT await p.wait() — the process stays alive for the sandbox
// lifetime and keeps the port open for the tunnel.
sb.exec(["sandbox-agent", "server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)]);
return sb.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const sb = await client.sandboxes.fromId(sandboxId);
await sb.terminate();
},
async getUrl(sandboxId: string): Promise<string> {
const sb = await client.sandboxes.fromId(sandboxId);
const tunnels = await sb.tunnels();
const tunnel = tunnels[agentPort];
if (!tunnel) {
throw new Error(`modal: no tunnel found for port ${agentPort}`);
}
return tunnel.url;
},
async ensureServer(sandboxId: string): Promise<void> {
const sb = await client.sandboxes.fromId(sandboxId);
sb.exec(["sandbox-agent", "server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)]);
},
};
}

View file

@ -0,0 +1,7 @@
export const DEFAULT_SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.3.2-full";
export const SANDBOX_AGENT_INSTALL_SCRIPT = "https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh";
export const DEFAULT_AGENTS = ["claude", "codex"] as const;
export function buildServerStartCommand(port: number): string {
return `nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${port} >/tmp/sandbox-agent.log 2>&1 &`;
}

View file

@ -0,0 +1,31 @@
export interface SandboxProvider {
/** Provider name. Must match the prefix in sandbox IDs (for example "e2b"). */
name: string;
/** Provision a new sandbox and return the provider-specific ID. */
create(): Promise<string>;
/** Permanently tear down a sandbox. */
destroy(sandboxId: string): Promise<void>;
/**
* Return the sandbox-agent base URL for this sandbox.
* Providers that cannot expose a URL should implement `getFetch()` instead.
*/
getUrl?(sandboxId: string): Promise<string>;
/**
* Return a fetch implementation that routes requests to the sandbox.
* Providers that expose a URL can implement `getUrl()` instead.
*/
getFetch?(sandboxId: string): Promise<typeof globalThis.fetch>;
/**
* Ensure the sandbox-agent server process is running inside the sandbox.
* Called during health-wait after consecutive failures, and before
* reconnecting to an existing sandbox. Implementations should be
* idempotent if the server is already running, this should be a no-op
* (e.g. the duplicate process exits on port conflict).
*/
ensureServer?(sandboxId: string): Promise<void>;
}

View file

@ -0,0 +1,65 @@
import { Sandbox } from "@vercel/sandbox";
import type { SandboxProvider } from "./types.ts";
import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts";
const DEFAULT_AGENT_PORT = 3000;
export interface VercelProviderOptions {
create?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
agentPort?: number;
}
async function resolveCreateOptions(value: VercelProviderOptions["create"], agentPort: number): Promise<Record<string, unknown>> {
const resolved = typeof value === "function" ? await value() : (value ?? {});
return {
ports: [agentPort],
...resolved,
};
}
async function runVercelCommand(sandbox: InstanceType<typeof Sandbox>, cmd: string, args: string[] = []): Promise<void> {
const result = await sandbox.runCommand({ cmd, args });
if (result.exitCode !== 0) {
const stderr = await result.stderr();
throw new Error(`vercel command failed: ${cmd} ${args.join(" ")}\n${stderr}`);
}
}
export function vercel(options: VercelProviderOptions = {}): SandboxProvider {
const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT;
return {
name: "vercel",
async create(): Promise<string> {
const sandbox = await Sandbox.create((await resolveCreateOptions(options.create, agentPort)) as Parameters<typeof Sandbox.create>[0]);
await runVercelCommand(sandbox, "sh", ["-c", `curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`]);
for (const agent of DEFAULT_AGENTS) {
await runVercelCommand(sandbox, "sandbox-agent", ["install-agent", agent]);
}
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
detached: true,
});
return sandbox.sandboxId;
},
async destroy(sandboxId: string): Promise<void> {
const sandbox = await Sandbox.get({ sandboxId });
await sandbox.stop();
},
async getUrl(sandboxId: string): Promise<string> {
const sandbox = await Sandbox.get({ sandboxId });
return sandbox.domain(agentPort);
},
async ensureServer(sandboxId: string): Promise<void> {
const sandbox = await Sandbox.get({ sandboxId });
await sandbox.runCommand({
cmd: "sandbox-agent",
args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
detached: true,
});
},
};
}

View file

@ -98,6 +98,7 @@ export interface SessionRecord {
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
sandboxId?: string;
sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
@ -131,11 +132,11 @@ export interface ListEventsRequest extends ListPageRequest {
}
export interface SessionPersistDriver {
getSession(id: string): Promise<SessionRecord | null>;
getSession(id: string): Promise<SessionRecord | undefined>;
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
updateSession(session: SessionRecord): Promise<void>;
listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>>;
insertEvent(event: SessionEvent): Promise<void>;
insertEvent(sessionId: string, event: SessionEvent): Promise<void>;
}
export interface InMemorySessionPersistDriverOptions {
@ -158,9 +159,9 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver {
this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const session = this.sessions.get(id);
return session ? cloneSessionRecord(session) : null;
return session ? cloneSessionRecord(session) : undefined;
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
@ -219,15 +220,15 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
const events = this.eventsBySession.get(event.sessionId) ?? [];
async insertEvent(sessionId: string, event: SessionEvent): Promise<void> {
const events = this.eventsBySession.get(sessionId) ?? [];
events.push(cloneSessionEvent(event));
if (events.length > this.maxEventsPerSession) {
events.splice(0, events.length - this.maxEventsPerSession);
}
this.eventsBySession.set(event.sessionId, events);
this.eventsBySession.set(sessionId, events);
}
}

View file

@ -70,19 +70,19 @@ class StrictUniqueSessionPersistDriver implements SessionPersistDriver {
return this.events.listEvents(request);
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(sessionId: string, event: SessionEvent): Promise<void> {
await sleep(5);
const indexes = this.eventIndexesBySession.get(event.sessionId) ?? new Set<number>();
const indexes = this.eventIndexesBySession.get(sessionId) ?? new Set<number>();
if (indexes.has(event.eventIndex)) {
throw new Error("UNIQUE constraint failed: sandbox_agent_events.session_id, sandbox_agent_events.event_index");
}
indexes.add(event.eventIndex);
this.eventIndexesBySession.set(event.sessionId, indexes);
this.eventIndexesBySession.set(sessionId, indexes);
await sleep(5);
await this.events.insertEvent(event);
await this.events.insertEvent(sessionId, event);
}
}

View file

@ -0,0 +1,417 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { createRequire } from "node:module";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
const _require = createRequire(import.meta.url);
import { InMemorySessionPersistDriver, SandboxAgent, type SandboxProvider } from "../src/index.ts";
import { local } from "../src/providers/local.ts";
import { docker } from "../src/providers/docker.ts";
import { e2b } from "../src/providers/e2b.ts";
import { daytona } from "../src/providers/daytona.ts";
import { vercel } from "../src/providers/vercel.ts";
import { modal } from "../src/providers/modal.ts";
import { computesdk } from "../src/providers/computesdk.ts";
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
for (const candidate of cargoPaths) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
const BINARY_PATH = findBinary();
if (!BINARY_PATH) {
throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
function isModuleAvailable(name: string): boolean {
try {
_require.resolve(name);
return true;
} catch {
return false;
}
}
function isDockerAvailable(): boolean {
try {
execSync("docker info", { stdio: "ignore", timeout: 5_000 });
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Provider registry — each entry defines how to create a provider and
// what preconditions are required for it to run.
// ---------------------------------------------------------------------------
interface ProviderEntry {
name: string;
/** Human-readable reasons this provider can't run, or empty if ready. */
skipReasons: string[];
/** Return a fresh provider instance for a single test. */
createProvider: () => SandboxProvider;
/** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */
setup?: () => { cleanup: () => void };
/** Agent to use for session tests. */
agent: string;
/** Timeout for start() — remote providers need longer. */
startTimeoutMs?: number;
/** Some providers (e.g. local) can verify the sandbox is gone after destroy. */
canVerifyDestroyedSandbox?: boolean;
/**
* Whether session tests (createSession, prompt) should run.
* The mock agent only works with local provider (requires mock-acp process binary).
* Remote providers need a real agent (claude) which requires compatible server version + API keys.
*/
sessionTestsEnabled: boolean;
}
function missingEnvVars(...vars: string[]): string[] {
const missing = vars.filter((v) => !process.env[v]);
return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : [];
}
function missingModules(...modules: string[]): string[] {
const missing = modules.filter((m) => !isModuleAvailable(m));
return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : [];
}
function collectApiKeys(): Record<string, string> {
const keys: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) keys.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) keys.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
return keys;
}
function buildProviders(): ProviderEntry[] {
const entries: ProviderEntry[] = [];
// --- local ---
// Uses the mock-acp process binary created by prepareMockAgentDataHome.
{
let dataHome: string | undefined;
entries.push({
name: "local",
skipReasons: [],
agent: "mock",
canVerifyDestroyedSandbox: true,
sessionTestsEnabled: true,
setup() {
dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-"));
return {
cleanup: () => {
if (dataHome) rmSync(dataHome, { recursive: true, force: true });
},
};
},
createProvider() {
return local({
log: "silent",
env: prepareMockAgentDataHome(dataHome!),
});
},
});
}
// --- docker ---
// Requires SANDBOX_AGENT_DOCKER_IMAGE (e.g. "sandbox-agent-dev:local").
// Session tests disabled: released server images use a different ACP protocol
// version than the current SDK branch, causing "Query closed before response
// received" errors on session creation.
{
entries.push({
name: "docker",
skipReasons: [
...missingEnvVars("SANDBOX_AGENT_DOCKER_IMAGE"),
...missingModules("dockerode", "get-port"),
...(isDockerAvailable() ? [] : ["Docker daemon not available"]),
],
agent: "claude",
startTimeoutMs: 180_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
const apiKeys = [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
].filter(Boolean);
return docker({
image: process.env.SANDBOX_AGENT_DOCKER_IMAGE,
env: apiKeys,
});
},
});
}
// --- e2b ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "e2b",
skipReasons: [...missingEnvVars("E2B_API_KEY"), ...missingModules("@e2b/code-interpreter")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return e2b({
create: { envs: collectApiKeys() },
});
},
});
}
// --- daytona ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "daytona",
skipReasons: [...missingEnvVars("DAYTONA_API_KEY"), ...missingModules("@daytonaio/sdk")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return daytona({
create: { envVars: collectApiKeys() },
});
},
});
}
// --- vercel ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "vercel",
skipReasons: [...missingEnvVars("VERCEL_ACCESS_TOKEN"), ...missingModules("@vercel/sandbox")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return vercel({
create: { env: collectApiKeys() },
});
},
});
}
// --- modal ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "modal",
skipReasons: [...missingEnvVars("MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"), ...missingModules("modal")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return modal({
create: { secrets: collectApiKeys() },
});
},
});
}
// --- computesdk ---
// Session tests disabled: see docker comment above (ACP protocol mismatch).
{
entries.push({
name: "computesdk",
skipReasons: [...missingEnvVars("COMPUTESDK_API_KEY"), ...missingModules("computesdk")],
agent: "claude",
startTimeoutMs: 300_000,
canVerifyDestroyedSandbox: false,
sessionTestsEnabled: false,
createProvider() {
return computesdk({
create: { envs: collectApiKeys() },
});
},
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Shared test suite — runs the same assertions against every provider.
//
// Provider lifecycle tests (start, sandboxId, reconnect, destroy) use only
// listAgents() and never create sessions — these work regardless of which
// agents are installed or whether API keys are present.
//
// Session tests (createSession, prompt) are only enabled for providers where
// the agent is known to work. For local, the mock-acp process binary is
// created by test setup. For remote providers, a real agent (claude) is used
// which requires ANTHROPIC_API_KEY and a compatible server version.
// ---------------------------------------------------------------------------
function providerSuite(entry: ProviderEntry) {
const skip = entry.skipReasons.length > 0;
const descFn = skip ? describe.skip : describe;
descFn(`SandboxProvider: ${entry.name}`, () => {
let sdk: SandboxAgent | undefined;
let cleanupFn: (() => void) | undefined;
if (skip) {
it.skip(`skipped — ${entry.skipReasons.join("; ")}`, () => {});
return;
}
beforeAll(() => {
const result = entry.setup?.();
cleanupFn = result?.cleanup;
});
afterEach(async () => {
if (!sdk) return;
await sdk.destroySandbox().catch(async () => {
await sdk?.dispose().catch(() => {});
});
sdk = undefined;
}, 30_000);
afterAll(() => {
cleanupFn?.();
});
// -- lifecycle tests (no session creation) --
it(
"starts with a prefixed sandboxId and passes health",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
expect(sdk.sandboxId).toMatch(new RegExp(`^${entry.name}/`));
// listAgents() awaits the internal health gate, confirming the server is ready.
const agents = await sdk.listAgents();
expect(agents.agents.length).toBeGreaterThan(0);
},
entry.startTimeoutMs,
);
it("rejects mismatched sandboxId prefixes", async () => {
await expect(
SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId: "wrong-provider/example",
}),
).rejects.toThrow(/provider/i);
});
it(
"reconnects after dispose without destroying the sandbox",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const sandboxId = sdk.sandboxId;
expect(sandboxId).toBeTruthy();
await sdk.dispose();
const reconnected = await SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId,
});
const agents = await reconnected.listAgents();
expect(agents.agents.length).toBeGreaterThan(0);
sdk = reconnected;
},
entry.startTimeoutMs ? entry.startTimeoutMs * 2 : undefined,
);
it(
"destroySandbox tears the sandbox down",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const sandboxId = sdk.sandboxId;
expect(sandboxId).toBeTruthy();
await sdk.destroySandbox();
sdk = undefined;
if (entry.canVerifyDestroyedSandbox) {
const reconnected = await SandboxAgent.start({
sandbox: entry.createProvider(),
sandboxId,
skipHealthCheck: true,
});
await expect(reconnected.listAgents()).rejects.toThrow();
}
},
entry.startTimeoutMs,
);
// -- session tests (require working agent) --
const sessionIt = entry.sessionTestsEnabled ? it : it.skip;
sessionIt(
"creates sessions with persisted sandboxId",
async () => {
const persist = new InMemorySessionPersistDriver();
sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist });
const session = await sdk.createSession({ agent: entry.agent });
const record = await persist.getSession(session.id);
expect(record?.sandboxId).toBe(sdk.sandboxId);
},
entry.startTimeoutMs,
);
sessionIt(
"sends a prompt and receives a response",
async () => {
sdk = await SandboxAgent.start({ sandbox: entry.createProvider() });
const session = await sdk.createSession({ agent: entry.agent });
const events: unknown[] = [];
const off = session.onEvent((event) => {
events.push(event);
});
const result = await session.prompt([{ type: "text", text: "Say hello in one word." }]);
off();
expect(result.stopReason).toBe("end_turn");
expect(events.length).toBeGreaterThan(0);
},
entry.startTimeoutMs ? entry.startTimeoutMs * 2 : 30_000,
);
});
}
// ---------------------------------------------------------------------------
// Register all providers
// ---------------------------------------------------------------------------
for (const entry of buildProviders()) {
providerSuite(entry);
}

View file

@ -1,9 +1,20 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
entry: [
"src/index.ts",
"src/providers/local.ts",
"src/providers/e2b.ts",
"src/providers/daytona.ts",
"src/providers/docker.ts",
"src/providers/vercel.ts",
"src/providers/cloudflare.ts",
"src/providers/modal.ts",
"src/providers/computesdk.ts",
],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port", "modal", "computesdk"],
});

View file

@ -4,5 +4,7 @@ export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
teardownTimeout: 10000,
pool: "forks",
},
});