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) }