diff --git a/CLAUDE.md b/CLAUDE.md index d7e091b..c651fb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ - `examples/docker/src/index.ts` - `examples/e2b/src/index.ts` - `examples/vercel/src/index.ts` + - `sdks/typescript/src/providers/shared.ts` - `scripts/release/main.ts` - `scripts/release/promote-artifacts.ts` - `scripts/release/sdk.ts` diff --git a/docs/agent-capabilities.mdx b/docs/agent-capabilities.mdx index 13f2723..acb582c 100644 --- a/docs/agent-capabilities.mdx +++ b/docs/agent-capabilities.mdx @@ -37,7 +37,7 @@ await writeFile( const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" }); await sdk.createSession({ agent: "claude", - sessionInit: { cwd, mcpServers: [] }, + cwd, }); ``` diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index cf56e9c..0f9e2ab 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -21,10 +21,7 @@ const sdk = await SandboxAgent.connect({ const session = await sdk.createSession({ agent: "codex", - sessionInit: { - cwd: "/", - mcpServers: [], - }, + cwd: "/", }); console.log(session.id, session.agentSessionId); diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 78585a2..36e0302 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -1,64 +1,59 @@ --- title: "Architecture" -description: "How the client, sandbox, server, and agent fit together." -icon: "microchip" +description: "How the Sandbox Agent server, SDK, and agent processes fit together." --- -Sandbox Agent runs as an HTTP server inside your sandbox. Your app talks to it remotely. +Sandbox Agent is a lightweight HTTP server that runs **inside** a sandbox. It: + +- **Agent management**: Installs, spawns, and stops coding agent processes +- **Sessions**: Routes prompts to agents and streams events back in real time +- **Sandbox APIs**: Filesystem, process, and terminal access for the sandbox environment ## Components -- `Your client`: your app code using the `sandbox-agent` SDK. -- `Sandbox`: isolated runtime (E2B, Daytona, Docker, etc.). -- `Sandbox Agent server`: process inside the sandbox exposing HTTP transport. -- `Agent`: Claude/Codex/OpenCode/Amp process managed by Sandbox Agent. - -```mermaid placement="top-right" - flowchart LR - CLIENT["Sandbox Agent SDK"] - SERVER["Sandbox Agent server"] - AGENT["Agent process"] +```mermaid +flowchart LR + CLIENT["Your App"] subgraph SANDBOX["Sandbox"] - direction TB - SERVER --> AGENT + direction TB + SERVER["Sandbox Agent Server"] + AGENT["Agent Process
(Claude, Codex, etc.)"] + SERVER --> AGENT end - CLIENT -->|HTTP| SERVER + CLIENT -->|"SDK (HTTP)"| SERVER ``` -## Suggested Topology +- **Your app**: Uses the `sandbox-agent` TypeScript SDK to talk to the server over HTTP. +- **Sandbox**: An isolated runtime (local process, Docker, E2B, Daytona, Vercel, Cloudflare). +- **Sandbox Agent server**: A single binary inside the sandbox that manages agent lifecycles, routes prompts, streams events, and exposes filesystem/process/terminal APIs. +- **Agent process**: A coding agent (Claude Code, Codex, etc.) spawned by the server. Each session maps to one agent process. -Run the SDK on your backend, then call it from your frontend. +## What `SandboxAgent.start()` does -This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler. +1. **Provision**: The provider creates a sandbox (starts a container, creates a VM, etc.) +2. **Install**: The Sandbox Agent binary is installed inside the sandbox +3. **Boot**: The server starts listening on an HTTP port +4. **Health check**: The SDK waits for `/v1/health` to respond +5. **Ready**: The SDK returns a connected client -```mermaid placement="top-right" - flowchart LR - BROWSER["Browser"] - subgraph BACKEND["Your backend"] - direction TB - SDK["Sandbox Agent SDK"] - end - subgraph SANDBOX_SIMPLE["Sandbox"] - SERVER_SIMPLE["Sandbox Agent server"] - end +For the `local` provider, provisioning is a no-op and the server runs as a local subprocess. - BROWSER --> BACKEND - BACKEND --> SDK --> SERVER_SIMPLE +## Server endpoints + +See the [HTTP API reference](/api-reference) for the full list of server endpoints. + +## Agent installation + +Agents are installed lazily on first use. To avoid the cold-start delay, pre-install them: + +```bash +sandbox-agent install-agent --all ``` -### Backend requirements +The `rivetdev/sandbox-agent:0.3.2-full` Docker image ships with all agents pre-installed. -Your backend layer needs to handle: +## Production topology -- **Long-running connections**: prompts can take minutes. -- **Session affinity**: follow-up messages must reach the same session. -- **State between requests**: session metadata and event history must persist across requests. -- **Graceful recovery**: sessions should resume after backend restarts. - -We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require. - -## Session persistence - -For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence). +For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns. diff --git a/docs/custom-tools.mdx b/docs/custom-tools.mdx index 727fb02..2fb3e15 100644 --- a/docs/custom-tools.mdx +++ b/docs/custom-tools.mdx @@ -80,9 +80,7 @@ await sdk.setMcpConfig( const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ @@ -145,9 +143,7 @@ await sdk.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skill); ```ts const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/docs/deploy/cloudflare.mdx b/docs/deploy/cloudflare.mdx index deca490..1cecdd7 100644 --- a/docs/deploy/cloudflare.mdx +++ b/docs/deploy/cloudflare.mdx @@ -31,7 +31,38 @@ RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex EXPOSE 8000 ``` -## TypeScript example +## TypeScript example (with provider) + +For standalone scripts, use the `cloudflare` provider: + +```bash +npm install sandbox-agent@0.3.x @cloudflare/sandbox +``` + +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { cloudflare } from "sandbox-agent/cloudflare"; + +const sdk = await SandboxAgent.start({ + sandbox: cloudflare(), +}); + +try { + const session = await sdk.createSession({ agent: "codex" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + +The `cloudflare` provider uses `containerFetch` under the hood, automatically stripping `AbortSignal` to avoid dropped streaming updates. + +## TypeScript example (Durable Objects) + +For Workers with Durable Objects, use `SandboxAgent.connect(...)` with a custom `fetch` backed by `sandbox.containerFetch(...)`: ```typescript import { getSandbox, type Sandbox } from "@cloudflare/sandbox"; @@ -109,7 +140,6 @@ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); export default app; ``` -Create the SDK client inside the Worker using custom `fetch` backed by `sandbox.containerFetch(...)`. This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path and does not require a `baseUrl`. ## Troubleshooting streaming updates diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index 5eb8f5d..b65aec9 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -15,40 +15,37 @@ See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/). ## TypeScript example -```typescript -import { Daytona } from "@daytonaio/sdk"; -import { SandboxAgent } from "sandbox-agent"; +```bash +npm install sandbox-agent@0.3.x @daytonaio/sdk +``` -const daytona = new Daytona(); +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { daytona } from "sandbox-agent/daytona"; const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const sandbox = await daytona.create({ envVars }); +const sdk = await SandboxAgent.start({ + sandbox: daytona({ + create: { envVars }, + }), +}); -await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" -); - -await sandbox.process.executeCommand("sandbox-agent install-agent claude"); -await sandbox.process.executeCommand("sandbox-agent install-agent codex"); - -await sandbox.process.executeCommand( - "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &" -); - -await new Promise((r) => setTimeout(r, 2000)); - -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); -await session.prompt([{ type: "text", text: "Summarize this repository" }]); - -await sandbox.delete(); +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} ``` +The `daytona` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default and starts the server automatically. + ## Using snapshots for faster startup ```typescript diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 030ddc9..b674b7a 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -15,43 +15,43 @@ Run the published full image with all supported agents pre-installed: docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - rivetdev/sandbox-agent:0.3.1-full \ + rivetdev/sandbox-agent:0.3.2-full \ server --no-token --host 0.0.0.0 --port 3000 ``` -The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. +The `0.3.2-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. -## TypeScript with dockerode +## TypeScript with the Docker provider + +```bash +npm install sandbox-agent@0.3.x dockerode get-port +``` ```typescript -import Docker from "dockerode"; import { SandboxAgent } from "sandbox-agent"; +import { docker } from "sandbox-agent/docker"; -const docker = new Docker(); -const PORT = 3000; - -const container = await docker.createContainer({ - Image: "rivetdev/sandbox-agent:0.3.1-full", - Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], - Env: [ - `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, - `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, - `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, - ].filter(Boolean), - ExposedPorts: { [`${PORT}/tcp`]: {} }, - HostConfig: { - AutoRemove: true, - PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, - }, +const sdk = await SandboxAgent.start({ + sandbox: docker({ + env: [ + `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, + `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + ].filter(Boolean), + }), }); -await container.start(); +try { + const session = await sdk.createSession({ agent: "codex" }); + await session.prompt([{ type: "text", text: "Summarize this repository." }]); +} finally { + await sdk.destroySandbox(); +} +``` -const baseUrl = `http://127.0.0.1:${PORT}`; -const sdk = await SandboxAgent.connect({ baseUrl }); +The `docker` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default. Override with `image`: -const session = await sdk.createSession({ agent: "codex" }); -await session.prompt([{ type: "text", text: "Summarize this repository." }]); +```typescript +docker({ image: "my-custom-image:latest" }) ``` ## Building a custom image with everything preinstalled diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index 8ea4c74..4e056ee 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -10,42 +10,37 @@ description: "Deploy Sandbox Agent inside an E2B sandbox." ## TypeScript example +```bash +npm install sandbox-agent@0.3.x @e2b/code-interpreter +``` + ```typescript -import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; +import { e2b } from "sandbox-agent/e2b"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); - -await sandbox.commands.run( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" -); - -await sandbox.commands.run("sandbox-agent install-agent claude"); -await sandbox.commands.run("sandbox-agent install-agent codex"); - -await sandbox.commands.run( - "sandbox-agent server --no-token --host 0.0.0.0 --port 3000", - { background: true, timeoutMs: 0 } -); - -const baseUrl = `https://${sandbox.getHost(3000)}`; -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); -const off = session.onEvent((event) => { - console.log(event.sender, event.payload); +const sdk = await SandboxAgent.start({ + sandbox: e2b({ + create: { envs }, + }), }); -await session.prompt([{ type: "text", text: "Summarize this repository" }]); -off(); - -await sandbox.kill(); +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} ``` +The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. + ## Faster cold starts For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. diff --git a/docs/deploy/local.mdx b/docs/deploy/local.mdx index eab8f3f..90e2ba6 100644 --- a/docs/deploy/local.mdx +++ b/docs/deploy/local.mdx @@ -32,12 +32,15 @@ Or with npm/Bun: ## With the TypeScript SDK -The SDK can spawn and manage the server as a subprocess: +The SDK can spawn and manage the server as a subprocess using the `local` provider: ```typescript import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const sdk = await SandboxAgent.start(); +const sdk = await SandboxAgent.start({ + sandbox: local(), +}); const session = await sdk.createSession({ agent: "claude", @@ -47,7 +50,21 @@ await session.prompt([ { type: "text", text: "Summarize this repository." }, ]); -await sdk.dispose(); +await sdk.destroySandbox(); ``` This starts the server on an available local port and connects automatically. + +Pass options to customize the local provider: + +```typescript +const sdk = await SandboxAgent.start({ + sandbox: local({ + port: 3000, + log: "inherit", + env: { + ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY, + }, + }), +}); +``` diff --git a/docs/deploy/vercel.mdx b/docs/deploy/vercel.mdx index 2025d67..db97236 100644 --- a/docs/deploy/vercel.mdx +++ b/docs/deploy/vercel.mdx @@ -10,52 +10,40 @@ description: "Deploy Sandbox Agent inside a Vercel Sandbox." ## TypeScript example -```typescript -import { Sandbox } from "@vercel/sandbox"; -import { SandboxAgent } from "sandbox-agent"; - -const envs: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - -const sandbox = await Sandbox.create({ - runtime: "node24", - ports: [3000], -}); - -const run = async (cmd: string, args: string[] = []) => { - const result = await sandbox.runCommand({ cmd, args, env: envs }); - if (result.exitCode !== 0) { - throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); - } -}; - -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); -await run("sandbox-agent", ["install-agent", "claude"]); -await run("sandbox-agent", ["install-agent", "codex"]); - -await sandbox.runCommand({ - cmd: "sandbox-agent", - args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"], - env: envs, - detached: true, -}); - -const baseUrl = sandbox.domain(3000); -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); - -const off = session.onEvent((event) => { - console.log(event.sender, event.payload); -}); - -await session.prompt([{ type: "text", text: "Summarize this repository" }]); -off(); - -await sandbox.stop(); +```bash +npm install sandbox-agent@0.3.x @vercel/sandbox ``` +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { vercel } from "sandbox-agent/vercel"; + +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const sdk = await SandboxAgent.start({ + sandbox: vercel({ + create: { + runtime: "node24", + env, + }, + }), +}); + +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + +The `vercel` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. + ## Authentication Vercel Sandboxes support OIDC token auth (recommended) and access-token auth. diff --git a/docs/docs.json b/docs/docs.json index 9ba082c..3f2be1a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -58,13 +58,13 @@ "icon": "server", "pages": [ "deploy/local", - "deploy/computesdk", "deploy/e2b", "deploy/daytona", "deploy/vercel", "deploy/cloudflare", "deploy/docker", - "deploy/boxlite" + "deploy/boxlite", + "deploy/computesdk" ] } ] @@ -79,11 +79,12 @@ }, { "group": "Orchestration", - "pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"] + "pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"] }, { "group": "Reference", "pages": [ + "architecture", "agent-capabilities", "cli", "inspector", diff --git a/docs/mcp-config.mdx b/docs/mcp-config.mdx index 71e8105..cc1c976 100644 --- a/docs/mcp-config.mdx +++ b/docs/mcp-config.mdx @@ -27,9 +27,7 @@ await sdk.setMcpConfig( // Create a session using the configured MCP servers const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/docs/orchestration-architecture.mdx b/docs/orchestration-architecture.mdx new file mode 100644 index 0000000..08c776c --- /dev/null +++ b/docs/orchestration-architecture.mdx @@ -0,0 +1,43 @@ +--- +title: "Orchestration Architecture" +description: "Production topology, backend requirements, and session persistence." +icon: "sitemap" +--- + +This page covers production topology and backend requirements. Read [Architecture](/architecture) first for an overview of how the server, SDK, and agent processes fit together. + +## Suggested Topology + +Run the SDK on your backend, then call it from your frontend. + +This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler. + +```mermaid placement="top-right" + flowchart LR + BROWSER["Browser"] + subgraph BACKEND["Your backend"] + direction TB + SDK["Sandbox Agent SDK"] + end + subgraph SANDBOX_SIMPLE["Sandbox"] + SERVER_SIMPLE["Sandbox Agent server"] + end + + BROWSER --> BACKEND + BACKEND --> SDK --> SERVER_SIMPLE +``` + +### Backend requirements + +Your backend layer needs to handle: + +- **Long-running connections**: prompts can take minutes. +- **Session affinity**: follow-up messages must reach the same session. +- **State between requests**: session metadata and event history must persist across requests. +- **Graceful recovery**: sessions should resume after backend restarts. + +We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require. + +## Session persistence + +For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence). diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index caf2c21..07bd55a 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -1,20 +1,22 @@ --- title: "Quickstart" -description: "Start the server and send your first message." +description: "Get a coding agent running in a sandbox in under a minute." icon: "rocket" --- - + - + ```bash - npx skills add rivet-dev/skills -s sandbox-agent + npm install sandbox-agent@0.3.x ``` - + ```bash - bunx skills add rivet-dev/skills -s sandbox-agent + bun add sandbox-agent@0.3.x + # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). + bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -23,52 +25,10 @@ icon: "rocket" Each coding agent requires API keys to connect to their respective LLM providers. - - - ```bash - export ANTHROPIC_API_KEY="sk-ant-..." - export OPENAI_API_KEY="sk-..." - ``` - - - - ```typescript - import { Sandbox } from "@e2b/code-interpreter"; - - const envs: Record = {}; - if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - - const sandbox = await Sandbox.create({ envs }); - ``` - - - - ```typescript - import { Daytona } from "@daytonaio/sdk"; - - const envVars: Record = {}; - if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - - const daytona = new Daytona(); - const sandbox = await daytona.create({ - snapshot: "sandbox-agent-ready", - envVars, - }); - ``` - - - - ```bash - docker run -p 2468:2468 \ - -e ANTHROPIC_API_KEY="sk-ant-..." \ - -e OPENAI_API_KEY="sk-..." \ - rivetdev/sandbox-agent:0.3.1-full \ - server --no-token --host 0.0.0.0 --port 2468 - ``` - - + ```bash + export ANTHROPIC_API_KEY="sk-ant-..." + export OPENAI_API_KEY="sk-..." + ``` @@ -83,173 +43,146 @@ icon: "rocket" - - - - Install and run the binary directly. + + `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. - ```bash - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - + + ```typescript Local + import { SandboxAgent } from "sandbox-agent"; + import { local } from "sandbox-agent/local"; - - Run without installing globally. + // Runs on your machine. Best for local development and testing. + const sdk = await SandboxAgent.start({ + sandbox: local(), + }); + ``` - ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 - ``` - + ```typescript E2B + import { SandboxAgent } from "sandbox-agent"; + import { e2b } from "sandbox-agent/e2b"; - - Run without installing globally. + const sdk = await SandboxAgent.start({ + sandbox: e2b({ create: { envs } }), + }); + ``` - ```bash - bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 - ``` - + ```typescript Daytona + import { SandboxAgent } from "sandbox-agent"; + import { daytona } from "sandbox-agent/daytona"; - - Install globally, then run. + const sdk = await SandboxAgent.start({ + sandbox: daytona({ create: { envVars } }), + }); + ``` - ```bash - npm install -g @sandbox-agent/cli@0.3.x - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - + ```typescript Vercel + import { SandboxAgent } from "sandbox-agent"; + import { vercel } from "sandbox-agent/vercel"; - - Install globally, then run. + const sdk = await SandboxAgent.start({ + sandbox: vercel({ create: { runtime: "node24", env } }), + }); + ``` - ```bash - bun add -g @sandbox-agent/cli@0.3.x - # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - + ```typescript Cloudflare + import { SandboxAgent } from "sandbox-agent"; + import { cloudflare } from "sandbox-agent/cloudflare"; - - For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. + const sdk = await SandboxAgent.start({ + sandbox: cloudflare({ sdk: cfSandboxClient }), + }); + ``` - ```bash - npm install sandbox-agent@0.3.x - ``` + ```typescript Docker + import { SandboxAgent } from "sandbox-agent"; + import { docker } from "sandbox-agent/docker"; - ```typescript - import { SandboxAgent } from "sandbox-agent"; + // Good for testing. Not security-hardened like cloud sandboxes. + const sdk = await SandboxAgent.start({ + sandbox: docker({ + env: [`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`], + }), + }); + ``` + - const sdk = await SandboxAgent.start(); - ``` - - - - For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. - - ```bash - bun add sandbox-agent@0.3.x - # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 - ``` - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - - const sdk = await SandboxAgent.start(); - ``` - - - - If you're running from source instead of the installed CLI. - - ```bash - cargo run -p sandbox-agent -- server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely. + Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details. - - Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure networking at the infrastructure layer. + + Implement the `SandboxProvider` interface to use any sandbox platform: - If you expose the server publicly, use `--token "$SANDBOX_TOKEN"` to require authentication: + ```typescript + import { SandboxAgent, type SandboxProvider } from "sandbox-agent"; - ```bash - sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 + const myProvider: SandboxProvider = { + name: "my-provider", + async create() { + // Provision a sandbox, install & start the server, return an ID + return "sandbox-123"; + }, + async destroy(sandboxId) { + // Tear down the sandbox + }, + async getUrl(sandboxId) { + // Return the Sandbox Agent server URL + return `https://${sandboxId}.my-platform.dev:3000`; + }, + }; + + const sdk = await SandboxAgent.start({ + sandbox: myProvider, + }); ``` + - Then pass the token when connecting: + + If you already have a Sandbox Agent server running, connect directly: + ```typescript + const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); + ``` + + + - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - - const sdk = await SandboxAgent.connect({ - baseUrl: "http://your-server:2468", - token: process.env.SANDBOX_TOKEN, - }); - ``` - - ```bash - curl "http://your-server:2468/v1/health" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh + sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` - - + ```bash - sandbox-agent --token "$SANDBOX_TOKEN" api agents list \ - --endpoint http://your-server:2468 + npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 + ``` + + + ```bash + docker run -p 2468:2468 \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e OPENAI_API_KEY="sk-..." \ + rivetdev/sandbox-agent:0.3.2-full \ + server --no-token --host 0.0.0.0 --port 2468 ``` - - If you're calling the server from a browser, see the [CORS configuration guide](/cors). - - - Supported agent IDs: `claude`, `codex`, `opencode`, `amp`, `pi`, `cursor`, `mock`. - - To preinstall agents: - - ```bash - sandbox-agent install-agent --all - ``` - - If agents are not installed up front, they are lazily installed when creating a session. - - - + ```typescript - import { SandboxAgent } from "sandbox-agent"; - - const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - }); - const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/", - mcpServers: [], - }, }); - console.log(session.id); - ``` - + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); - - ```typescript const result = await session.prompt([ { type: "text", text: "Summarize the repository and suggest next steps." }, ]); @@ -258,24 +191,16 @@ icon: "rocket" ``` - + ```typescript - const off = session.onEvent((event) => { - console.log(event.sender, event.payload); - }); - - const page = await sdk.getEvents({ - sessionId: session.id, - limit: 50, - }); - - console.log(page.items.length); - off(); + await sdk.destroySandbox(); // tears down the sandbox and disconnects ``` + + Use `sdk.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later). - - Open the Inspector UI at `/ui/` on your server (for example, `http://localhost:2468/ui/`) to inspect sessions and events in a GUI. + + Open the Inspector at `/ui/` on your server (e.g. `http://localhost:2468/ui/`) to view sessions and events in a GUI. Sandbox Agent Inspector @@ -283,16 +208,40 @@ icon: "rocket" +## Full example + +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; + +const sdk = await SandboxAgent.start({ + sandbox: local(), +}); + +try { + const session = await sdk.createSession({ agent: "claude" }); + + session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); + }); + + const result = await session.prompt([ + { type: "text", text: "Write a function that checks if a number is prime." }, + ]); + + console.log("Done:", result.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + ## Next steps - - - Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence. + + + Full TypeScript SDK API surface. - Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare. - - - Use the latest TypeScript SDK API. + Deploy to E2B, Daytona, Docker, Vercel, or Cloudflare. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index fc4aee1..1b8a707 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -84,25 +84,40 @@ const sdk = await SandboxAgent.connect({ }); ``` -Local autospawn (Node.js only): +Local spawn with a sandbox provider: ```ts import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const localSdk = await SandboxAgent.start(); +const sdk = await SandboxAgent.start({ + sandbox: local(), +}); -await localSdk.dispose(); +// sdk.sandboxId — prefixed provider ID (e.g. "local/127.0.0.1:2468") + +await sdk.destroySandbox(); // tears down sandbox + disposes client ``` +`SandboxAgent.start(...)` requires a `sandbox` provider. Built-in providers: + +| Import | Provider | +|--------|----------| +| `sandbox-agent/local` | Local subprocess | +| `sandbox-agent/docker` | Docker container | +| `sandbox-agent/e2b` | E2B sandbox | +| `sandbox-agent/daytona` | Daytona workspace | +| `sandbox-agent/vercel` | Vercel Sandbox | +| `sandbox-agent/cloudflare` | Cloudflare Sandbox | + +Use `sdk.dispose()` to disconnect without destroying the sandbox, or `sdk.destroySandbox()` to tear down both. + ## Session flow ```ts const session = await sdk.createSession({ agent: "mock", - sessionInit: { - cwd: "/", - mcpServers: [], - }, + cwd: "/", }); const prompt = await session.prompt([ @@ -223,6 +238,7 @@ Parameters: - `token` (optional): Bearer token for authenticated servers - `headers` (optional): Additional request headers - `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls +- `skipHealthCheck` (optional): set `true` to skip the startup `/v1/health` wait - `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` diff --git a/docs/security.mdx b/docs/security.mdx index ec00f49..c8b02ad 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -4,7 +4,7 @@ description: "Backend-first auth and access control patterns." icon: "shield" --- -As covered in [Architecture](/architecture), run the Sandbox Agent client on your backend, not in the browser. +As covered in [Orchestration Architecture](/orchestration-architecture), run the Sandbox Agent client on your backend, not in the browser. This keeps sandbox credentials private and gives you one place for authz, rate limiting, and audit logging. @@ -92,7 +92,7 @@ export const workspace = actor({ const session = await sdk.createSession({ agent: "claude", - sessionInit: { cwd: "/workspace" }, + cwd: "/workspace", }); session.onEvent((event) => { diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx index eaa4de0..b8328ec 100644 --- a/docs/session-persistence.mdx +++ b/docs/session-persistence.mdx @@ -10,7 +10,7 @@ With persistence enabled, sessions can be restored after runtime/session loss. S Each driver stores: -- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sessionInit`) +- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`) - `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`) ## Persistence drivers @@ -160,11 +160,11 @@ Implement `SessionPersistDriver` for custom backends. import type { SessionPersistDriver } from "sandbox-agent"; class MyDriver implements SessionPersistDriver { - async getSession(id) { return null; } + async getSession(id) { return undefined; } async listSessions(request) { return { items: [] }; } async updateSession(session) {} async listEvents(request) { return { items: [] }; } - async insertEvent(event) {} + async insertEvent(sessionId, event) {} } ``` diff --git a/docs/skills-config.mdx b/docs/skills-config.mdx index c85bc2c..c3145c2 100644 --- a/docs/skills-config.mdx +++ b/docs/skills-config.mdx @@ -35,9 +35,7 @@ await sdk.setSkillsConfig( // Create a session using the configured skills const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index bdcd53a..171166b 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -25,7 +25,7 @@ const baseUrl = "http://localhost:3000"; console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts index 46f43d6..8e32644 100644 --- a/examples/computesdk/src/computesdk.ts +++ b/examples/computesdk/src/computesdk.ts @@ -131,7 +131,7 @@ export async function runComputeSdkExample(): Promise { process.once("SIGTERM", handleExit); const client = await SandboxAgent.connect({ baseUrl }); - const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home", mcpServers: [] } }); + const session = await client.createSession({ agent: detectAgent(), cwd: "/home" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 09f4cff..b881113 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,42 +1,31 @@ -import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; - -const daytona = new Daytona(); +import { daytona } from "sandbox-agent/daytona"; +import { detectAgent } from "@sandbox-agent/example-shared"; const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -// Use default image and install sandbox-agent at runtime (faster startup, no snapshot build) -console.log("Creating Daytona sandbox..."); -const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); +const client = await SandboxAgent.start({ + sandbox: daytona({ + create: { envVars }, + }), +}); -// Install sandbox-agent and start server -console.log("Installing sandbox-agent..."); -await sandbox.process.executeCommand("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); +console.log(`UI: ${client.inspectorUrl}`); -console.log("Installing agents..."); -await sandbox.process.executeCommand("sandbox-agent install-agent claude"); -await sandbox.process.executeCommand("sandbox-agent install-agent codex"); +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/daytona", +}); -await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +session.prompt([{ type: "text", text: "Say hello from Daytona in one sentence." }]); -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.delete(60); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/docker/package.json b/examples/docker/package.json index 2c29cfe..7b796c9 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -9,10 +9,10 @@ "dependencies": { "@sandbox-agent/example-shared": "workspace:*", "dockerode": "latest", + "get-port": "latest", "sandbox-agent": "workspace:*" }, "devDependencies": { - "@types/dockerode": "latest", "@types/node": "latest", "tsx": "latest", "typescript": "latest", diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index 74469f3..9f50859 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,68 +1,40 @@ -import Docker from "dockerode"; import fs from "node:fs"; import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { docker } from "sandbox-agent/docker"; +import { detectAgent } from "@sandbox-agent/example-shared"; import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker"; -const IMAGE = FULL_IMAGE; -const PORT = 3000; -const agent = detectAgent(); const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : []; +const env = [ + 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}` : "", + process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", +].filter(Boolean); -const docker = new Docker({ socketPath: "/var/run/docker.sock" }); - -// Pull image if needed -try { - await docker.getImage(IMAGE).inspect(); -} catch { - console.log(`Pulling ${IMAGE}...`); - await new Promise((resolve, reject) => { - docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => { - if (err) return reject(err); - docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); - }); - }); -} - -console.log("Starting container..."); -const container = await docker.createContainer({ - Image: IMAGE, - Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], - Env: [ - 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}` : "", - process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", - ].filter(Boolean), - ExposedPorts: { [`${PORT}/tcp`]: {} }, - HostConfig: { - AutoRemove: true, - PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, - Binds: bindMounts, - }, +const client = await SandboxAgent.start({ + sandbox: docker({ + image: FULL_IMAGE, + env, + binds: bindMounts, + }), }); -await container.start(); -const baseUrl = `http://127.0.0.1:${PORT}`; +console.log(`UI: ${client.inspectorUrl}`); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } }); -const sessionId = session.id; +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/sandbox", +}); -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - try { - await container.stop({ t: 5 }); - } catch {} - try { - await container.remove({ force: true }); - } catch {} +session.prompt([{ type: "text", text: "Say hello from Docker in one sentence." }]); + +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index 7dd2882..996b99f 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,45 +1,31 @@ -import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { e2b } from "sandbox-agent/e2b"; +import { detectAgent } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -console.log("Creating E2B sandbox..."); -const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); +const client = await SandboxAgent.start({ + sandbox: e2b({ + create: { envs }, + }), +}); -const run = async (cmd: string) => { - const result = await sandbox.commands.run(cmd); - if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}\n${result.stderr}`); - return result; -}; +console.log(`UI: ${client.inspectorUrl}`); -console.log("Installing sandbox-agent..."); -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/user", +}); -console.log("Installing agents..."); -await run("sandbox-agent install-agent claude"); -await run("sandbox-agent install-agent codex"); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -console.log("Starting server..."); -await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --port 3000", { background: true, timeoutMs: 0 }); +session.prompt([{ type: "text", text: "Say hello from E2B in one sentence." }]); -const baseUrl = `https://${sandbox.getHost(3000)}`; - -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.kill(); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts index abe4e08..71d65c0 100644 --- a/examples/file-system/src/index.ts +++ b/examples/file-system/src/index.ts @@ -44,7 +44,7 @@ const readmeText = new TextDecoder().decode(readmeBytes); console.log(` README.md content: ${readmeText.trim()}`); console.log("Creating session..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/opt/my-project" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "read the README in /opt/my-project"'); diff --git a/examples/permissions/src/index.ts b/examples/permissions/src/index.ts index 811f65c..e684e34 100644 --- a/examples/permissions/src/index.ts +++ b/examples/permissions/src/index.ts @@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { Command } from "commander"; import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; const options = parseOptions(); const agent = options.agent.trim().toLowerCase(); @@ -9,10 +10,7 @@ const autoReply = parsePermissionReply(options.reply); const promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`; const sdk = await SandboxAgent.start({ - spawn: { - enabled: true, - log: "inherit", - }, + sandbox: local({ log: "inherit" }), }); try { @@ -43,10 +41,7 @@ try { const session = await sdk.createSession({ agent, ...(mode ? { mode } : {}), - sessionInit: { - cwd: process.cwd(), - mcpServers: [], - }, + cwd: process.cwd(), }); const rl = autoReply diff --git a/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts index 44b2161..490be64 100644 --- a/examples/skills-custom-tool/src/index.ts +++ b/examples/skills-custom-tool/src/index.ts @@ -36,7 +36,7 @@ await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { s // Create a session. console.log("Creating session with custom skill..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts index c04815c..3087ecc 100644 --- a/examples/skills/src/index.ts +++ b/examples/skills/src/index.ts @@ -15,7 +15,7 @@ await client.setSkillsConfig( ); console.log("Creating session..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "How do I start sandbox-agent?"'); diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 4a63bfc..9839893 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,56 +1,34 @@ -import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { vercel } from "sandbox-agent/vercel"; +import { detectAgent } from "@sandbox-agent/example-shared"; -const envs: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -console.log("Creating Vercel sandbox..."); -const sandbox = await Sandbox.create({ - runtime: "node24", - ports: [3000], +const client = await SandboxAgent.start({ + sandbox: vercel({ + create: { + runtime: "node24", + env, + }, + }), }); -const run = async (cmd: string, args: string[] = []) => { - const result = await sandbox.runCommand({ cmd, args, env: envs }); - if (result.exitCode !== 0) { - const stderr = await result.stderr(); - throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${stderr}`); - } - return result; -}; +console.log(`UI: ${client.inspectorUrl}`); -console.log("Installing sandbox-agent..."); -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); - -console.log("Installing agents..."); -await run("sandbox-agent", ["install-agent", "claude"]); -await run("sandbox-agent", ["install-agent", "codex"]); - -console.log("Starting server..."); -await sandbox.runCommand({ - cmd: "sandbox-agent", - args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"], - env: envs, - detached: true, +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/vercel-sandbox", }); -const baseUrl = sandbox.domain(3000); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); -const sessionId = session.id; +session.prompt([{ type: "text", text: "Say hello from Vercel in one sentence." }]); -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.stop(); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/frontend/packages/website/src/components/GetStarted.tsx b/frontend/packages/website/src/components/GetStarted.tsx index 57cccef..8a03b34 100644 --- a/frontend/packages/website/src/components/GetStarted.tsx +++ b/frontend/packages/website/src/components/GetStarted.tsx @@ -5,8 +5,11 @@ import { Code, Server, GitBranch } from "lucide-react"; import { CopyButton } from "./ui/CopyButton"; const sdkCodeRaw = `import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const client = await SandboxAgent.start(); +const client = await SandboxAgent.start({ + sandbox: local(), +}); await client.createSession("my-session", { agent: "claude-code", @@ -32,13 +35,26 @@ function SdkCodeHighlighted() { "sandbox-agent" ; + {"\n"} + import + {" { "} + local + {" } "} + from + + "sandbox-agent/local" + ; {"\n\n"} const client = await SandboxAgent. start - (); + {"({"} + {"\n"} + {" sandbox: local(),"} + {"\n"} + {"});"} {"\n\n"} await client. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8396837..59a5a6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,13 +154,13 @@ importers: dockerode: specifier: latest version: 4.0.9 + get-port: + specifier: latest + version: 7.1.0 sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: - '@types/dockerode': - specifier: latest - version: 4.0.1 '@types/node': specifier: latest version: 25.5.0 @@ -1025,12 +1025,33 @@ importers: specifier: workspace:* version: link:../cli devDependencies: + '@cloudflare/sandbox': + specifier: '>=0.1.0' + version: 0.7.17(@opencode-ai/sdk@1.2.24) + '@daytonaio/sdk': + specifier: '>=0.12.0' + version: 0.151.0(ws@8.19.0) + '@e2b/code-interpreter': + specifier: '>=1.0.0' + version: 2.3.3 + '@types/dockerode': + specifier: ^4.0.0 + version: 4.0.1 '@types/node': specifier: ^22.0.0 version: 22.19.7 '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@vercel/sandbox': + specifier: '>=0.1.0' + version: 1.8.1 + dockerode: + specifier: '>=4.0.0' + version: 4.0.9 + get-port: + specifier: '>=7.0.0' + version: 7.1.0 openapi-typescript: specifier: ^6.7.0 version: 6.7.6 @@ -11273,7 +11294,7 @@ snapshots: glob: 11.1.0 openapi-fetch: 0.14.1 platform: 1.3.6 - tar: 7.5.6 + tar: 7.5.7 earcut@2.2.4: {} diff --git a/sdks/persist-indexeddb/src/index.ts b/sdks/persist-indexeddb/src/index.ts index 945e993..b6af1c8 100644 --- a/sdks/persist-indexeddb/src/index.ts +++ b/sdks/persist-indexeddb/src/index.ts @@ -31,11 +31,11 @@ export class IndexedDbSessionPersistDriver implements SessionPersistDriver { this.dbPromise = this.openDatabase(); } - async getSession(id: string): Promise { + async getSession(id: string): Promise { const db = await this.dbPromise; const row = await requestToPromise(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 { + async insertEvent(_sessionId: string, event: SessionEvent): Promise { 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, }; } diff --git a/sdks/persist-indexeddb/tests/driver.test.ts b/sdks/persist-indexeddb/tests/driver.test.ts index 78acbe1..5c743be 100644 --- a/sdks/persist-indexeddb/tests/driver.test.ts +++ b/sdks/persist-indexeddb/tests/driver.test.ts @@ -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", diff --git a/sdks/persist-postgres/src/index.ts b/sdks/persist-postgres/src/index.ts index 7c77827..8b79791 100644 --- a/sdks/persist-postgres/src/index.ts +++ b/sdks/persist-postgres/src/index.ts @@ -33,18 +33,18 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { this.initialized = this.initialize(); } - async getSession(id: string): Promise { + async getSession(id: string): Promise { await this.ready(); const result = await this.pool.query( - `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( - `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 { + async insertEvent(_sessionId: string, event: SessionEvent): Promise { 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, }; } diff --git a/sdks/persist-rivet/src/index.ts b/sdks/persist-rivet/src/index.ts index d236040..b89b7df 100644 --- a/sdks/persist-rivet/src/index.ts +++ b/sdks/persist-rivet/src/index.ts @@ -50,9 +50,9 @@ export class RivetSessionPersistDriver implements SessionPersistDriver { return this.ctx.state[this.stateKey] as RivetPersistData; } - async getSession(id: string): Promise { + async getSession(id: string): Promise { const session = this.data.sessions[id]; - return session ? cloneSessionRecord(session) : null; + return session ? cloneSessionRecord(session) : undefined; } async listSessions(request: ListPageRequest = {}): Promise> { @@ -112,15 +112,15 @@ export class RivetSessionPersistDriver implements SessionPersistDriver { }; } - async insertEvent(event: SessionEvent): Promise { - const events = this.data.events[event.sessionId] ?? []; + async insertEvent(sessionId: string, event: SessionEvent): Promise { + 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; } } diff --git a/sdks/persist-rivet/tests/driver.test.ts b/sdks/persist-rivet/tests/driver.test.ts index c16e733..839cffe 100644 --- a/sdks/persist-rivet/tests/driver.test.ts +++ b/sdks/persist-rivet/tests/driver.test.ts @@ -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", diff --git a/sdks/persist-sqlite/src/index.ts b/sdks/persist-sqlite/src/index.ts index 379c4ef..b04b0fc 100644 --- a/sdks/persist-sqlite/src/index.ts +++ b/sdks/persist-sqlite/src/index.ts @@ -15,16 +15,16 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { this.initialize(); } - async getSession(id: string): Promise { + async getSession(id: string): Promise { 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 { + async insertEvent(_sessionId: string, event: SessionEvent): Promise { 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, }; } diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 2c94592..64d42e6 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -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", diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 9945c0a..94fbc3e 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -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; 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; 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; 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 { - 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 { + 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 { @@ -922,10 +986,23 @@ export class SandboxAgent { await connection.close(); }), ); + } - if (this.spawnHandle) { - await this.spawnHandle.dispose(); - this.spawnHandle = undefined; + async destroySandbox(): Promise { + 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 | undefined): Omit { +function normalizeSessionInit(value: Omit | undefined, cwdShorthand?: string): Omit { 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 { + if (provider.getFetch) { + return await provider.getFetch(rawSandboxId); + } + + return undefined; +} + +async function resolveProviderToken(provider: SandboxProvider, rawSandboxId: string): Promise { + const maybeGetToken = ( + provider as SandboxProvider & { + getToken?: (sandboxId: string) => string | undefined | Promise; + } + ).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 { try { const text = await response.clone().text(); diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 99bc1b6..f0ebe2e 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -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, diff --git a/sdks/typescript/src/providers/cloudflare.ts b/sdks/typescript/src/providers/cloudflare.ts new file mode 100644 index 0000000..c17adfc --- /dev/null +++ b/sdks/typescript/src/providers/cloudflare.ts @@ -0,0 +1,79 @@ +import type { SandboxProvider } from "./types.ts"; + +const DEFAULT_AGENT_PORT = 3000; + +export interface CloudflareSandboxClient { + create?(options?: Record): Promise<{ id?: string; sandboxId?: string }>; + connect?( + sandboxId: string, + options?: Record, + ): Promise<{ + close?(): Promise; + stop?(): Promise; + containerFetch(input: RequestInfo | URL, init?: RequestInit, port?: number): Promise; + }>; +} + +export interface CloudflareProviderOptions { + sdk: CloudflareSandboxClient; + create?: Record | (() => Record | Promise>); + agentPort?: number; +} + +async function resolveCreateOptions(value: CloudflareProviderOptions["create"]): Promise> { + 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 { + 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 { + 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 { + 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, + ); + }, + }; +} diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts new file mode 100644 index 0000000..eb34b88 --- /dev/null +++ b/sdks/typescript/src/providers/daytona.ts @@ -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[0]>; + +export interface DaytonaProviderOptions { + create?: DaytonaCreateParams | (() => DaytonaCreateParams | Promise); + image?: string; + agentPort?: number; + previewTtlSeconds?: number; + deleteTimeoutSeconds?: number; +} + +async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise { + 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 { + 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 { + const sandbox = await client.get(sandboxId); + if (!sandbox) { + return; + } + await sandbox.delete(options.deleteTimeoutSeconds); + }, + async getUrl(sandboxId: string): Promise { + 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 { + const sandbox = await client.get(sandboxId); + if (!sandbox) { + throw new Error(`daytona sandbox not found: ${sandboxId}`); + } + await sandbox.process.executeCommand(buildServerStartCommand(agentPort)); + }, + }; +} diff --git a/sdks/typescript/src/providers/docker.ts b/sdks/typescript/src/providers/docker.ts new file mode 100644 index 0000000..9e49687 --- /dev/null +++ b/sdks/typescript/src/providers/docker.ts @@ -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); + binds?: string[] | (() => string[] | Promise); + createContainerOptions?: Record; +} + +async function resolveValue(value: T | (() => T | Promise) | undefined, fallback: T): Promise { + if (value === undefined) { + return fallback; + } + if (typeof value === "function") { + return await (value as () => T | Promise)(); + } + return value; +} + +function extractMappedPort( + inspect: { NetworkSettings?: { Ports?: Record | 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 { + 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 { + const container = client.getContainer(sandboxId); + try { + await container.stop({ t: 5 }); + } catch {} + try { + await container.remove({ force: true }); + } catch {} + }, + async getUrl(sandboxId: string): Promise { + const container = client.getContainer(sandboxId); + const hostPort = extractMappedPort(await container.inspect(), agentPort); + return `http://${host}:${hostPort}`; + }, + }; +} diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts new file mode 100644 index 0000000..833fe9d --- /dev/null +++ b/sdks/typescript/src/providers/e2b.ts @@ -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 | (() => Record | Promise>); + connect?: Record | ((sandboxId: string) => Record | Promise>); + agentPort?: number; +} + +async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise> { + if (!value) return {}; + if (typeof value === "function") { + if (sandboxId) { + return await (value as (id: string) => Record | Promise>)(sandboxId); + } + return await (value as () => Record | Promise>)(); + } + return value; +} + +export function e2b(options: E2BProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + + return { + name: "e2b", + async create(): Promise { + 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 { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + await sandbox.kill(); + }, + async getUrl(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + return `https://${sandbox.getHost(agentPort)}`; + }, + }; +} diff --git a/sdks/typescript/src/providers/local.ts b/sdks/typescript/src/providers/local.ts new file mode 100644 index 0000000..18fc3d4 --- /dev/null +++ b/sdks/typescript/src/providers/local.ts @@ -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; +} + +const localSandboxes = new Map(); + +type LocalSandboxProvider = SandboxProvider & { + getToken(sandboxId: string): Promise; +}; + +export function local(options: LocalProviderOptions = {}): SandboxProvider { + const provider: LocalSandboxProvider = { + name: "local", + async create(): Promise { + 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 { + const handle = localSandboxes.get(sandboxId); + if (!handle) { + return; + } + localSandboxes.delete(sandboxId); + await handle.dispose(); + }, + async getUrl(sandboxId: string): Promise { + return `http://${sandboxId}`; + }, + async getFetch(sandboxId: string): Promise { + 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 { + return options.token ?? localSandboxes.get(sandboxId)?.token; + }, + }; + return provider; +} + +function baseUrlToSandboxId(baseUrl: string): string { + return new URL(baseUrl).host; +} diff --git a/sdks/typescript/src/providers/shared.ts b/sdks/typescript/src/providers/shared.ts new file mode 100644 index 0000000..d838a0a --- /dev/null +++ b/sdks/typescript/src/providers/shared.ts @@ -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 &`; +} diff --git a/sdks/typescript/src/providers/types.ts b/sdks/typescript/src/providers/types.ts new file mode 100644 index 0000000..e51163f --- /dev/null +++ b/sdks/typescript/src/providers/types.ts @@ -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; + + /** Permanently tear down a sandbox. */ + destroy(sandboxId: string): Promise; + + /** + * Return the sandbox-agent base URL for this sandbox. + * Providers that cannot expose a URL should implement `getFetch()` instead. + */ + getUrl?(sandboxId: string): Promise; + + /** + * Return a fetch implementation that routes requests to the sandbox. + * Providers that expose a URL can implement `getUrl()` instead. + */ + getFetch?(sandboxId: string): Promise; + + /** + * 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; +} diff --git a/sdks/typescript/src/providers/vercel.ts b/sdks/typescript/src/providers/vercel.ts new file mode 100644 index 0000000..98d21c1 --- /dev/null +++ b/sdks/typescript/src/providers/vercel.ts @@ -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 | (() => Record | Promise>); + agentPort?: number; +} + +async function resolveCreateOptions(value: VercelProviderOptions["create"], agentPort: number): Promise> { + const resolved = typeof value === "function" ? await value() : (value ?? {}); + return { + ports: [agentPort], + ...resolved, + }; +} + +async function runVercelCommand(sandbox: InstanceType, cmd: string, args: string[] = []): Promise { + 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 { + const sandbox = await Sandbox.create((await resolveCreateOptions(options.create, agentPort)) as Parameters[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 { + const sandbox = await Sandbox.get({ sandboxId }); + await sandbox.stop(); + }, + async getUrl(sandboxId: string): Promise { + const sandbox = await Sandbox.get({ sandboxId }); + return sandbox.domain(agentPort); + }, + }; +} diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 6865690..f2a7af3 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -98,6 +98,7 @@ export interface SessionRecord { lastConnectionId: string; createdAt: number; destroyedAt?: number; + sandboxId?: string; sessionInit?: Omit; configOptions?: SessionConfigOption[]; modes?: SessionModeState | null; @@ -131,11 +132,11 @@ export interface ListEventsRequest extends ListPageRequest { } export interface SessionPersistDriver { - getSession(id: string): Promise; + getSession(id: string): Promise; listSessions(request?: ListPageRequest): Promise>; updateSession(session: SessionRecord): Promise; listEvents(request: ListEventsRequest): Promise>; - insertEvent(event: SessionEvent): Promise; + insertEvent(sessionId: string, event: SessionEvent): Promise; } 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 { + async getSession(id: string): Promise { const session = this.sessions.get(id); - return session ? cloneSessionRecord(session) : null; + return session ? cloneSessionRecord(session) : undefined; } async listSessions(request: ListPageRequest = {}): Promise> { @@ -219,15 +220,15 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver { }; } - async insertEvent(event: SessionEvent): Promise { - const events = this.eventsBySession.get(event.sessionId) ?? []; + async insertEvent(sessionId: string, event: SessionEvent): Promise { + 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); } } diff --git a/sdks/typescript/tests/providers.test.ts b/sdks/typescript/tests/providers.test.ts new file mode 100644 index 0000000..4b874b0 --- /dev/null +++ b/sdks/typescript/tests/providers.test.ts @@ -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 { + const keys: Record = {}; + 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); +} diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts index faf3167..fe84102 100644 --- a/sdks/typescript/tsup.config.ts +++ b/sdks/typescript/tsup.config.ts @@ -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"], }); diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts index 8676010..e83d10a 100644 --- a/sdks/typescript/vitest.config.ts +++ b/sdks/typescript/vitest.config.ts @@ -4,5 +4,7 @@ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30000, + teardownTimeout: 10000, + pool: "forks", }, });