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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-15 12:39:05 -07:00
parent 3426cbc6ec
commit 6a42f06342
53 changed files with 1689 additions and 667 deletions

View file

@ -74,6 +74,7 @@
- `examples/docker/src/index.ts` - `examples/docker/src/index.ts`
- `examples/e2b/src/index.ts` - `examples/e2b/src/index.ts`
- `examples/vercel/src/index.ts` - `examples/vercel/src/index.ts`
- `sdks/typescript/src/providers/shared.ts`
- `scripts/release/main.ts` - `scripts/release/main.ts`
- `scripts/release/promote-artifacts.ts` - `scripts/release/promote-artifacts.ts`
- `scripts/release/sdk.ts` - `scripts/release/sdk.ts`

View file

@ -37,7 +37,7 @@ await writeFile(
const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" }); const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" });
await sdk.createSession({ await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd, mcpServers: [] }, cwd,
}); });
``` ```

View file

@ -21,10 +21,7 @@ const sdk = await SandboxAgent.connect({
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "codex", agent: "codex",
sessionInit: { cwd: "/",
cwd: "/",
mcpServers: [],
},
}); });
console.log(session.id, session.agentSessionId); console.log(session.id, session.agentSessionId);

View file

@ -1,64 +1,59 @@
--- ---
title: "Architecture" title: "Architecture"
description: "How the client, sandbox, server, and agent fit together." description: "How the Sandbox Agent server, SDK, and agent processes fit together."
icon: "microchip"
--- ---
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 ## Components
- `Your client`: your app code using the `sandbox-agent` SDK. ```mermaid
- `Sandbox`: isolated runtime (E2B, Daytona, Docker, etc.). flowchart LR
- `Sandbox Agent server`: process inside the sandbox exposing HTTP transport. CLIENT["Your App"]
- `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"]
subgraph SANDBOX["Sandbox"] subgraph SANDBOX["Sandbox"]
direction TB direction TB
SERVER --> AGENT SERVER["Sandbox Agent Server"]
AGENT["Agent Process<br/>(Claude, Codex, etc.)"]
SERVER --> AGENT
end 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" For the `local` provider, provisioning is a no-op and the server runs as a local subprocess.
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 ## Server endpoints
BACKEND --> SDK --> SERVER_SIMPLE
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. For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns.
- **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).

View file

