From 35840facdd958db0374acefb438c1f51b0487448 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 15 Mar 2026 20:14:30 -0700 Subject: [PATCH] SDK: Add ensureServer() for automatic server recovery Add ensureServer() to SandboxProvider interface to handle cases where the sandbox-agent server stops or goes to sleep. The SDK now calls this method after 3 consecutive health-check failures, allowing providers to restart the server if needed. Most built-in providers (E2B, Daytona, Vercel, Modal, ComputeSDK) implement this. Docker and Cloudflare manage server lifecycle differently, and Local uses managed child processes. Also update docs for quickstart, architecture, multiplayer, and session persistence; mark persist-* packages as deprecated; and add ensureServer implementations to all applicable providers. Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 12 + docker/release/linux-aarch64.Dockerfile | 5 +- docker/release/linux-x86_64.Dockerfile | 5 +- docker/release/macos-aarch64.Dockerfile | 5 +- docker/release/macos-x86_64.Dockerfile | 5 +- docker/release/windows.Dockerfile | 5 +- docker/runtime/Dockerfile | 3 - docker/runtime/Dockerfile.full | 3 - docs/architecture.mdx | 8 +- docs/multiplayer.mdx | 9 +- docs/quickstart.mdx | 371 ++++++++++++------ docs/session-persistence.mdx | 22 +- examples/cloudflare/tests/cloudflare.test.ts | 154 ++++++++ examples/cloudflare/vitest.config.ts | 8 + examples/docker/tests/docker.test.ts | 39 +- examples/persist-postgres/src/persist.ts | 34 +- examples/persist-sqlite/src/persist.ts | 28 +- examples/shared/src/docker.ts | 21 +- .../inspector/src/persist-indexeddb.ts | 6 + sdks/persist-indexeddb/README.md | 2 +- sdks/persist-indexeddb/package.json | 2 +- sdks/persist-indexeddb/src/index.ts | 4 +- sdks/persist-postgres/README.md | 4 +- sdks/persist-postgres/src/index.ts | 2 +- sdks/persist-rivet/README.md | 2 +- sdks/persist-rivet/package.json | 2 +- sdks/persist-rivet/src/index.ts | 4 +- sdks/persist-sqlite/README.md | 4 +- sdks/persist-sqlite/package.json | 2 +- sdks/persist-sqlite/src/index.ts | 2 +- sdks/typescript/src/client.ts | 15 +- sdks/typescript/src/providers/computesdk.ts | 7 + sdks/typescript/src/providers/daytona.ts | 2 +- sdks/typescript/src/providers/e2b.ts | 5 + sdks/typescript/src/providers/modal.ts | 4 + sdks/typescript/src/providers/types.ts | 9 +- sdks/typescript/src/providers/vercel.ts | 8 + .../sandbox-agent/src/acp_proxy_runtime.rs | 2 +- 38 files changed, 620 insertions(+), 205 deletions(-) create mode 100644 examples/cloudflare/tests/cloudflare.test.ts create mode 100644 examples/cloudflare/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index c651fb1..35e18f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,18 @@ - `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`). - Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier. +## Persist Packages (Deprecated) + +- The `@sandbox-agent/persist-*` npm packages (`persist-sqlite`, `persist-postgres`, `persist-indexeddb`, `persist-rivet`) are deprecated stubs. They still publish to npm but throw a deprecation error at import time. +- Driver implementations now live inline in examples and consuming packages: + - SQLite: `examples/persist-sqlite/src/persist.ts` + - Postgres: `examples/persist-postgres/src/persist.ts` + - IndexedDB: `frontend/packages/inspector/src/persist-indexeddb.ts` + - Rivet: inlined in `docs/multiplayer.mdx` + - In-memory: built into the main `sandbox-agent` SDK (`InMemorySessionPersistDriver`) +- Docs (`docs/session-persistence.mdx`) link to the example implementations on GitHub instead of referencing the packages. +- Do not re-add `@sandbox-agent/persist-*` as dependencies anywhere. New persist drivers should be copied into the consuming project directly. + ## Install Version References - Channel policy: diff --git a/docker/release/linux-aarch64.Dockerfile b/docker/release/linux-aarch64.Dockerfile index 412e6c0..d5ff208 100644 --- a/docker/release/linux-aarch64.Dockerfile +++ b/docker/release/linux-aarch64.Dockerfile @@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then react (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/release/linux-x86_64.Dockerfile b/docker/release/linux-x86_64.Dockerfile index 323e471..1c41711 100644 --- a/docker/release/linux-x86_64.Dockerfile +++ b/docker/release/linux-x86_64.Dockerfile @@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then react (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/release/macos-aarch64.Dockerfile b/docker/release/macos-aarch64.Dockerfile index 000157e..5d918b2 100644 --- a/docker/release/macos-aarch64.Dockerfile +++ b/docker/release/macos-aarch64.Dockerfile @@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then react (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/release/macos-x86_64.Dockerfile b/docker/release/macos-x86_64.Dockerfile index 9082018..9b52aa6 100644 --- a/docker/release/macos-x86_64.Dockerfile +++ b/docker/release/macos-x86_64.Dockerfile @@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then react (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/release/windows.Dockerfile b/docker/release/windows.Dockerfile index 9c7694d..92067db 100644 --- a/docker/release/windows.Dockerfile +++ b/docker/release/windows.Dockerfile @@ -10,7 +10,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -21,15 +20,13 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then react (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index bdd1a16..e0a3335 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -12,7 +12,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -23,7 +22,6 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript @@ -31,7 +29,6 @@ COPY sdks/typescript ./sdks/typescript RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build diff --git a/docker/runtime/Dockerfile.full b/docker/runtime/Dockerfile.full index beb1664..9ab4c0d 100644 --- a/docker/runtime/Dockerfile.full +++ b/docker/runtime/Dockerfile.full @@ -11,7 +11,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ -COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ @@ -20,14 +19,12 @@ RUN pnpm install --filter @sandbox-agent/inspector... COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client -COPY sdks/persist-indexeddb ./sdks/persist-indexeddb COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup -RUN cd sdks/persist-indexeddb && pnpm exec tsup RUN cd sdks/react && pnpm exec tsup COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 36e0302..a28f133 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -40,7 +40,11 @@ flowchart LR For the `local` provider, provisioning is a no-op and the server runs as a local subprocess. -## Server endpoints +### Server recovery + +If the server process stops, the SDK automatically calls the provider's `ensureServer()` after 3 consecutive health-check failures. Most built-in providers implement this. Custom providers can add `ensureServer(sandboxId)` to their `SandboxProvider` object. + +## Server HTTP API See the [HTTP API reference](/api-reference) for the full list of server endpoints. @@ -54,6 +58,6 @@ sandbox-agent install-agent --all The `rivetdev/sandbox-agent:0.3.2-full` Docker image ships with all agents pre-installed. -## Production topology +## Production-ready agent orchestration For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns. diff --git a/docs/multiplayer.mdx b/docs/multiplayer.mdx index c09ca23..215bb1c 100644 --- a/docs/multiplayer.mdx +++ b/docs/multiplayer.mdx @@ -22,16 +22,13 @@ Use [actor keys](https://rivet.dev/docs/actors/keys) to map each workspace to on import { actor, setup } from "rivetkit"; import { SandboxAgent, type SessionPersistDriver, type SessionRecord, type SessionEvent, type ListPageRequest, type ListPage, type ListEventsRequest } from "sandbox-agent"; -// Inline Rivet persist driver — copy into your project. -// See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet -interface ActorContextLike { state: Record; } interface RivetPersistData { sessions: Record; events: Record; } type RivetPersistState = { _sandboxAgentPersist: RivetPersistData }; class RivetSessionPersistDriver implements SessionPersistDriver { private readonly stateKey: string; - private readonly ctx: ActorContextLike; - constructor(ctx: ActorContextLike, options: { stateKey?: string } = {}) { + private readonly ctx: { state: Record }; + constructor(ctx: { state: Record }, options: { stateKey?: string } = {}) { this.ctx = ctx; this.stateKey = options.stateKey ?? "_sandboxAgentPersist"; if (!this.ctx.state[this.stateKey]) { @@ -146,5 +143,5 @@ await conn.prompt({ ## Notes - Keep sandbox calls actor-only. Browser clients should not call Sandbox Agent directly. -- Inline the Rivet persist driver (shown above) so session history persists in actor state. +- Copy the Rivet persist driver from the example above into your project so session history persists in actor state. - For client connection patterns, see [Rivet JavaScript client](https://rivet.dev/docs/clients/javascript). diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 321d1a3..deb5e4b 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -23,112 +23,177 @@ icon: "rocket" - `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. Pass your LLM API keys so the agent can reach its provider. + `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. - - ```typescript Local - import { SandboxAgent } from "sandbox-agent"; - import { local } from "sandbox-agent/local"; + + + ```bash + npm install sandbox-agent@0.3.x + ``` - // Runs on your machine. Inherits process.env automatically. - const sdk = await SandboxAgent.start({ - sandbox: local(), - }); - ``` + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { local } from "sandbox-agent/local"; - ```typescript E2B - import { SandboxAgent } from "sandbox-agent"; - import { e2b } from "sandbox-agent/e2b"; + // Runs on your machine. Inherits process.env automatically. + const client = await SandboxAgent.start({ + sandbox: local(), + }); + ``` - const sdk = await SandboxAgent.start({ - sandbox: e2b({ - create: { - // Pass whichever keys your agent needs - envs: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - }, - }, - }), - }); - ``` + See [Local deploy guide](/deploy/local) - ```typescript Daytona - import { SandboxAgent } from "sandbox-agent"; - import { daytona } from "sandbox-agent/daytona"; + + Local inherits `process.env` automatically, so no extra config is needed if your environment variables are already set. + + - const sdk = await SandboxAgent.start({ - sandbox: daytona({ - create: { - envVars: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - }, - }, - }), - }); - ``` + + ```bash + npm install sandbox-agent@0.3.x @e2b/code-interpreter + ``` - ```typescript Vercel - import { SandboxAgent } from "sandbox-agent"; - import { vercel } from "sandbox-agent/vercel"; + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { e2b } from "sandbox-agent/e2b"; - const sdk = await SandboxAgent.start({ - sandbox: vercel({ - create: { - runtime: "node24", - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - }, - }, - }), - }); - ``` + // Provisions a cloud sandbox on E2B, installs the server, and connects. + const client = await SandboxAgent.start({ + sandbox: e2b(), + }); + ``` - ```typescript Modal - import { SandboxAgent } from "sandbox-agent"; - import { modal } from "sandbox-agent/modal"; + See [E2B deploy guide](/deploy/e2b) - const sdk = await SandboxAgent.start({ - sandbox: modal({ - create: { - // Pass whichever keys your agent needs - secrets: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - }, - }, - }), - }); - ``` + + ```typescript + e2b({ create: { envs: { ANTHROPIC_API_KEY: "..." } } }) + ``` + + - ```typescript Cloudflare - import { SandboxAgent } from "sandbox-agent"; - import { cloudflare } from "sandbox-agent/cloudflare"; + + ```bash + npm install sandbox-agent@0.3.x @daytonaio/sdk + ``` - const sdk = await SandboxAgent.start({ - sandbox: cloudflare({ sdk: cfSandboxClient }), - }); - ``` + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { daytona } from "sandbox-agent/daytona"; - ```typescript Docker - import { SandboxAgent } from "sandbox-agent"; - import { docker } from "sandbox-agent/docker"; + // Provisions a Daytona workspace with the server pre-installed. + const client = await SandboxAgent.start({ + sandbox: daytona(), + }); + ``` - // Good for testing. Not security-hardened like cloud sandboxes. - const sdk = await SandboxAgent.start({ - sandbox: docker({ - env: [ - `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, - `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, - ], - }), - }); - ``` - + See [Daytona deploy guide](/deploy/daytona) - 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. For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials). + + ```typescript + daytona({ create: { envVars: { ANTHROPIC_API_KEY: "..." } } }) + ``` + + + + + ```bash + npm install sandbox-agent@0.3.x @vercel/sandbox + ``` + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { vercel } from "sandbox-agent/vercel"; + + // Provisions a Vercel sandbox with the server installed on boot. + const client = await SandboxAgent.start({ + sandbox: vercel(), + }); + ``` + + See [Vercel deploy guide](/deploy/vercel) + + + ```typescript + vercel({ create: { env: { ANTHROPIC_API_KEY: "..." } } }) + ``` + + + + + ```bash + npm install sandbox-agent@0.3.x modal + ``` + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { modal } from "sandbox-agent/modal"; + + // Builds a container image with agents pre-installed (cached after first run), + // starts a Modal sandbox from that image, and connects. + const client = await SandboxAgent.start({ + sandbox: modal(), + }); + ``` + + See [Modal deploy guide](/deploy/modal) + + + ```typescript + modal({ create: { secrets: { ANTHROPIC_API_KEY: "..." } } }) + ``` + + + + + ```bash + npm install sandbox-agent@0.3.x @cloudflare/sandbox + ``` + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { cloudflare } from "sandbox-agent/cloudflare"; + import { SandboxClient } from "@cloudflare/sandbox"; + + // Uses the Cloudflare Sandbox SDK to provision and connect. + // The Cloudflare SDK handles server lifecycle internally. + const cfSandboxClient = new SandboxClient(); + const client = await SandboxAgent.start({ + sandbox: cloudflare({ sdk: cfSandboxClient }), + }); + ``` + + See [Cloudflare deploy guide](/deploy/cloudflare) + + + Pass credentials via the Cloudflare SDK's environment configuration. See the [Cloudflare deploy guide](/deploy/cloudflare) for details. + + + + + ```bash + npm install sandbox-agent@0.3.x dockerode get-port + ``` + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { docker } from "sandbox-agent/docker"; + + // Runs a Docker container locally. Good for testing. + const client = await SandboxAgent.start({ + sandbox: docker(), + }); + ``` + + See [Docker deploy guide](/deploy/docker) + + + ```typescript + docker({ env: ["ANTHROPIC_API_KEY=..."] }) + ``` + + + @@ -152,7 +217,7 @@ icon: "rocket" }, }; - const sdk = await SandboxAgent.start({ + const client = await SandboxAgent.start({ sandbox: myProvider, }); ``` @@ -162,7 +227,7 @@ icon: "rocket" If you already have a Sandbox Agent server running, connect directly: ```typescript - const sdk = await SandboxAgent.connect({ + const client = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", }); ``` @@ -196,29 +261,115 @@ icon: "rocket" - ```typescript - const session = await sdk.createSession({ - agent: "claude", - }); + - session.onEvent((event) => { - console.log(event.sender, event.payload); - }); + ```typescript Claude + const session = await client.createSession({ + agent: "claude", + }); - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); - console.log(result.stopReason); - ``` + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + ```typescript Codex + const session = await client.createSession({ + agent: "codex", + }); + + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + ```typescript OpenCode + const session = await client.createSession({ + agent: "opencode", + }); + + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + ```typescript Cursor + const session = await client.createSession({ + agent: "cursor", + }); + + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + ```typescript Amp + const session = await client.createSession({ + agent: "amp", + }); + + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + ```typescript Pi + const session = await client.createSession({ + agent: "pi", + }); + + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); + + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); + + console.log(result.stopReason); + ``` + + + + See [Agent Sessions](/agent-sessions) for the full sessions API. ```typescript - await sdk.destroySandbox(); // tears down the sandbox and disconnects + await client.destroySandbox(); // tears down the sandbox and disconnects ``` - Use `sdk.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later). + Use `client.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later). @@ -236,7 +387,7 @@ icon: "rocket" import { SandboxAgent } from "sandbox-agent"; import { e2b } from "sandbox-agent/e2b"; -const sdk = await SandboxAgent.start({ +const client = await SandboxAgent.start({ sandbox: e2b({ create: { envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, @@ -245,7 +396,7 @@ const sdk = await SandboxAgent.start({ }); try { - const session = await sdk.createSession({ agent: "claude" }); + const session = await client.createSession({ agent: "claude" }); session.onEvent((event) => { console.log(`[${event.sender}]`, JSON.stringify(event.payload)); @@ -257,7 +408,7 @@ try { console.log("Done:", result.stopReason); } finally { - await sdk.destroySandbox(); + await client.destroySandbox(); } ``` diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx index 8f26457..5505864 100644 --- a/docs/session-persistence.mdx +++ b/docs/session-persistence.mdx @@ -10,11 +10,19 @@ With persistence enabled, sessions can be restored after runtime/session loss. S Each driver stores: -- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`) +- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`, optional `configOptions`, optional `modes`) - `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`) ## Persistence drivers +### Rivet + +Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with persistence in actor state. + +### IndexedDB (browser) + +Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project. + ### In-memory (built-in) Best for local dev and ephemeral workloads. No extra dependencies required. @@ -55,7 +63,7 @@ const sdk = await SandboxAgent.connect({ }); ``` -See the [full SQLite example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project. +See the [full SQLite example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project. ### Postgres @@ -80,15 +88,7 @@ const sdk = await SandboxAgent.connect({ }); ``` -See the [full Postgres example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project. - -### IndexedDB (browser) - -Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/nichochar/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project. - -### Rivet - -Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with inline persistence. +See the [full Postgres example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project. ### Custom driver diff --git a/examples/cloudflare/tests/cloudflare.test.ts b/examples/cloudflare/tests/cloudflare.test.ts new file mode 100644 index 0000000..d00c2ce --- /dev/null +++ b/examples/cloudflare/tests/cloudflare.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = resolve(__dirname, ".."); + +/** + * Cloudflare Workers integration test. + * + * Set RUN_CLOUDFLARE_EXAMPLES=1 to enable. Requires wrangler and Docker. + * + * This starts `wrangler dev` which: + * 1. Builds the Dockerfile (cloudflare/sandbox base + sandbox-agent) + * 2. Starts a local Workers runtime with Durable Objects and containers + * 3. Exposes the app on a local port + * + * We then test through the proxy endpoint which forwards to sandbox-agent + * running inside the container. + */ +const shouldRun = process.env.RUN_CLOUDFLARE_EXAMPLES === "1"; +const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 600_000; + +const testFn = shouldRun ? it : it.skip; + +interface WranglerDev { + baseUrl: string; + cleanup: () => void; +} + +async function startWranglerDev(): Promise { + // Build frontend assets first (wrangler expects dist/ to exist) + execSync("npx vite build", { cwd: PROJECT_DIR, stdio: "pipe" }); + + return new Promise((resolve, reject) => { + const child: ChildProcess = spawn("npx", ["wrangler", "dev", "--port", "0"], { + cwd: PROJECT_DIR, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + env: { + ...process.env, + // Ensure wrangler picks up API keys to pass to the container + NODE_ENV: "development", + }, + }); + + let stdout = ""; + let stderr = ""; + let resolved = false; + + const cleanup = () => { + if (child.pid) { + // Kill process group to ensure wrangler and its children are cleaned up + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch {} + } + } + }; + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + cleanup(); + reject(new Error(`wrangler dev did not start within 120s.\nstdout: ${stdout}\nstderr: ${stderr}`)); + } + }, 120_000); + + const onData = (chunk: Buffer) => { + const text = chunk.toString(); + stdout += text; + + // wrangler dev prints "Ready on http://localhost:XXXX" when ready + const match = stdout.match(/Ready on (https?:\/\/[^\s]+)/i) ?? stdout.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/); + if (match && !resolved) { + resolved = true; + clearTimeout(timer); + resolve({ baseUrl: match[1], cleanup }); + } + }; + + child.stdout?.on("data", onData); + child.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + stderr += text; + // Some wrangler versions print ready message to stderr + const match = text.match(/Ready on (https?:\/\/[^\s]+)/i) ?? text.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/); + if (match && !resolved) { + resolved = true; + clearTimeout(timer); + resolve({ baseUrl: match[1], cleanup }); + } + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + reject(new Error(`wrangler dev failed to start: ${err.message}`)); + } + }); + + child.on("exit", (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + reject(new Error(`wrangler dev exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`)); + } + }); + }); +} + +describe("cloudflare example", () => { + testFn( + "starts wrangler dev and sandbox-agent responds via proxy", + async () => { + const { baseUrl, cleanup } = await startWranglerDev(); + try { + // The Cloudflare example proxies requests through /sandbox/:name/proxy/* + // Wait for the container inside the Durable Object to start sandbox-agent + const healthUrl = `${baseUrl}/sandbox/test/proxy/v1/health`; + + let healthy = false; + for (let i = 0; i < 120; i++) { + try { + const res = await fetch(healthUrl); + if (res.ok) { + const data = await res.json(); + // The proxied health endpoint returns {name: "Sandbox Agent", ...} + if (data.status === "ok" || data.name === "Sandbox Agent") { + healthy = true; + break; + } + } + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + } + expect(healthy).toBe(true); + + // Confirm a second request also works + const response = await fetch(healthUrl); + expect(response.ok).toBe(true); + } finally { + cleanup(); + } + }, + timeoutMs, + ); +}); diff --git a/examples/cloudflare/vitest.config.ts b/examples/cloudflare/vitest.config.ts new file mode 100644 index 0000000..52a3740 --- /dev/null +++ b/examples/cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + root: ".", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/examples/docker/tests/docker.test.ts b/examples/docker/tests/docker.test.ts index 66730f0..683f033 100644 --- a/examples/docker/tests/docker.test.ts +++ b/examples/docker/tests/docker.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect } from "vitest"; -import { buildHeaders } from "@sandbox-agent/example-shared"; -import { setupDockerSandboxAgent } from "../src/docker.ts"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; -const shouldRun = process.env.RUN_DOCKER_EXAMPLES === "1"; +/** + * Docker integration test. + * + * Set SANDBOX_AGENT_DOCKER_IMAGE to the image tag to test (e.g. a locally-built + * full image). The test starts a container from that image, waits for + * sandbox-agent to become healthy, and validates the /v1/health endpoint. + */ +const image = process.env.SANDBOX_AGENT_DOCKER_IMAGE; +const shouldRun = Boolean(image); const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000; const testFn = shouldRun ? it : it.skip; @@ -11,11 +18,29 @@ describe("docker example", () => { testFn( "starts sandbox-agent and responds to /v1/health", async () => { - const { baseUrl, token, cleanup } = await setupDockerSandboxAgent(); + const { baseUrl, cleanup } = await startDockerSandbox({ + port: 2468, + image: image!, + }); try { - const response = await fetch(`${baseUrl}/v1/health`, { - headers: buildHeaders({ token }), - }); + // Wait for health check + let healthy = false; + for (let i = 0; i < 60; i++) { + try { + const res = await fetch(`${baseUrl}/v1/health`); + if (res.ok) { + const data = await res.json(); + if (data.status === "ok") { + healthy = true; + break; + } + } + } catch {} + await new Promise((r) => setTimeout(r, 1000)); + } + expect(healthy).toBe(true); + + const response = await fetch(`${baseUrl}/v1/health`); expect(response.ok).toBe(true); const data = await response.json(); expect(data.status).toBe("ok"); diff --git a/examples/persist-postgres/src/persist.ts b/examples/persist-postgres/src/persist.ts index 8b79791..2a6ccff 100644 --- a/examples/persist-postgres/src/persist.ts +++ b/examples/persist-postgres/src/persist.ts @@ -37,7 +37,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { await this.ready(); const result = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json FROM ${this.table("sessions")} WHERE id = $1`, [id], @@ -57,7 +57,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { const limit = normalizeLimit(request.limit); const rowsResult = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json FROM ${this.table("sessions")} ORDER BY created_at ASC, id ASC LIMIT $1 OFFSET $2`, @@ -79,8 +79,8 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { await this.pool.query( `INSERT INTO ${this.table("sessions")} ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(id) DO UPDATE SET agent = EXCLUDED.agent, agent_session_id = EXCLUDED.agent_session_id, @@ -88,7 +88,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { created_at = EXCLUDED.created_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, + config_options_json = EXCLUDED.config_options_json, + modes_json = EXCLUDED.modes_json`, [ session.id, session.agent, @@ -97,7 +99,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { session.createdAt, session.destroyedAt ?? null, session.sandboxId ?? null, - session.sessionInit ?? null, + session.sessionInit ? JSON.stringify(session.sessionInit) : null, + session.configOptions ? JSON.stringify(session.configOptions) : null, + session.modes !== undefined ? JSON.stringify(session.modes) : null, ], ); } @@ -174,7 +178,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { created_at BIGINT NOT NULL, destroyed_at BIGINT, sandbox_id TEXT, - session_init_json JSONB + session_init_json JSONB, + config_options_json JSONB, + modes_json JSONB ) `); @@ -183,6 +189,16 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver { ADD COLUMN IF NOT EXISTS sandbox_id TEXT `); + await this.pool.query(` + ALTER TABLE ${this.table("sessions")} + ADD COLUMN IF NOT EXISTS config_options_json JSONB + `); + + await this.pool.query(` + ALTER TABLE ${this.table("sessions")} + ADD COLUMN IF NOT EXISTS modes_json JSONB + `); + await this.pool.query(` CREATE TABLE IF NOT EXISTS ${this.table("events")} ( id TEXT PRIMARY KEY, @@ -238,6 +254,8 @@ type SessionRow = { destroyed_at: string | number | null; sandbox_id: string | null; session_init_json: unknown | null; + config_options_json: unknown | null; + modes_json: unknown | null; }; type EventRow = { @@ -260,6 +278,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord { 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, + configOptions: row.config_options_json ? (row.config_options_json as SessionRecord["configOptions"]) : undefined, + modes: row.modes_json ? (row.modes_json as SessionRecord["modes"]) : undefined, }; } diff --git a/examples/persist-sqlite/src/persist.ts b/examples/persist-sqlite/src/persist.ts index b04b0fc..2292903 100644 --- a/examples/persist-sqlite/src/persist.ts +++ b/examples/persist-sqlite/src/persist.ts @@ -18,7 +18,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { async getSession(id: string): Promise { const row = this.db .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json FROM sessions WHERE id = ?`, ) .get(id) as SessionRow | undefined; @@ -36,7 +36,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { const rows = this.db .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json FROM sessions ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?`, @@ -56,8 +56,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { this.db .prepare( `INSERT INTO sessions ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET agent = excluded.agent, agent_session_id = excluded.agent_session_id, @@ -65,7 +65,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { created_at = excluded.created_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, + config_options_json = excluded.config_options_json, + modes_json = excluded.modes_json`, ) .run( session.id, @@ -76,6 +78,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { session.destroyedAt ?? null, session.sandboxId ?? null, session.sessionInit ? JSON.stringify(session.sessionInit) : null, + session.configOptions ? JSON.stringify(session.configOptions) : null, + session.modes !== undefined ? JSON.stringify(session.modes) : null, ); } @@ -134,7 +138,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { created_at INTEGER NOT NULL, destroyed_at INTEGER, sandbox_id TEXT, - session_init_json TEXT + session_init_json TEXT, + config_options_json TEXT, + modes_json TEXT ) `); @@ -142,6 +148,12 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver { if (!sessionColumns.some((column) => column.name === "sandbox_id")) { this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`); } + if (!sessionColumns.some((column) => column.name === "config_options_json")) { + this.db.exec(`ALTER TABLE sessions ADD COLUMN config_options_json TEXT`); + } + if (!sessionColumns.some((column) => column.name === "modes_json")) { + this.db.exec(`ALTER TABLE sessions ADD COLUMN modes_json TEXT`); + } this.ensureEventsTable(); } @@ -233,6 +245,8 @@ type SessionRow = { destroyed_at: number | null; sandbox_id: string | null; session_init_json: string | null; + config_options_json: string | null; + modes_json: string | null; }; type EventRow = { @@ -260,6 +274,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord { 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, + configOptions: row.config_options_json ? (JSON.parse(row.config_options_json) as SessionRecord["configOptions"]) : undefined, + modes: row.modes_json ? (JSON.parse(row.modes_json) as SessionRecord["modes"]) : undefined, }; } diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 2feca37..8459535 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -78,11 +78,11 @@ function readClaudeCredentialFiles(): ClaudeCredentialFile[] { const candidates: Array<{ hostPath: string; containerPath: string }> = [ { hostPath: path.join(homeDir, ".claude", ".credentials.json"), - containerPath: "/root/.claude/.credentials.json", + containerPath: ".claude/.credentials.json", }, { hostPath: path.join(homeDir, ".claude-oauth-credentials.json"), - containerPath: "/root/.claude-oauth-credentials.json", + containerPath: ".claude-oauth-credentials.json", }, ]; @@ -180,10 +180,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`; bootstrapEnv[envKey] = file.base64Content; - return [ - `mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`, - `printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`, - ]; + // Use $HOME-relative paths so credentials work regardless of container user + const containerDir = path.posix.dirname(file.containerPath); + return [`mkdir -p "$HOME/${containerDir}"`, `printf %s "$${envKey}" | base64 -d > "$HOME/${file.containerPath}"`]; }); setupCommands.unshift(...credentialBootstrapCommands); } @@ -200,8 +199,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)], ExposedPorts: { [`${port}/tcp`]: {} }, HostConfig: { @@ -253,10 +253,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { + await cleanup(); process.exit(0); }; - process.once("SIGINT", cleanup); - process.once("SIGTERM", cleanup); + process.once("SIGINT", signalCleanup); + process.once("SIGTERM", signalCleanup); return { baseUrl, cleanup }; } diff --git a/frontend/packages/inspector/src/persist-indexeddb.ts b/frontend/packages/inspector/src/persist-indexeddb.ts index b6af1c8..23475cb 100644 --- a/frontend/packages/inspector/src/persist-indexeddb.ts +++ b/frontend/packages/inspector/src/persist-indexeddb.ts @@ -141,6 +141,8 @@ type SessionRow = { destroyedAt?: number; sandboxId?: string; sessionInit?: SessionRecord["sessionInit"]; + configOptions?: SessionRecord["configOptions"]; + modes?: SessionRecord["modes"]; }; type EventRow = { @@ -163,6 +165,8 @@ function encodeSessionRow(session: SessionRecord): SessionRow { destroyedAt: session.destroyedAt, sandboxId: session.sandboxId, sessionInit: session.sessionInit, + configOptions: session.configOptions, + modes: session.modes, }; } @@ -176,6 +180,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord { destroyedAt: row.destroyedAt, sandboxId: row.sandboxId, sessionInit: row.sessionInit, + configOptions: row.configOptions, + modes: row.modes, }; } diff --git a/sdks/persist-indexeddb/README.md b/sdks/persist-indexeddb/README.md index e2962ef..02dc5c2 100644 --- a/sdks/persist-indexeddb/README.md +++ b/sdks/persist-indexeddb/README.md @@ -2,4 +2,4 @@ > **Deprecated:** This package has been deprecated and removed. -Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. +Copy the driver source into your project. See the [reference implementation](https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-indexeddb/package.json b/sdks/persist-indexeddb/package.json index 599b951..da05325 100644 --- a/sdks/persist-indexeddb/package.json +++ b/sdks/persist-indexeddb/package.json @@ -1,7 +1,7 @@ { "name": "@sandbox-agent/persist-indexeddb", "version": "0.3.2", - "description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK", + "description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/sdks/persist-indexeddb/src/index.ts b/sdks/persist-indexeddb/src/index.ts index d704d05..e388530 100644 --- a/sdks/persist-indexeddb/src/index.ts +++ b/sdks/persist-indexeddb/src/index.ts @@ -1,5 +1,5 @@ throw new Error( "@sandbox-agent/persist-indexeddb has been deprecated and removed. " + - "Copy the reference implementation into your project instead. " + - "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-indexeddb", + "Copy the reference implementation from frontend/packages/inspector/src/persist-indexeddb.ts into your project instead. " + + "See https://github.com/rivet-dev/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts", ); diff --git a/sdks/persist-postgres/README.md b/sdks/persist-postgres/README.md index 61550cd..5a3afba 100644 --- a/sdks/persist-postgres/README.md +++ b/sdks/persist-postgres/README.md @@ -1,5 +1,5 @@ # @sandbox-agent/persist-postgres -> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-postgres`](../../examples/persist-postgres). +> **Deprecated:** This package has been deprecated and removed. -Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres). +Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-postgres/src/index.ts b/sdks/persist-postgres/src/index.ts index 0ae3e99..ec76a53 100644 --- a/sdks/persist-postgres/src/index.ts +++ b/sdks/persist-postgres/src/index.ts @@ -1,5 +1,5 @@ throw new Error( "@sandbox-agent/persist-postgres has been deprecated and removed. " + "Copy the reference implementation from examples/persist-postgres into your project instead. " + - "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres", + "See https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres", ); diff --git a/sdks/persist-rivet/README.md b/sdks/persist-rivet/README.md index 49e4a7a..ce93b8d 100644 --- a/sdks/persist-rivet/README.md +++ b/sdks/persist-rivet/README.md @@ -2,4 +2,4 @@ > **Deprecated:** This package has been deprecated and removed. -Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. +Copy the driver source into your project. See the [multiplayer docs](https://github.com/rivet-dev/sandbox-agent/tree/main/docs/multiplayer.mdx) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-rivet/package.json b/sdks/persist-rivet/package.json index b297670..047bea6 100644 --- a/sdks/persist-rivet/package.json +++ b/sdks/persist-rivet/package.json @@ -1,7 +1,7 @@ { "name": "@sandbox-agent/persist-rivet", "version": "0.3.2", - "description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK", + "description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/sdks/persist-rivet/src/index.ts b/sdks/persist-rivet/src/index.ts index 8e77d33..87907c6 100644 --- a/sdks/persist-rivet/src/index.ts +++ b/sdks/persist-rivet/src/index.ts @@ -1,5 +1,5 @@ throw new Error( "@sandbox-agent/persist-rivet has been deprecated and removed. " + - "Copy the reference implementation into your project instead. " + - "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet", + "Copy the reference implementation from docs/multiplayer.mdx into your project instead. " + + "See https://github.com/rivet-dev/sandbox-agent/tree/main/docs/multiplayer.mdx", ); diff --git a/sdks/persist-sqlite/README.md b/sdks/persist-sqlite/README.md index c1fe980..07296fe 100644 --- a/sdks/persist-sqlite/README.md +++ b/sdks/persist-sqlite/README.md @@ -1,5 +1,5 @@ # @sandbox-agent/persist-sqlite -> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-sqlite`](../../examples/persist-sqlite). +> **Deprecated:** This package has been deprecated and removed. -Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite). +Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite) and the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance. diff --git a/sdks/persist-sqlite/package.json b/sdks/persist-sqlite/package.json index 3deef42..6c08fec 100644 --- a/sdks/persist-sqlite/package.json +++ b/sdks/persist-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@sandbox-agent/persist-sqlite", "version": "0.3.2", - "description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK", + "description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/sdks/persist-sqlite/src/index.ts b/sdks/persist-sqlite/src/index.ts index 75386b2..fa76679 100644 --- a/sdks/persist-sqlite/src/index.ts +++ b/sdks/persist-sqlite/src/index.ts @@ -1,5 +1,5 @@ throw new Error( "@sandbox-agent/persist-sqlite has been deprecated and removed. " + "Copy the reference implementation from examples/persist-sqlite into your project instead. " + - "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite", + "See https://github.com/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite", ); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index e94b521..f64c833 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -89,6 +89,7 @@ const HEALTH_WAIT_MIN_DELAY_MS = 500; const HEALTH_WAIT_MAX_DELAY_MS = 15_000; const HEALTH_WAIT_LOG_AFTER_MS = 5_000; const HEALTH_WAIT_LOG_EVERY_MS = 10_000; +const HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES = 3; export interface SandboxAgentHealthWaitOptions { timeoutMs?: number; @@ -903,7 +904,7 @@ export class SandboxAgent { const createdSandbox = !existingSandbox; if (existingSandbox) { - await provider.wake?.(rawSandboxId); + await provider.ensureServer?.(rawSandboxId); } try { @@ -2118,6 +2119,7 @@ export class SandboxAgent { let delayMs = HEALTH_WAIT_MIN_DELAY_MS; let nextLogAt = startedAt + HEALTH_WAIT_LOG_AFTER_MS; let lastError: unknown; + let consecutiveFailures = 0; while (!this.disposed && (deadline === undefined || Date.now() < deadline)) { throwIfAborted(signal); @@ -2128,11 +2130,22 @@ export class SandboxAgent { return; } lastError = new Error(`Unexpected health response: ${JSON.stringify(health)}`); + consecutiveFailures++; } catch (error) { if (isAbortError(error)) { throw error; } lastError = error; + consecutiveFailures++; + } + + if (consecutiveFailures >= HEALTH_WAIT_ENSURE_SERVER_AFTER_FAILURES && this.sandboxProvider?.ensureServer && this.sandboxProviderRawId) { + try { + await this.sandboxProvider.ensureServer(this.sandboxProviderRawId); + } catch { + // Best-effort; the next health check will determine if it worked. + } + consecutiveFailures = 0; } const now = Date.now(); diff --git a/sdks/typescript/src/providers/computesdk.ts b/sdks/typescript/src/providers/computesdk.ts index e1b1a69..7bca7ca 100644 --- a/sdks/typescript/src/providers/computesdk.ts +++ b/sdks/typescript/src/providers/computesdk.ts @@ -49,5 +49,12 @@ export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProv if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`); return sandbox.getUrl({ port: agentPort }); }, + async ensureServer(sandboxId: string): Promise { + const sandbox = await compute.sandbox.getById(sandboxId); + if (!sandbox) throw new Error(`computesdk sandbox not found: ${sandboxId}`); + await sandbox.runCommand(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { + background: true, + }); + }, }; } diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts index fcae45e..19026de 100644 --- a/sdks/typescript/src/providers/daytona.ts +++ b/sdks/typescript/src/providers/daytona.ts @@ -56,7 +56,7 @@ export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider { const preview = await sandbox.getSignedPreviewUrl(agentPort, previewTtlSeconds); return typeof preview === "string" ? preview : preview.url; }, - async wake(sandboxId: string): Promise { + async ensureServer(sandboxId: string): Promise { const sandbox = await client.get(sandboxId); if (!sandbox) { throw new Error(`daytona sandbox not found: ${sandboxId}`); diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts index 833fe9d..84d767c 100644 --- a/sdks/typescript/src/providers/e2b.ts +++ b/sdks/typescript/src/providers/e2b.ts @@ -53,5 +53,10 @@ export function e2b(options: E2BProviderOptions = {}): SandboxProvider { const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); return `https://${sandbox.getHost(agentPort)}`; }, + async ensureServer(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 }); + }, }; } diff --git a/sdks/typescript/src/providers/modal.ts b/sdks/typescript/src/providers/modal.ts index 3b99592..394272b 100644 --- a/sdks/typescript/src/providers/modal.ts +++ b/sdks/typescript/src/providers/modal.ts @@ -66,5 +66,9 @@ export function modal(options: ModalProviderOptions = {}): SandboxProvider { } return tunnel.url; }, + async ensureServer(sandboxId: string): Promise { + const sb = await client.sandboxes.fromId(sandboxId); + sb.exec(["sandbox-agent", "server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)]); + }, }; } diff --git a/sdks/typescript/src/providers/types.ts b/sdks/typescript/src/providers/types.ts index e51163f..ea778de 100644 --- a/sdks/typescript/src/providers/types.ts +++ b/sdks/typescript/src/providers/types.ts @@ -21,8 +21,11 @@ export interface SandboxProvider { getFetch?(sandboxId: string): Promise; /** - * Optional hook invoked before reconnecting to an existing sandbox. - * Useful for providers where the sandbox-agent process may need to be restarted. + * Ensure the sandbox-agent server process is running inside the sandbox. + * Called during health-wait after consecutive failures, and before + * reconnecting to an existing sandbox. Implementations should be + * idempotent — if the server is already running, this should be a no-op + * (e.g. the duplicate process exits on port conflict). */ - wake?(sandboxId: string): Promise; + ensureServer?(sandboxId: string): Promise; } diff --git a/sdks/typescript/src/providers/vercel.ts b/sdks/typescript/src/providers/vercel.ts index 98d21c1..09d41cf 100644 --- a/sdks/typescript/src/providers/vercel.ts +++ b/sdks/typescript/src/providers/vercel.ts @@ -53,5 +53,13 @@ export function vercel(options: VercelProviderOptions = {}): SandboxProvider { const sandbox = await Sandbox.get({ sandboxId }); return sandbox.domain(agentPort); }, + async ensureServer(sandboxId: string): Promise { + const sandbox = await Sandbox.get({ sandboxId }); + await sandbox.runCommand({ + cmd: "sandbox-agent", + args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)], + detached: true, + }); + }, }; } diff --git a/server/packages/sandbox-agent/src/acp_proxy_runtime.rs b/server/packages/sandbox-agent/src/acp_proxy_runtime.rs index 3710e2f..212356e 100644 --- a/server/packages/sandbox-agent/src/acp_proxy_runtime.rs +++ b/server/packages/sandbox-agent/src/acp_proxy_runtime.rs @@ -415,7 +415,7 @@ impl AcpProxyRuntime { async fn is_ready(&self, agent: AgentId) -> bool { if agent == AgentId::Mock { - return self.inner.agent_manager.agent_process_path(agent).exists(); + return true; } self.inner.agent_manager.is_installed(agent) }