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>
This commit is contained in:
Nathan Flurry 2026-03-15 12:39:05 -07:00
parent 3426cbc6ec
commit 6a42f06342
53 changed files with 1689 additions and 667 deletions

View file

@ -31,11 +31,11 @@ export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
this.dbPromise = this.openDatabase();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const db = await this.dbPromise;
const row = await requestToPromise<IDBValidKey | SessionRow | undefined>(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id));
if (!row || typeof row !== "object") {
return null;
return undefined;
}
return decodeSessionRow(row as SessionRow);
}
@ -84,7 +84,7 @@ export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
const db = await this.dbPromise;
await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => {
tx.objectStore(EVENTS_STORE).put(encodeEventRow(event));
@ -139,6 +139,7 @@ type SessionRow = {
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
sandboxId?: string;
sessionInit?: SessionRecord["sessionInit"];
};
@ -160,6 +161,7 @@ function encodeSessionRow(session: SessionRecord): SessionRow {
lastConnectionId: session.lastConnectionId,
createdAt: session.createdAt,
destroyedAt: session.destroyedAt,
sandboxId: session.sandboxId,
sessionInit: session.sessionInit,
};
}
@ -172,6 +174,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.lastConnectionId,
createdAt: row.createdAt,
destroyedAt: row.destroyedAt,
sandboxId: row.sandboxId,
sessionInit: row.sessionInit,
};
}

View file

@ -28,7 +28,7 @@ describe("IndexedDbSessionPersistDriver", () => {
destroyedAt: 300,
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-1",
eventIndex: 1,
sessionId: "s-1",
@ -38,7 +38,7 @@ describe("IndexedDbSessionPersistDriver", () => {
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-2",
eventIndex: 2,
sessionId: "s-1",

View file

@ -33,18 +33,18 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
this.initialized = this.initialize();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
await this.ready();
const result = await this.pool.query<SessionRow>(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM ${this.table("sessions")}
WHERE id = $1`,
[id],
);
if (result.rows.length === 0) {
return null;
return undefined;
}
return decodeSessionRow(result.rows[0]);
@ -57,7 +57,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
const limit = normalizeLimit(request.limit);
const rowsResult = await this.pool.query<SessionRow>(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM ${this.table("sessions")}
ORDER BY created_at ASC, id ASC
LIMIT $1 OFFSET $2`,
@ -79,14 +79,15 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
await this.pool.query(
`INSERT INTO ${this.table("sessions")} (
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
) VALUES ($1, $2, $3, $4, $5, $6, $7)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT(id) DO UPDATE SET
agent = EXCLUDED.agent,
agent_session_id = EXCLUDED.agent_session_id,
last_connection_id = EXCLUDED.last_connection_id,
created_at = EXCLUDED.created_at,
destroyed_at = EXCLUDED.destroyed_at,
sandbox_id = EXCLUDED.sandbox_id,
session_init_json = EXCLUDED.session_init_json`,
[
session.id,
@ -95,6 +96,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
session.lastConnectionId,
session.createdAt,
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ?? null,
],
);
@ -127,7 +129,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
await this.ready();
await this.pool.query(
@ -171,10 +173,16 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
last_connection_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
destroyed_at BIGINT,
sandbox_id TEXT,
session_init_json JSONB
)
`);
await this.pool.query(`
ALTER TABLE ${this.table("sessions")}
ADD COLUMN IF NOT EXISTS sandbox_id TEXT
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS ${this.table("events")} (
id TEXT PRIMARY KEY,
@ -228,6 +236,7 @@ type SessionRow = {
last_connection_id: string;
created_at: string | number;
destroyed_at: string | number | null;
sandbox_id: string | null;
session_init_json: unknown | null;
};
@ -249,6 +258,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.last_connection_id,
createdAt: parseInteger(row.created_at),
destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at),
sandboxId: row.sandbox_id ?? undefined,
sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined,
};
}

View file

@ -50,9 +50,9 @@ export class RivetSessionPersistDriver implements SessionPersistDriver {
return this.ctx.state[this.stateKey] as RivetPersistData;
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const session = this.data.sessions[id];
return session ? cloneSessionRecord(session) : null;
return session ? cloneSessionRecord(session) : undefined;
}
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
@ -112,15 +112,15 @@ export class RivetSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
const events = this.data.events[event.sessionId] ?? [];
async insertEvent(sessionId: string, event: SessionEvent): Promise<void> {
const events = this.data.events[sessionId] ?? [];
events.push(cloneSessionEvent(event));
if (events.length > this.maxEventsPerSession) {
events.splice(0, events.length - this.maxEventsPerSession);
}
this.data.events[event.sessionId] = events;
this.data.events[sessionId] = events;
}
}

View file

@ -59,7 +59,7 @@ describe("RivetSessionPersistDriver", () => {
expect(loaded?.destroyedAt).toBe(300);
const missing = await driver.getSession("s-nonexistent");
expect(missing).toBeNull();
expect(missing).toBeUndefined();
});
it("pages sessions sorted by createdAt", async () => {
@ -103,7 +103,7 @@ describe("RivetSessionPersistDriver", () => {
createdAt: 1,
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-1",
eventIndex: 1,
sessionId: "s-1",
@ -113,7 +113,7 @@ describe("RivetSessionPersistDriver", () => {
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
});
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: "evt-2",
eventIndex: 2,
sessionId: "s-1",
@ -159,9 +159,9 @@ describe("RivetSessionPersistDriver", () => {
createdAt: 300,
});
expect(await driver.getSession("s-1")).toBeNull();
expect(await driver.getSession("s-2")).not.toBeNull();
expect(await driver.getSession("s-3")).not.toBeNull();
expect(await driver.getSession("s-1")).toBeUndefined();
expect(await driver.getSession("s-2")).toBeDefined();
expect(await driver.getSession("s-3")).toBeDefined();
});
it("trims oldest events when maxEventsPerSession exceeded", async () => {
@ -176,7 +176,7 @@ describe("RivetSessionPersistDriver", () => {
});
for (let i = 1; i <= 3; i++) {
await driver.insertEvent({
await driver.insertEvent("s-1", {
id: `evt-${i}`,
eventIndex: i,
sessionId: "s-1",

View file

@ -15,16 +15,16 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
this.initialize();
}
async getSession(id: string): Promise<SessionRecord | null> {
async getSession(id: string): Promise<SessionRecord | undefined> {
const row = this.db
.prepare(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM sessions WHERE id = ?`,
)
.get(id) as SessionRow | undefined;
if (!row) {
return null;
return undefined;
}
return decodeSessionRow(row);
@ -36,7 +36,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
const rows = this.db
.prepare(
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
FROM sessions
ORDER BY created_at ASC, id ASC
LIMIT ? OFFSET ?`,
@ -56,14 +56,15 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
this.db
.prepare(
`INSERT INTO sessions (
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
agent = excluded.agent,
agent_session_id = excluded.agent_session_id,
last_connection_id = excluded.last_connection_id,
created_at = excluded.created_at,
destroyed_at = excluded.destroyed_at,
sandbox_id = excluded.sandbox_id,
session_init_json = excluded.session_init_json`,
)
.run(
@ -73,6 +74,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
session.lastConnectionId,
session.createdAt,
session.destroyedAt ?? null,
session.sandboxId ?? null,
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
);
}
@ -101,7 +103,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
};
}
async insertEvent(event: SessionEvent): Promise<void> {
async insertEvent(_sessionId: string, event: SessionEvent): Promise<void> {
this.db
.prepare(
`INSERT INTO events (
@ -131,10 +133,16 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
last_connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
destroyed_at INTEGER,
sandbox_id TEXT,
session_init_json TEXT
)
`);
const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[];
if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
}
this.ensureEventsTable();
}
@ -223,6 +231,7 @@ type SessionRow = {
last_connection_id: string;
created_at: number;
destroyed_at: number | null;
sandbox_id: string | null;
session_init_json: string | null;
};
@ -249,6 +258,7 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
lastConnectionId: row.last_connection_id,
createdAt: row.created_at,
destroyedAt: row.destroyed_at ?? undefined,
sandboxId: row.sandbox_id ?? undefined,
sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined,
};
}

View file

@ -14,6 +14,58 @@
".": {
"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"
}
},
"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"
},
"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
}
},
"dependencies": {
@ -33,8 +85,15 @@
"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",
"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,
@ -101,6 +101,8 @@ interface SandboxAgentConnectCommonOptions {
replayMaxChars?: number;
signal?: AbortSignal;
token?: string;
skipHealthCheck?: boolean;
/** @deprecated Use skipHealthCheck instead. */
waitForHealth?: boolean | SandboxAgentHealthWaitOptions;
}
@ -115,17 +117,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 +144,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 +836,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 +871,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 +884,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.wake?.(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 +986,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 +1033,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 +1043,7 @@ export class SandboxAgent {
agentSessionId: response.sessionId,
lastConnectionId: live.connectionId,
createdAt: nowMs(),
sandboxId: this.sandboxProviderId,
sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
@ -2255,17 +2333,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 +2483,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 +2508,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,65 @@
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]>;
export interface DaytonaProviderOptions {
create?: DaytonaCreateParams | (() => DaytonaCreateParams | Promise<DaytonaCreateParams>);
image?: string;
agentPort?: number;
previewTtlSeconds?: number;
deleteTimeoutSeconds?: number;
}
async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise<DaytonaCreateParams | 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 wake(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,57 @@
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)}`;
},
};
}

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,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,28 @@
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>;
/**
* Optional hook invoked before reconnecting to an existing sandbox.
* Useful for providers where the sandbox-agent process may need to be restarted.
*/
wake?(sandboxId: string): Promise<void>;
}

View file

@ -0,0 +1,57 @@
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);
},
};
}

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

@ -0,0 +1,379 @@
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 { 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() },
});
},
});
}
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,18 @@
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",
],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port"],
});

View file

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