@ -80,9 +80,7 @@ await sdk.setMcpConfig(
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd: "/workspace",
cwd: "/workspace",
},
}); });
await session.prompt([ await session.prompt([
@ -145,9 +143,7 @@ await sdk.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skill);
```ts ```ts
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd: "/workspace",
cwd: "/workspace",
},
}); });
await session.prompt([ await session.prompt([

View file

@ -31,7 +31,38 @@ RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex
EXPOSE 8000 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 ```typescript
import { getSandbox, type Sandbox } from "@cloudflare/sandbox"; import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
@ -109,7 +140,6 @@ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
export default app; 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`. This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path and does not require a `baseUrl`.
## Troubleshooting streaming updates ## Troubleshooting streaming updates

View file

@ -15,40 +15,37 @@ See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/).
## TypeScript example ## TypeScript example
```typescript ```bash
import { Daytona } from "@daytonaio/sdk"; npm install sandbox-agent@0.3.x @daytonaio/sdk
import { SandboxAgent } from "sandbox-agent"; ```
const daytona = new Daytona(); ```typescript
import { SandboxAgent } from "sandbox-agent";
import { daytona } from "sandbox-agent/daytona";
const envVars: Record<string, string> = {}; const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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; 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( try {
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" const session = await sdk.createSession({ agent: "claude" });
); const response = await session.prompt([
{ type: "text", text: "Summarize this repository" },
await sandbox.process.executeCommand("sandbox-agent install-agent claude"); ]);
await sandbox.process.executeCommand("sandbox-agent install-agent codex"); console.log(response.stopReason);
} finally {
await sandbox.process.executeCommand( await sdk.destroySandbox();
"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();
``` ```
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 ## Using snapshots for faster startup
```typescript ```typescript

View file

@ -15,43 +15,43 @@ Run the published full image with all supported agents pre-installed:
docker run --rm -p 3000:3000 \ docker run --rm -p 3000:3000 \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-e OPENAI_API_KEY="$OPENAI_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 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 ```typescript
import Docker from "dockerode";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { docker } from "sandbox-agent/docker";
const docker = new Docker(); const sdk = await SandboxAgent.start({
const PORT = 3000; sandbox: docker({
env: [
const container = await docker.createContainer({ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
Image: "rivetdev/sandbox-agent:0.3.1-full", `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], ].filter(Boolean),
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}` }] },
},
}); });
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}`; The `docker` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default. Override with `image`:
const sdk = await SandboxAgent.connect({ baseUrl });
const session = await sdk.createSession({ agent: "codex" }); ```typescript
await session.prompt([{ type: "text", text: "Summarize this repository." }]); docker({ image: "my-custom-image:latest" })
``` ```
## Building a custom image with everything preinstalled ## Building a custom image with everything preinstalled

View file

@ -10,42 +10,37 @@ description: "Deploy Sandbox Agent inside an E2B sandbox."
## TypeScript example ## TypeScript example
```bash
npm install sandbox-agent@0.3.x @e2b/code-interpreter
```
```typescript ```typescript
import { Sandbox } from "@e2b/code-interpreter";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { e2b } from "sandbox-agent/e2b";
const envs: Record<string, string> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); const sdk = await SandboxAgent.start({
sandbox: e2b({
await sandbox.commands.run( create: { envs },
"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);
}); });
await session.prompt([{ type: "text", text: "Summarize this repository" }]); try {
off(); const session = await sdk.createSession({ agent: "claude" });
const response = await session.prompt([
await sandbox.kill(); { 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 ## Faster cold starts
For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed.

View file

@ -32,12 +32,15 @@ Or with npm/Bun:
## With the TypeScript SDK ## 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 ```typescript
import { SandboxAgent } from "sandbox-agent"; 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({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
@ -47,7 +50,21 @@ await session.prompt([
{ type: "text", text: "Summarize this repository." }, { type: "text", text: "Summarize this repository." },
]); ]);
await sdk.dispose(); await sdk.destroySandbox();
``` ```
This starts the server on an available local port and connects automatically. 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,
},
}),
});
```

View file

@ -10,52 +10,40 @@ description: "Deploy Sandbox Agent inside a Vercel Sandbox."
## TypeScript example ## TypeScript example
```typescript ```bash
import { Sandbox } from "@vercel/sandbox"; npm install sandbox-agent@0.3.x @vercel/sandbox
import { SandboxAgent } from "sandbox-agent";
const envs: Record<string, string> = {};
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();
``` ```
```typescript
import { SandboxAgent } from "sandbox-agent";
import { vercel } from "sandbox-agent/vercel";
const env: Record<string, string> = {};
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 ## Authentication
Vercel Sandboxes support OIDC token auth (recommended) and access-token auth. Vercel Sandboxes support OIDC token auth (recommended) and access-token auth.

View file

@ -58,13 +58,13 @@
"icon": "server", "icon": "server",
"pages": [ "pages": [
"deploy/local", "deploy/local",
"deploy/computesdk",
"deploy/e2b", "deploy/e2b",
"deploy/daytona", "deploy/daytona",
"deploy/vercel", "deploy/vercel",
"deploy/cloudflare", "deploy/cloudflare",
"deploy/docker", "deploy/docker",
"deploy/boxlite" "deploy/boxlite",
"deploy/computesdk"
] ]
} }
] ]
@ -79,11 +79,12 @@
}, },
{ {
"group": "Orchestration", "group": "Orchestration",
"pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"] "pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"]
}, },
{ {
"group": "Reference", "group": "Reference",
"pages": [ "pages": [
"architecture",
"agent-capabilities", "agent-capabilities",
"cli", "cli",
"inspector", "inspector",

View file

@ -27,9 +27,7 @@ await sdk.setMcpConfig(
// Create a session using the configured MCP servers // Create a session using the configured MCP servers
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd: "/workspace",
cwd: "/workspace",
},
}); });
await session.prompt([ await session.prompt([

View file

@ -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).

View file

@ -1,20 +1,22 @@
--- ---
title: "Quickstart" 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" icon: "rocket"
--- ---
<Steps> <Steps>
<Step title="Install skill (optional)"> <Step title="Install">
<Tabs> <Tabs>
<Tab title="npx"> <Tab title="npm">
```bash ```bash
npx skills add rivet-dev/skills -s sandbox-agent npm install sandbox-agent@0.3.x
``` ```
</Tab> </Tab>
<Tab title="bunx"> <Tab title="bun">
```bash ```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
``` ```
</Tab> </Tab>
</Tabs> </Tabs>
@ -23,52 +25,10 @@ icon: "rocket"
<Step title="Set environment variables"> <Step title="Set environment variables">
Each coding agent requires API keys to connect to their respective LLM providers. Each coding agent requires API keys to connect to their respective LLM providers.
<Tabs> ```bash
<Tab title="Local shell"> export ANTHROPIC_API_KEY="sk-ant-..."
```bash export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..." ```
export OPENAI_API_KEY="sk-..."
```
</Tab>
<Tab title="E2B">
```typescript
import { Sandbox } from "@e2b/code-interpreter";
const envs: Record<string, string> = {};
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 });
```
</Tab>
<Tab title="Daytona">
```typescript
import { Daytona } from "@daytonaio/sdk";
const envVars: Record<string, string> = {};
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,
});
```
</Tab>
<Tab title="Docker">
```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
```
</Tab>
</Tabs>
<AccordionGroup> <AccordionGroup>
<Accordion title="Extracting API keys from current machine"> <Accordion title="Extracting API keys from current machine">
@ -83,173 +43,146 @@ icon: "rocket"
</AccordionGroup> </AccordionGroup>
</Step> </Step>
<Step title="Run the server"> <Step title="Start the sandbox">
<Tabs> `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client.
<Tab title="curl">
Install and run the binary directly.
```bash <CodeGroup>
curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh ```typescript Local
sandbox-agent server --no-token --host 0.0.0.0 --port 2468 import { SandboxAgent } from "sandbox-agent";
``` import { local } from "sandbox-agent/local";
</Tab>
<Tab title="npx"> // Runs on your machine. Best for local development and testing.
Run without installing globally. const sdk = await SandboxAgent.start({
sandbox: local(),
});
```
```bash ```typescript E2B
npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 import { SandboxAgent } from "sandbox-agent";
``` import { e2b } from "sandbox-agent/e2b";
</Tab>
<Tab title="bunx"> const sdk = await SandboxAgent.start({
Run without installing globally. sandbox: e2b({ create: { envs } }),
});
```
```bash ```typescript Daytona
bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 import { SandboxAgent } from "sandbox-agent";
``` import { daytona } from "sandbox-agent/daytona";
</Tab>
<Tab title="npm i -g"> const sdk = await SandboxAgent.start({
Install globally, then run. sandbox: daytona({ create: { envVars } }),
});
```
```bash ```typescript Vercel
npm install -g @sandbox-agent/cli@0.3.x import { SandboxAgent } from "sandbox-agent";
sandbox-agent server --no-token --host 0.0.0.0 --port 2468 import { vercel } from "sandbox-agent/vercel";
```
</Tab>
<Tab title="bun add -g"> const sdk = await SandboxAgent.start({
Install globally, then run. sandbox: vercel({ create: { runtime: "node24", env } }),
});
```
```bash ```typescript Cloudflare
bun add -g @sandbox-agent/cli@0.3.x import { SandboxAgent } from "sandbox-agent";
# Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). import { cloudflare } from "sandbox-agent/cloudflare";
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
```
</Tab>
<Tab title="Node.js (local)"> const sdk = await SandboxAgent.start({
For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. sandbox: cloudflare({ sdk: cfSandboxClient }),
});
```
```bash ```typescript Docker
npm install sandbox-agent@0.3.x import { SandboxAgent } from "sandbox-agent";
``` import { docker } from "sandbox-agent/docker";
```typescript // Good for testing. Not security-hardened like cloud sandboxes.
import { SandboxAgent } from "sandbox-agent"; const sdk = await SandboxAgent.start({
sandbox: docker({
env: [`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`],
}),
});
```
</CodeGroup>
const sdk = await SandboxAgent.start(); 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.
```
</Tab>
<Tab title="Bun (local)">
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();
```
</Tab>
<Tab title="Build from source">
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
```
</Tab>
</Tabs>
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.
<AccordionGroup> <AccordionGroup>
<Accordion title="Configuring token"> <Accordion title="Implementing a custom provider">
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 const myProvider: SandboxProvider = {
sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 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,
});
``` ```
</Accordion>
Then pass the token when connecting: <Accordion title="Connecting to an existing server">
If you already have a Sandbox Agent server running, connect directly:
```typescript
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
```
</Accordion>
<Accordion title="Starting the server manually">
<Tabs> <Tabs>
<Tab title="TypeScript">
```typescript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://your-server:2468",
token: process.env.SANDBOX_TOKEN,
});
```
</Tab>
<Tab title="curl"> <Tab title="curl">
```bash ```bash
curl "http://your-server:2468/v1/health" \ curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh
-H "Authorization: Bearer $SANDBOX_TOKEN" sandbox-agent server --no-token --host 0.0.0.0 --port 2468
``` ```
</Tab> </Tab>
<Tab title="npx">
<Tab title="CLI">
```bash ```bash
sandbox-agent --token "$SANDBOX_TOKEN" api agents list \ npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468
--endpoint http://your-server:2468 ```
</Tab>
<Tab title="Docker">
```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
``` ```
</Tab> </Tab>
</Tabs> </Tabs>
</Accordion> </Accordion>
<Accordion title="CORS">
If you're calling the server from a browser, see the [CORS configuration guide](/cors).
</Accordion>
</AccordionGroup> </AccordionGroup>
</Step> </Step>
<Step title="Install agents (optional)"> <Step title="Create a session and send a prompt">
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.
</Step>
<Step title="Create a session">
```typescript ```typescript
import { SandboxAgent } from "sandbox-agent";
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
});
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: {
cwd: "/",
mcpServers: [],
},
}); });
console.log(session.id); session.onEvent((event) => {
``` console.log(event.sender, event.payload);
</Step> });
<Step title="Send a message">
```typescript
const result = await session.prompt([ const result = await session.prompt([
{ type: "text", text: "Summarize the repository and suggest next steps." }, { type: "text", text: "Summarize the repository and suggest next steps." },
]); ]);
@ -258,24 +191,16 @@ icon: "rocket"
``` ```
</Step> </Step>
<Step title="Read events"> <Step title="Clean up">
```typescript ```typescript
const off = session.onEvent((event) => { await sdk.destroySandbox(); // tears down the sandbox and disconnects
console.log(event.sender, event.payload);
});
const page = await sdk.getEvents({
sessionId: session.id,
limit: 50,
});
console.log(page.items.length);
off();
``` ```
Use `sdk.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later).
</Step> </Step>
<Step title="Test with Inspector"> <Step title="Inspect with the UI">
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.
<Frame> <Frame>
<img src="/images/inspector.png" alt="Sandbox Agent Inspector" /> <img src="/images/inspector.png" alt="Sandbox Agent Inspector" />
@ -283,16 +208,40 @@ icon: "rocket"
</Step> </Step>
</Steps> </Steps>
## 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 ## Next steps
<CardGroup cols={3}> <CardGroup cols={2}>
<Card title="Session Persistence" icon="database" href="/session-persistence"> <Card title="SDK Overview" icon="compass" href="/sdk-overview">
Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence. Full TypeScript SDK API surface.
</Card> </Card>
<Card title="Deploy to a Sandbox" icon="box" href="/deploy/local"> <Card title="Deploy to a Sandbox" icon="box" href="/deploy/local">
Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare. Deploy to E2B, Daytona, Docker, Vercel, or Cloudflare.
</Card>
<Card title="SDK Overview" icon="compass" href="/sdk-overview">
Use the latest TypeScript SDK API.
</Card> </Card>
</CardGroup> </CardGroup>

View file

@ -84,25 +84,40 @@ const sdk = await SandboxAgent.connect({
}); });
``` ```
Local autospawn (Node.js only): Local spawn with a sandbox provider:
```ts ```ts
import { SandboxAgent } from "sandbox-agent"; 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 ## Session flow
```ts ```ts
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "mock", agent: "mock",
sessionInit: { cwd: "/",
cwd: "/",
mcpServers: [],
},
}); });
const prompt = await session.prompt([ const prompt = await session.prompt([
@ -223,6 +238,7 @@ Parameters:
- `token` (optional): Bearer token for authenticated servers - `token` (optional): Bearer token for authenticated servers
- `headers` (optional): Additional request headers - `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls - `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 - `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()` - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`

View file

@ -4,7 +4,7 @@ description: "Backend-first auth and access control patterns."
icon: "shield" 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. 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({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd: "/workspace" }, cwd: "/workspace",
}); });
session.onEvent((event) => { session.onEvent((event) => {

View file

@ -10,7 +10,7 @@ With persistence enabled, sessions can be restored after runtime/session loss. S
Each driver stores: 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`) - `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`)
## Persistence drivers ## Persistence drivers
@ -160,11 +160,11 @@ Implement `SessionPersistDriver` for custom backends.
import type { SessionPersistDriver } from "sandbox-agent"; import type { SessionPersistDriver } from "sandbox-agent";
class MyDriver implements SessionPersistDriver { class MyDriver implements SessionPersistDriver {
async getSession(id) { return null; } async getSession(id) { return undefined; }
async listSessions(request) { return { items: [] }; } async listSessions(request) { return { items: [] }; }
async updateSession(session) {} async updateSession(session) {}
async listEvents(request) { return { items: [] }; } async listEvents(request) { return { items: [] }; }
async insertEvent(event) {} async insertEvent(sessionId, event) {}
} }
``` ```

View file

@ -35,9 +35,7 @@ await sdk.setSkillsConfig(
// Create a session using the configured skills // Create a session using the configured skills
const session = await sdk.createSession({ const session = await sdk.createSession({
agent: "claude", agent: "claude",
sessionInit: { cwd: "/workspace",
cwd: "/workspace",
},
}); });
await session.prompt([ await session.prompt([

View file

@ -25,7 +25,7 @@ const baseUrl = "http://localhost:3000";
console.log("Connecting to server..."); console.log("Connecting to server...");
const client = await SandboxAgent.connect({ baseUrl }); 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; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);

View file

@ -131,7 +131,7 @@ export async function runComputeSdkExample(): Promise<void> {
process.once("SIGTERM", handleExit); process.once("SIGTERM", handleExit);
const client = await SandboxAgent.connect({ baseUrl }); 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; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);

View file

@ -1,42 +1,31 @@
import { Daytona } from "@daytonaio/sdk";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { daytona } from "sandbox-agent/daytona";
import { detectAgent } from "@sandbox-agent/example-shared";
const daytona = new Daytona();
const envVars: Record<string, string> = {}; const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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; 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) const client = await SandboxAgent.start({
console.log("Creating Daytona sandbox..."); sandbox: daytona({
const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); create: { envVars },
}),
});
// Install sandbox-agent and start server console.log(`UI: ${client.inspectorUrl}`);
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("Installing agents..."); const session = await client.createSession({
await sandbox.process.executeCommand("sandbox-agent install-agent claude"); agent: detectAgent(),
await sandbox.process.executeCommand("sandbox-agent install-agent codex"); 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..."); process.once("SIGINT", async () => {
const client = await SandboxAgent.connect({ baseUrl }); await client.destroySandbox();
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.exit(0); process.exit(0);
}; });
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);

View file

@ -9,10 +9,10 @@
"dependencies": { "dependencies": {
"@sandbox-agent/example-shared": "workspace:*", "@sandbox-agent/example-shared": "workspace:*",
"dockerode": "latest", "dockerode": "latest",
"get-port": "latest",
"sandbox-agent": "workspace:*" "sandbox-agent": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "latest",
"@types/node": "latest", "@types/node": "latest",
"tsx": "latest", "tsx": "latest",
"typescript": "latest", "typescript": "latest",

View file

@ -1,68 +1,40 @@
import Docker from "dockerode";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { SandboxAgent } from "sandbox-agent"; 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"; 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 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 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" }); const client = await SandboxAgent.start({
sandbox: docker({
// Pull image if needed image: FULL_IMAGE,
try { env,
await docker.getImage(IMAGE).inspect(); binds: bindMounts,
} catch { }),
console.log(`Pulling ${IMAGE}...`);
await new Promise<void>((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,
},
}); });
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({
const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } }); agent: detectAgent(),
const sessionId = session.id; cwd: "/home/sandbox",
});
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); session.onEvent((event) => {
console.log(" Press Ctrl+C to stop."); console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
const keepAlive = setInterval(() => {}, 60_000); session.prompt([{ type: "text", text: "Say hello from Docker in one sentence." }]);
const cleanup = async () => {
clearInterval(keepAlive); process.once("SIGINT", async () => {
try { await client.destroySandbox();
await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
process.exit(0); process.exit(0);
}; });
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);

View file

@ -1,45 +1,31 @@
import { Sandbox } from "@e2b/code-interpreter";
import { SandboxAgent } from "sandbox-agent"; 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<string, string> = {}; const envs: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 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; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
console.log("Creating E2B sandbox..."); const client = await SandboxAgent.start({
const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); sandbox: e2b({
create: { envs },
}),
});
const run = async (cmd: string) => { console.log(`UI: ${client.inspectorUrl}`);
const result = await sandbox.commands.run(cmd);
if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}\n${result.stderr}`);
return result;
};
console.log("Installing sandbox-agent..."); const session = await client.createSession({
await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); agent: detectAgent(),
cwd: "/home/user",
});
console.log("Installing agents..."); session.onEvent((event) => {
await run("sandbox-agent install-agent claude"); console.log(`[${event.sender}]`, JSON.stringify(event.payload));
await run("sandbox-agent install-agent codex"); });
console.log("Starting server..."); session.prompt([{ type: "text", text: "Say hello from E2B in one sentence." }]);
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)}`; process.once("SIGINT", async () => {
await client.destroySandbox();
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.exit(0); process.exit(0);
}; });
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);

View file

@ -44,7 +44,7 @@ const readmeText = new TextDecoder().decode(readmeBytes);
console.log(` README.md content: ${readmeText.trim()}`); console.log(` README.md content: ${readmeText.trim()}`);
console.log("Creating session..."); 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; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "read the README in /opt/my-project"'); console.log(' Try: "read the README in /opt/my-project"');

View file

@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import { Command } from "commander"; import { Command } from "commander";
import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent"; import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent";
import { local } from "sandbox-agent/local";
const options = parseOptions(); const options = parseOptions();
const agent = options.agent.trim().toLowerCase(); 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 promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
const sdk = await SandboxAgent.start({ const sdk = await SandboxAgent.start({
spawn: { sandbox: local({ log: "inherit" }),
enabled: true,
log: "inherit",
},
}); });
try { try {
@ -43,10 +41,7 @@ try {
const session = await sdk.createSession({ const session = await sdk.createSession({
agent, agent,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
sessionInit: { cwd: process.cwd(),
cwd: process.cwd(),
mcpServers: [],
},
}); });
const rl = autoReply const rl = autoReply

View file

@ -36,7 +36,7 @@ await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { s
// Create a session. // Create a session.
console.log("Creating session with custom skill..."); 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; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "generate a random number between 1 and 100"'); console.log(' Try: "generate a random number between 1 and 100"');

View file

@ -15,7 +15,7 @@ await client.setSkillsConfig(
); );
console.log("Creating session..."); 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; const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`);
console.log(' Try: "How do I start sandbox-agent?"'); console.log(' Try: "How do I start sandbox-agent?"');

View file

@ -1,56 +1,34 @@
import { Sandbox } from "@vercel/sandbox";
import { SandboxAgent } from "sandbox-agent"; 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<string, string> = {}; const env: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
console.log("Creating Vercel sandbox..."); const client = await SandboxAgent.start({
const sandbox = await Sandbox.create({ sandbox: vercel({
runtime: "node24", create: {
ports: [3000], runtime: "node24",
env,
},
}),
}); });
const run = async (cmd: string, args: string[] = []) => { console.log(`UI: ${client.inspectorUrl}`);
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("Installing sandbox-agent..."); const session = await client.createSession({
await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); agent: detectAgent(),
cwd: "/home/vercel-sandbox",
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 baseUrl = sandbox.domain(3000); session.onEvent((event) => {
console.log(`[${event.sender}]`, JSON.stringify(event.payload));
});
console.log("Connecting to server..."); session.prompt([{ type: "text", text: "Say hello from Vercel in one sentence." }]);
const client = await SandboxAgent.connect({ baseUrl });
const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } });
const sessionId = session.id;
console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); process.once("SIGINT", async () => {
console.log(" Press Ctrl+C to stop."); await client.destroySandbox();
const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => {
clearInterval(keepAlive);
await sandbox.stop();
process.exit(0); process.exit(0);
}; });
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);

View file

@ -5,8 +5,11 @@ import { Code, Server, GitBranch } from "lucide-react";
import { CopyButton } from "./ui/CopyButton"; import { CopyButton } from "./ui/CopyButton";
const sdkCodeRaw = `import { SandboxAgent } from "sandbox-agent"; 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", { await client.createSession("my-session", {
agent: "claude-code", agent: "claude-code",
@ -32,13 +35,26 @@ function SdkCodeHighlighted() {
<span className="text-zinc-300"> </span> <span className="text-zinc-300"> </span>
<span className="text-green-400">"sandbox-agent"</span> <span className="text-green-400">"sandbox-agent"</span>
<span className="text-zinc-300">;</span> <span className="text-zinc-300">;</span>
{"\n"}
<span className="text-purple-400">import</span>
<span className="text-zinc-300">{" { "}</span>
<span className="text-white">local</span>
<span className="text-zinc-300">{" } "}</span>
<span className="text-purple-400">from</span>
<span className="text-zinc-300"> </span>
<span className="text-green-400">"sandbox-agent/local"</span>
<span className="text-zinc-300">;</span>
{"\n\n"} {"\n\n"}
<span className="text-purple-400">const</span> <span className="text-purple-400">const</span>
<span className="text-zinc-300"> client = </span> <span className="text-zinc-300"> client = </span>
<span className="text-purple-400">await</span> <span className="text-purple-400">await</span>
<span className="text-zinc-300"> SandboxAgent.</span> <span className="text-zinc-300"> SandboxAgent.</span>
<span className="text-blue-400">start</span> <span className="text-blue-400">start</span>
<span className="text-zinc-300">();</span> <span className="text-zinc-300">{"({"}</span>
{"\n"}
<span className="text-zinc-300">{" sandbox: local(),"}</span>
{"\n"}
<span className="text-zinc-300">{"});"}</span>
{"\n\n"} {"\n\n"}
<span className="text-purple-400">await</span> <span className="text-purple-400">await</span>
<span className="text-zinc-300"> client.</span> <span className="text-zinc-300"> client.</span>

29
pnpm-lock.yaml generated
View file

@ -154,13 +154,13 @@ importers:
dockerode: dockerode:
specifier: latest specifier: latest
version: 4.0.9 version: 4.0.9
get-port:
specifier: latest
version: 7.1.0
sandbox-agent: sandbox-agent:
specifier: workspace:* specifier: workspace:*
version: link:../../sdks/typescript version: link:../../sdks/typescript
devDependencies: devDependencies:
'@types/dockerode':
specifier: latest
version: 4.0.1
'@types/node': '@types/node':
specifier: latest specifier: latest
version: 25.5.0 version: 25.5.0
@ -1025,12 +1025,33 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../cli version: link:../cli
devDependencies: 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': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.7 version: 22.19.7
'@types/ws': '@types/ws':
specifier: ^8.18.1 specifier: ^8.18.1
version: 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: openapi-typescript:
specifier: ^6.7.0 specifier: ^6.7.0
version: 6.7.6 version: 6.7.6
@ -11273,7 +11294,7 @@ snapshots:
glob: 11.1.0 glob: 11.1.0
openapi-fetch: 0.14.1 openapi-fetch: 0.14.1
platform: 1.3.6 platform: 1.3.6
tar: 7.5.6 tar: 7.5.7
earcut@2.2.4: {} earcut@2.2.4: {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,58 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "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": { "dependencies": {
@ -33,8 +85,15 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "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/node": "^22.0.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vercel/sandbox": ">=0.1.0",
"dockerode": ">=4.0.0",
"get-port": ">=7.0.0",
"openapi-typescript": "^6.7.0", "openapi-typescript": "^6.7.0",
"tsup": "^8.0.0", "tsup": "^8.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View file

@ -22,7 +22,7 @@ import {
type SetSessionModeResponse, type SetSessionModeResponse,
type SetSessionModeRequest, type SetSessionModeRequest,
} from "acp-http-client"; } from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts"; import type { SandboxProvider } from "./providers/types.ts";
import { import {
type AcpServerListResponse, type AcpServerListResponse,
type AgentInfo, type AgentInfo,
@ -101,6 +101,8 @@ interface SandboxAgentConnectCommonOptions {
replayMaxChars?: number; replayMaxChars?: number;
signal?: AbortSignal; signal?: AbortSignal;
token?: string; token?: string;
skipHealthCheck?: boolean;
/** @deprecated Use skipHealthCheck instead. */
waitForHealth?: boolean | SandboxAgentHealthWaitOptions; waitForHealth?: boolean | SandboxAgentHealthWaitOptions;
} }
@ -115,17 +117,24 @@ export type SandboxAgentConnectOptions =
}); });
export interface SandboxAgentStartOptions { export interface SandboxAgentStartOptions {
sandbox: SandboxProvider;
sandboxId?: string;
skipHealthCheck?: boolean;
fetch?: typeof fetch; fetch?: typeof fetch;
headers?: HeadersInit; headers?: HeadersInit;
persist?: SessionPersistDriver; persist?: SessionPersistDriver;
replayMaxEvents?: number; replayMaxEvents?: number;
replayMaxChars?: number; replayMaxChars?: number;
spawn?: SandboxAgentSpawnOptions | boolean; signal?: AbortSignal;
token?: string;
} }
export interface SessionCreateRequest { export interface SessionCreateRequest {
id?: string; id?: string;
agent: string; agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string; model?: string;
mode?: string; mode?: string;
@ -135,6 +144,9 @@ export interface SessionCreateRequest {
export interface SessionResumeOrCreateRequest { export interface SessionResumeOrCreateRequest {
id: string; id: string;
agent: string; agent: string;
/** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */
cwd?: string;
/** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */
sessionInit?: Omit<NewSessionRequest, "_meta">; sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string; model?: string;
mode?: string; mode?: string;
@ -824,12 +836,14 @@ export class SandboxAgent {
private readonly defaultHeaders?: HeadersInit; private readonly defaultHeaders?: HeadersInit;
private readonly healthWait: NormalizedHealthWaitOptions; private readonly healthWait: NormalizedHealthWaitOptions;
private readonly healthWaitAbortController = new AbortController(); private readonly healthWaitAbortController = new AbortController();
private sandboxProvider?: SandboxProvider;
private sandboxProviderId?: string;
private sandboxProviderRawId?: string;
private readonly persist: SessionPersistDriver; private readonly persist: SessionPersistDriver;
private readonly replayMaxEvents: number; private readonly replayMaxEvents: number;
private readonly replayMaxChars: number; private readonly replayMaxChars: number;
private spawnHandle?: SandboxAgentSpawnHandle;
private healthPromise?: Promise<void>; private healthPromise?: Promise<void>;
private healthError?: Error; private healthError?: Error;
private disposed = false; private disposed = false;
@ -857,7 +871,7 @@ export class SandboxAgent {
} }
this.fetcher = resolvedFetch; this.fetcher = resolvedFetch;
this.defaultHeaders = options.headers; 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.persist = options.persist ?? new InMemorySessionPersistDriver();
this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS); this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS);
@ -870,29 +884,79 @@ export class SandboxAgent {
return new SandboxAgent(options); return new SandboxAgent(options);
} }
static async start(options: SandboxAgentStartOptions = {}): Promise<SandboxAgent> { static async start(options: SandboxAgentStartOptions): Promise<SandboxAgent> {
const spawnOptions = normalizeSpawnOptions(options.spawn, true); const provider = options.sandbox;
if (!spawnOptions.enabled) { if (!provider.getUrl && !provider.getFetch) {
throw new Error("SandboxAgent.start requires spawn to be enabled."); throw new Error(`Sandbox provider '${provider.name}' must implement getUrl() or getFetch().`);
} }
const { spawnSandboxAgent } = await import("./spawn.js"); const existingSandbox = options.sandboxId ? parseSandboxProviderId(options.sandboxId) : null;
const resolvedFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
const handle = await spawnSandboxAgent(spawnOptions, resolvedFetch);
const client = new SandboxAgent({ if (existingSandbox && existingSandbox.provider !== provider.name) {
baseUrl: handle.baseUrl, throw new Error(
token: handle.token, `SandboxAgent.start received sandboxId '${options.sandboxId}' for provider '${existingSandbox.provider}', but the configured provider is '${provider.name}'.`,
fetch: options.fetch, );
headers: options.headers, }
waitForHealth: false,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
});
client.spawnHandle = handle; const rawSandboxId = existingSandbox?.rawId ?? (await provider.create());
return client; const prefixedSandboxId = `${provider.name}/${rawSandboxId}`;
const createdSandbox = !existingSandbox;
if (existingSandbox) {
await provider.wake?.(rawSandboxId);
}
try {
const fetcher = await resolveProviderFetch(provider, rawSandboxId);
const baseUrl = provider.getUrl ? await provider.getUrl(rawSandboxId) : undefined;
const providerFetch = options.fetch ?? fetcher;
const commonConnectOptions = {
headers: options.headers,
persist: options.persist,
replayMaxEvents: options.replayMaxEvents,
replayMaxChars: options.replayMaxChars,
signal: options.signal,
skipHealthCheck: options.skipHealthCheck,
token: options.token ?? (await resolveProviderToken(provider, rawSandboxId)),
};
const client = providerFetch
? new SandboxAgent({
...commonConnectOptions,
baseUrl,
fetch: providerFetch,
})
: new SandboxAgent({
...commonConnectOptions,
baseUrl: requireSandboxBaseUrl(baseUrl, provider.name),
});
client.sandboxProvider = provider;
client.sandboxProviderId = prefixedSandboxId;
client.sandboxProviderRawId = rawSandboxId;
return client;
} catch (error) {
if (createdSandbox) {
try {
await provider.destroy(rawSandboxId);
} catch {
// Best-effort cleanup if connect fails after provisioning.
}
}
throw error;
}
}
get sandboxId(): string | undefined {
return this.sandboxProviderId;
}
get sandbox(): SandboxProvider | undefined {
return this.sandboxProvider;
}
get inspectorUrl(): string {
return `${this.baseUrl.replace(/\/+$/, "")}/ui/`;
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
@ -922,10 +986,23 @@ export class SandboxAgent {
await connection.close(); await connection.close();
}), }),
); );
}
if (this.spawnHandle) { async destroySandbox(): Promise<void> {
await this.spawnHandle.dispose(); const provider = this.sandboxProvider;
this.spawnHandle = undefined; 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 localSessionId = request.id?.trim() || randomId();
const live = await this.getLiveConnection(request.agent.trim()); 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); const response = await live.createRemoteSession(localSessionId, sessionInit);
@ -966,6 +1043,7 @@ export class SandboxAgent {
agentSessionId: response.sessionId, agentSessionId: response.sessionId,
lastConnectionId: live.connectionId, lastConnectionId: live.connectionId,
createdAt: nowMs(), createdAt: nowMs(),
sandboxId: this.sandboxProviderId,
sessionInit, sessionInit,
configOptions: cloneConfigOptions(response.configOptions), configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes), modes: cloneModes(response.modes),
@ -2255,17 +2333,17 @@ function toAgentQuery(options: AgentQueryOptions | undefined): Record<string, Qu
}; };
} }
function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined): Omit<NewSessionRequest, "_meta"> { function normalizeSessionInit(value: Omit<NewSessionRequest, "_meta"> | undefined, cwdShorthand?: string): Omit<NewSessionRequest, "_meta"> {
if (!value) { if (!value) {
return { return {
cwd: defaultCwd(), cwd: cwdShorthand ?? defaultCwd(),
mcpServers: [], mcpServers: [],
}; };
} }
return { return {
...value, ...value,
cwd: value.cwd ?? defaultCwd(), cwd: value.cwd ?? cwdShorthand ?? defaultCwd(),
mcpServers: value.mcpServers ?? [], mcpServers: value.mcpServers ?? [],
}; };
} }
@ -2405,16 +2483,23 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb
return Math.floor(value as number); return Math.floor(value as number);
} }
function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptions | undefined, signal: AbortSignal | undefined): NormalizedHealthWaitOptions { function normalizeHealthWaitOptions(
if (value === false) { skipHealthCheck: boolean | undefined,
waitForHealth: boolean | SandboxAgentHealthWaitOptions | undefined,
signal: AbortSignal | undefined,
): NormalizedHealthWaitOptions {
if (skipHealthCheck === true || waitForHealth === false) {
return { enabled: false }; return { enabled: false };
} }
if (value === true || value === undefined) { if (waitForHealth === true || waitForHealth === undefined) {
return { enabled: true, signal }; 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 { return {
enabled: true, enabled: true,
@ -2423,24 +2508,47 @@ function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptio
}; };
} }
function normalizeSpawnOptions( function parseSandboxProviderId(sandboxId: string): { provider: string; rawId: string } {
spawn: SandboxAgentSpawnOptions | boolean | undefined, const slashIndex = sandboxId.indexOf("/");
defaultEnabled: boolean, if (slashIndex < 1 || slashIndex === sandboxId.length - 1) {
): SandboxAgentSpawnOptions & { enabled: boolean } { throw new Error(`Sandbox IDs must be prefixed as "{provider}/{id}". Received '${sandboxId}'.`);
if (spawn === false) {
return { enabled: false };
}
if (spawn === true || spawn === undefined) {
return { enabled: defaultEnabled };
} }
return { return {
...spawn, provider: sandboxId.slice(0, slashIndex),
enabled: spawn.enabled ?? defaultEnabled, rawId: sandboxId.slice(slashIndex + 1),
}; };
} }
function requireSandboxBaseUrl(baseUrl: string | undefined, providerName: string): string {
if (!baseUrl) {
throw new Error(`Sandbox provider '${providerName}' did not return a base URL.`);
}
return baseUrl;
}
async function resolveProviderFetch(provider: SandboxProvider, rawSandboxId: string): Promise<typeof globalThis.fetch | undefined> {
if (provider.getFetch) {
return await provider.getFetch(rawSandboxId);
}
return undefined;
}
async function resolveProviderToken(provider: SandboxProvider, rawSandboxId: string): Promise<string | undefined> {
const maybeGetToken = (
provider as SandboxProvider & {
getToken?: (sandboxId: string) => string | undefined | Promise<string | undefined>;
}
).getToken;
if (typeof maybeGetToken !== "function") {
return undefined;
}
const token = await maybeGetToken.call(provider, rawSandboxId);
return typeof token === "string" && token ? token : undefined;
}
async function readProblem(response: Response): Promise<ProblemDetails | undefined> { async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
try { try {
const text = await response.clone().text(); const text = await response.clone().text();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
export interface SandboxProvider {
/** Provider name. Must match the prefix in sandbox IDs (for example "e2b"). */
name: string;
/** Provision a new sandbox and return the provider-specific ID. */
create(): Promise<string>;
/** Permanently tear down a sandbox. */
destroy(sandboxId: string): Promise<void>;
/**
* Return the sandbox-agent base URL for this sandbox.
* Providers that cannot expose a URL should implement `getFetch()` instead.
*/
getUrl?(sandboxId: string): Promise<string>;
/**
* Return a fetch implementation that routes requests to the sandbox.
* Providers that expose a URL can implement `getUrl()` instead.
*/
getFetch?(sandboxId: string): Promise<typeof globalThis.fetch>;
/**
* Optional hook invoked before reconnecting to an existing sandbox.
* Useful for providers where the sandbox-agent process may need to be restarted.
*/
wake?(sandboxId: string): Promise<void>;
}

View file

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

View file

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

View file

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

View file

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

View file

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