mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 17:01:06 +00:00
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:
parent
3426cbc6ec
commit
6a42f06342
53 changed files with 1689 additions and 667 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
79
sdks/typescript/src/providers/cloudflare.ts
Normal file
79
sdks/typescript/src/providers/cloudflare.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
65
sdks/typescript/src/providers/daytona.ts
Normal file
65
sdks/typescript/src/providers/daytona.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
85
sdks/typescript/src/providers/docker.ts
Normal file
85
sdks/typescript/src/providers/docker.ts
Normal 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}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
57
sdks/typescript/src/providers/e2b.ts
Normal file
57
sdks/typescript/src/providers/e2b.ts
Normal 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)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
84
sdks/typescript/src/providers/local.ts
Normal file
84
sdks/typescript/src/providers/local.ts
Normal 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;
|
||||
}
|
||||
7
sdks/typescript/src/providers/shared.ts
Normal file
7
sdks/typescript/src/providers/shared.ts
Normal 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 &`;
|
||||
}
|
||||
28
sdks/typescript/src/providers/types.ts
Normal file
28
sdks/typescript/src/providers/types.ts
Normal 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>;
|
||||
}
|
||||
57
sdks/typescript/src/providers/vercel.ts
Normal file
57
sdks/typescript/src/providers/vercel.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
379
sdks/typescript/tests/providers.test.ts
Normal file
379
sdks/typescript/tests/providers.test.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ export default defineConfig({
|
|||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
teardownTimeout: 10000,
|
||||
pool: "forks",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue