mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 15:03: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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue