From cf7e2a92c67d40952a654c0249c643400c297e9d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 15 Mar 2026 20:29:28 -0700 Subject: [PATCH] SDK: Add ensureServer() for automatic server recovery (#260) * SDK sandbox provisioning: built-in providers, docs restructure, and quickstart overhaul - Add built-in sandbox providers (local, docker, e2b, daytona, vercel, cloudflare) to the TypeScript SDK so users import directly instead of passing client instances - Restructure docs: rename architecture to orchestration-architecture, add new architecture page for server overview, improve getting started flow - Rewrite quickstart to be TypeScript-first with provider CodeGroup and custom provider accordion - Update all examples to use new provider APIs - Update persist drivers and foundry for new SDK surface Co-Authored-By: Claude Opus 4.6 * Fix SDK typecheck errors and update persist drivers for insertEvent signature - Fix insertEvent call in client.ts to pass sessionId as first argument - Update Daytona provider create options to use Partial type (image has default) - Update StrictUniqueSessionPersistDriver in tests to match new insertEvent signature - Sync persist packages, openapi spec, and docs with upstream changes Co-Authored-By: Claude Opus 4.6 * Add Modal and ComputeSDK built-in providers, update examples and docs - Add `sandbox-agent/modal` provider using Modal SDK with node:22-slim image - Add `sandbox-agent/computesdk` provider using ComputeSDK's unified sandbox API - Update Modal and ComputeSDK examples to use new SDK providers - Update Modal and ComputeSDK deploy docs with provider-based examples - Add Modal to quickstart CodeGroup and docs.json navigation - Add provider test entries for Modal and ComputeSDK - Remove old standalone example files (modal.ts, computesdk.ts) Co-Authored-By: Claude Opus 4.6 * Fix Modal provider: pre-install agents in image, fire-and-forget exec for server - Pre-install agents in Dockerfile commands so they are cached across creates - Use fire-and-forget exec (no wait) to keep server alive in Modal sandbox - Add memoryMiB option (default 2GB) to avoid OOM during agent install Co-Authored-By: Claude Opus 4.6 * Sync upstream changes: multiplayer docs, logos, openapi spec, foundry config Co-Authored-By: Claude Opus 4.6 * 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 * wip --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 36 +- 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/agent-capabilities.mdx | 127 ---- docs/agent-sessions.mdx | 5 +- docs/agents/amp.mdx | 20 + docs/agents/claude.mdx | 49 ++ docs/agents/codex.mdx | 20 + docs/agents/cursor.mdx | 34 ++ docs/agents/opencode.mdx | 31 + docs/agents/pi.mdx | 20 + docs/architecture.mdx | 85 ++- docs/cli.mdx | 2 +- docs/custom-tools.mdx | 8 +- docs/deploy/cloudflare.mdx | 34 +- docs/deploy/computesdk.mdx | 212 ++----- docs/deploy/daytona.mdx | 47 +- docs/deploy/docker.mdx | 50 +- docs/deploy/e2b.mdx | 45 +- docs/deploy/local.mdx | 23 +- docs/deploy/modal.mdx | 109 +--- docs/deploy/vercel.mdx | 76 +-- docs/docs.json | 22 +- docs/mcp-config.mdx | 4 +- docs/multiplayer.mdx | 38 +- docs/orchestration-architecture.mdx | 43 ++ docs/quickstart.mdx | 549 +++++++++++------- docs/sdk-overview.mdx | 45 +- docs/security.mdx | 4 +- docs/session-persistence.mdx | 104 +--- docs/skills-config.mdx | 4 +- examples/boxlite/src/index.ts | 2 +- examples/cloudflare/tests/cloudflare.test.ts | 154 +++++ .../cloudflare}/vitest.config.ts | 2 +- examples/computesdk/package.json | 2 +- examples/computesdk/src/computesdk.ts | 151 ----- examples/computesdk/src/index.ts | 30 + examples/computesdk/tests/computesdk.test.ts | 25 +- examples/daytona/src/index.ts | 49 +- examples/docker/package.json | 2 +- examples/docker/src/index.ts | 80 +-- examples/docker/tests/docker.test.ts | 39 +- examples/e2b/src/index.ts | 51 +- examples/file-system/src/index.ts | 2 +- examples/modal/package.json | 2 +- examples/modal/src/index.ts | 30 + examples/modal/src/modal.ts | 123 ---- examples/modal/tests/modal.test.ts | 25 +- examples/permissions/src/index.ts | 11 +- examples/persist-postgres/package.json | 1 - examples/persist-postgres/src/index.ts | 2 +- examples/persist-postgres/src/persist.ts | 336 +++++++++++ examples/persist-sqlite/package.json | 3 +- examples/persist-sqlite/src/index.ts | 2 +- examples/persist-sqlite/src/persist.ts | 310 ++++++++++ examples/shared/src/docker.ts | 21 +- examples/skills-custom-tool/src/index.ts | 2 +- examples/skills/src/index.ts | 2 +- examples/vercel/src/index.ts | 68 +-- foundry/compose.dev.yaml | 2 - foundry/docker/backend.Dockerfile | 1 - foundry/packages/backend/package.json | 1 - frontend/packages/inspector/package.json | 7 +- frontend/packages/inspector/src/App.tsx | 2 +- .../inspector/src/persist-indexeddb.ts | 320 ++++++++++ .../website/public/logos/cloudflare.svg | 3 + .../website/public/logos/computesdk.svg | 3 + .../packages/website/public/logos/docker.svg | 3 + .../packages/website/public/logos/modal.svg | 3 + .../website/src/components/GetStarted.tsx | 20 +- pnpm-lock.yaml | 135 ++--- sdks/persist-indexeddb/README.md | 5 + sdks/persist-indexeddb/package.json | 13 +- sdks/persist-indexeddb/src/index.ts | 316 +--------- sdks/persist-indexeddb/tests/driver.test.ts | 96 --- .../tests/integration.test.ts | 129 ---- sdks/persist-postgres/README.md | 5 + sdks/persist-postgres/package.json | 14 +- sdks/persist-postgres/src/index.ts | 311 +--------- .../tests/integration.test.ts | 245 -------- sdks/persist-rivet/README.md | 5 + sdks/persist-rivet/package.json | 20 +- sdks/persist-rivet/src/index.ts | 173 +----- sdks/persist-rivet/tests/driver.test.ts | 236 -------- sdks/persist-sqlite/README.md | 5 + sdks/persist-sqlite/package.json | 15 +- sdks/persist-sqlite/src/index.ts | 289 +-------- sdks/persist-sqlite/tests/integration.test.ts | 131 ----- sdks/typescript/package.json | 77 +++ sdks/typescript/src/client.ts | 215 +++++-- sdks/typescript/src/index.ts | 1 + sdks/typescript/src/providers/cloudflare.ts | 79 +++ sdks/typescript/src/providers/computesdk.ts | 60 ++ sdks/typescript/src/providers/daytona.ts | 67 +++ sdks/typescript/src/providers/docker.ts | 85 +++ sdks/typescript/src/providers/e2b.ts | 62 ++ sdks/typescript/src/providers/local.ts | 84 +++ sdks/typescript/src/providers/modal.ts | 74 +++ sdks/typescript/src/providers/shared.ts | 7 + sdks/typescript/src/providers/types.ts | 31 + sdks/typescript/src/providers/vercel.ts | 65 +++ sdks/typescript/src/types.ts | 15 +- sdks/typescript/tests/integration.test.ts | 8 +- sdks/typescript/tests/providers.test.ts | 417 +++++++++++++ sdks/typescript/tsup.config.ts | 13 +- sdks/typescript/vitest.config.ts | 2 + .../sandbox-agent/src/acp_proxy_runtime.rs | 2 +- 112 files changed, 3739 insertions(+), 3537 deletions(-) delete mode 100644 docs/agent-capabilities.mdx create mode 100644 docs/agents/amp.mdx create mode 100644 docs/agents/claude.mdx create mode 100644 docs/agents/codex.mdx create mode 100644 docs/agents/cursor.mdx create mode 100644 docs/agents/opencode.mdx create mode 100644 docs/agents/pi.mdx create mode 100644 docs/orchestration-architecture.mdx create mode 100644 examples/cloudflare/tests/cloudflare.test.ts rename {sdks/persist-sqlite => examples/cloudflare}/vitest.config.ts (84%) delete mode 100644 examples/computesdk/src/computesdk.ts create mode 100644 examples/computesdk/src/index.ts create mode 100644 examples/modal/src/index.ts delete mode 100644 examples/modal/src/modal.ts create mode 100644 examples/persist-postgres/src/persist.ts create mode 100644 examples/persist-sqlite/src/persist.ts create mode 100644 frontend/packages/inspector/src/persist-indexeddb.ts create mode 100644 frontend/packages/website/public/logos/cloudflare.svg create mode 100644 frontend/packages/website/public/logos/computesdk.svg create mode 100644 frontend/packages/website/public/logos/docker.svg create mode 100644 frontend/packages/website/public/logos/modal.svg create mode 100644 sdks/persist-indexeddb/README.md delete mode 100644 sdks/persist-indexeddb/tests/driver.test.ts delete mode 100644 sdks/persist-indexeddb/tests/integration.test.ts create mode 100644 sdks/persist-postgres/README.md delete mode 100644 sdks/persist-postgres/tests/integration.test.ts create mode 100644 sdks/persist-rivet/README.md delete mode 100644 sdks/persist-rivet/tests/driver.test.ts create mode 100644 sdks/persist-sqlite/README.md delete mode 100644 sdks/persist-sqlite/tests/integration.test.ts create mode 100644 sdks/typescript/src/providers/cloudflare.ts create mode 100644 sdks/typescript/src/providers/computesdk.ts create mode 100644 sdks/typescript/src/providers/daytona.ts create mode 100644 sdks/typescript/src/providers/docker.ts create mode 100644 sdks/typescript/src/providers/e2b.ts create mode 100644 sdks/typescript/src/providers/local.ts create mode 100644 sdks/typescript/src/providers/modal.ts create mode 100644 sdks/typescript/src/providers/shared.ts create mode 100644 sdks/typescript/src/providers/types.ts create mode 100644 sdks/typescript/src/providers/vercel.ts create mode 100644 sdks/typescript/tests/providers.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d7e091b..cfd28a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,42 @@ - Regenerate `docs/openapi.json` when HTTP contracts change. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Append blockers/decisions to `research/acp/friction.md` during ACP work. -- `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`). +- Each agent has its own doc page at `docs/agents/.mdx` listing models, modes, and thought levels. Update the relevant page when changing `fallback_config_options`. To regenerate capability data, run `cd scripts/agent-configs && npx tsx dump.ts`. 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. +## Adding Providers + +When adding a new sandbox provider, update all of the following: + +- `sdks/typescript/src/providers/.ts` — provider implementation +- `sdks/typescript/package.json` — add `./` export, peerDependencies, peerDependenciesMeta, devDependencies +- `sdks/typescript/tsup.config.ts` — add entry point and external +- `sdks/typescript/tests/providers.test.ts` — add test entry +- `examples//` — create example with `src/index.ts` and `tests/.test.ts` +- `docs/deploy/.mdx` — create deploy guide +- `docs/docs.json` — add to Deploy pages navigation +- `docs/quickstart.mdx` — add tab in "Start the sandbox" step, add credentials entry in "Passing LLM credentials" accordion + +## Adding Agents + +When adding a new agent, update all of the following: + +- `docs/agents/.mdx` — create agent page with usage snippet and capabilities table +- `docs/docs.json` — add to the Agents group under Agent +- `docs/quickstart.mdx` — add tab in the "Create a session and send a prompt" CodeGroup + +## 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: @@ -74,6 +107,7 @@ - `examples/docker/src/index.ts` - `examples/e2b/src/index.ts` - `examples/vercel/src/index.ts` + - `sdks/typescript/src/providers/shared.ts` - `scripts/release/main.ts` - `scripts/release/promote-artifacts.ts` - `scripts/release/sdk.ts` diff --git a/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/agent-capabilities.mdx b/docs/agent-capabilities.mdx deleted file mode 100644 index 13f2723..0000000 --- a/docs/agent-capabilities.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Agent Capabilities" -description: "Models, modes, and thought levels supported by each agent." ---- - -Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details. - - - - _Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._ - - -## Claude - -| Category | Values | -|----------|--------| -| **Models** | `default`, `sonnet`, `opus`, `haiku` | -| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` | -| **Thought levels** | Unsupported | - -### Configuring Effort Level For Claude - -Claude does not natively support changing effort level after a session starts, so configure it in the filesystem before creating the session. - -```ts -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { SandboxAgent } from "sandbox-agent"; - -const cwd = "/path/to/workspace"; -await mkdir(path.join(cwd, ".claude"), { recursive: true }); -await writeFile( - path.join(cwd, ".claude", "settings.json"), - JSON.stringify({ effortLevel: "high" }, null, 2), -); - -const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" }); -await sdk.createSession({ - agent: "claude", - sessionInit: { cwd, mcpServers: [] }, -}); -``` - - - -1. `~/.claude/settings.json` -2. `/.claude/settings.json` -3. `/.claude/settings.local.json` - - - -## Codex - -| Category | Values | -|----------|--------| -| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` | -| **Modes** | `read-only` (default), `auto`, `full-access` | -| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` | - -## OpenCode - -| Category | Values | -|----------|--------| -| **Models** | See below | -| **Modes** | `build` (default), `plan` | -| **Thought levels** | Unsupported | - - - -| Provider | Models | -|----------|--------| -| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` | -| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` | -| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` | -| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` | - - - -## Cursor - -| Category | Values | -|----------|--------| -| **Models** | See below | -| **Modes** | Unsupported | -| **Thought levels** | Unsupported | - - - -| Group | Models | -|-------|--------| -| **Auto** | `auto` | -| **Composer** | `composer-1.5`, `composer-1` | -| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` | -| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` | -| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` | -| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` | -| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` | - - - -## Amp - -| Category | Values | -|----------|--------| -| **Models** | `amp-default` | -| **Modes** | `default`, `bypass` | -| **Thought levels** | Unsupported | - -## Pi - -| Category | Values | -|----------|--------| -| **Models** | `default` | -| **Modes** | Unsupported | -| **Thought levels** | Unsupported | - -## Generating a live report - -Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`. - -```bash -sandbox-agent api agents report -``` - - - The live report reflects what the agent adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access. - diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index cf56e9c..0f9e2ab 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -21,10 +21,7 @@ const sdk = await SandboxAgent.connect({ const session = await sdk.createSession({ agent: "codex", - sessionInit: { - cwd: "/", - mcpServers: [], - }, + cwd: "/", }); console.log(session.id, session.agentSessionId); diff --git a/docs/agents/amp.mdx b/docs/agents/amp.mdx new file mode 100644 index 0000000..f94e97d --- /dev/null +++ b/docs/agents/amp.mdx @@ -0,0 +1,20 @@ +--- +title: "Amp" +description: "Use Amp as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "amp", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | `amp-default` | +| **Modes** | `default`, `bypass` | +| **Thought levels** | Unsupported | diff --git a/docs/agents/claude.mdx b/docs/agents/claude.mdx new file mode 100644 index 0000000..2e4fd43 --- /dev/null +++ b/docs/agents/claude.mdx @@ -0,0 +1,49 @@ +--- +title: "Claude" +description: "Use Claude Code as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "claude", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | `default`, `sonnet`, `opus`, `haiku` | +| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` | +| **Thought levels** | Unsupported | + +## Configuring effort level + +Claude does not support changing effort level after a session starts. Configure it in the filesystem before creating the session. + +```ts +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const cwd = "/path/to/workspace"; +await mkdir(path.join(cwd, ".claude"), { recursive: true }); +await writeFile( + path.join(cwd, ".claude", "settings.json"), + JSON.stringify({ effortLevel: "high" }, null, 2), +); + +const session = await client.createSession({ + agent: "claude", + cwd, +}); +``` + + + +1. `~/.claude/settings.json` +2. `/.claude/settings.json` +3. `/.claude/settings.local.json` + + diff --git a/docs/agents/codex.mdx b/docs/agents/codex.mdx new file mode 100644 index 0000000..d359beb --- /dev/null +++ b/docs/agents/codex.mdx @@ -0,0 +1,20 @@ +--- +title: "Codex" +description: "Use OpenAI Codex as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "codex", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` | +| **Modes** | `read-only` (default), `auto`, `full-access` | +| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` | diff --git a/docs/agents/cursor.mdx b/docs/agents/cursor.mdx new file mode 100644 index 0000000..0905baa --- /dev/null +++ b/docs/agents/cursor.mdx @@ -0,0 +1,34 @@ +--- +title: "Cursor" +description: "Use Cursor as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "cursor", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | See below | +| **Modes** | Unsupported | +| **Thought levels** | Unsupported | + + + +| Group | Models | +|-------|--------| +| **Auto** | `auto` | +| **Composer** | `composer-1.5`, `composer-1` | +| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` | +| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` | +| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` | +| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` | +| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` | + + diff --git a/docs/agents/opencode.mdx b/docs/agents/opencode.mdx new file mode 100644 index 0000000..db7b640 --- /dev/null +++ b/docs/agents/opencode.mdx @@ -0,0 +1,31 @@ +--- +title: "OpenCode" +description: "Use OpenCode as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "opencode", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | See below | +| **Modes** | `build` (default), `plan` | +| **Thought levels** | Unsupported | + + + +| Provider | Models | +|----------|--------| +| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` | +| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` | +| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` | +| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` | + + diff --git a/docs/agents/pi.mdx b/docs/agents/pi.mdx new file mode 100644 index 0000000..1d56370 --- /dev/null +++ b/docs/agents/pi.mdx @@ -0,0 +1,20 @@ +--- +title: "Pi" +description: "Use Pi as a sandbox agent." +--- + +## Usage + +```typescript +const session = await client.createSession({ + agent: "pi", +}); +``` + +## Capabilities + +| Category | Values | +|----------|--------| +| **Models** | `default` | +| **Modes** | Unsupported | +| **Thought levels** | Unsupported | diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 78585a2..a28f133 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -1,64 +1,63 @@ --- title: "Architecture" -description: "How the client, sandbox, server, and agent fit together." -icon: "microchip" +description: "How the Sandbox Agent server, SDK, and agent processes fit together." --- -Sandbox Agent runs as an HTTP server inside your sandbox. Your app talks to it remotely. +Sandbox Agent is a lightweight HTTP server that runs **inside** a sandbox. It: + +- **Agent management**: Installs, spawns, and stops coding agent processes +- **Sessions**: Routes prompts to agents and streams events back in real time +- **Sandbox APIs**: Filesystem, process, and terminal access for the sandbox environment ## Components -- `Your client`: your app code using the `sandbox-agent` SDK. -- `Sandbox`: isolated runtime (E2B, Daytona, Docker, etc.). -- `Sandbox Agent server`: process inside the sandbox exposing HTTP transport. -- `Agent`: Claude/Codex/OpenCode/Amp process managed by Sandbox Agent. - -```mermaid placement="top-right" - flowchart LR - CLIENT["Sandbox Agent SDK"] - SERVER["Sandbox Agent server"] - AGENT["Agent process"] +```mermaid +flowchart LR + CLIENT["Your App"] subgraph SANDBOX["Sandbox"] - direction TB - SERVER --> AGENT + direction TB + SERVER["Sandbox Agent Server"] + AGENT["Agent Process
(Claude, Codex, etc.)"] + SERVER --> AGENT end - CLIENT -->|HTTP| SERVER + CLIENT -->|"SDK (HTTP)"| SERVER ``` -## Suggested Topology +- **Your app**: Uses the `sandbox-agent` TypeScript SDK to talk to the server over HTTP. +- **Sandbox**: An isolated runtime (local process, Docker, E2B, Daytona, Vercel, Cloudflare). +- **Sandbox Agent server**: A single binary inside the sandbox that manages agent lifecycles, routes prompts, streams events, and exposes filesystem/process/terminal APIs. +- **Agent process**: A coding agent (Claude Code, Codex, etc.) spawned by the server. Each session maps to one agent process. -Run the SDK on your backend, then call it from your frontend. +## What `SandboxAgent.start()` does -This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler. +1. **Provision**: The provider creates a sandbox (starts a container, creates a VM, etc.) +2. **Install**: The Sandbox Agent binary is installed inside the sandbox +3. **Boot**: The server starts listening on an HTTP port +4. **Health check**: The SDK waits for `/v1/health` to respond +5. **Ready**: The SDK returns a connected client -```mermaid placement="top-right" - flowchart LR - BROWSER["Browser"] - subgraph BACKEND["Your backend"] - direction TB - SDK["Sandbox Agent SDK"] - end - subgraph SANDBOX_SIMPLE["Sandbox"] - SERVER_SIMPLE["Sandbox Agent server"] - end +For the `local` provider, provisioning is a no-op and the server runs as a local subprocess. - BROWSER --> BACKEND - BACKEND --> SDK --> SERVER_SIMPLE +### Server 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. + +## Agent installation + +Agents are installed lazily on first use. To avoid the cold-start delay, pre-install them: + +```bash +sandbox-agent install-agent --all ``` -### Backend requirements +The `rivetdev/sandbox-agent:0.3.2-full` Docker image ships with all agents pre-installed. -Your backend layer needs to handle: +## Production-ready agent orchestration -- **Long-running connections**: prompts can take minutes. -- **Session affinity**: follow-up messages must reach the same session. -- **State between requests**: session metadata and event history must persist across requests. -- **Graceful recovery**: sessions should resume after backend restarts. - -We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require. - -## Session persistence - -For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence). +For production deployments, see [Orchestration Architecture](/orchestration-architecture) for recommended topology, backend requirements, and session persistence patterns. diff --git a/docs/cli.mdx b/docs/cli.mdx index 6177fb3..2ad3b08 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -259,7 +259,7 @@ Example output: } ``` -See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent. +See individual agent pages (e.g. [Claude](/agents/claude), [Codex](/agents/codex)) for supported models, modes, and thought levels. #### api agents install diff --git a/docs/custom-tools.mdx b/docs/custom-tools.mdx index 727fb02..2fb3e15 100644 --- a/docs/custom-tools.mdx +++ b/docs/custom-tools.mdx @@ -80,9 +80,7 @@ await sdk.setMcpConfig( const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ @@ -145,9 +143,7 @@ await sdk.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skill); ```ts const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/docs/deploy/cloudflare.mdx b/docs/deploy/cloudflare.mdx index deca490..1cecdd7 100644 --- a/docs/deploy/cloudflare.mdx +++ b/docs/deploy/cloudflare.mdx @@ -31,7 +31,38 @@ RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex EXPOSE 8000 ``` -## TypeScript example +## TypeScript example (with provider) + +For standalone scripts, use the `cloudflare` provider: + +```bash +npm install sandbox-agent@0.3.x @cloudflare/sandbox +``` + +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { cloudflare } from "sandbox-agent/cloudflare"; + +const sdk = await SandboxAgent.start({ + sandbox: cloudflare(), +}); + +try { + const session = await sdk.createSession({ agent: "codex" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + +The `cloudflare` provider uses `containerFetch` under the hood, automatically stripping `AbortSignal` to avoid dropped streaming updates. + +## TypeScript example (Durable Objects) + +For Workers with Durable Objects, use `SandboxAgent.connect(...)` with a custom `fetch` backed by `sandbox.containerFetch(...)`: ```typescript import { getSandbox, type Sandbox } from "@cloudflare/sandbox"; @@ -109,7 +140,6 @@ app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); export default app; ``` -Create the SDK client inside the Worker using custom `fetch` backed by `sandbox.containerFetch(...)`. This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path and does not require a `baseUrl`. ## Troubleshooting streaming updates diff --git a/docs/deploy/computesdk.mdx b/docs/deploy/computesdk.mdx index 5e07da0..1adfffe 100644 --- a/docs/deploy/computesdk.mdx +++ b/docs/deploy/computesdk.mdx @@ -1,160 +1,61 @@ --- title: "ComputeSDK" -description: "Deploy the daemon using ComputeSDK's provider-agnostic sandbox API." +description: "Deploy Sandbox Agent using ComputeSDK's provider-agnostic sandbox API." --- -[ComputeSDK](https://computesdk.com) provides a unified interface for managing sandboxes across multiple providers. Write once, deploy anywhere—switch providers by changing environment variables. +[ComputeSDK](https://computesdk.com) provides a unified interface for managing sandboxes across multiple providers. Write once, deploy anywhere by changing environment variables. ## Prerequisites - `COMPUTESDK_API_KEY` from [console.computesdk.com](https://console.computesdk.com) - Provider API key (one of: `E2B_API_KEY`, `DAYTONA_API_KEY`, `VERCEL_TOKEN`, `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`, `BLAXEL_API_KEY`, `CSB_API_KEY`) -- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` -## TypeScript Example +## TypeScript example + +```bash +npm install sandbox-agent@0.3.x computesdk +``` ```typescript -import { - compute, - detectProvider, - getMissingEnvVars, - getProviderConfigFromEnv, - isProviderAuthComplete, - isValidProvider, - PROVIDER_NAMES, - type ExplicitComputeConfig, - type ProviderName, -} from "computesdk"; import { SandboxAgent } from "sandbox-agent"; +import { computesdk } from "sandbox-agent/computesdk"; -const PORT = 3000; -const REQUEST_TIMEOUT_MS = - Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000; - -/** - * Detects and validates the provider to use. - * Priority: COMPUTESDK_PROVIDER env var > auto-detection from API keys - */ -function resolveProvider(): ProviderName { - const providerOverride = process.env.COMPUTESDK_PROVIDER; - - if (providerOverride) { - if (!isValidProvider(providerOverride)) { - throw new Error( - `Unsupported provider "${providerOverride}". Supported: ${PROVIDER_NAMES.join(", ")}` - ); - } - if (!isProviderAuthComplete(providerOverride)) { - const missing = getMissingEnvVars(providerOverride); - throw new Error( - `Missing credentials for "${providerOverride}". Set: ${missing.join(", ")}` - ); - } - return providerOverride as ProviderName; - } - - const detected = detectProvider(); - if (!detected) { - throw new Error( - `No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}` - ); - } - return detected as ProviderName; -} - -function configureComputeSDK(): void { - const provider = resolveProvider(); - - const config: ExplicitComputeConfig = { - provider, - computesdkApiKey: process.env.COMPUTESDK_API_KEY, - requestTimeoutMs: REQUEST_TIMEOUT_MS, - }; - - // Add provider-specific config from environment - const providerConfig = getProviderConfigFromEnv(provider); - if (Object.keys(providerConfig).length > 0) { - (config as any)[provider] = providerConfig; - } - - compute.setConfig(config); -} - -configureComputeSDK(); - -// Build environment variables to pass to sandbox const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -// Create sandbox -const sandbox = await compute.sandbox.create({ - envs: Object.keys(envs).length > 0 ? envs : undefined, +const sdk = await SandboxAgent.start({ + sandbox: computesdk({ + create: { envs }, + }), }); -// Helper to run commands with error handling -const run = async (cmd: string, options?: { background?: boolean }) => { - const result = await sandbox.runCommand(cmd, options); - if (typeof result?.exitCode === "number" && result.exitCode !== 0) { - throw new Error(`Command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`); - } - return result; -}; - -// Install sandbox-agent -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); - -// Install agents conditionally based on available API keys -if (envs.ANTHROPIC_API_KEY) { - await run("sandbox-agent install-agent claude"); +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); } -if (envs.OPENAI_API_KEY) { - await run("sandbox-agent install-agent codex"); -} - -// Start the server in the background -await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, { background: true }); - -// Get the public URL for the sandbox -const baseUrl = await sandbox.getUrl({ port: PORT }); - -// Wait for server to be ready -const deadline = Date.now() + REQUEST_TIMEOUT_MS; -while (Date.now() < deadline) { - try { - const response = await fetch(`${baseUrl}/v1/health`); - if (response.ok) { - const data = await response.json(); - if (data?.status === "ok") break; - } - } catch { - // Server not ready yet - } - await new Promise((r) => setTimeout(r, 500)); -} - -// Connect to the server -const client = await SandboxAgent.connect({ baseUrl }); - -// Detect which agent to use based on available API keys -const agent = envs.ANTHROPIC_API_KEY ? "claude" : "codex"; - -// Create a session and start coding -await client.createSession("my-session", { agent }); - -await client.postMessage("my-session", { - message: "Summarize this repository", -}); - -for await (const event of client.streamEvents("my-session")) { - console.log(event.type, event.data); -} - -// Cleanup -await sandbox.destroy(); ``` -## Supported Providers +The `computesdk` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. ComputeSDK routes to your configured provider behind the scenes. + +Before calling `SandboxAgent.start()`, configure ComputeSDK with your provider: + +```typescript +import { compute } from "computesdk"; + +compute.setConfig({ + provider: "e2b", // or auto-detect via detectProvider() + computesdkApiKey: process.env.COMPUTESDK_API_KEY, +}); +``` + +## Supported providers ComputeSDK auto-detects your provider from environment variables: @@ -169,46 +70,7 @@ ComputeSDK auto-detects your provider from environment variables: ## Notes -- **Provider resolution order**: `COMPUTESDK_PROVIDER` env var takes priority, otherwise auto-detection from API keys. -- **Conditional agent installation**: Only agents with available API keys are installed, reducing setup time. -- **Command error handling**: The example validates exit codes and throws on failures for easier debugging. +- **Provider resolution**: Set `COMPUTESDK_PROVIDER` to force a specific provider, or let ComputeSDK auto-detect from API keys. - `sandbox.runCommand(..., { background: true })` keeps the server running while your app continues. - `sandbox.getUrl({ port })` returns a public URL for the sandbox port. -- Always destroy the sandbox when you are done to avoid leaking resources. -- If sandbox creation times out, set `COMPUTESDK_TIMEOUT_MS` to a higher value (default: 120000ms). - -## Explicit Provider Selection - -To force a specific provider instead of auto-detection, set the `COMPUTESDK_PROVIDER` environment variable: - -```bash -export COMPUTESDK_PROVIDER=e2b -``` - -Or configure programmatically using `getProviderConfigFromEnv()`: - -```typescript -import { compute, getProviderConfigFromEnv, type ExplicitComputeConfig } from "computesdk"; - -const config: ExplicitComputeConfig = { - provider: "e2b", - computesdkApiKey: process.env.COMPUTESDK_API_KEY, - requestTimeoutMs: 120_000, -}; - -// Automatically populate provider-specific config from environment -const providerConfig = getProviderConfigFromEnv("e2b"); -if (Object.keys(providerConfig).length > 0) { - (config as any).e2b = providerConfig; -} - -compute.setConfig(config); -``` - -## Direct Mode (No ComputeSDK API Key) - -To bypass the ComputeSDK gateway and use provider SDKs directly, see the provider-specific examples: - -- [E2B](/deploy/e2b) -- [Daytona](/deploy/daytona) -- [Vercel](/deploy/vercel) +- Always destroy the sandbox when done to avoid leaking resources. diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index 5eb8f5d..b65aec9 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -15,40 +15,37 @@ See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/). ## TypeScript example -```typescript -import { Daytona } from "@daytonaio/sdk"; -import { SandboxAgent } from "sandbox-agent"; +```bash +npm install sandbox-agent@0.3.x @daytonaio/sdk +``` -const daytona = new Daytona(); +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { daytona } from "sandbox-agent/daytona"; const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const sandbox = await daytona.create({ envVars }); +const sdk = await SandboxAgent.start({ + sandbox: daytona({ + create: { envVars }, + }), +}); -await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" -); - -await sandbox.process.executeCommand("sandbox-agent install-agent claude"); -await sandbox.process.executeCommand("sandbox-agent install-agent codex"); - -await sandbox.process.executeCommand( - "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &" -); - -await new Promise((r) => setTimeout(r, 2000)); - -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); -await session.prompt([{ type: "text", text: "Summarize this repository" }]); - -await sandbox.delete(); +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} ``` +The `daytona` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default and starts the server automatically. + ## Using snapshots for faster startup ```typescript diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 030ddc9..b674b7a 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -15,43 +15,43 @@ Run the published full image with all supported agents pre-installed: docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - rivetdev/sandbox-agent:0.3.1-full \ + rivetdev/sandbox-agent:0.3.2-full \ server --no-token --host 0.0.0.0 --port 3000 ``` -The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. +The `0.3.2-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. -## TypeScript with dockerode +## TypeScript with the Docker provider + +```bash +npm install sandbox-agent@0.3.x dockerode get-port +``` ```typescript -import Docker from "dockerode"; import { SandboxAgent } from "sandbox-agent"; +import { docker } from "sandbox-agent/docker"; -const docker = new Docker(); -const PORT = 3000; - -const container = await docker.createContainer({ - Image: "rivetdev/sandbox-agent:0.3.1-full", - Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], - Env: [ - `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, - `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, - `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, - ].filter(Boolean), - ExposedPorts: { [`${PORT}/tcp`]: {} }, - HostConfig: { - AutoRemove: true, - PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, - }, +const sdk = await SandboxAgent.start({ + sandbox: docker({ + env: [ + `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, + `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, + ].filter(Boolean), + }), }); -await container.start(); +try { + const session = await sdk.createSession({ agent: "codex" }); + await session.prompt([{ type: "text", text: "Summarize this repository." }]); +} finally { + await sdk.destroySandbox(); +} +``` -const baseUrl = `http://127.0.0.1:${PORT}`; -const sdk = await SandboxAgent.connect({ baseUrl }); +The `docker` provider uses the `rivetdev/sandbox-agent:0.3.2-full` image by default. Override with `image`: -const session = await sdk.createSession({ agent: "codex" }); -await session.prompt([{ type: "text", text: "Summarize this repository." }]); +```typescript +docker({ image: "my-custom-image:latest" }) ``` ## Building a custom image with everything preinstalled diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index 8ea4c74..4e056ee 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -10,42 +10,37 @@ description: "Deploy Sandbox Agent inside an E2B sandbox." ## TypeScript example +```bash +npm install sandbox-agent@0.3.x @e2b/code-interpreter +``` + ```typescript -import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; +import { e2b } from "sandbox-agent/e2b"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); - -await sandbox.commands.run( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" -); - -await sandbox.commands.run("sandbox-agent install-agent claude"); -await sandbox.commands.run("sandbox-agent install-agent codex"); - -await sandbox.commands.run( - "sandbox-agent server --no-token --host 0.0.0.0 --port 3000", - { background: true, timeoutMs: 0 } -); - -const baseUrl = `https://${sandbox.getHost(3000)}`; -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); -const off = session.onEvent((event) => { - console.log(event.sender, event.payload); +const sdk = await SandboxAgent.start({ + sandbox: e2b({ + create: { envs }, + }), }); -await session.prompt([{ type: "text", text: "Summarize this repository" }]); -off(); - -await sandbox.kill(); +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} ``` +The `e2b` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. + ## Faster cold starts For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. diff --git a/docs/deploy/local.mdx b/docs/deploy/local.mdx index eab8f3f..90e2ba6 100644 --- a/docs/deploy/local.mdx +++ b/docs/deploy/local.mdx @@ -32,12 +32,15 @@ Or with npm/Bun: ## With the TypeScript SDK -The SDK can spawn and manage the server as a subprocess: +The SDK can spawn and manage the server as a subprocess using the `local` provider: ```typescript import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const sdk = await SandboxAgent.start(); +const sdk = await SandboxAgent.start({ + sandbox: local(), +}); const session = await sdk.createSession({ agent: "claude", @@ -47,7 +50,21 @@ await session.prompt([ { type: "text", text: "Summarize this repository." }, ]); -await sdk.dispose(); +await sdk.destroySandbox(); ``` This starts the server on an available local port and connects automatically. + +Pass options to customize the local provider: + +```typescript +const sdk = await SandboxAgent.start({ + sandbox: local({ + port: 3000, + log: "inherit", + env: { + ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY, + }, + }), +}); +``` diff --git a/docs/deploy/modal.mdx b/docs/deploy/modal.mdx index cb081b0..02a3828 100644 --- a/docs/deploy/modal.mdx +++ b/docs/deploy/modal.mdx @@ -10,88 +10,43 @@ description: "Deploy Sandbox Agent inside a Modal sandbox." ## TypeScript example -```typescript -import { ModalClient } from "modal"; -import { SandboxAgent } from "sandbox-agent"; - -const modal = new ModalClient(); -const app = await modal.apps.fromName("sandbox-agent", { createIfMissing: true }); - -const image = modal.images - .fromRegistry("ubuntu:22.04") - .dockerfileCommands([ - "RUN apt-get update && apt-get install -y curl ca-certificates", - "RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - ]); - -const envs: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - -const secrets = Object.keys(envs).length > 0 - ? [await modal.secrets.fromObject(envs)] - : []; - -const sb = await modal.sandboxes.create(app, image, { - encryptedPorts: [3000], - secrets, -}); - -const exec = async (cmd: string) => { - const p = await sb.exec(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe" }); - const exitCode = await p.wait(); - if (exitCode !== 0) { - const stderr = await p.stderr.readText(); - throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`); - } -}; - -await exec("sandbox-agent install-agent claude"); -await exec("sandbox-agent install-agent codex"); - -await sb.exec( - ["bash", "-c", "sandbox-agent server --no-token --host 0.0.0.0 --port 3000 &"], -); - -const tunnels = await sb.tunnels(); -const baseUrl = tunnels[3000].url; - -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); -const off = session.onEvent((event) => { - console.log(event.sender, event.payload); -}); - -await session.prompt([{ type: "text", text: "Summarize this repository" }]); -off(); - -await sb.terminate(); +```bash +npm install sandbox-agent@0.3.x modal ``` +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { modal } from "sandbox-agent/modal"; + +const secrets: Record = {}; +if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) secrets.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const sdk = await SandboxAgent.start({ + sandbox: modal({ + create: { secrets }, + }), +}); + +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + +The `modal` provider handles app creation, image building, sandbox provisioning, agent installation, server startup, and tunnel networking automatically. + ## Faster cold starts -Modal caches image layers, so the `dockerfileCommands` that install `curl` and `sandbox-agent` only run on the first build. Subsequent sandbox creates reuse the cached image. - -## Running the test - -The example includes a health-check test. First, build the SDK: - -```bash -pnpm --filter sandbox-agent build -``` - -Then run the test with your Modal credentials: - -```bash -MODAL_TOKEN_ID= MODAL_TOKEN_SECRET= npx vitest run -``` - -Run from `examples/modal/`. The test will skip if credentials are not set. +Modal caches image layers, so the Dockerfile commands that install `curl` and `sandbox-agent` only run on the first build. Subsequent sandbox creates reuse the cached image. ## Notes - Modal sandboxes use [gVisor](https://gvisor.dev/) for strong isolation. -- Ports are exposed via encrypted tunnels (`encryptedPorts`). Use `sb.tunnels()` to get the public HTTPS URL. -- Environment variables (API keys) are passed as Modal [Secrets](https://modal.com/docs/guide/secrets) rather than plain env vars for security. -- Always call `sb.terminate()` when done to avoid leaking sandbox resources. +- Ports are exposed via encrypted tunnels (`encryptedPorts`). The provider uses `sb.tunnels()` to get the public HTTPS URL. +- Environment variables (API keys) are passed as Modal [Secrets](https://modal.com/docs/guide/secrets) for security. diff --git a/docs/deploy/vercel.mdx b/docs/deploy/vercel.mdx index 2025d67..db97236 100644 --- a/docs/deploy/vercel.mdx +++ b/docs/deploy/vercel.mdx @@ -10,52 +10,40 @@ description: "Deploy Sandbox Agent inside a Vercel Sandbox." ## TypeScript example -```typescript -import { Sandbox } from "@vercel/sandbox"; -import { SandboxAgent } from "sandbox-agent"; - -const envs: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - -const sandbox = await Sandbox.create({ - runtime: "node24", - ports: [3000], -}); - -const run = async (cmd: string, args: string[] = []) => { - const result = await sandbox.runCommand({ cmd, args, env: envs }); - if (result.exitCode !== 0) { - throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); - } -}; - -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); -await run("sandbox-agent", ["install-agent", "claude"]); -await run("sandbox-agent", ["install-agent", "codex"]); - -await sandbox.runCommand({ - cmd: "sandbox-agent", - args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"], - env: envs, - detached: true, -}); - -const baseUrl = sandbox.domain(3000); -const sdk = await SandboxAgent.connect({ baseUrl }); - -const session = await sdk.createSession({ agent: "claude" }); - -const off = session.onEvent((event) => { - console.log(event.sender, event.payload); -}); - -await session.prompt([{ type: "text", text: "Summarize this repository" }]); -off(); - -await sandbox.stop(); +```bash +npm install sandbox-agent@0.3.x @vercel/sandbox ``` +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { vercel } from "sandbox-agent/vercel"; + +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const sdk = await SandboxAgent.start({ + sandbox: vercel({ + create: { + runtime: "node24", + env, + }, + }), +}); + +try { + const session = await sdk.createSession({ agent: "claude" }); + const response = await session.prompt([ + { type: "text", text: "Summarize this repository" }, + ]); + console.log(response.stopReason); +} finally { + await sdk.destroySandbox(); +} +``` + +The `vercel` provider handles sandbox creation, Sandbox Agent installation, agent setup, and server startup automatically. + ## Authentication Vercel Sandboxes support OIDC token auth (recommended) and access-token auth. diff --git a/docs/docs.json b/docs/docs.json index 9ba082c..16620fe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -58,20 +58,32 @@ "icon": "server", "pages": [ "deploy/local", - "deploy/computesdk", "deploy/e2b", "deploy/daytona", "deploy/vercel", "deploy/cloudflare", "deploy/docker", - "deploy/boxlite" + "deploy/modal", + "deploy/boxlite", + "deploy/computesdk" ] } ] }, { "group": "Agent", - "pages": ["agent-sessions", "attachments", "skills-config", "mcp-config", "custom-tools"] + "pages": [ + "agent-sessions", + { + "group": "Agents", + "icon": "robot", + "pages": ["agents/claude", "agents/codex", "agents/opencode", "agents/cursor", "agents/amp", "agents/pi"] + }, + "attachments", + "skills-config", + "mcp-config", + "custom-tools" + ] }, { "group": "System", @@ -79,12 +91,12 @@ }, { "group": "Orchestration", - "pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"] + "pages": ["orchestration-architecture", "session-persistence", "observability", "multiplayer", "security"] }, { "group": "Reference", "pages": [ - "agent-capabilities", + "architecture", "cli", "inspector", "opencode-compatibility", diff --git a/docs/mcp-config.mdx b/docs/mcp-config.mdx index 71e8105..cc1c976 100644 --- a/docs/mcp-config.mdx +++ b/docs/mcp-config.mdx @@ -27,9 +27,7 @@ await sdk.setMcpConfig( // Create a session using the configured MCP servers const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/docs/multiplayer.mdx b/docs/multiplayer.mdx index 4f405ea..215bb1c 100644 --- a/docs/multiplayer.mdx +++ b/docs/multiplayer.mdx @@ -20,8 +20,40 @@ Use [actor keys](https://rivet.dev/docs/actors/keys) to map each workspace to on ```ts Actor (server) import { actor, setup } from "rivetkit"; -import { SandboxAgent } from "sandbox-agent"; -import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet"; +import { SandboxAgent, type SessionPersistDriver, type SessionRecord, type SessionEvent, type ListPageRequest, type ListPage, type ListEventsRequest } from "sandbox-agent"; + +interface RivetPersistData { sessions: Record; events: Record; } +type RivetPersistState = { _sandboxAgentPersist: RivetPersistData }; + +class RivetSessionPersistDriver implements SessionPersistDriver { + private readonly 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]) { + this.ctx.state[this.stateKey] = { sessions: {}, events: {} }; + } + } + private get data(): RivetPersistData { return this.ctx.state[this.stateKey] as RivetPersistData; } + async getSession(id: string) { const s = this.data.sessions[id]; return s ? { ...s } : undefined; } + async listSessions(request: ListPageRequest = {}): Promise> { + const sorted = Object.values(this.data.sessions).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); + const offset = Number(request.cursor ?? 0); + const limit = request.limit ?? 100; + const slice = sorted.slice(offset, offset + limit); + return { items: slice, nextCursor: offset + slice.length < sorted.length ? String(offset + slice.length) : undefined }; + } + async updateSession(session: SessionRecord) { this.data.sessions[session.id] = { ...session }; if (!this.data.events[session.id]) this.data.events[session.id] = []; } + async listEvents(request: ListEventsRequest): Promise> { + const all = [...(this.data.events[request.sessionId] ?? [])].sort((a, b) => a.eventIndex - b.eventIndex || a.id.localeCompare(b.id)); + const offset = Number(request.cursor ?? 0); + const limit = request.limit ?? 100; + const slice = all.slice(offset, offset + limit); + return { items: slice, nextCursor: offset + slice.length < all.length ? String(offset + slice.length) : undefined }; + } + async insertEvent(sessionId: string, event: SessionEvent) { const events = this.data.events[sessionId] ?? []; events.push({ ...event, payload: JSON.parse(JSON.stringify(event.payload)) }); this.data.events[sessionId] = events; } +} type WorkspaceState = RivetPersistState & { sandboxId: string; @@ -111,5 +143,5 @@ await conn.prompt({ ## Notes - Keep sandbox calls actor-only. Browser clients should not call Sandbox Agent directly. -- Use `@sandbox-agent/persist-rivet` 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/orchestration-architecture.mdx b/docs/orchestration-architecture.mdx new file mode 100644 index 0000000..08c776c --- /dev/null +++ b/docs/orchestration-architecture.mdx @@ -0,0 +1,43 @@ +--- +title: "Orchestration Architecture" +description: "Production topology, backend requirements, and session persistence." +icon: "sitemap" +--- + +This page covers production topology and backend requirements. Read [Architecture](/architecture) first for an overview of how the server, SDK, and agent processes fit together. + +## Suggested Topology + +Run the SDK on your backend, then call it from your frontend. + +This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler. + +```mermaid placement="top-right" + flowchart LR + BROWSER["Browser"] + subgraph BACKEND["Your backend"] + direction TB + SDK["Sandbox Agent SDK"] + end + subgraph SANDBOX_SIMPLE["Sandbox"] + SERVER_SIMPLE["Sandbox Agent server"] + end + + BROWSER --> BACKEND + BACKEND --> SDK --> SERVER_SIMPLE +``` + +### Backend requirements + +Your backend layer needs to handle: + +- **Long-running connections**: prompts can take minutes. +- **Session affinity**: follow-up messages must reach the same session. +- **State between requests**: session metadata and event history must persist across requests. +- **Graceful recovery**: sessions should resume after backend restarts. + +We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require. + +## Session persistence + +For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence). diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index caf2c21..19d9742 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -1,281 +1,370 @@ --- title: "Quickstart" -description: "Start the server and send your first message." +description: "Get a coding agent running in a sandbox in under a minute." icon: "rocket" --- - + - + ```bash - npx skills add rivet-dev/skills -s sandbox-agent + npm install sandbox-agent@0.3.x ``` - + ```bash - bunx skills add rivet-dev/skills -s sandbox-agent - ``` - - - - - - Each coding agent requires API keys to connect to their respective LLM providers. - - - - ```bash - export ANTHROPIC_API_KEY="sk-ant-..." - export OPENAI_API_KEY="sk-..." - ``` - - - - ```typescript - import { Sandbox } from "@e2b/code-interpreter"; - - const envs: Record = {}; - if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - - const sandbox = await Sandbox.create({ envs }); - ``` - - - - ```typescript - import { Daytona } from "@daytonaio/sdk"; - - const envVars: Record = {}; - if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - - const daytona = new Daytona(); - const sandbox = await daytona.create({ - snapshot: "sandbox-agent-ready", - envVars, - }); - ``` - - - - ```bash - docker run -p 2468:2468 \ - -e ANTHROPIC_API_KEY="sk-ant-..." \ - -e OPENAI_API_KEY="sk-..." \ - rivetdev/sandbox-agent:0.3.1-full \ - server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - - - Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from local Claude Code or Codex config files. - - - Use the `mock` agent for SDK and integration testing without provider credentials. - - - For per-tenant token tracking, budget enforcement, or usage-based billing, see [LLM Credentials](/llm-credentials) for gateway options like OpenRouter, LiteLLM, and Portkey. - - - - - - - - Install and run the binary directly. - - ```bash - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - Run without installing globally. - - ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - Run without installing globally. - - ```bash - bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - Install globally, then run. - - ```bash - npm install -g @sandbox-agent/cli@0.3.x - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 - ``` - - - - Install globally, then run. - - ```bash - bun add -g @sandbox-agent/cli@0.3.x + bun add sandbox-agent@0.3.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 - sandbox-agent server --no-token --host 0.0.0.0 --port 2468 + bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` + + - - For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. + + `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. + + ```bash npm install sandbox-agent@0.3.x ``` ```typescript import { SandboxAgent } from "sandbox-agent"; + import { local } from "sandbox-agent/local"; - const sdk = await SandboxAgent.start(); + // Runs on your machine. Inherits process.env automatically. + const client = await SandboxAgent.start({ + sandbox: local(), + }); ``` + + See [Local deploy guide](/deploy/local) - - For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. - + ```bash - bun add sandbox-agent@0.3.x - # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 + npm install sandbox-agent@0.3.x @e2b/code-interpreter ``` ```typescript import { SandboxAgent } from "sandbox-agent"; + import { e2b } from "sandbox-agent/e2b"; - const sdk = await SandboxAgent.start(); + // Provisions a cloud sandbox on E2B, installs the server, and connects. + const client = await SandboxAgent.start({ + sandbox: e2b(), + }); ``` + + See [E2B deploy guide](/deploy/e2b) - - If you're running from source instead of the installed CLI. - + ```bash - cargo run -p sandbox-agent -- server --no-token --host 0.0.0.0 --port 2468 + npm install sandbox-agent@0.3.x @daytonaio/sdk ``` + + ```typescript + import { SandboxAgent } from "sandbox-agent"; + import { daytona } from "sandbox-agent/daytona"; + + // Provisions a Daytona workspace with the server pre-installed. + const client = await SandboxAgent.start({ + sandbox: daytona(), + }); + ``` + + See [Daytona deploy guide](/deploy/daytona) + + + + ```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) + + + + ```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) + + + + ```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) + + + + ```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) - Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely. +
+ + **More info:** - - Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure networking at the infrastructure layer. + + Agents need API keys for their LLM provider. Each provider passes credentials differently: - If you expose the server publicly, use `--token "$SANDBOX_TOKEN"` to require authentication: + ```typescript + // Local — inherits process.env automatically - ```bash - sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 + // E2B + e2b({ create: { envs: { ANTHROPIC_API_KEY: "..." } } }) + + // Daytona + daytona({ create: { envVars: { ANTHROPIC_API_KEY: "..." } } }) + + // Vercel + vercel({ create: { env: { ANTHROPIC_API_KEY: "..." } } }) + + // Modal + modal({ create: { secrets: { ANTHROPIC_API_KEY: "..." } } }) + + // Docker + docker({ env: ["ANTHROPIC_API_KEY=..."] }) ``` - Then pass the token when connecting: + For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials). + + + Implement the `SandboxProvider` interface to use any sandbox platform: + + ```typescript + import { SandboxAgent, type SandboxProvider } from "sandbox-agent"; + + const myProvider: SandboxProvider = { + name: "my-provider", + async create() { + // Provision a sandbox, install & start the server, return an ID + return "sandbox-123"; + }, + async destroy(sandboxId) { + // Tear down the sandbox + }, + async getUrl(sandboxId) { + // Return the Sandbox Agent server URL + return `https://${sandboxId}.my-platform.dev:3000`; + }, + }; + + const client = await SandboxAgent.start({ + sandbox: myProvider, + }); + ``` + + + + If you already have a Sandbox Agent server running, connect directly: + + ```typescript + const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); + ``` + + + - - ```typescript - import { SandboxAgent } from "sandbox-agent"; - - const sdk = await SandboxAgent.connect({ - baseUrl: "http://your-server:2468", - token: process.env.SANDBOX_TOKEN, - }); - ``` - - ```bash - curl "http://your-server:2468/v1/health" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh + sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` - - + ```bash - sandbox-agent --token "$SANDBOX_TOKEN" api agents list \ - --endpoint http://your-server:2468 + npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 + ``` + + + ```bash + docker run -p 2468:2468 \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e OPENAI_API_KEY="sk-..." \ + rivetdev/sandbox-agent:0.3.2-full \ + server --no-token --host 0.0.0.0 --port 2468 ``` - - If you're calling the server from a browser, see the [CORS configuration guide](/cors). - - - Supported agent IDs: `claude`, `codex`, `opencode`, `amp`, `pi`, `cursor`, `mock`. + + - To preinstall agents: + ```typescript Claude + const session = await client.createSession({ + agent: "claude", + }); - ```bash - sandbox-agent install-agent --all - ``` + session.onEvent((event) => { + console.log(event.sender, event.payload); + }); - If agents are not installed up front, they are lazily installed when creating a session. + 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 - import { SandboxAgent } from "sandbox-agent"; - - const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - }); - - const session = await sdk.createSession({ - agent: "claude", - sessionInit: { - cwd: "/", - mcpServers: [], - }, - }); - - console.log(session.id); + await client.destroySandbox(); // tears down the sandbox and disconnects ``` + + Use `client.dispose()` instead to disconnect without destroying the sandbox (for reconnecting later). - - ```typescript - const result = await session.prompt([ - { type: "text", text: "Summarize the repository and suggest next steps." }, - ]); - - console.log(result.stopReason); - ``` - - - - ```typescript - const off = session.onEvent((event) => { - console.log(event.sender, event.payload); - }); - - const page = await sdk.getEvents({ - sessionId: session.id, - limit: 50, - }); - - console.log(page.items.length); - off(); - ``` - - - - Open the Inspector UI at `/ui/` on your server (for example, `http://localhost:2468/ui/`) to inspect sessions and events in a GUI. + + Open the Inspector at `/ui/` on your server (e.g. `http://localhost:2468/ui/`) to view sessions and events in a GUI. Sandbox Agent Inspector @@ -283,16 +372,44 @@ icon: "rocket" +## Full example + +```typescript +import { SandboxAgent } from "sandbox-agent"; +import { e2b } from "sandbox-agent/e2b"; + +const client = await SandboxAgent.start({ + sandbox: e2b({ + create: { + envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, + }, + }), +}); + +try { + const session = await client.createSession({ agent: "claude" }); + + session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); + }); + + const result = await session.prompt([ + { type: "text", text: "Write a function that checks if a number is prime." }, + ]); + + console.log("Done:", result.stopReason); +} finally { + await client.destroySandbox(); +} +``` + ## Next steps - - - Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence. + + + Full TypeScript SDK API surface. - Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare. - - - Use the latest TypeScript SDK API. + Deploy to E2B, Daytona, Docker, Vercel, or Cloudflare. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index fc4aee1..a0f9b84 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -23,12 +23,6 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. -## Optional persistence drivers - -```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x @sandbox-agent/persist-sqlite@0.3.x @sandbox-agent/persist-postgres@0.3.x -``` - ## Optional React components ```bash @@ -68,15 +62,12 @@ const sdk = await SandboxAgent.connect({ controller.abort(); ``` -With persistence: +With persistence (see [Persisting Sessions](/session-persistence) for driver options): ```ts -import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SandboxAgent, InMemorySessionPersistDriver } from "sandbox-agent"; -const persist = new SQLiteSessionPersistDriver({ - filename: "./sessions.db", -}); +const persist = new InMemorySessionPersistDriver(); const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", @@ -84,25 +75,40 @@ const sdk = await SandboxAgent.connect({ }); ``` -Local autospawn (Node.js only): +Local spawn with a sandbox provider: ```ts import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const localSdk = await SandboxAgent.start(); +const sdk = await SandboxAgent.start({ + sandbox: local(), +}); -await localSdk.dispose(); +// sdk.sandboxId — prefixed provider ID (e.g. "local/127.0.0.1:2468") + +await sdk.destroySandbox(); // tears down sandbox + disposes client ``` +`SandboxAgent.start(...)` requires a `sandbox` provider. Built-in providers: + +| Import | Provider | +|--------|----------| +| `sandbox-agent/local` | Local subprocess | +| `sandbox-agent/docker` | Docker container | +| `sandbox-agent/e2b` | E2B sandbox | +| `sandbox-agent/daytona` | Daytona workspace | +| `sandbox-agent/vercel` | Vercel Sandbox | +| `sandbox-agent/cloudflare` | Cloudflare Sandbox | + +Use `sdk.dispose()` to disconnect without destroying the sandbox, or `sdk.destroySandbox()` to tear down both. + ## Session flow ```ts const session = await sdk.createSession({ agent: "mock", - sessionInit: { - cwd: "/", - mcpServers: [], - }, + cwd: "/", }); const prompt = await session.prompt([ @@ -223,6 +229,7 @@ Parameters: - `token` (optional): Bearer token for authenticated servers - `headers` (optional): Additional request headers - `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls +- `skipHealthCheck` (optional): set `true` to skip the startup `/v1/health` wait - `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` diff --git a/docs/security.mdx b/docs/security.mdx index ec00f49..c8b02ad 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -4,7 +4,7 @@ description: "Backend-first auth and access control patterns." icon: "shield" --- -As covered in [Architecture](/architecture), run the Sandbox Agent client on your backend, not in the browser. +As covered in [Orchestration Architecture](/orchestration-architecture), run the Sandbox Agent client on your backend, not in the browser. This keeps sandbox credentials private and gives you one place for authz, rate limiting, and audit logging. @@ -92,7 +92,7 @@ export const workspace = actor({ const session = await sdk.createSession({ agent: "claude", - sessionInit: { cwd: "/workspace" }, + cwd: "/workspace", }); session.onEvent((event) => { diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx index eaa4de0..5505864 100644 --- a/docs/session-persistence.mdx +++ b/docs/session-persistence.mdx @@ -10,14 +10,22 @@ With persistence enabled, sessions can be restored after runtime/session loss. S Each driver stores: -- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sessionInit`) +- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sandboxId`, optional `sessionInit`, optional `configOptions`, optional `modes`) - `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`) ## Persistence drivers -### In-memory +### Rivet -Best for local dev and ephemeral workloads. +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. ```ts import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent"; @@ -33,91 +41,17 @@ const sdk = await SandboxAgent.connect({ }); ``` -### Rivet - -Recommended for sandbox orchestration with actor state. - -```bash -npm install @sandbox-agent/persist-rivet@0.3.x -``` - -```ts -import { actor } from "rivetkit"; -import { SandboxAgent } from "sandbox-agent"; -import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet"; - -type PersistedState = RivetPersistState & { - sandboxId: string; - baseUrl: string; -}; - -export default actor({ - createState: async () => { - return { - sandboxId: "sbx_123", - baseUrl: "http://127.0.0.1:2468", - } satisfies Partial; - }, - createVars: async (c) => { - const persist = new RivetSessionPersistDriver(c); - const sdk = await SandboxAgent.connect({ - baseUrl: c.state.baseUrl, - persist, - }); - - const session = await sdk.resumeOrCreateSession({ id: "default", agent: "codex" }); - - const unsubscribe = session.onEvent((event) => { - c.broadcast("session.event", event); - }); - - return { sdk, session, unsubscribe }; - }, - actions: { - sendMessage: async (c, message: string) => { - await c.vars.session.prompt([{ type: "text", text: message }]); - }, - }, - onSleep: async (c) => { - c.vars.unsubscribe?.(); - await c.vars.sdk.dispose(); - }, -}); -``` - -### IndexedDB - -Best for browser apps that should survive reloads. - -```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x -``` - -```ts -import { SandboxAgent } from "sandbox-agent"; -import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; - -const persist = new IndexedDbSessionPersistDriver({ - databaseName: "sandbox-agent-session-store", -}); - -const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - persist, -}); -``` - ### SQLite Best for local/server Node apps that need durable storage without a DB server. ```bash -npm install @sandbox-agent/persist-sqlite@0.3.x +npm install better-sqlite3 ``` ```ts import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SQLiteSessionPersistDriver } from "./persist.ts"; const persist = new SQLiteSessionPersistDriver({ filename: "./sandbox-agent.db", @@ -129,17 +63,19 @@ const sdk = await SandboxAgent.connect({ }); ``` +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 Use when you already run Postgres and want shared relational storage. ```bash -npm install @sandbox-agent/persist-postgres@0.3.x +npm install pg ``` ```ts import { SandboxAgent } from "sandbox-agent"; -import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres"; +import { PostgresSessionPersistDriver } from "./persist.ts"; const persist = new PostgresSessionPersistDriver({ connectionString: process.env.DATABASE_URL, @@ -152,6 +88,8 @@ const sdk = await SandboxAgent.connect({ }); ``` +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 Implement `SessionPersistDriver` for custom backends. @@ -160,11 +98,11 @@ Implement `SessionPersistDriver` for custom backends. import type { SessionPersistDriver } from "sandbox-agent"; class MyDriver implements SessionPersistDriver { - async getSession(id) { return null; } + async getSession(id) { return undefined; } async listSessions(request) { return { items: [] }; } async updateSession(session) {} async listEvents(request) { return { items: [] }; } - async insertEvent(event) {} + async insertEvent(sessionId, event) {} } ``` diff --git a/docs/skills-config.mdx b/docs/skills-config.mdx index c85bc2c..c3145c2 100644 --- a/docs/skills-config.mdx +++ b/docs/skills-config.mdx @@ -35,9 +35,7 @@ await sdk.setSkillsConfig( // Create a session using the configured skills const session = await sdk.createSession({ agent: "claude", - sessionInit: { - cwd: "/workspace", - }, + cwd: "/workspace", }); await session.prompt([ diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index bdcd53a..171166b 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -25,7 +25,7 @@ const baseUrl = "http://localhost:3000"; console.log("Connecting to server..."); const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/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/sdks/persist-sqlite/vitest.config.ts b/examples/cloudflare/vitest.config.ts similarity index 84% rename from sdks/persist-sqlite/vitest.config.ts rename to examples/cloudflare/vitest.config.ts index 8a85a83..52a3740 100644 --- a/sdks/persist-sqlite/vitest.config.ts +++ b/examples/cloudflare/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + root: ".", include: ["tests/**/*.test.ts"], - testTimeout: 60000, }, }); diff --git a/examples/computesdk/package.json b/examples/computesdk/package.json index e22b51b..243b3b1 100644 --- a/examples/computesdk/package.json +++ b/examples/computesdk/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/computesdk.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts deleted file mode 100644 index 46f43d6..0000000 --- a/examples/computesdk/src/computesdk.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - compute, - detectProvider, - getMissingEnvVars, - getProviderConfigFromEnv, - isProviderAuthComplete, - isValidProvider, - PROVIDER_NAMES, - type ExplicitComputeConfig, - type ProviderName, -} from "computesdk"; -import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; - -const PORT = 3000; -const REQUEST_TIMEOUT_MS = Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000; - -/** - * Detects and validates the provider to use. - * Priority: COMPUTESDK_PROVIDER env var > auto-detection from API keys - */ -function resolveProvider(): ProviderName { - const providerOverride = process.env.COMPUTESDK_PROVIDER; - - if (providerOverride) { - if (!isValidProvider(providerOverride)) { - throw new Error(`Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}`); - } - if (!isProviderAuthComplete(providerOverride)) { - const missing = getMissingEnvVars(providerOverride); - throw new Error(`Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}`); - } - console.log(`Using ComputeSDK provider: ${providerOverride} (explicit)`); - return providerOverride as ProviderName; - } - - const detected = detectProvider(); - if (!detected) { - throw new Error(`No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}`); - } - console.log(`Using ComputeSDK provider: ${detected} (auto-detected)`); - return detected as ProviderName; -} - -function configureComputeSDK(): void { - const provider = resolveProvider(); - - const config: ExplicitComputeConfig = { - provider, - computesdkApiKey: process.env.COMPUTESDK_API_KEY, - requestTimeoutMs: REQUEST_TIMEOUT_MS, - }; - - const providerConfig = getProviderConfigFromEnv(provider); - if (Object.keys(providerConfig).length > 0) { - const configWithProvider = config as ExplicitComputeConfig & Record>; - configWithProvider[provider] = providerConfig; - } - - compute.setConfig(config); -} - -configureComputeSDK(); - -const buildEnv = (): Record => { - const env: Record = {}; - if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - return env; -}; - -export async function setupComputeSdkSandboxAgent(): Promise<{ - baseUrl: string; - cleanup: () => Promise; -}> { - const env = buildEnv(); - - console.log("Creating ComputeSDK sandbox..."); - const sandbox = await compute.sandbox.create({ - envs: Object.keys(env).length > 0 ? env : undefined, - }); - - const run = async (cmd: string, options?: { background?: boolean }) => { - const result = await sandbox.runCommand(cmd, options); - if (typeof result?.exitCode === "number" && result.exitCode !== 0) { - throw new Error(`Command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`); - } - return result; - }; - - console.log("Installing sandbox-agent..."); - await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); - - if (env.ANTHROPIC_API_KEY) { - console.log("Installing Claude agent..."); - await run("sandbox-agent install-agent claude"); - } - - if (env.OPENAI_API_KEY) { - console.log("Installing Codex agent..."); - await run("sandbox-agent install-agent codex"); - } - - console.log("Starting server..."); - await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, { background: true }); - - const baseUrl = await sandbox.getUrl({ port: PORT }); - - const cleanup = async () => { - try { - await sandbox.destroy(); - } catch (error) { - console.warn("Cleanup failed:", error instanceof Error ? error.message : error); - } - }; - - return { baseUrl, cleanup }; -} - -export async function runComputeSdkExample(): Promise { - const { baseUrl, cleanup } = await setupComputeSdkSandboxAgent(); - - const handleExit = async () => { - await cleanup(); - process.exit(0); - }; - - process.once("SIGINT", handleExit); - process.once("SIGTERM", handleExit); - - const client = await SandboxAgent.connect({ baseUrl }); - const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home", mcpServers: [] } }); - const sessionId = session.id; - - console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); - console.log(" Press Ctrl+C to stop."); - - // Keep alive until SIGINT/SIGTERM triggers cleanup above - await new Promise(() => {}); -} - -const isDirectRun = Boolean(process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)); - -if (isDirectRun) { - runComputeSdkExample().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); - }); -} diff --git a/examples/computesdk/src/index.ts b/examples/computesdk/src/index.ts new file mode 100644 index 0000000..63d4aee --- /dev/null +++ b/examples/computesdk/src/index.ts @@ -0,0 +1,30 @@ +import { SandboxAgent } from "sandbox-agent"; +import { computesdk } from "sandbox-agent/computesdk"; +import { detectAgent } from "@sandbox-agent/example-shared"; + +const envs: Record = {}; +if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const client = await SandboxAgent.start({ + sandbox: computesdk({ + create: { envs }, + }), +}); + +console.log(`UI: ${client.inspectorUrl}`); + +const session = await client.createSession({ + agent: detectAgent(), +}); + +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); + +session.prompt([{ type: "text", text: "Say hello from ComputeSDK in one sentence." }]); + +process.once("SIGINT", async () => { + await client.destroySandbox(); + process.exit(0); +}); diff --git a/examples/computesdk/tests/computesdk.test.ts b/examples/computesdk/tests/computesdk.test.ts index 0bbd24c..61ebb2c 100644 --- a/examples/computesdk/tests/computesdk.test.ts +++ b/examples/computesdk/tests/computesdk.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { buildHeaders } from "@sandbox-agent/example-shared"; -import { setupComputeSdkSandboxAgent } from "../src/computesdk.ts"; +import { SandboxAgent } from "sandbox-agent"; +import { computesdk } from "sandbox-agent/computesdk"; const hasModal = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET); const hasVercel = Boolean(process.env.VERCEL_TOKEN || process.env.VERCEL_OIDC_TOKEN); @@ -13,20 +13,23 @@ const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) const testFn = shouldRun ? it : it.skip; -describe("computesdk example", () => { +describe("computesdk provider", () => { testFn( "starts sandbox-agent and responds to /v1/health", async () => { - const { baseUrl, cleanup } = await setupComputeSdkSandboxAgent(); + const envs: Record = {}; + if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + const sdk = await SandboxAgent.start({ + sandbox: computesdk({ create: { envs } }), + }); + try { - const response = await fetch(`${baseUrl}/v1/health`, { - headers: buildHeaders({}), - }); - expect(response.ok).toBe(true); - const data = await response.json(); - expect(data.status).toBe("ok"); + const health = await sdk.getHealth(); + expect(health.status).toBe("ok"); } finally { - await cleanup(); + await sdk.destroySandbox(); } }, timeoutMs, diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 09f4cff..b881113 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,42 +1,31 @@ -import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; - -const daytona = new Daytona(); +import { daytona } from "sandbox-agent/daytona"; +import { detectAgent } from "@sandbox-agent/example-shared"; const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -// Use default image and install sandbox-agent at runtime (faster startup, no snapshot build) -console.log("Creating Daytona sandbox..."); -const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); +const client = await SandboxAgent.start({ + sandbox: daytona({ + create: { envVars }, + }), +}); -// Install sandbox-agent and start server -console.log("Installing sandbox-agent..."); -await sandbox.process.executeCommand("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); +console.log(`UI: ${client.inspectorUrl}`); -console.log("Installing agents..."); -await sandbox.process.executeCommand("sandbox-agent install-agent claude"); -await sandbox.process.executeCommand("sandbox-agent install-agent codex"); +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/daytona", +}); -await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +session.prompt([{ type: "text", text: "Say hello from Daytona in one sentence." }]); -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.delete(60); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/docker/package.json b/examples/docker/package.json index 2c29cfe..7b796c9 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -9,10 +9,10 @@ "dependencies": { "@sandbox-agent/example-shared": "workspace:*", "dockerode": "latest", + "get-port": "latest", "sandbox-agent": "workspace:*" }, "devDependencies": { - "@types/dockerode": "latest", "@types/node": "latest", "tsx": "latest", "typescript": "latest", diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index 74469f3..9f50859 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,68 +1,40 @@ -import Docker from "dockerode"; import fs from "node:fs"; import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { docker } from "sandbox-agent/docker"; +import { detectAgent } from "@sandbox-agent/example-shared"; import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker"; -const IMAGE = FULL_IMAGE; -const PORT = 3000; -const agent = detectAgent(); const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : []; +const env = [ + process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", + process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", + process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", +].filter(Boolean); -const docker = new Docker({ socketPath: "/var/run/docker.sock" }); - -// Pull image if needed -try { - await docker.getImage(IMAGE).inspect(); -} catch { - console.log(`Pulling ${IMAGE}...`); - await new Promise((resolve, reject) => { - docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => { - if (err) return reject(err); - docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); - }); - }); -} - -console.log("Starting container..."); -const container = await docker.createContainer({ - Image: IMAGE, - Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], - Env: [ - process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", - process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", - process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", - ].filter(Boolean), - ExposedPorts: { [`${PORT}/tcp`]: {} }, - HostConfig: { - AutoRemove: true, - PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, - Binds: bindMounts, - }, +const client = await SandboxAgent.start({ + sandbox: docker({ + image: FULL_IMAGE, + env, + binds: bindMounts, + }), }); -await container.start(); -const baseUrl = `http://127.0.0.1:${PORT}`; +console.log(`UI: ${client.inspectorUrl}`); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } }); -const sessionId = session.id; +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/sandbox", +}); -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - try { - await container.stop({ t: 5 }); - } catch {} - try { - await container.remove({ force: true }); - } catch {} +session.prompt([{ type: "text", text: "Say hello from Docker in one sentence." }]); + +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/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/e2b/src/index.ts b/examples/e2b/src/index.ts index 7dd2882..c20ebaa 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,45 +1,28 @@ -import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { e2b } from "sandbox-agent/e2b"; +import { detectAgent } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -console.log("Creating E2B sandbox..."); -const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); +const client = await SandboxAgent.start({ + // ✨ NEW ✨ + sandbox: e2b({ create: { envs } }), +}); -const run = async (cmd: string) => { - const result = await sandbox.commands.run(cmd); - if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}\n${result.stderr}`); - return result; -}; +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/user", +}); -console.log("Installing sandbox-agent..."); -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -console.log("Installing agents..."); -await run("sandbox-agent install-agent claude"); -await run("sandbox-agent install-agent codex"); +session.prompt([{ type: "text", text: "Say hello from E2B in one sentence." }]); -console.log("Starting server..."); -await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --port 3000", { background: true, timeoutMs: 0 }); - -const baseUrl = `https://${sandbox.getHost(3000)}`; - -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.kill(); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts index abe4e08..71d65c0 100644 --- a/examples/file-system/src/index.ts +++ b/examples/file-system/src/index.ts @@ -44,7 +44,7 @@ const readmeText = new TextDecoder().decode(readmeBytes); console.log(` README.md content: ${readmeText.trim()}`); console.log("Creating session..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/opt/my-project" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "read the README in /opt/my-project"'); diff --git a/examples/modal/package.json b/examples/modal/package.json index 61debbd..d3e51ec 100644 --- a/examples/modal/package.json +++ b/examples/modal/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/modal.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/modal/src/index.ts b/examples/modal/src/index.ts new file mode 100644 index 0000000..35eef8d --- /dev/null +++ b/examples/modal/src/index.ts @@ -0,0 +1,30 @@ +import { SandboxAgent } from "sandbox-agent"; +import { modal } from "sandbox-agent/modal"; +import { detectAgent } from "@sandbox-agent/example-shared"; + +const secrets: Record = {}; +if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) secrets.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const client = await SandboxAgent.start({ + sandbox: modal({ + create: { secrets }, + }), +}); + +console.log(`UI: ${client.inspectorUrl}`); + +const session = await client.createSession({ + agent: detectAgent(), +}); + +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); + +session.prompt([{ type: "text", text: "Say hello from Modal in one sentence." }]); + +process.once("SIGINT", async () => { + await client.destroySandbox(); + process.exit(0); +}); diff --git a/examples/modal/src/modal.ts b/examples/modal/src/modal.ts deleted file mode 100644 index d525ad3..0000000 --- a/examples/modal/src/modal.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { ModalClient } from "modal"; -import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; -import { run } from "node:test"; - -const PORT = 3000; -const APP_NAME = "sandbox-agent"; - -async function buildSecrets(modal: ModalClient) { - const envVars: Record = {}; - if (process.env.ANTHROPIC_API_KEY) - envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - if (process.env.OPENAI_API_KEY) - envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - - if (Object.keys(envVars).length === 0) return []; - return [await modal.secrets.fromObject(envVars)]; -} - -export async function setupModalSandboxAgent(): Promise<{ - baseUrl: string; - cleanup: () => Promise; -}> { - const modal = new ModalClient(); - const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true }); - - const image = modal.images - .fromRegistry("ubuntu:22.04") - .dockerfileCommands([ - "RUN apt-get update && apt-get install -y curl ca-certificates", - "RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", - ]); - - const secrets = await buildSecrets(modal); - - console.log("Creating Modal sandbox!"); - const sb = await modal.sandboxes.create(app, image, { - secrets: secrets, - encryptedPorts: [PORT], - }); - console.log(`Sandbox created: ${sb.sandboxId}`); - - const exec = async (cmd: string) => { - const p = await sb.exec(["bash", "-c", cmd], { - stdout: "pipe", - stderr: "pipe", - }); - const exitCode = await p.wait(); - if (exitCode !== 0) { - const stderr = await p.stderr.readText(); - throw new Error(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`); - } - }; - - if (process.env.ANTHROPIC_API_KEY) { - console.log("Installing Claude agent..."); - await exec("sandbox-agent install-agent claude"); - } - if (process.env.OPENAI_API_KEY) { - console.log("Installing Codex agent..."); - await exec("sandbox-agent install-agent codex"); - } - - console.log("Starting server..."); - - await sb.exec( - ["bash", "-c", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT} &`], - ); - - const tunnels = await sb.tunnels(); - const tunnel = tunnels[PORT]; - if (!tunnel) { - throw new Error(`No tunnel found for port ${PORT}`); - } - const baseUrl = tunnel.url; - - console.log("Waiting for server..."); - await waitForHealth({ baseUrl }); - - const cleanup = async () => { - try { - await sb.terminate(); - } catch (error) { - console.warn("Cleanup failed:", error instanceof Error ? error.message : error); - } - }; - - return { baseUrl, cleanup }; -} - -export async function runModalExample(): Promise { - const { baseUrl, cleanup } = await setupModalSandboxAgent(); - - const handleExit = async () => { - await cleanup(); - process.exit(0); - }; - - process.once("SIGINT", handleExit); - process.once("SIGTERM", handleExit); - - const client = await SandboxAgent.connect({ baseUrl }); - const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); - const sessionId = session.id; - - console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); - console.log(" Press Ctrl+C to stop."); - - await new Promise(() => {}); -} - -const isDirectRun = Boolean( - process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url), -); - -if (isDirectRun) { - runModalExample().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); - }); -} diff --git a/examples/modal/tests/modal.test.ts b/examples/modal/tests/modal.test.ts index 9c27a21..010256a 100644 --- a/examples/modal/tests/modal.test.ts +++ b/examples/modal/tests/modal.test.ts @@ -1,26 +1,29 @@ import { describe, it, expect } from "vitest"; -import { buildHeaders } from "@sandbox-agent/example-shared"; -import { setupModalSandboxAgent } from "../src/modal.ts"; +import { SandboxAgent } from "sandbox-agent"; +import { modal } from "sandbox-agent/modal"; const shouldRun = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET); const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000; const testFn = shouldRun ? it : it.skip; -describe("modal example", () => { +describe("modal provider", () => { testFn( "starts sandbox-agent and responds to /v1/health", async () => { - const { baseUrl, cleanup } = await setupModalSandboxAgent(); + const secrets: Record = {}; + if (process.env.ANTHROPIC_API_KEY) secrets.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) secrets.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + const sdk = await SandboxAgent.start({ + sandbox: modal({ create: { secrets } }), + }); + try { - const response = await fetch(`${baseUrl}/v1/health`, { - headers: buildHeaders({}), - }); - expect(response.ok).toBe(true); - const data = await response.json(); - expect(data.status).toBe("ok"); + const health = await sdk.getHealth(); + expect(health.status).toBe("ok"); } finally { - await cleanup(); + await sdk.destroySandbox(); } }, timeoutMs, diff --git a/examples/permissions/src/index.ts b/examples/permissions/src/index.ts index 811f65c..e684e34 100644 --- a/examples/permissions/src/index.ts +++ b/examples/permissions/src/index.ts @@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { Command } from "commander"; import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; const options = parseOptions(); const agent = options.agent.trim().toLowerCase(); @@ -9,10 +10,7 @@ const autoReply = parsePermissionReply(options.reply); const promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`; const sdk = await SandboxAgent.start({ - spawn: { - enabled: true, - log: "inherit", - }, + sandbox: local({ log: "inherit" }), }); try { @@ -43,10 +41,7 @@ try { const session = await sdk.createSession({ agent, ...(mode ? { mode } : {}), - sessionInit: { - cwd: process.cwd(), - mcpServers: [], - }, + cwd: process.cwd(), }); const rl = autoReply diff --git a/examples/persist-postgres/package.json b/examples/persist-postgres/package.json index 8114ffb..8445516 100644 --- a/examples/persist-postgres/package.json +++ b/examples/persist-postgres/package.json @@ -8,7 +8,6 @@ }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "@sandbox-agent/persist-postgres": "workspace:*", "pg": "latest", "sandbox-agent": "workspace:*" }, diff --git a/examples/persist-postgres/src/index.ts b/examples/persist-postgres/src/index.ts index 73f9f04..43eecbd 100644 --- a/examples/persist-postgres/src/index.ts +++ b/examples/persist-postgres/src/index.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { Client } from "pg"; import { setTimeout as delay } from "node:timers/promises"; import { SandboxAgent } from "sandbox-agent"; -import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres"; +import { PostgresSessionPersistDriver } from "./persist.ts"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import { detectAgent } from "@sandbox-agent/example-shared"; diff --git a/examples/persist-postgres/src/persist.ts b/examples/persist-postgres/src/persist.ts new file mode 100644 index 0000000..2a6ccff --- /dev/null +++ b/examples/persist-postgres/src/persist.ts @@ -0,0 +1,336 @@ +import { Pool, type PoolConfig } from "pg"; +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_LIST_LIMIT = 100; + +export interface PostgresSessionPersistDriverOptions { + connectionString?: string; + pool?: Pool; + poolConfig?: PoolConfig; + schema?: string; +} + +export class PostgresSessionPersistDriver implements SessionPersistDriver { + private readonly pool: Pool; + private readonly ownsPool: boolean; + private readonly schema: string; + private readonly initialized: Promise; + + constructor(options: PostgresSessionPersistDriverOptions = {}) { + this.schema = normalizeSchema(options.schema ?? "public"); + + if (options.pool) { + this.pool = options.pool; + this.ownsPool = false; + } else { + this.pool = new Pool({ + connectionString: options.connectionString, + ...options.poolConfig, + }); + this.ownsPool = true; + } + + this.initialized = this.initialize(); + } + + async getSession(id: string): Promise { + await this.ready(); + + const result = await this.pool.query( + `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json + FROM ${this.table("sessions")} + WHERE id = $1`, + [id], + ); + + if (result.rows.length === 0) { + return undefined; + } + + return decodeSessionRow(result.rows[0]); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + await this.ready(); + + const offset = parseCursor(request.cursor); + 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, config_options_json, modes_json + FROM ${this.table("sessions")} + ORDER BY created_at ASC, id ASC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + + const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`); + const total = parseInteger(countResult.rows[0]?.count ?? "0"); + const nextOffset = offset + rowsResult.rows.length; + + return { + items: rowsResult.rows.map(decodeSessionRow), + nextCursor: nextOffset < total ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + await this.ready(); + + 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, 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, + last_connection_id = EXCLUDED.last_connection_id, + created_at = EXCLUDED.created_at, + destroyed_at = EXCLUDED.destroyed_at, + sandbox_id = EXCLUDED.sandbox_id, + session_init_json = EXCLUDED.session_init_json, + config_options_json = EXCLUDED.config_options_json, + modes_json = EXCLUDED.modes_json`, + [ + session.id, + session.agent, + session.agentSessionId, + session.lastConnectionId, + session.createdAt, + 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, + ], + ); + } + + async listEvents(request: ListEventsRequest): Promise> { + await this.ready(); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rowsResult = await this.pool.query( + `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json + FROM ${this.table("events")} + WHERE session_id = $1 + ORDER BY event_index ASC, id ASC + LIMIT $2 OFFSET $3`, + [request.sessionId, limit, offset], + ); + + const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [ + request.sessionId, + ]); + const total = parseInteger(countResult.rows[0]?.count ?? "0"); + const nextOffset = offset + rowsResult.rows.length; + + return { + items: rowsResult.rows.map(decodeEventRow), + nextCursor: nextOffset < total ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + await this.ready(); + + await this.pool.query( + `INSERT INTO ${this.table("events")} ( + id, event_index, session_id, created_at, connection_id, sender, payload_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(id) DO UPDATE SET + event_index = EXCLUDED.event_index, + session_id = EXCLUDED.session_id, + created_at = EXCLUDED.created_at, + connection_id = EXCLUDED.connection_id, + sender = EXCLUDED.sender, + payload_json = EXCLUDED.payload_json`, + [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload], + ); + } + + async close(): Promise { + if (!this.ownsPool) { + return; + } + await this.pool.end(); + } + + private async ready(): Promise { + await this.initialized; + } + + private table(name: "sessions" | "events"): string { + return `"${this.schema}"."${name}"`; + } + + private async initialize(): Promise { + await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${this.table("sessions")} ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + agent_session_id TEXT NOT NULL, + last_connection_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + destroyed_at BIGINT, + sandbox_id TEXT, + session_init_json JSONB, + config_options_json JSONB, + modes_json JSONB + ) + `); + + await this.pool.query(` + ALTER TABLE ${this.table("sessions")} + 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, + event_index BIGINT NOT NULL, + session_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + connection_id TEXT NOT NULL, + sender TEXT NOT NULL, + payload_json JSONB NOT NULL + ) + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ALTER COLUMN id TYPE TEXT USING id::TEXT + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ADD COLUMN IF NOT EXISTS event_index BIGINT + `); + + await this.pool.query(` + WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index + FROM ${this.table("events")} + ) + UPDATE ${this.table("events")} AS current_events + SET event_index = ranked.ranked_index + FROM ranked + WHERE current_events.id = ranked.id + AND current_events.event_index IS NULL + `); + + await this.pool.query(` + ALTER TABLE ${this.table("events")} + ALTER COLUMN event_index SET NOT NULL + `); + + await this.pool.query(` + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON ${this.table("events")}(session_id, event_index, id) + `); + } +} + +type SessionRow = { + id: string; + agent: string; + agent_session_id: string; + last_connection_id: string; + created_at: string | number; + 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 = { + id: string | number; + event_index: string | number; + session_id: string; + created_at: string | number; + connection_id: string; + sender: string; + payload_json: unknown; +}; + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agent_session_id, + lastConnectionId: row.last_connection_id, + createdAt: parseInteger(row.created_at), + destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at), + sandboxId: row.sandbox_id ?? undefined, + sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined, + configOptions: row.config_options_json ? (row.config_options_json as SessionRecord["configOptions"]) : undefined, + modes: row.modes_json ? (row.modes_json as SessionRecord["modes"]) : undefined, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: String(row.id), + eventIndex: parseInteger(row.event_index), + sessionId: row.session_id, + createdAt: parseInteger(row.created_at), + connectionId: row.connection_id, + sender: parseSender(row.sender), + payload: row.payload_json as SessionEvent["payload"], + }; +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function parseInteger(value: string | number): number { + const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid integer value returned by postgres: ${String(value)}`); + } + return parsed; +} + +function parseSender(value: string): SessionEvent["sender"] { + if (value === "agent" || value === "client") { + return value; + } + throw new Error(`Invalid sender value returned by postgres: ${value}`); +} + +function normalizeSchema(schema: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) { + throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`); + } + return schema; +} diff --git a/examples/persist-sqlite/package.json b/examples/persist-sqlite/package.json index 8b7b822..be6bf0d 100644 --- a/examples/persist-sqlite/package.json +++ b/examples/persist-sqlite/package.json @@ -8,10 +8,11 @@ }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "@sandbox-agent/persist-sqlite": "workspace:*", + "better-sqlite3": "^11.0.0", "sandbox-agent": "workspace:*" }, "devDependencies": { + "@types/better-sqlite3": "^7.0.0", "@types/node": "latest", "tsx": "latest", "typescript": "latest" diff --git a/examples/persist-sqlite/src/index.ts b/examples/persist-sqlite/src/index.ts index d2c4ef2..943e902 100644 --- a/examples/persist-sqlite/src/index.ts +++ b/examples/persist-sqlite/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; +import { SQLiteSessionPersistDriver } from "./persist.ts"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import { detectAgent } from "@sandbox-agent/example-shared"; diff --git a/examples/persist-sqlite/src/persist.ts b/examples/persist-sqlite/src/persist.ts new file mode 100644 index 0000000..2292903 --- /dev/null +++ b/examples/persist-sqlite/src/persist.ts @@ -0,0 +1,310 @@ +import Database from "better-sqlite3"; +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_LIST_LIMIT = 100; + +export interface SQLiteSessionPersistDriverOptions { + filename?: string; +} + +export class SQLiteSessionPersistDriver implements SessionPersistDriver { + private readonly db: Database.Database; + + constructor(options: SQLiteSessionPersistDriverOptions = {}) { + this.db = new Database(options.filename ?? ":memory:"); + this.initialize(); + } + + 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, config_options_json, modes_json + FROM sessions WHERE id = ?`, + ) + .get(id) as SessionRow | undefined; + + if (!row) { + return undefined; + } + + return decodeSessionRow(row); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rows = this.db + .prepare( + `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 ?`, + ) + .all(limit, offset) as SessionRow[]; + + const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number }; + const nextOffset = offset + rows.length; + + return { + items: rows.map(decodeSessionRow), + nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + this.db + .prepare( + `INSERT INTO sessions ( + 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, + last_connection_id = excluded.last_connection_id, + created_at = excluded.created_at, + destroyed_at = excluded.destroyed_at, + sandbox_id = excluded.sandbox_id, + session_init_json = excluded.session_init_json, + config_options_json = excluded.config_options_json, + modes_json = excluded.modes_json`, + ) + .run( + session.id, + session.agent, + session.agentSessionId, + session.lastConnectionId, + session.createdAt, + 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, + ); + } + + async listEvents(request: ListEventsRequest): Promise> { + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + + const rows = this.db + .prepare( + `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json + FROM events + WHERE session_id = ? + ORDER BY event_index ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .all(request.sessionId, limit, offset) as EventRow[]; + + const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number }; + + const nextOffset = offset + rows.length; + + return { + items: rows.map(decodeEventRow), + nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + this.db + .prepare( + `INSERT INTO events ( + id, event_index, session_id, created_at, connection_id, sender, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + event_index = excluded.event_index, + session_id = excluded.session_id, + created_at = excluded.created_at, + connection_id = excluded.connection_id, + sender = excluded.sender, + payload_json = excluded.payload_json`, + ) + .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload)); + } + + close(): void { + this.db.close(); + } + + private initialize(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + agent TEXT NOT NULL, + agent_session_id TEXT NOT NULL, + last_connection_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + destroyed_at INTEGER, + sandbox_id TEXT, + session_init_json TEXT, + config_options_json TEXT, + modes_json TEXT + ) + `); + + const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[]; + if (!sessionColumns.some((column) => column.name === "sandbox_id")) { + this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`); + } + 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(); + } + + private ensureEventsTable(): void { + const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[]; + if (tableInfo.length === 0) { + this.createEventsTable(); + return; + } + + const idColumn = tableInfo.find((column) => column.name === "id"); + const hasEventIndex = tableInfo.some((column) => column.name === "event_index"); + const idType = (idColumn?.type ?? "").trim().toUpperCase(); + const idIsText = idType === "TEXT"; + + if (!idIsText || !hasEventIndex) { + this.rebuildEventsTable(hasEventIndex); + } + + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON events(session_id, event_index, id) + `); + } + + private createEventsTable(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + event_index INTEGER NOT NULL, + session_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + connection_id TEXT NOT NULL, + sender TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_session_order + ON events(session_id, event_index, id) + `); + } + + private rebuildEventsTable(hasEventIndex: boolean): void { + this.db.exec(` + ALTER TABLE events RENAME TO events_legacy; + `); + + this.createEventsTable(); + + if (hasEventIndex) { + this.db.exec(` + INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) + SELECT + CAST(id AS TEXT), + COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)), + session_id, + created_at, + connection_id, + sender, + payload_json + FROM events_legacy + `); + } else { + this.db.exec(` + INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) + SELECT + CAST(id AS TEXT), + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC), + session_id, + created_at, + connection_id, + sender, + payload_json + FROM events_legacy + `); + } + + this.db.exec(`DROP TABLE events_legacy`); + } +} + +type SessionRow = { + id: string; + agent: string; + agent_session_id: string; + last_connection_id: string; + created_at: number; + destroyed_at: number | null; + sandbox_id: string | null; + session_init_json: string | null; + config_options_json: string | null; + modes_json: string | null; +}; + +type EventRow = { + id: string; + event_index: number; + session_id: string; + created_at: number; + connection_id: string; + sender: "client" | "agent"; + payload_json: string; +}; + +type TableInfoRow = { + name: string; + type: string; +}; + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agent_session_id, + lastConnectionId: row.last_connection_id, + createdAt: row.created_at, + destroyedAt: row.destroyed_at ?? undefined, + sandboxId: row.sandbox_id ?? undefined, + sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined, + 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, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: row.id, + eventIndex: row.event_index, + sessionId: row.session_id, + createdAt: row.created_at, + connectionId: row.connection_id, + sender: row.sender, + payload: JSON.parse(row.payload_json), + }; +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} 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/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts index 44b2161..490be64 100644 --- a/examples/skills-custom-tool/src/index.ts +++ b/examples/skills-custom-tool/src/index.ts @@ -36,7 +36,7 @@ await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { s // Create a session. console.log("Creating session with custom skill..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts index c04815c..3087ecc 100644 --- a/examples/skills/src/index.ts +++ b/examples/skills/src/index.ts @@ -15,7 +15,7 @@ await client.setSkillsConfig( ); console.log("Creating session..."); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), cwd: "/root" }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "How do I start sandbox-agent?"'); diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 4a63bfc..9839893 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,56 +1,34 @@ -import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { vercel } from "sandbox-agent/vercel"; +import { detectAgent } from "@sandbox-agent/example-shared"; -const envs: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -console.log("Creating Vercel sandbox..."); -const sandbox = await Sandbox.create({ - runtime: "node24", - ports: [3000], +const client = await SandboxAgent.start({ + sandbox: vercel({ + create: { + runtime: "node24", + env, + }, + }), }); -const run = async (cmd: string, args: string[] = []) => { - const result = await sandbox.runCommand({ cmd, args, env: envs }); - if (result.exitCode !== 0) { - const stderr = await result.stderr(); - throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${stderr}`); - } - return result; -}; +console.log(`UI: ${client.inspectorUrl}`); -console.log("Installing sandbox-agent..."); -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); - -console.log("Installing agents..."); -await run("sandbox-agent", ["install-agent", "claude"]); -await run("sandbox-agent", ["install-agent", "codex"]); - -console.log("Starting server..."); -await sandbox.runCommand({ - cmd: "sandbox-agent", - args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"], - env: envs, - detached: true, +const session = await client.createSession({ + agent: detectAgent(), + cwd: "/home/vercel-sandbox", }); -const baseUrl = sandbox.domain(3000); +session.onEvent((event) => { + console.log(`[${event.sender}]`, JSON.stringify(event.payload)); +}); -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); -const sessionId = session.id; +session.prompt([{ type: "text", text: "Say hello from Vercel in one sentence." }]); -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.stop(); +process.once("SIGINT", async () => { + await client.destroySandbox(); process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); +}); diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml index b96805e..c57d971 100644 --- a/foundry/compose.dev.yaml +++ b/foundry/compose.dev.yaml @@ -65,7 +65,6 @@ services: - "foundry_backend_root_node_modules:/app/node_modules" - "foundry_backend_backend_node_modules:/app/foundry/packages/backend/node_modules" - "foundry_backend_shared_node_modules:/app/foundry/packages/shared/node_modules" - - "foundry_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules" - "foundry_backend_typescript_node_modules:/app/sdks/typescript/node_modules" - "foundry_backend_pnpm_store:/root/.local/share/pnpm/store" # Persist RivetKit local storage across container restarts. @@ -120,7 +119,6 @@ volumes: foundry_backend_root_node_modules: {} foundry_backend_backend_node_modules: {} foundry_backend_shared_node_modules: {} - foundry_backend_persist_rivet_node_modules: {} foundry_backend_typescript_node_modules: {} foundry_backend_pnpm_store: {} foundry_rivetkit_storage: {} diff --git a/foundry/docker/backend.Dockerfile b/foundry/docker/backend.Dockerfile index c41fd1f..3dc1c7d 100644 --- a/foundry/docker/backend.Dockerfile +++ b/foundry/docker/backend.Dockerfile @@ -13,7 +13,6 @@ RUN pnpm --filter @sandbox-agent/foundry-shared build RUN pnpm --filter acp-http-client build RUN pnpm --filter @sandbox-agent/cli-shared build RUN SKIP_OPENAPI_GEN=1 pnpm --filter sandbox-agent build -RUN pnpm --filter @sandbox-agent/persist-rivet build RUN pnpm --filter @sandbox-agent/foundry-backend build RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod /out diff --git a/foundry/packages/backend/package.json b/foundry/packages/backend/package.json index e11cd62..562bab7 100644 --- a/foundry/packages/backend/package.json +++ b/foundry/packages/backend/package.json @@ -18,7 +18,6 @@ "@hono/node-ws": "^1.3.0", "@iarna/toml": "^2.2.5", "@sandbox-agent/foundry-shared": "workspace:*", - "@sandbox-agent/persist-rivet": "workspace:*", "better-auth": "^1.5.5", "dockerode": "^4.0.9", "drizzle-kit": "^0.31.8", diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json index 9671ecb..45b7224 100644 --- a/frontend/packages/inspector/package.json +++ b/frontend/packages/inspector/package.json @@ -6,10 +6,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build", + "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vite build", "preview": "vite preview", - "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit", - "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run" + "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && tsc --noEmit", + "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vitest run" }, "devDependencies": { "@sandbox-agent/react": "workspace:*", @@ -23,7 +23,6 @@ "vitest": "^3.0.0" }, "dependencies": { - "@sandbox-agent/persist-indexeddb": "workspace:*", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index ac06904..f6e319c 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -24,7 +24,7 @@ type ConfigOption = { }; type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; -import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; +import { IndexedDbSessionPersistDriver } from "./persist-indexeddb"; import ChatPanel from "./components/chat/ChatPanel"; import ConnectScreen from "./components/ConnectScreen"; import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel"; diff --git a/frontend/packages/inspector/src/persist-indexeddb.ts b/frontend/packages/inspector/src/persist-indexeddb.ts new file mode 100644 index 0000000..23475cb --- /dev/null +++ b/frontend/packages/inspector/src/persist-indexeddb.ts @@ -0,0 +1,320 @@ +import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; + +const DEFAULT_DB_NAME = "sandbox-agent-session-store"; +const DEFAULT_DB_VERSION = 2; +const SESSIONS_STORE = "sessions"; +const EVENTS_STORE = "events"; +const EVENTS_BY_SESSION_INDEX = "by_session_index"; +const DEFAULT_LIST_LIMIT = 100; + +export interface IndexedDbSessionPersistDriverOptions { + databaseName?: string; + databaseVersion?: number; + indexedDb?: IDBFactory; +} + +export class IndexedDbSessionPersistDriver implements SessionPersistDriver { + private readonly indexedDb: IDBFactory; + private readonly dbName: string; + private readonly dbVersion: number; + private readonly dbPromise: Promise; + + constructor(options: IndexedDbSessionPersistDriverOptions = {}) { + const indexedDb = options.indexedDb ?? globalThis.indexedDB; + if (!indexedDb) { + throw new Error("IndexedDB is not available in this runtime."); + } + + this.indexedDb = indexedDb; + this.dbName = options.databaseName ?? DEFAULT_DB_NAME; + this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION; + this.dbPromise = this.openDatabase(); + } + + async getSession(id: string): Promise { + const db = await this.dbPromise; + const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id)); + if (!row || typeof row !== "object") { + return undefined; + } + return decodeSessionRow(row as SessionRow); + } + + async listSessions(request: ListPageRequest = {}): Promise> { + const db = await this.dbPromise; + const rows = await getAllRows(db, SESSIONS_STORE); + + rows.sort((a, b) => { + if (a.createdAt !== b.createdAt) { + return a.createdAt - b.createdAt; + } + return a.id.localeCompare(b.id); + }); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + const slice = rows.slice(offset, offset + limit).map(decodeSessionRow); + const nextOffset = offset + slice.length; + + return { + items: slice, + nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, + }; + } + + async updateSession(session: SessionRecord): Promise { + const db = await this.dbPromise; + await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => { + tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session)); + }); + } + + async listEvents(request: ListEventsRequest): Promise> { + const db = await this.dbPromise; + const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder); + + const offset = parseCursor(request.cursor); + const limit = normalizeLimit(request.limit); + const slice = rows.slice(offset, offset + limit).map(decodeEventRow); + const nextOffset = offset + slice.length; + + return { + items: slice, + nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, + }; + } + + async insertEvent(_sessionId: string, event: SessionEvent): Promise { + const db = await this.dbPromise; + await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => { + tx.objectStore(EVENTS_STORE).put(encodeEventRow(event)); + }); + } + + async close(): Promise { + const db = await this.dbPromise; + db.close(); + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = this.indexedDb.open(this.dbName, this.dbVersion); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(SESSIONS_STORE)) { + db.createObjectStore(SESSIONS_STORE, { keyPath: "id" }); + } + + if (!db.objectStoreNames.contains(EVENTS_STORE)) { + const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" }); + events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { + unique: false, + }); + } else { + const tx = request.transaction; + if (!tx) { + return; + } + const events = tx.objectStore(EVENTS_STORE); + if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) { + events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { + unique: false, + }); + } + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB")); + }); + } +} + +type SessionRow = { + id: string; + agent: string; + agentSessionId: string; + lastConnectionId: string; + createdAt: number; + destroyedAt?: number; + sandboxId?: string; + sessionInit?: SessionRecord["sessionInit"]; + configOptions?: SessionRecord["configOptions"]; + modes?: SessionRecord["modes"]; +}; + +type EventRow = { + id: number | string; + eventIndex?: number; + sessionId: string; + createdAt: number; + connectionId: string; + sender: "client" | "agent"; + payload: unknown; +}; + +function encodeSessionRow(session: SessionRecord): SessionRow { + return { + id: session.id, + agent: session.agent, + agentSessionId: session.agentSessionId, + lastConnectionId: session.lastConnectionId, + createdAt: session.createdAt, + destroyedAt: session.destroyedAt, + sandboxId: session.sandboxId, + sessionInit: session.sessionInit, + configOptions: session.configOptions, + modes: session.modes, + }; +} + +function decodeSessionRow(row: SessionRow): SessionRecord { + return { + id: row.id, + agent: row.agent, + agentSessionId: row.agentSessionId, + lastConnectionId: row.lastConnectionId, + createdAt: row.createdAt, + destroyedAt: row.destroyedAt, + sandboxId: row.sandboxId, + sessionInit: row.sessionInit, + configOptions: row.configOptions, + modes: row.modes, + }; +} + +function encodeEventRow(event: SessionEvent): EventRow { + return { + id: event.id, + eventIndex: event.eventIndex, + sessionId: event.sessionId, + createdAt: event.createdAt, + connectionId: event.connectionId, + sender: event.sender, + payload: event.payload, + }; +} + +function decodeEventRow(row: EventRow): SessionEvent { + return { + id: String(row.id), + eventIndex: parseEventIndex(row.eventIndex, row.id), + sessionId: row.sessionId, + createdAt: row.createdAt, + connectionId: row.connectionId, + sender: row.sender, + payload: row.payload as SessionEvent["payload"], + }; +} + +async function getAllRows(db: IDBDatabase, storeName: string): Promise { + return await transactionPromise(db, [storeName], "readonly", async (tx) => { + const request = tx.objectStore(storeName).getAll(); + return (await requestToPromise(request)) as T[]; + }); +} + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) < 1) { + return DEFAULT_LIST_LIMIT; + } + return Math.floor(limit as number); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function compareEventRowsByOrder(a: EventRow, b: EventRow): number { + const indexA = parseEventIndex(a.eventIndex, a.id); + const indexB = parseEventIndex(b.eventIndex, b.id); + if (indexA !== indexB) { + return indexA - indexB; + } + return String(a.id).localeCompare(String(b.id)); +} + +function parseEventIndex(value: number | undefined, fallback: number | string): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + + const parsed = Number.parseInt(String(fallback), 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + return parsed; +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); + }); +} + +function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(stores, mode); + let settled = false; + let resultValue: T | undefined; + let runCompleted = false; + let txCompleted = false; + + function tryResolve() { + if (settled || !runCompleted || !txCompleted) { + return; + } + settled = true; + resolve(resultValue as T); + } + + tx.oncomplete = () => { + txCompleted = true; + tryResolve(); + }; + + tx.onerror = () => { + if (settled) { + return; + } + settled = true; + reject(tx.error ?? new Error("IndexedDB transaction failed")); + }; + + tx.onabort = () => { + if (settled) { + return; + } + settled = true; + reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }; + + Promise.resolve(run(tx)) + .then((value) => { + resultValue = value; + runCompleted = true; + tryResolve(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + try { + tx.abort(); + } catch { + // no-op + } + }); + }); +} diff --git a/frontend/packages/website/public/logos/cloudflare.svg b/frontend/packages/website/public/logos/cloudflare.svg new file mode 100644 index 0000000..76a2e80 --- /dev/null +++ b/frontend/packages/website/public/logos/cloudflare.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/website/public/logos/computesdk.svg b/frontend/packages/website/public/logos/computesdk.svg new file mode 100644 index 0000000..45c6271 --- /dev/null +++ b/frontend/packages/website/public/logos/computesdk.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/website/public/logos/docker.svg b/frontend/packages/website/public/logos/docker.svg new file mode 100644 index 0000000..33582ef --- /dev/null +++ b/frontend/packages/website/public/logos/docker.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/website/public/logos/modal.svg b/frontend/packages/website/public/logos/modal.svg new file mode 100644 index 0000000..990b5bd --- /dev/null +++ b/frontend/packages/website/public/logos/modal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/packages/website/src/components/GetStarted.tsx b/frontend/packages/website/src/components/GetStarted.tsx index 57cccef..8a03b34 100644 --- a/frontend/packages/website/src/components/GetStarted.tsx +++ b/frontend/packages/website/src/components/GetStarted.tsx @@ -5,8 +5,11 @@ import { Code, Server, GitBranch } from "lucide-react"; import { CopyButton } from "./ui/CopyButton"; const sdkCodeRaw = `import { SandboxAgent } from "sandbox-agent"; +import { local } from "sandbox-agent/local"; -const client = await SandboxAgent.start(); +const client = await SandboxAgent.start({ + sandbox: local(), +}); await client.createSession("my-session", { agent: "claude-code", @@ -32,13 +35,26 @@ function SdkCodeHighlighted() { "sandbox-agent" ; + {"\n"} + import + {" { "} + local + {" } "} + from + + "sandbox-agent/local" + ; {"\n\n"} const client = await SandboxAgent. start - (); + {"({"} + {"\n"} + {" sandbox: local(),"} + {"\n"} + {"});"} {"\n\n"} await client. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8396837..e4e7838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,13 +154,13 @@ importers: dockerode: specifier: latest version: 4.0.9 + get-port: + specifier: latest + version: 7.1.0 sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: - '@types/dockerode': - specifier: latest - version: 4.0.1 '@types/node': specifier: latest version: 25.5.0 @@ -345,9 +345,6 @@ importers: '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared - '@sandbox-agent/persist-postgres': - specifier: workspace:* - version: link:../../sdks/persist-postgres pg: specifier: latest version: 8.20.0 @@ -373,13 +370,16 @@ importers: '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared - '@sandbox-agent/persist-sqlite': - specifier: workspace:* - version: link:../../sdks/persist-sqlite + better-sqlite3: + specifier: ^11.0.0 + version: 11.10.0 sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: + '@types/better-sqlite3': + specifier: ^7.0.0 + version: 7.6.13 '@types/node': specifier: latest version: 25.5.0 @@ -640,9 +640,6 @@ importers: frontend/packages/inspector: dependencies: - '@sandbox-agent/persist-indexeddb': - specifier: workspace:* - version: link:../../../sdks/persist-indexeddb lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -897,57 +894,30 @@ importers: sdks/gigacode/platforms/win32-x64: {} sdks/persist-indexeddb: - dependencies: - sandbox-agent: - specifier: workspace:* - version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.7 - fake-indexeddb: - specifier: ^6.2.4 - version: 6.2.5 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.7.0 version: 5.9.3 - vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-postgres: - dependencies: - pg: - specifier: ^8.16.3 - version: 8.18.0 - sandbox-agent: - specifier: workspace:* - version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.7 - '@types/pg': - specifier: ^8.15.6 - version: 8.16.0 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.7.0 version: 5.9.3 - vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-rivet: - dependencies: - sandbox-agent: - specifier: workspace:* - version: link:../typescript devDependencies: '@types/node': specifier: ^22.0.0 @@ -958,22 +928,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 - vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/persist-sqlite: - dependencies: - better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 - sandbox-agent: - specifier: workspace:* - version: link:../typescript devDependencies: - '@types/better-sqlite3': - specifier: ^7.0.0 - version: 7.6.13 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -983,9 +940,6 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 - vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/react: dependencies: @@ -1025,12 +979,39 @@ importers: specifier: workspace:* version: link:../cli devDependencies: + '@cloudflare/sandbox': + specifier: '>=0.1.0' + version: 0.7.17(@opencode-ai/sdk@1.2.24) + '@daytonaio/sdk': + specifier: '>=0.12.0' + version: 0.151.0(ws@8.19.0) + '@e2b/code-interpreter': + specifier: '>=1.0.0' + version: 2.3.3 + '@types/dockerode': + specifier: ^4.0.0 + version: 4.0.1 '@types/node': specifier: ^22.0.0 version: 22.19.7 '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@vercel/sandbox': + specifier: '>=0.1.0' + version: 1.8.1 + computesdk: + specifier: '>=0.1.0' + version: 2.5.0 + dockerode: + specifier: '>=4.0.0' + version: 4.0.9 + get-port: + specifier: '>=7.0.0' + version: 7.1.0 + modal: + specifier: '>=0.1.0' + version: 0.7.3 openapi-typescript: specifier: ^6.7.0 version: 6.7.6 @@ -3607,9 +3588,6 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@types/pg@8.16.0': - resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} - '@types/pg@8.18.0': resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} @@ -5823,9 +5801,6 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.11.0: - resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} - pg-connection-string@2.12.0: resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} @@ -5833,11 +5808,6 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} - peerDependencies: - pg: '>=8.0' - pg-pool@3.13.0: resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: @@ -5853,15 +5823,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.18.0: - resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.20.0: resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} @@ -10190,12 +10151,6 @@ snapshots: dependencies: undici-types: 7.18.2 - '@types/pg@8.16.0': - dependencies: - '@types/node': 24.10.9 - pg-protocol: 1.11.0 - pg-types: 2.2.0 - '@types/pg@8.18.0': dependencies: '@types/node': 24.10.9 @@ -11273,7 +11228,7 @@ snapshots: glob: 11.1.0 openapi-fetch: 0.14.1 platform: 1.3.6 - tar: 7.5.6 + tar: 7.5.7 earcut@2.2.4: {} @@ -12783,16 +12738,10 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: {} - pg-connection-string@2.12.0: {} pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.18.0): - dependencies: - pg: 8.18.0 - pg-pool@3.13.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -12809,16 +12758,6 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.18.0: - dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.11.0(pg@8.18.0) - pg-protocol: 1.11.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.3.0 - pg@8.20.0: dependencies: pg-connection-string: 2.12.0 diff --git a/sdks/persist-indexeddb/README.md b/sdks/persist-indexeddb/README.md new file mode 100644 index 0000000..02dc5c2 --- /dev/null +++ b/sdks/persist-indexeddb/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-indexeddb + +> **Deprecated:** This package has been deprecated and removed. + +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 179e0be..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", @@ -16,23 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "sandbox-agent": "workspace:*" - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", - "fake-indexeddb": "^6.2.4", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-indexeddb/src/index.ts b/sdks/persist-indexeddb/src/index.ts index 945e993..e388530 100644 --- a/sdks/persist-indexeddb/src/index.ts +++ b/sdks/persist-indexeddb/src/index.ts @@ -1,311 +1,5 @@ -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_DB_NAME = "sandbox-agent-session-store"; -const DEFAULT_DB_VERSION = 2; -const SESSIONS_STORE = "sessions"; -const EVENTS_STORE = "events"; -const EVENTS_BY_SESSION_INDEX = "by_session_index"; -const DEFAULT_LIST_LIMIT = 100; - -export interface IndexedDbSessionPersistDriverOptions { - databaseName?: string; - databaseVersion?: number; - indexedDb?: IDBFactory; -} - -export class IndexedDbSessionPersistDriver implements SessionPersistDriver { - private readonly indexedDb: IDBFactory; - private readonly dbName: string; - private readonly dbVersion: number; - private readonly dbPromise: Promise; - - constructor(options: IndexedDbSessionPersistDriverOptions = {}) { - const indexedDb = options.indexedDb ?? globalThis.indexedDB; - if (!indexedDb) { - throw new Error("IndexedDB is not available in this runtime."); - } - - this.indexedDb = indexedDb; - this.dbName = options.databaseName ?? DEFAULT_DB_NAME; - this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION; - this.dbPromise = this.openDatabase(); - } - - async getSession(id: string): Promise { - const db = await this.dbPromise; - const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id)); - if (!row || typeof row !== "object") { - return null; - } - return decodeSessionRow(row as SessionRow); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const db = await this.dbPromise; - const rows = await getAllRows(db, SESSIONS_STORE); - - rows.sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - const slice = rows.slice(offset, offset + limit).map(decodeSessionRow); - const nextOffset = offset + slice.length; - - return { - items: slice, - nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - const db = await this.dbPromise; - await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => { - tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session)); - }); - } - - async listEvents(request: ListEventsRequest): Promise> { - const db = await this.dbPromise; - const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - const slice = rows.slice(offset, offset + limit).map(decodeEventRow); - const nextOffset = offset + slice.length; - - return { - items: slice, - nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined, - }; - } - - async insertEvent(event: SessionEvent): Promise { - const db = await this.dbPromise; - await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => { - tx.objectStore(EVENTS_STORE).put(encodeEventRow(event)); - }); - } - - async close(): Promise { - const db = await this.dbPromise; - db.close(); - } - - private openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = this.indexedDb.open(this.dbName, this.dbVersion); - - request.onupgradeneeded = () => { - const db = request.result; - - if (!db.objectStoreNames.contains(SESSIONS_STORE)) { - db.createObjectStore(SESSIONS_STORE, { keyPath: "id" }); - } - - if (!db.objectStoreNames.contains(EVENTS_STORE)) { - const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" }); - events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { - unique: false, - }); - } else { - const tx = request.transaction; - if (!tx) { - return; - } - const events = tx.objectStore(EVENTS_STORE); - if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) { - events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], { - unique: false, - }); - } - } - }; - - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB")); - }); - } -} - -type SessionRow = { - id: string; - agent: string; - agentSessionId: string; - lastConnectionId: string; - createdAt: number; - destroyedAt?: number; - sessionInit?: SessionRecord["sessionInit"]; -}; - -type EventRow = { - id: number | string; - eventIndex?: number; - sessionId: string; - createdAt: number; - connectionId: string; - sender: "client" | "agent"; - payload: unknown; -}; - -function encodeSessionRow(session: SessionRecord): SessionRow { - return { - id: session.id, - agent: session.agent, - agentSessionId: session.agentSessionId, - lastConnectionId: session.lastConnectionId, - createdAt: session.createdAt, - destroyedAt: session.destroyedAt, - sessionInit: session.sessionInit, - }; -} - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agentSessionId, - lastConnectionId: row.lastConnectionId, - createdAt: row.createdAt, - destroyedAt: row.destroyedAt, - sessionInit: row.sessionInit, - }; -} - -function encodeEventRow(event: SessionEvent): EventRow { - return { - id: event.id, - eventIndex: event.eventIndex, - sessionId: event.sessionId, - createdAt: event.createdAt, - connectionId: event.connectionId, - sender: event.sender, - payload: event.payload, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: String(row.id), - eventIndex: parseEventIndex(row.eventIndex, row.id), - sessionId: row.sessionId, - createdAt: row.createdAt, - connectionId: row.connectionId, - sender: row.sender, - payload: row.payload as SessionEvent["payload"], - }; -} - -async function getAllRows(db: IDBDatabase, storeName: string): Promise { - return await transactionPromise(db, [storeName], "readonly", async (tx) => { - const request = tx.objectStore(storeName).getAll(); - return (await requestToPromise(request)) as T[]; - }); -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function compareEventRowsByOrder(a: EventRow, b: EventRow): number { - const indexA = parseEventIndex(a.eventIndex, a.id); - const indexB = parseEventIndex(b.eventIndex, b.id); - if (indexA !== indexB) { - return indexA - indexB; - } - return String(a.id).localeCompare(String(b.id)); -} - -function parseEventIndex(value: number | undefined, fallback: number | string): number { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.max(0, Math.floor(value)); - } - - const parsed = Number.parseInt(String(fallback), 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function requestToPromise(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); - }); -} - -function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(stores, mode); - let settled = false; - let resultValue: T | undefined; - let runCompleted = false; - let txCompleted = false; - - function tryResolve() { - if (settled || !runCompleted || !txCompleted) { - return; - } - settled = true; - resolve(resultValue as T); - } - - tx.oncomplete = () => { - txCompleted = true; - tryResolve(); - }; - - tx.onerror = () => { - if (settled) { - return; - } - settled = true; - reject(tx.error ?? new Error("IndexedDB transaction failed")); - }; - - tx.onabort = () => { - if (settled) { - return; - } - settled = true; - reject(tx.error ?? new Error("IndexedDB transaction aborted")); - }; - - Promise.resolve(run(tx)) - .then((value) => { - resultValue = value; - runCompleted = true; - tryResolve(); - }) - .catch((error) => { - if (!settled) { - settled = true; - reject(error); - } - try { - tx.abort(); - } catch { - // no-op - } - }); - }); -} +throw new Error( + "@sandbox-agent/persist-indexeddb has been deprecated and removed. " + + "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-indexeddb/tests/driver.test.ts b/sdks/persist-indexeddb/tests/driver.test.ts deleted file mode 100644 index 78acbe1..0000000 --- a/sdks/persist-indexeddb/tests/driver.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import "fake-indexeddb/auto"; -import { describe, it, expect } from "vitest"; -import { IndexedDbSessionPersistDriver } from "../src/index.ts"; - -function uniqueDbName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -describe("IndexedDbSessionPersistDriver", () => { - it("stores and pages sessions and events", async () => { - const dbName = uniqueDbName("indexeddb-driver"); - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - destroyedAt: 300, - }); - - await driver.insertEvent({ - id: "evt-1", - eventIndex: 1, - sessionId: "s-1", - createdAt: 1, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - - await driver.insertEvent({ - id: "evt-2", - eventIndex: 2, - sessionId: "s-1", - createdAt: 2, - connectionId: "c-1", - sender: "agent", - payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } }, - }); - - const loaded = await driver.getSession("s-2"); - expect(loaded?.destroyedAt).toBe(300); - - const page1 = await driver.listSessions({ limit: 1 }); - expect(page1.items).toHaveLength(1); - expect(page1.items[0]?.id).toBe("s-1"); - expect(page1.nextCursor).toBeTruthy(); - - const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 }); - expect(page2.items).toHaveLength(1); - expect(page2.items[0]?.id).toBe("s-2"); - expect(page2.nextCursor).toBeUndefined(); - - const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 }); - expect(eventsPage.items).toHaveLength(2); - expect(eventsPage.items[0]?.id).toBe("evt-1"); - expect(eventsPage.items[0]?.eventIndex).toBe(1); - expect(eventsPage.items[1]?.id).toBe("evt-2"); - expect(eventsPage.items[1]?.eventIndex).toBe(2); - - await driver.close(); - }); - - it("persists across driver instances for same database", async () => { - const dbName = uniqueDbName("indexeddb-reopen"); - - { - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - await driver.close(); - } - - { - const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const session = await driver.getSession("s-1"); - expect(session?.id).toBe("s-1"); - await driver.close(); - } - }); -}); diff --git a/sdks/persist-indexeddb/tests/integration.test.ts b/sdks/persist-indexeddb/tests/integration.test.ts deleted file mode 100644 index 4a27ac5..0000000 --- a/sdks/persist-indexeddb/tests/integration.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import "fake-indexeddb/auto"; -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { IndexedDbSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -function uniqueDbName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -describe("IndexedDB persistence end-to-end", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "indexeddb-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("restores sessions/events across sdk instances", async () => { - const dbName = uniqueDbName("sandbox-agent-browser-e2e"); - - const persist1 = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "indexeddb-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - await persist1.close(); - - const persist2 = new IndexedDbSessionPersistDriver({ databaseName: dbName }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "indexeddb-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - await persist2.close(); - }); -}); diff --git a/sdks/persist-postgres/README.md b/sdks/persist-postgres/README.md new file mode 100644 index 0000000..5a3afba --- /dev/null +++ b/sdks/persist-postgres/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/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/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/package.json b/sdks/persist-postgres/package.json index 49bd9f1..caa49f6 100644 --- a/sdks/persist-postgres/package.json +++ b/sdks/persist-postgres/package.json @@ -1,7 +1,7 @@ { "name": "@sandbox-agent/persist-postgres", "version": "0.3.2", - "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK", + "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)", "license": "Apache-2.0", "repository": { "type": "git", @@ -16,24 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "pg": "^8.16.3", - "sandbox-agent": "workspace:*" - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", - "@types/pg": "^8.15.6", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-postgres/src/index.ts b/sdks/persist-postgres/src/index.ts index 7c77827..ec76a53 100644 --- a/sdks/persist-postgres/src/index.ts +++ b/sdks/persist-postgres/src/index.ts @@ -1,306 +1,5 @@ -import { Pool, type PoolConfig } from "pg"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_LIST_LIMIT = 100; - -export interface PostgresSessionPersistDriverOptions { - connectionString?: string; - pool?: Pool; - poolConfig?: PoolConfig; - schema?: string; -} - -export class PostgresSessionPersistDriver implements SessionPersistDriver { - private readonly pool: Pool; - private readonly ownsPool: boolean; - private readonly schema: string; - private readonly initialized: Promise; - - constructor(options: PostgresSessionPersistDriverOptions = {}) { - this.schema = normalizeSchema(options.schema ?? "public"); - - if (options.pool) { - this.pool = options.pool; - this.ownsPool = false; - } else { - this.pool = new Pool({ - connectionString: options.connectionString, - ...options.poolConfig, - }); - this.ownsPool = true; - } - - this.initialized = this.initialize(); - } - - async getSession(id: string): Promise { - await this.ready(); - - const result = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - FROM ${this.table("sessions")} - WHERE id = $1`, - [id], - ); - - if (result.rows.length === 0) { - return null; - } - - return decodeSessionRow(result.rows[0]); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - await this.ready(); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rowsResult = await this.pool.query( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - FROM ${this.table("sessions")} - ORDER BY created_at ASC, id ASC - LIMIT $1 OFFSET $2`, - [limit, offset], - ); - - const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`); - const total = parseInteger(countResult.rows[0]?.count ?? "0"); - const nextOffset = offset + rowsResult.rows.length; - - return { - items: rowsResult.rows.map(decodeSessionRow), - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - await this.ready(); - - await this.pool.query( - `INSERT INTO ${this.table("sessions")} ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT(id) DO UPDATE SET - agent = EXCLUDED.agent, - agent_session_id = EXCLUDED.agent_session_id, - last_connection_id = EXCLUDED.last_connection_id, - created_at = EXCLUDED.created_at, - destroyed_at = EXCLUDED.destroyed_at, - session_init_json = EXCLUDED.session_init_json`, - [ - session.id, - session.agent, - session.agentSessionId, - session.lastConnectionId, - session.createdAt, - session.destroyedAt ?? null, - session.sessionInit ?? null, - ], - ); - } - - async listEvents(request: ListEventsRequest): Promise> { - await this.ready(); - - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rowsResult = await this.pool.query( - `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json - FROM ${this.table("events")} - WHERE session_id = $1 - ORDER BY event_index ASC, id ASC - LIMIT $2 OFFSET $3`, - [request.sessionId, limit, offset], - ); - - const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [ - request.sessionId, - ]); - const total = parseInteger(countResult.rows[0]?.count ?? "0"); - const nextOffset = offset + rowsResult.rows.length; - - return { - items: rowsResult.rows.map(decodeEventRow), - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async insertEvent(event: SessionEvent): Promise { - await this.ready(); - - await this.pool.query( - `INSERT INTO ${this.table("events")} ( - id, event_index, session_id, created_at, connection_id, sender, payload_json - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT(id) DO UPDATE SET - event_index = EXCLUDED.event_index, - session_id = EXCLUDED.session_id, - created_at = EXCLUDED.created_at, - connection_id = EXCLUDED.connection_id, - sender = EXCLUDED.sender, - payload_json = EXCLUDED.payload_json`, - [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload], - ); - } - - async close(): Promise { - if (!this.ownsPool) { - return; - } - await this.pool.end(); - } - - private async ready(): Promise { - await this.initialized; - } - - private table(name: "sessions" | "events"): string { - return `"${this.schema}"."${name}"`; - } - - private async initialize(): Promise { - await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`); - - await this.pool.query(` - CREATE TABLE IF NOT EXISTS ${this.table("sessions")} ( - id TEXT PRIMARY KEY, - agent TEXT NOT NULL, - agent_session_id TEXT NOT NULL, - last_connection_id TEXT NOT NULL, - created_at BIGINT NOT NULL, - destroyed_at BIGINT, - session_init_json JSONB - ) - `); - - await this.pool.query(` - CREATE TABLE IF NOT EXISTS ${this.table("events")} ( - id TEXT PRIMARY KEY, - event_index BIGINT NOT NULL, - session_id TEXT NOT NULL, - created_at BIGINT NOT NULL, - connection_id TEXT NOT NULL, - sender TEXT NOT NULL, - payload_json JSONB NOT NULL - ) - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ALTER COLUMN id TYPE TEXT USING id::TEXT - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ADD COLUMN IF NOT EXISTS event_index BIGINT - `); - - await this.pool.query(` - WITH ranked AS ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index - FROM ${this.table("events")} - ) - UPDATE ${this.table("events")} AS current_events - SET event_index = ranked.ranked_index - FROM ranked - WHERE current_events.id = ranked.id - AND current_events.event_index IS NULL - `); - - await this.pool.query(` - ALTER TABLE ${this.table("events")} - ALTER COLUMN event_index SET NOT NULL - `); - - await this.pool.query(` - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON ${this.table("events")}(session_id, event_index, id) - `); - } -} - -type SessionRow = { - id: string; - agent: string; - agent_session_id: string; - last_connection_id: string; - created_at: string | number; - destroyed_at: string | number | null; - session_init_json: unknown | null; -}; - -type EventRow = { - id: string | number; - event_index: string | number; - session_id: string; - created_at: string | number; - connection_id: string; - sender: string; - payload_json: unknown; -}; - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agent_session_id, - lastConnectionId: row.last_connection_id, - createdAt: parseInteger(row.created_at), - destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at), - sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: String(row.id), - eventIndex: parseInteger(row.event_index), - sessionId: row.session_id, - createdAt: parseInteger(row.created_at), - connectionId: row.connection_id, - sender: parseSender(row.sender), - payload: row.payload_json as SessionEvent["payload"], - }; -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} - -function parseInteger(value: string | number): number { - const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) { - throw new Error(`Invalid integer value returned by postgres: ${String(value)}`); - } - return parsed; -} - -function parseSender(value: string): SessionEvent["sender"] { - if (value === "agent" || value === "client") { - return value; - } - throw new Error(`Invalid sender value returned by postgres: ${value}`); -} - -function normalizeSchema(schema: string): string { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) { - throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`); - } - return schema; -} +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/rivet-dev/sandbox-agent/tree/main/examples/persist-postgres", +); diff --git a/sdks/persist-postgres/tests/integration.test.ts b/sdks/persist-postgres/tests/integration.test.ts deleted file mode 100644 index ddd4123..0000000 --- a/sdks/persist-postgres/tests/integration.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { Client } from "pg"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { PostgresSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -interface PostgresContainer { - containerId: string; - connectionString: string; -} - -describe("Postgres persistence driver", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - let postgres: PostgresContainer | null = null; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "postgres-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - beforeEach(async () => { - postgres = await startPostgresContainer(); - }); - - afterEach(() => { - if (postgres) { - stopPostgresContainer(postgres.containerId); - postgres = null; - } - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("persists session/event history across SDK instances and supports replay restore", async () => { - const connectionString = requirePostgres(postgres).connectionString; - - const persist1 = new PostgresSessionPersistDriver({ - connectionString, - }); - - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "postgres-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - await persist1.close(); - - const persist2 = new PostgresSessionPersistDriver({ - connectionString, - }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "postgres-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - expect(events.items.every((event) => typeof event.id === "string")).toBe(true); - expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true); - - for (let i = 1; i < events.items.length; i += 1) { - expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex); - } - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - await persist2.close(); - }); -}); - -async function startPostgresContainer(): Promise { - const name = `sandbox-agent-postgres-${randomUUID()}`; - const containerId = runDockerCommand([ - "run", - "-d", - "--rm", - "--name", - name, - "-e", - "POSTGRES_USER=postgres", - "-e", - "POSTGRES_PASSWORD=postgres", - "-e", - "POSTGRES_DB=sandboxagent", - "-p", - "127.0.0.1::5432", - "postgres:16-alpine", - ]); - - const portOutput = runDockerCommand(["port", containerId, "5432/tcp"]); - const port = parsePort(portOutput); - const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandboxagent`; - await waitForPostgres(connectionString); - - return { - containerId, - connectionString, - }; -} - -function stopPostgresContainer(containerId: string): void { - try { - runDockerCommand(["rm", "-f", containerId]); - } catch { - // Container may already be gone when test teardown runs. - } -} - -function runDockerCommand(args: string[]): string { - return execFileSync("docker", args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); -} - -function parsePort(output: string): string { - const firstLine = output.split("\n")[0]?.trim() ?? ""; - const match = firstLine.match(/:(\d+)$/); - if (!match) { - throw new Error(`Failed to parse docker port output: '${output}'`); - } - return match[1]; -} - -async function waitForPostgres(connectionString: string): Promise { - const timeoutMs = 30000; - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - - while (Date.now() < deadline) { - const client = new Client({ connectionString }); - try { - await client.connect(); - await client.query("SELECT 1"); - await client.end(); - return; - } catch (error) { - lastError = error; - try { - await client.end(); - } catch { - // Ignore cleanup failures while retrying. - } - await delay(250); - } - } - - throw new Error(`Postgres container did not become ready: ${String(lastError)}`); -} - -function delay(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -} - -function requirePostgres(container: PostgresContainer | null): PostgresContainer { - if (!container) { - throw new Error("Postgres container was not initialized for this test."); - } - return container; -} diff --git a/sdks/persist-rivet/README.md b/sdks/persist-rivet/README.md new file mode 100644 index 0000000..ce93b8d --- /dev/null +++ b/sdks/persist-rivet/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/persist-rivet + +> **Deprecated:** This package has been deprecated and removed. + +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 723e3b4..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", @@ -16,30 +16,16 @@ "import": "./dist/index.js" } }, - "dependencies": { - "sandbox-agent": "workspace:*" - }, - "peerDependencies": { - "rivetkit": ">=0.5.0" - }, - "peerDependenciesMeta": { - "rivetkit": { - "optional": true - } - }, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-rivet/src/index.ts b/sdks/persist-rivet/src/index.ts index d236040..87907c6 100644 --- a/sdks/persist-rivet/src/index.ts +++ b/sdks/persist-rivet/src/index.ts @@ -1,168 +1,5 @@ -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -/** Structural type compatible with rivetkit's ActorContext without importing it. */ -export interface ActorContextLike { - state: Record; -} - -export interface RivetPersistData { - sessions: Record; - events: Record; -} - -export type RivetPersistState = { - _sandboxAgentPersist: RivetPersistData; -}; - -export interface RivetSessionPersistDriverOptions { - /** Maximum number of sessions to retain. Oldest are evicted first. Default: 1024. */ - maxSessions?: number; - /** Maximum events per session. Oldest are trimmed first. Default: 500. */ - maxEventsPerSession?: number; - /** Key on `c.state` where persist data is stored. Default: `"_sandboxAgentPersist"`. */ - stateKey?: string; -} - -const DEFAULT_MAX_SESSIONS = 1024; -const DEFAULT_MAX_EVENTS_PER_SESSION = 500; -const DEFAULT_LIST_LIMIT = 100; -const DEFAULT_STATE_KEY = "_sandboxAgentPersist"; - -export class RivetSessionPersistDriver implements SessionPersistDriver { - private readonly maxSessions: number; - private readonly maxEventsPerSession: number; - private readonly stateKey: string; - private readonly ctx: ActorContextLike; - - constructor(ctx: ActorContextLike, options: RivetSessionPersistDriverOptions = {}) { - this.ctx = ctx; - this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS); - this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION); - this.stateKey = options.stateKey ?? DEFAULT_STATE_KEY; - - // Auto-initialize if absent; preserve existing data on actor wake. - if (!this.ctx.state[this.stateKey]) { - this.ctx.state[this.stateKey] = { sessions: {}, events: {} } satisfies RivetPersistData; - } - } - - private get data(): RivetPersistData { - return this.ctx.state[this.stateKey] as RivetPersistData; - } - - async getSession(id: string): Promise { - const session = this.data.sessions[id]; - return session ? cloneSessionRecord(session) : null; - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const sorted = Object.values(this.data.sessions).sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }); - const page = paginate(sorted, request); - return { - items: page.items.map(cloneSessionRecord), - nextCursor: page.nextCursor, - }; - } - - async updateSession(session: SessionRecord): Promise { - this.data.sessions[session.id] = { ...session }; - - if (!this.data.events[session.id]) { - this.data.events[session.id] = []; - } - - const ids = Object.keys(this.data.sessions); - if (ids.length <= this.maxSessions) { - return; - } - - const overflow = ids.length - this.maxSessions; - const removable = Object.values(this.data.sessions) - .sort((a, b) => { - if (a.createdAt !== b.createdAt) { - return a.createdAt - b.createdAt; - } - return a.id.localeCompare(b.id); - }) - .slice(0, overflow) - .map((s) => s.id); - - for (const sessionId of removable) { - delete this.data.sessions[sessionId]; - delete this.data.events[sessionId]; - } - } - - async listEvents(request: ListEventsRequest): Promise> { - const all = [...(this.data.events[request.sessionId] ?? [])].sort((a, b) => { - if (a.eventIndex !== b.eventIndex) { - return a.eventIndex - b.eventIndex; - } - return a.id.localeCompare(b.id); - }); - const page = paginate(all, request); - return { - items: page.items.map(cloneSessionEvent), - nextCursor: page.nextCursor, - }; - } - - async insertEvent(event: SessionEvent): Promise { - const events = this.data.events[event.sessionId] ?? []; - events.push(cloneSessionEvent(event)); - - if (events.length > this.maxEventsPerSession) { - events.splice(0, events.length - this.maxEventsPerSession); - } - - this.data.events[event.sessionId] = events; - } -} - -function cloneSessionRecord(session: SessionRecord): SessionRecord { - return { - ...session, - sessionInit: session.sessionInit ? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"]) : undefined, - }; -} - -function cloneSessionEvent(event: SessionEvent): SessionEvent { - return { - ...event, - payload: JSON.parse(JSON.stringify(event.payload)) as SessionEvent["payload"], - }; -} - -function normalizeCap(value: number | undefined, fallback: number): number { - if (!Number.isFinite(value) || (value ?? 0) < 1) { - return fallback; - } - return Math.floor(value as number); -} - -function paginate(items: T[], request: ListPageRequest): ListPage { - const offset = parseCursor(request.cursor); - const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT); - const slice = items.slice(offset, offset + limit); - const nextOffset = offset + slice.length; - return { - items: slice, - nextCursor: nextOffset < items.length ? String(nextOffset) : undefined, - }; -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} +throw new Error( + "@sandbox-agent/persist-rivet has been deprecated and removed. " + + "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-rivet/tests/driver.test.ts b/sdks/persist-rivet/tests/driver.test.ts deleted file mode 100644 index c16e733..0000000 --- a/sdks/persist-rivet/tests/driver.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { RivetSessionPersistDriver } from "../src/index.ts"; -import type { RivetPersistData } from "../src/index.ts"; - -function makeCtx() { - return { state: {} as Record }; -} - -describe("RivetSessionPersistDriver", () => { - it("auto-initializes state on construction", () => { - const ctx = makeCtx(); - new RivetSessionPersistDriver(ctx); - const data = ctx.state._sandboxAgentPersist as RivetPersistData; - expect(data).toBeDefined(); - expect(data.sessions).toEqual({}); - expect(data.events).toEqual({}); - }); - - it("preserves existing state on construction (actor wake)", async () => { - const ctx = makeCtx(); - const driver1 = new RivetSessionPersistDriver(ctx); - - await driver1.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - // Simulate actor wake: new driver instance, same state object - const driver2 = new RivetSessionPersistDriver(ctx); - const session = await driver2.getSession("s-1"); - expect(session?.id).toBe("s-1"); - expect(session?.createdAt).toBe(100); - }); - - it("stores and retrieves sessions", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - destroyedAt: 300, - }); - - const loaded = await driver.getSession("s-2"); - expect(loaded?.destroyedAt).toBe(300); - - const missing = await driver.getSession("s-nonexistent"); - expect(missing).toBeNull(); - }); - - it("pages sessions sorted by createdAt", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - }); - - const page1 = await driver.listSessions({ limit: 1 }); - expect(page1.items).toHaveLength(1); - expect(page1.items[0]?.id).toBe("s-1"); - expect(page1.nextCursor).toBeTruthy(); - - const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 }); - expect(page2.items).toHaveLength(1); - expect(page2.items[0]?.id).toBe("s-2"); - expect(page2.nextCursor).toBeUndefined(); - }); - - it("stores and pages events", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - await driver.insertEvent({ - id: "evt-1", - eventIndex: 1, - sessionId: "s-1", - createdAt: 1, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - - await driver.insertEvent({ - id: "evt-2", - eventIndex: 2, - sessionId: "s-1", - createdAt: 2, - connectionId: "c-1", - sender: "agent", - payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } }, - }); - - const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 }); - expect(eventsPage.items).toHaveLength(2); - expect(eventsPage.items[0]?.id).toBe("evt-1"); - expect(eventsPage.items[0]?.eventIndex).toBe(1); - expect(eventsPage.items[1]?.id).toBe("evt-2"); - expect(eventsPage.items[1]?.eventIndex).toBe(2); - }); - - it("evicts oldest sessions when maxSessions exceeded", async () => { - const driver = new RivetSessionPersistDriver(makeCtx(), { maxSessions: 2 }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 100, - }); - - await driver.updateSession({ - id: "s-2", - agent: "mock", - agentSessionId: "a-2", - lastConnectionId: "c-2", - createdAt: 200, - }); - - // Adding a third session should evict the oldest (s-1) - await driver.updateSession({ - id: "s-3", - agent: "mock", - agentSessionId: "a-3", - lastConnectionId: "c-3", - createdAt: 300, - }); - - expect(await driver.getSession("s-1")).toBeNull(); - expect(await driver.getSession("s-2")).not.toBeNull(); - expect(await driver.getSession("s-3")).not.toBeNull(); - }); - - it("trims oldest events when maxEventsPerSession exceeded", async () => { - const driver = new RivetSessionPersistDriver(makeCtx(), { maxEventsPerSession: 2 }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - for (let i = 1; i <= 3; i++) { - await driver.insertEvent({ - id: `evt-${i}`, - eventIndex: i, - sessionId: "s-1", - createdAt: i, - connectionId: "c-1", - sender: "client", - payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } }, - }); - } - - const page = await driver.listEvents({ sessionId: "s-1" }); - expect(page.items).toHaveLength(2); - // Oldest event (evt-1) should be trimmed - expect(page.items[0]?.id).toBe("evt-2"); - expect(page.items[1]?.id).toBe("evt-3"); - }); - - it("clones data to prevent external mutation", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - const s1 = await driver.getSession("s-1"); - const s2 = await driver.getSession("s-1"); - expect(s1).toEqual(s2); - expect(s1).not.toBe(s2); // Different object references - }); - - it("supports custom stateKey", async () => { - const ctx = makeCtx(); - const driver = new RivetSessionPersistDriver(ctx, { stateKey: "myPersist" }); - - await driver.updateSession({ - id: "s-1", - agent: "mock", - agentSessionId: "a-1", - lastConnectionId: "c-1", - createdAt: 1, - }); - - expect((ctx.state.myPersist as RivetPersistData).sessions["s-1"]).toBeDefined(); - expect(ctx.state._sandboxAgentPersist).toBeUndefined(); - }); - - it("returns empty results for unknown session events", async () => { - const driver = new RivetSessionPersistDriver(makeCtx()); - const page = await driver.listEvents({ sessionId: "nonexistent" }); - expect(page.items).toHaveLength(0); - expect(page.nextCursor).toBeUndefined(); - }); -}); diff --git a/sdks/persist-sqlite/README.md b/sdks/persist-sqlite/README.md new file mode 100644 index 0000000..07296fe --- /dev/null +++ b/sdks/persist-sqlite/README.md @@ -0,0 +1,5 @@ +# @sandbox-agent/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/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 852e384..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", @@ -16,24 +16,17 @@ "import": "./dist/index.js" } }, - "dependencies": { - "better-sqlite3": "^11.0.0", - "sandbox-agent": "workspace:*" - }, + "dependencies": {}, "files": [ "dist" ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" + "typecheck": "tsc --noEmit" }, "devDependencies": { - "@types/better-sqlite3": "^7.0.0", "@types/node": "^22.0.0", "tsup": "^8.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" + "typescript": "^5.7.0" } } diff --git a/sdks/persist-sqlite/src/index.ts b/sdks/persist-sqlite/src/index.ts index 379c4ef..fa76679 100644 --- a/sdks/persist-sqlite/src/index.ts +++ b/sdks/persist-sqlite/src/index.ts @@ -1,284 +1,5 @@ -import Database from "better-sqlite3"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; - -const DEFAULT_LIST_LIMIT = 100; - -export interface SQLiteSessionPersistDriverOptions { - filename?: string; -} - -export class SQLiteSessionPersistDriver implements SessionPersistDriver { - private readonly db: Database.Database; - - constructor(options: SQLiteSessionPersistDriverOptions = {}) { - this.db = new Database(options.filename ?? ":memory:"); - this.initialize(); - } - - async getSession(id: string): Promise { - const row = this.db - .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - FROM sessions WHERE id = ?`, - ) - .get(id) as SessionRow | undefined; - - if (!row) { - return null; - } - - return decodeSessionRow(row); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rows = this.db - .prepare( - `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - FROM sessions - ORDER BY created_at ASC, id ASC - LIMIT ? OFFSET ?`, - ) - .all(limit, offset) as SessionRow[]; - - const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number }; - const nextOffset = offset + rows.length; - - return { - items: rows.map(decodeSessionRow), - nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - this.db - .prepare( - `INSERT INTO sessions ( - id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, session_init_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - agent = excluded.agent, - agent_session_id = excluded.agent_session_id, - last_connection_id = excluded.last_connection_id, - created_at = excluded.created_at, - destroyed_at = excluded.destroyed_at, - session_init_json = excluded.session_init_json`, - ) - .run( - session.id, - session.agent, - session.agentSessionId, - session.lastConnectionId, - session.createdAt, - session.destroyedAt ?? null, - session.sessionInit ? JSON.stringify(session.sessionInit) : null, - ); - } - - async listEvents(request: ListEventsRequest): Promise> { - const offset = parseCursor(request.cursor); - const limit = normalizeLimit(request.limit); - - const rows = this.db - .prepare( - `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json - FROM events - WHERE session_id = ? - ORDER BY event_index ASC, id ASC - LIMIT ? OFFSET ?`, - ) - .all(request.sessionId, limit, offset) as EventRow[]; - - const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number }; - - const nextOffset = offset + rows.length; - - return { - items: rows.map(decodeEventRow), - nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined, - }; - } - - async insertEvent(event: SessionEvent): Promise { - this.db - .prepare( - `INSERT INTO events ( - id, event_index, session_id, created_at, connection_id, sender, payload_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - event_index = excluded.event_index, - session_id = excluded.session_id, - created_at = excluded.created_at, - connection_id = excluded.connection_id, - sender = excluded.sender, - payload_json = excluded.payload_json`, - ) - .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload)); - } - - close(): void { - this.db.close(); - } - - private initialize(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - agent TEXT NOT NULL, - agent_session_id TEXT NOT NULL, - last_connection_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - destroyed_at INTEGER, - session_init_json TEXT - ) - `); - - this.ensureEventsTable(); - } - - private ensureEventsTable(): void { - const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[]; - if (tableInfo.length === 0) { - this.createEventsTable(); - return; - } - - const idColumn = tableInfo.find((column) => column.name === "id"); - const hasEventIndex = tableInfo.some((column) => column.name === "event_index"); - const idType = (idColumn?.type ?? "").trim().toUpperCase(); - const idIsText = idType === "TEXT"; - - if (!idIsText || !hasEventIndex) { - this.rebuildEventsTable(hasEventIndex); - } - - this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON events(session_id, event_index, id) - `); - } - - private createEventsTable(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - event_index INTEGER NOT NULL, - session_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - connection_id TEXT NOT NULL, - sender TEXT NOT NULL, - payload_json TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_events_session_order - ON events(session_id, event_index, id) - `); - } - - private rebuildEventsTable(hasEventIndex: boolean): void { - this.db.exec(` - ALTER TABLE events RENAME TO events_legacy; - `); - - this.createEventsTable(); - - if (hasEventIndex) { - this.db.exec(` - INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) - SELECT - CAST(id AS TEXT), - COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)), - session_id, - created_at, - connection_id, - sender, - payload_json - FROM events_legacy - `); - } else { - this.db.exec(` - INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json) - SELECT - CAST(id AS TEXT), - ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC), - session_id, - created_at, - connection_id, - sender, - payload_json - FROM events_legacy - `); - } - - this.db.exec(`DROP TABLE events_legacy`); - } -} - -type SessionRow = { - id: string; - agent: string; - agent_session_id: string; - last_connection_id: string; - created_at: number; - destroyed_at: number | null; - session_init_json: string | null; -}; - -type EventRow = { - id: string; - event_index: number; - session_id: string; - created_at: number; - connection_id: string; - sender: "client" | "agent"; - payload_json: string; -}; - -type TableInfoRow = { - name: string; - type: string; -}; - -function decodeSessionRow(row: SessionRow): SessionRecord { - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agent_session_id, - lastConnectionId: row.last_connection_id, - createdAt: row.created_at, - destroyedAt: row.destroyed_at ?? undefined, - sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined, - }; -} - -function decodeEventRow(row: EventRow): SessionEvent { - return { - id: row.id, - eventIndex: row.event_index, - sessionId: row.session_id, - createdAt: row.created_at, - connectionId: row.connection_id, - sender: row.sender, - payload: JSON.parse(row.payload_json), - }; -} - -function normalizeLimit(limit: number | undefined): number { - if (!Number.isFinite(limit) || (limit ?? 0) < 1) { - return DEFAULT_LIST_LIMIT; - } - return Math.floor(limit as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return parsed; -} +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/rivet-dev/sandbox-agent/tree/main/examples/persist-sqlite", +); diff --git a/sdks/persist-sqlite/tests/integration.test.ts b/sdks/persist-sqlite/tests/integration.test.ts deleted file mode 100644 index 376406c..0000000 --- a/sdks/persist-sqlite/tests/integration.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { tmpdir } from "node:os"; -import { SandboxAgent } from "sandbox-agent"; -import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts"; -import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts"; -import { SQLiteSessionPersistDriver } from "../src/index.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function findBinary(): string | null { - if (process.env.SANDBOX_AGENT_BIN) { - return process.env.SANDBOX_AGENT_BIN; - } - - const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; - - for (const p of cargoPaths) { - if (existsSync(p)) { - return p; - } - } - - return null; -} - -const BINARY_PATH = findBinary(); -if (!BINARY_PATH) { - throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); -} -if (!process.env.SANDBOX_AGENT_BIN) { - process.env.SANDBOX_AGENT_BIN = BINARY_PATH; -} - -describe("SQLite persistence driver", () => { - let handle: SandboxAgentSpawnHandle; - let baseUrl: string; - let token: string; - let dataHome: string; - - beforeAll(async () => { - dataHome = mkdtempSync(join(tmpdir(), "sqlite-integration-")); - prepareMockAgentDataHome(dataHome); - - handle = await spawnSandboxAgent({ - enabled: true, - log: "silent", - timeoutMs: 30000, - env: { - XDG_DATA_HOME: dataHome, - HOME: dataHome, - USERPROFILE: dataHome, - APPDATA: join(dataHome, "AppData", "Roaming"), - LOCALAPPDATA: join(dataHome, "AppData", "Local"), - }, - }); - baseUrl = handle.baseUrl; - token = handle.token; - }); - - afterAll(async () => { - await handle.dispose(); - rmSync(dataHome, { recursive: true, force: true }); - }); - - it("persists session/event history across SDK instances and supports replay restore", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "sqlite-persist-")); - const dbPath = join(tempDir, "session-store.db"); - - const persist1 = new SQLiteSessionPersistDriver({ filename: dbPath }); - const sdk1 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist1, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const created = await sdk1.createSession({ agent: "mock" }); - await created.prompt([{ type: "text", text: "sqlite-first" }]); - const firstConnectionId = created.lastConnectionId; - - await sdk1.dispose(); - persist1.close(); - - const persist2 = new SQLiteSessionPersistDriver({ filename: dbPath }); - const sdk2 = await SandboxAgent.connect({ - baseUrl, - token, - persist: persist2, - replayMaxEvents: 40, - replayMaxChars: 16000, - }); - - const restored = await sdk2.resumeSession(created.id); - expect(restored.lastConnectionId).not.toBe(firstConnectionId); - - await restored.prompt([{ type: "text", text: "sqlite-second" }]); - - const sessions = await sdk2.listSessions({ limit: 20 }); - expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true); - - const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 }); - expect(events.items.length).toBeGreaterThan(0); - expect(events.items.every((event) => typeof event.id === "string")).toBe(true); - expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true); - - for (let i = 1; i < events.items.length; i += 1) { - expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex); - } - - const replayInjected = events.items.find((event) => { - if (event.sender !== "client") { - return false; - } - const payload = event.payload as Record; - const method = payload.method; - const params = payload.params as Record | undefined; - const prompt = Array.isArray(params?.prompt) ? params?.prompt : []; - const firstBlock = prompt[0] as Record | undefined; - return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below"); - }); - expect(replayInjected).toBeTruthy(); - - await sdk2.dispose(); - persist2.close(); - rmSync(tempDir, { recursive: true, force: true }); - }); -}); diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 2c94592..9fd62fe 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -14,6 +14,74 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./local": { + "types": "./dist/providers/local.d.ts", + "import": "./dist/providers/local.js" + }, + "./e2b": { + "types": "./dist/providers/e2b.d.ts", + "import": "./dist/providers/e2b.js" + }, + "./daytona": { + "types": "./dist/providers/daytona.d.ts", + "import": "./dist/providers/daytona.js" + }, + "./docker": { + "types": "./dist/providers/docker.d.ts", + "import": "./dist/providers/docker.js" + }, + "./vercel": { + "types": "./dist/providers/vercel.d.ts", + "import": "./dist/providers/vercel.js" + }, + "./cloudflare": { + "types": "./dist/providers/cloudflare.d.ts", + "import": "./dist/providers/cloudflare.js" + }, + "./modal": { + "types": "./dist/providers/modal.d.ts", + "import": "./dist/providers/modal.js" + }, + "./computesdk": { + "types": "./dist/providers/computesdk.d.ts", + "import": "./dist/providers/computesdk.js" + } + }, + "peerDependencies": { + "@cloudflare/sandbox": ">=0.1.0", + "@daytonaio/sdk": ">=0.12.0", + "@e2b/code-interpreter": ">=1.0.0", + "@vercel/sandbox": ">=0.1.0", + "dockerode": ">=4.0.0", + "get-port": ">=7.0.0", + "modal": ">=0.1.0", + "computesdk": ">=0.1.0" + }, + "peerDependenciesMeta": { + "@cloudflare/sandbox": { + "optional": true + }, + "@daytonaio/sdk": { + "optional": true + }, + "@e2b/code-interpreter": { + "optional": true + }, + "@vercel/sandbox": { + "optional": true + }, + "dockerode": { + "optional": true + }, + "get-port": { + "optional": true + }, + "modal": { + "optional": true + }, + "computesdk": { + "optional": true } }, "dependencies": { @@ -33,8 +101,17 @@ "test:watch": "vitest" }, "devDependencies": { + "@cloudflare/sandbox": ">=0.1.0", + "@daytonaio/sdk": ">=0.12.0", + "@e2b/code-interpreter": ">=1.0.0", + "@types/dockerode": "^4.0.0", "@types/node": "^22.0.0", "@types/ws": "^8.18.1", + "@vercel/sandbox": ">=0.1.0", + "dockerode": ">=4.0.0", + "get-port": ">=7.0.0", + "modal": ">=0.1.0", + "computesdk": ">=0.1.0", "openapi-typescript": "^6.7.0", "tsup": "^8.0.0", "typescript": "^5.7.0", diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 9945c0a..f64c833 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -22,7 +22,7 @@ import { type SetSessionModeResponse, type SetSessionModeRequest, } from "acp-http-client"; -import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts"; +import type { SandboxProvider } from "./providers/types.ts"; import { type AcpServerListResponse, type AgentInfo, @@ -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; @@ -101,6 +102,8 @@ interface SandboxAgentConnectCommonOptions { replayMaxChars?: number; signal?: AbortSignal; token?: string; + skipHealthCheck?: boolean; + /** @deprecated Use skipHealthCheck instead. */ waitForHealth?: boolean | SandboxAgentHealthWaitOptions; } @@ -115,17 +118,24 @@ export type SandboxAgentConnectOptions = }); export interface SandboxAgentStartOptions { + sandbox: SandboxProvider; + sandboxId?: string; + skipHealthCheck?: boolean; fetch?: typeof fetch; headers?: HeadersInit; persist?: SessionPersistDriver; replayMaxEvents?: number; replayMaxChars?: number; - spawn?: SandboxAgentSpawnOptions | boolean; + signal?: AbortSignal; + token?: string; } export interface SessionCreateRequest { id?: string; agent: string; + /** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */ + cwd?: string; + /** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */ sessionInit?: Omit; model?: string; mode?: string; @@ -135,6 +145,9 @@ export interface SessionCreateRequest { export interface SessionResumeOrCreateRequest { id: string; agent: string; + /** Shorthand for `sessionInit.cwd`. Ignored when `sessionInit` is provided. */ + cwd?: string; + /** Full session init. When omitted, built from `cwd` (or default) with empty `mcpServers`. */ sessionInit?: Omit; model?: string; mode?: string; @@ -824,12 +837,14 @@ export class SandboxAgent { private readonly defaultHeaders?: HeadersInit; private readonly healthWait: NormalizedHealthWaitOptions; private readonly healthWaitAbortController = new AbortController(); + private sandboxProvider?: SandboxProvider; + private sandboxProviderId?: string; + private sandboxProviderRawId?: string; private readonly persist: SessionPersistDriver; private readonly replayMaxEvents: number; private readonly replayMaxChars: number; - private spawnHandle?: SandboxAgentSpawnHandle; private healthPromise?: Promise; private healthError?: Error; private disposed = false; @@ -857,7 +872,7 @@ export class SandboxAgent { } this.fetcher = resolvedFetch; this.defaultHeaders = options.headers; - this.healthWait = normalizeHealthWaitOptions(options.waitForHealth, options.signal); + this.healthWait = normalizeHealthWaitOptions(options.skipHealthCheck, options.waitForHealth, options.signal); this.persist = options.persist ?? new InMemorySessionPersistDriver(); this.replayMaxEvents = normalizePositiveInt(options.replayMaxEvents, DEFAULT_REPLAY_MAX_EVENTS); @@ -870,29 +885,79 @@ export class SandboxAgent { return new SandboxAgent(options); } - static async start(options: SandboxAgentStartOptions = {}): Promise { - const spawnOptions = normalizeSpawnOptions(options.spawn, true); - if (!spawnOptions.enabled) { - throw new Error("SandboxAgent.start requires spawn to be enabled."); + static async start(options: SandboxAgentStartOptions): Promise { + const provider = options.sandbox; + if (!provider.getUrl && !provider.getFetch) { + throw new Error(`Sandbox provider '${provider.name}' must implement getUrl() or getFetch().`); } - const { spawnSandboxAgent } = await import("./spawn.js"); - const resolvedFetch = options.fetch ?? globalThis.fetch?.bind(globalThis); - const handle = await spawnSandboxAgent(spawnOptions, resolvedFetch); + const existingSandbox = options.sandboxId ? parseSandboxProviderId(options.sandboxId) : null; - const client = new SandboxAgent({ - baseUrl: handle.baseUrl, - token: handle.token, - fetch: options.fetch, - headers: options.headers, - waitForHealth: false, - persist: options.persist, - replayMaxEvents: options.replayMaxEvents, - replayMaxChars: options.replayMaxChars, - }); + if (existingSandbox && existingSandbox.provider !== provider.name) { + throw new Error( + `SandboxAgent.start received sandboxId '${options.sandboxId}' for provider '${existingSandbox.provider}', but the configured provider is '${provider.name}'.`, + ); + } - client.spawnHandle = handle; - return client; + const rawSandboxId = existingSandbox?.rawId ?? (await provider.create()); + const prefixedSandboxId = `${provider.name}/${rawSandboxId}`; + const createdSandbox = !existingSandbox; + + if (existingSandbox) { + await provider.ensureServer?.(rawSandboxId); + } + + try { + const fetcher = await resolveProviderFetch(provider, rawSandboxId); + const baseUrl = provider.getUrl ? await provider.getUrl(rawSandboxId) : undefined; + const providerFetch = options.fetch ?? fetcher; + const commonConnectOptions = { + headers: options.headers, + persist: options.persist, + replayMaxEvents: options.replayMaxEvents, + replayMaxChars: options.replayMaxChars, + signal: options.signal, + skipHealthCheck: options.skipHealthCheck, + token: options.token ?? (await resolveProviderToken(provider, rawSandboxId)), + }; + + const client = providerFetch + ? new SandboxAgent({ + ...commonConnectOptions, + baseUrl, + fetch: providerFetch, + }) + : new SandboxAgent({ + ...commonConnectOptions, + baseUrl: requireSandboxBaseUrl(baseUrl, provider.name), + }); + + client.sandboxProvider = provider; + client.sandboxProviderId = prefixedSandboxId; + client.sandboxProviderRawId = rawSandboxId; + return client; + } catch (error) { + if (createdSandbox) { + try { + await provider.destroy(rawSandboxId); + } catch { + // Best-effort cleanup if connect fails after provisioning. + } + } + throw error; + } + } + + get sandboxId(): string | undefined { + return this.sandboxProviderId; + } + + get sandbox(): SandboxProvider | undefined { + return this.sandboxProvider; + } + + get inspectorUrl(): string { + return `${this.baseUrl.replace(/\/+$/, "")}/ui/`; } async dispose(): Promise { @@ -922,10 +987,23 @@ export class SandboxAgent { await connection.close(); }), ); + } - if (this.spawnHandle) { - await this.spawnHandle.dispose(); - this.spawnHandle = undefined; + async destroySandbox(): Promise { + const provider = this.sandboxProvider; + const rawSandboxId = this.sandboxProviderRawId; + + try { + if (provider && rawSandboxId) { + await provider.destroy(rawSandboxId); + } else if (!provider || !rawSandboxId) { + throw new Error("SandboxAgent is not attached to a provisioned sandbox."); + } + } finally { + await this.dispose(); + this.sandboxProvider = undefined; + this.sandboxProviderId = undefined; + this.sandboxProviderRawId = undefined; } } @@ -956,7 +1034,7 @@ export class SandboxAgent { const localSessionId = request.id?.trim() || randomId(); const live = await this.getLiveConnection(request.agent.trim()); - const sessionInit = normalizeSessionInit(request.sessionInit); + const sessionInit = normalizeSessionInit(request.sessionInit, request.cwd); const response = await live.createRemoteSession(localSessionId, sessionInit); @@ -966,6 +1044,7 @@ export class SandboxAgent { agentSessionId: response.sessionId, lastConnectionId: live.connectionId, createdAt: nowMs(), + sandboxId: this.sandboxProviderId, sessionInit, configOptions: cloneConfigOptions(response.configOptions), modes: cloneModes(response.modes), @@ -1692,7 +1771,7 @@ export class SandboxAgent { }; try { - await this.persist.insertEvent(event); + await this.persist.insertEvent(localSessionId, event); break; } catch (error) { if (!isSessionEventIndexConflict(error) || attempt === MAX_EVENT_INDEX_INSERT_RETRIES - 1) { @@ -2040,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); @@ -2050,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(); @@ -2255,17 +2346,17 @@ function toAgentQuery(options: AgentQueryOptions | undefined): Record | undefined): Omit { +function normalizeSessionInit(value: Omit | undefined, cwdShorthand?: string): Omit { if (!value) { return { - cwd: defaultCwd(), + cwd: cwdShorthand ?? defaultCwd(), mcpServers: [], }; } return { ...value, - cwd: value.cwd ?? defaultCwd(), + cwd: value.cwd ?? cwdShorthand ?? defaultCwd(), mcpServers: value.mcpServers ?? [], }; } @@ -2405,16 +2496,23 @@ function normalizePositiveInt(value: number | undefined, fallback: number): numb return Math.floor(value as number); } -function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptions | undefined, signal: AbortSignal | undefined): NormalizedHealthWaitOptions { - if (value === false) { +function normalizeHealthWaitOptions( + skipHealthCheck: boolean | undefined, + waitForHealth: boolean | SandboxAgentHealthWaitOptions | undefined, + signal: AbortSignal | undefined, +): NormalizedHealthWaitOptions { + if (skipHealthCheck === true || waitForHealth === false) { return { enabled: false }; } - if (value === true || value === undefined) { + if (waitForHealth === true || waitForHealth === undefined) { return { enabled: true, signal }; } - const timeoutMs = typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0 ? Math.floor(value.timeoutMs) : undefined; + const timeoutMs = + typeof waitForHealth.timeoutMs === "number" && Number.isFinite(waitForHealth.timeoutMs) && waitForHealth.timeoutMs > 0 + ? Math.floor(waitForHealth.timeoutMs) + : undefined; return { enabled: true, @@ -2423,24 +2521,47 @@ function normalizeHealthWaitOptions(value: boolean | SandboxAgentHealthWaitOptio }; } -function normalizeSpawnOptions( - spawn: SandboxAgentSpawnOptions | boolean | undefined, - defaultEnabled: boolean, -): SandboxAgentSpawnOptions & { enabled: boolean } { - if (spawn === false) { - return { enabled: false }; - } - - if (spawn === true || spawn === undefined) { - return { enabled: defaultEnabled }; +function parseSandboxProviderId(sandboxId: string): { provider: string; rawId: string } { + const slashIndex = sandboxId.indexOf("/"); + if (slashIndex < 1 || slashIndex === sandboxId.length - 1) { + throw new Error(`Sandbox IDs must be prefixed as "{provider}/{id}". Received '${sandboxId}'.`); } return { - ...spawn, - enabled: spawn.enabled ?? defaultEnabled, + provider: sandboxId.slice(0, slashIndex), + rawId: sandboxId.slice(slashIndex + 1), }; } +function requireSandboxBaseUrl(baseUrl: string | undefined, providerName: string): string { + if (!baseUrl) { + throw new Error(`Sandbox provider '${providerName}' did not return a base URL.`); + } + return baseUrl; +} + +async function resolveProviderFetch(provider: SandboxProvider, rawSandboxId: string): Promise { + if (provider.getFetch) { + return await provider.getFetch(rawSandboxId); + } + + return undefined; +} + +async function resolveProviderToken(provider: SandboxProvider, rawSandboxId: string): Promise { + const maybeGetToken = ( + provider as SandboxProvider & { + getToken?: (sandboxId: string) => string | undefined | Promise; + } + ).getToken; + if (typeof maybeGetToken !== "function") { + return undefined; + } + + const token = await maybeGetToken.call(provider, rawSandboxId); + return typeof token === "string" && token ? token : undefined; +} + async function readProblem(response: Response): Promise { try { const text = await response.clone().text(); diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 99bc1b6..f0ebe2e 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -38,6 +38,7 @@ export type { export type { InspectorUrlOptions } from "./inspector.ts"; export { InMemorySessionPersistDriver } from "./types.ts"; +export type { SandboxProvider } from "./providers/types.ts"; export type { AcpEnvelope, diff --git a/sdks/typescript/src/providers/cloudflare.ts b/sdks/typescript/src/providers/cloudflare.ts new file mode 100644 index 0000000..c17adfc --- /dev/null +++ b/sdks/typescript/src/providers/cloudflare.ts @@ -0,0 +1,79 @@ +import type { SandboxProvider } from "./types.ts"; + +const DEFAULT_AGENT_PORT = 3000; + +export interface CloudflareSandboxClient { + create?(options?: Record): Promise<{ id?: string; sandboxId?: string }>; + connect?( + sandboxId: string, + options?: Record, + ): Promise<{ + close?(): Promise; + stop?(): Promise; + containerFetch(input: RequestInfo | URL, init?: RequestInit, port?: number): Promise; + }>; +} + +export interface CloudflareProviderOptions { + sdk: CloudflareSandboxClient; + create?: Record | (() => Record | Promise>); + agentPort?: number; +} + +async function resolveCreateOptions(value: CloudflareProviderOptions["create"]): Promise> { + if (!value) { + return {}; + } + if (typeof value === "function") { + return await value(); + } + return value; +} + +export function cloudflare(options: CloudflareProviderOptions): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const sdk = options.sdk; + + return { + name: "cloudflare", + async create(): Promise { + if (typeof sdk.create !== "function") { + throw new Error('sandbox provider "cloudflare" requires a sdk with a `create()` method.'); + } + const sandbox = await sdk.create(await resolveCreateOptions(options.create)); + const sandboxId = sandbox.sandboxId ?? sandbox.id; + if (!sandboxId) { + throw new Error("cloudflare sandbox did not return an id"); + } + return sandboxId; + }, + async destroy(sandboxId: string): Promise { + if (typeof sdk.connect !== "function") { + throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.'); + } + const sandbox = await sdk.connect(sandboxId); + if (typeof sandbox.close === "function") { + await sandbox.close(); + return; + } + if (typeof sandbox.stop === "function") { + await sandbox.stop(); + } + }, + async getFetch(sandboxId: string): Promise { + if (typeof sdk.connect !== "function") { + throw new Error('sandbox provider "cloudflare" requires a sdk with a `connect()` method.'); + } + const sandbox = await sdk.connect(sandboxId); + return async (input, init) => + sandbox.containerFetch( + input, + { + ...(init ?? {}), + signal: undefined, + }, + agentPort, + ); + }, + }; +} diff --git a/sdks/typescript/src/providers/computesdk.ts b/sdks/typescript/src/providers/computesdk.ts new file mode 100644 index 0000000..7bca7ca --- /dev/null +++ b/sdks/typescript/src/providers/computesdk.ts @@ -0,0 +1,60 @@ +import { compute } from "computesdk"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; + +const DEFAULT_AGENT_PORT = 3000; + +export interface ComputeSdkProviderOptions { + create?: { + envs?: Record; + }; + agentPort?: number; +} + +export function computesdk(options: ComputeSdkProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + + return { + name: "computesdk", + async create(): Promise { + const envs = options.create?.envs; + const sandbox = await compute.sandbox.create({ + envs: envs && Object.keys(envs).length > 0 ? envs : undefined, + }); + + const run = async (cmd: string, runOptions?: { background?: boolean }) => { + const result = await sandbox.runCommand(cmd, runOptions); + if (typeof result?.exitCode === "number" && result.exitCode !== 0) { + throw new Error(`computesdk command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`); + } + return result; + }; + + await run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`); + for (const agent of DEFAULT_AGENTS) { + await run(`sandbox-agent install-agent ${agent}`); + } + await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { + background: true, + }); + + return sandbox.sandboxId; + }, + async destroy(sandboxId: string): Promise { + const sandbox = await compute.sandbox.getById(sandboxId); + if (sandbox) await sandbox.destroy(); + }, + async getUrl(sandboxId: string): Promise { + const sandbox = await compute.sandbox.getById(sandboxId); + 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 new file mode 100644 index 0000000..19026de --- /dev/null +++ b/sdks/typescript/src/providers/daytona.ts @@ -0,0 +1,67 @@ +import { Daytona } from "@daytonaio/sdk"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_SANDBOX_AGENT_IMAGE, buildServerStartCommand } from "./shared.ts"; + +const DEFAULT_AGENT_PORT = 3000; +const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60; + +type DaytonaCreateParams = NonNullable[0]>; + +type DaytonaCreateOverrides = Partial; + +export interface DaytonaProviderOptions { + create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise); + image?: string; + agentPort?: number; + previewTtlSeconds?: number; + deleteTimeoutSeconds?: number; +} + +async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise { + if (!value) return undefined; + if (typeof value === "function") return await value(); + return value; +} + +export function daytona(options: DaytonaProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE; + const previewTtlSeconds = options.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS; + const client = new Daytona(); + + return { + name: "daytona", + async create(): Promise { + const createOpts = await resolveCreateOptions(options.create); + const sandbox = await client.create({ + image, + autoStopInterval: 0, + ...createOpts, + } as DaytonaCreateParams); + await sandbox.process.executeCommand(buildServerStartCommand(agentPort)); + return sandbox.id; + }, + async destroy(sandboxId: string): Promise { + const sandbox = await client.get(sandboxId); + if (!sandbox) { + return; + } + await sandbox.delete(options.deleteTimeoutSeconds); + }, + async getUrl(sandboxId: string): Promise { + const sandbox = await client.get(sandboxId); + if (!sandbox) { + throw new Error(`daytona sandbox not found: ${sandboxId}`); + } + const preview = await sandbox.getSignedPreviewUrl(agentPort, previewTtlSeconds); + return typeof preview === "string" ? preview : preview.url; + }, + async ensureServer(sandboxId: string): Promise { + const sandbox = await client.get(sandboxId); + if (!sandbox) { + throw new Error(`daytona sandbox not found: ${sandboxId}`); + } + await sandbox.process.executeCommand(buildServerStartCommand(agentPort)); + }, + }; +} diff --git a/sdks/typescript/src/providers/docker.ts b/sdks/typescript/src/providers/docker.ts new file mode 100644 index 0000000..9e49687 --- /dev/null +++ b/sdks/typescript/src/providers/docker.ts @@ -0,0 +1,85 @@ +import Docker from "dockerode"; +import getPort from "get-port"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_SANDBOX_AGENT_IMAGE } from "./shared.ts"; + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_AGENT_PORT = 3000; + +export interface DockerProviderOptions { + image?: string; + host?: string; + agentPort?: number; + env?: string[] | (() => string[] | Promise); + binds?: string[] | (() => string[] | Promise); + createContainerOptions?: Record; +} + +async function resolveValue(value: T | (() => T | Promise) | undefined, fallback: T): Promise { + if (value === undefined) { + return fallback; + } + if (typeof value === "function") { + return await (value as () => T | Promise)(); + } + return value; +} + +function extractMappedPort( + inspect: { NetworkSettings?: { Ports?: Record | null | undefined> } }, + containerPort: number, +): number { + const hostPort = inspect.NetworkSettings?.Ports?.[`${containerPort}/tcp`]?.[0]?.HostPort; + if (!hostPort) { + throw new Error(`docker sandbox-agent port ${containerPort} is not published`); + } + return Number(hostPort); +} + +export function docker(options: DockerProviderOptions = {}): SandboxProvider { + const image = options.image ?? DEFAULT_SANDBOX_AGENT_IMAGE; + const host = options.host ?? DEFAULT_HOST; + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const client = new Docker({ socketPath: "/var/run/docker.sock" }); + + return { + name: "docker", + async create(): Promise { + const hostPort = await getPort(); + const env = await resolveValue(options.env, []); + const binds = await resolveValue(options.binds, []); + + const container = await client.createContainer({ + Image: image, + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)], + Env: env, + ExposedPorts: { [`${agentPort}/tcp`]: {} }, + HostConfig: { + AutoRemove: true, + Binds: binds, + PortBindings: { + [`${agentPort}/tcp`]: [{ HostPort: String(hostPort) }], + }, + }, + ...(options.createContainerOptions ?? {}), + }); + + await container.start(); + return container.id; + }, + async destroy(sandboxId: string): Promise { + const container = client.getContainer(sandboxId); + try { + await container.stop({ t: 5 }); + } catch {} + try { + await container.remove({ force: true }); + } catch {} + }, + async getUrl(sandboxId: string): Promise { + const container = client.getContainer(sandboxId); + const hostPort = extractMappedPort(await container.inspect(), agentPort); + return `http://${host}:${hostPort}`; + }, + }; +} diff --git a/sdks/typescript/src/providers/e2b.ts b/sdks/typescript/src/providers/e2b.ts new file mode 100644 index 0000000..84d767c --- /dev/null +++ b/sdks/typescript/src/providers/e2b.ts @@ -0,0 +1,62 @@ +import { Sandbox } from "@e2b/code-interpreter"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; + +const DEFAULT_AGENT_PORT = 3000; + +export interface E2BProviderOptions { + create?: Record | (() => Record | Promise>); + connect?: Record | ((sandboxId: string) => Record | Promise>); + agentPort?: number; +} + +async function resolveOptions(value: E2BProviderOptions["create"] | E2BProviderOptions["connect"], sandboxId?: string): Promise> { + if (!value) return {}; + if (typeof value === "function") { + if (sandboxId) { + return await (value as (id: string) => Record | Promise>)(sandboxId); + } + return await (value as () => Record | Promise>)(); + } + return value; +} + +export function e2b(options: E2BProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + + return { + name: "e2b", + async create(): Promise { + const createOpts = await resolveOptions(options.create); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sandbox = await Sandbox.create({ allowInternetAccess: true, ...createOpts } as any); + + await sandbox.commands.run(`curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`).then((r) => { + if (r.exitCode !== 0) throw new Error(`e2b install failed:\n${r.stderr}`); + }); + for (const agent of DEFAULT_AGENTS) { + await sandbox.commands.run(`sandbox-agent install-agent ${agent}`).then((r) => { + if (r.exitCode !== 0) throw new Error(`e2b agent install failed: ${agent}\n${r.stderr}`); + }); + } + await sandbox.commands.run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${agentPort}`, { background: true, timeoutMs: 0 }); + + return sandbox.sandboxId; + }, + async destroy(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + await sandbox.kill(); + }, + async getUrl(sandboxId: string): Promise { + const connectOpts = await resolveOptions(options.connect, sandboxId); + const sandbox = await Sandbox.connect(sandboxId, connectOpts as any); + return `https://${sandbox.getHost(agentPort)}`; + }, + 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/local.ts b/sdks/typescript/src/providers/local.ts new file mode 100644 index 0000000..18fc3d4 --- /dev/null +++ b/sdks/typescript/src/providers/local.ts @@ -0,0 +1,84 @@ +import { spawnSandboxAgent, type SandboxAgentSpawnHandle, type SandboxAgentSpawnLogMode, type SandboxAgentSpawnOptions } from "../spawn.ts"; +import type { SandboxProvider } from "./types.ts"; + +export interface LocalProviderOptions { + host?: string; + port?: number; + token?: string; + binaryPath?: string; + log?: SandboxAgentSpawnLogMode; + env?: Record; +} + +const localSandboxes = new Map(); + +type LocalSandboxProvider = SandboxProvider & { + getToken(sandboxId: string): Promise; +}; + +export function local(options: LocalProviderOptions = {}): SandboxProvider { + const provider: LocalSandboxProvider = { + name: "local", + async create(): Promise { + const handle = await spawnSandboxAgent( + { + host: options.host, + port: options.port, + token: options.token, + binaryPath: options.binaryPath, + log: options.log, + env: options.env, + } satisfies SandboxAgentSpawnOptions, + globalThis.fetch?.bind(globalThis), + ); + + const rawSandboxId = baseUrlToSandboxId(handle.baseUrl); + localSandboxes.set(rawSandboxId, handle); + return rawSandboxId; + }, + async destroy(sandboxId: string): Promise { + const handle = localSandboxes.get(sandboxId); + if (!handle) { + return; + } + localSandboxes.delete(sandboxId); + await handle.dispose(); + }, + async getUrl(sandboxId: string): Promise { + return `http://${sandboxId}`; + }, + async getFetch(sandboxId: string): Promise { + const handle = localSandboxes.get(sandboxId); + const token = options.token ?? handle?.token; + const fetcher = globalThis.fetch?.bind(globalThis); + if (!fetcher) { + throw new Error("Fetch API is not available; provide a fetch implementation."); + } + + if (!token) { + return fetcher; + } + + return async (input, init) => { + const request = new Request(input, init); + const targetUrl = new URL(request.url); + targetUrl.protocol = "http:"; + targetUrl.host = sandboxId; + const headers = new Headers(request.headers); + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${token}`); + } + const forwarded = new Request(targetUrl.toString(), request); + return fetcher(new Request(forwarded, { headers })); + }; + }, + async getToken(sandboxId: string): Promise { + return options.token ?? localSandboxes.get(sandboxId)?.token; + }, + }; + return provider; +} + +function baseUrlToSandboxId(baseUrl: string): string { + return new URL(baseUrl).host; +} diff --git a/sdks/typescript/src/providers/modal.ts b/sdks/typescript/src/providers/modal.ts new file mode 100644 index 0000000..394272b --- /dev/null +++ b/sdks/typescript/src/providers/modal.ts @@ -0,0 +1,74 @@ +import { ModalClient } from "modal"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; + +const DEFAULT_AGENT_PORT = 3000; +const DEFAULT_APP_NAME = "sandbox-agent"; +const DEFAULT_MEMORY_MIB = 2048; + +export interface ModalProviderOptions { + create?: { + secrets?: Record; + appName?: string; + memoryMiB?: number; + }; + agentPort?: number; +} + +export function modal(options: ModalProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + const appName = options.create?.appName ?? DEFAULT_APP_NAME; + const memoryMiB = options.create?.memoryMiB ?? DEFAULT_MEMORY_MIB; + const client = new ModalClient(); + + return { + name: "modal", + async create(): Promise { + const app = await client.apps.fromName(appName, { createIfMissing: true }); + + // Pre-install sandbox-agent and agents in the image so they are cached + // across sandbox creates and don't need to be installed at runtime. + const installAgentCmds = DEFAULT_AGENTS.map((agent) => `RUN sandbox-agent install-agent ${agent}`); + const image = client.images + .fromRegistry("node:22-slim") + .dockerfileCommands([ + "RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*", + `RUN curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`, + ...installAgentCmds, + ]); + + const envVars = options.create?.secrets ?? {}; + const secrets = Object.keys(envVars).length > 0 ? [await client.secrets.fromObject(envVars)] : []; + + const sb = await client.sandboxes.create(app, image, { + encryptedPorts: [agentPort], + secrets, + memoryMiB, + }); + + // Start the server as a long-running exec process. We intentionally + // do NOT await p.wait() — the process stays alive for the sandbox + // lifetime and keeps the port open for the tunnel. + sb.exec(["sandbox-agent", "server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)]); + + return sb.sandboxId; + }, + async destroy(sandboxId: string): Promise { + const sb = await client.sandboxes.fromId(sandboxId); + await sb.terminate(); + }, + async getUrl(sandboxId: string): Promise { + const sb = await client.sandboxes.fromId(sandboxId); + const tunnels = await sb.tunnels(); + const tunnel = tunnels[agentPort]; + if (!tunnel) { + throw new Error(`modal: no tunnel found for port ${agentPort}`); + } + 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/shared.ts b/sdks/typescript/src/providers/shared.ts new file mode 100644 index 0000000..d838a0a --- /dev/null +++ b/sdks/typescript/src/providers/shared.ts @@ -0,0 +1,7 @@ +export const DEFAULT_SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.3.2-full"; +export const SANDBOX_AGENT_INSTALL_SCRIPT = "https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh"; +export const DEFAULT_AGENTS = ["claude", "codex"] as const; + +export function buildServerStartCommand(port: number): string { + return `nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${port} >/tmp/sandbox-agent.log 2>&1 &`; +} diff --git a/sdks/typescript/src/providers/types.ts b/sdks/typescript/src/providers/types.ts new file mode 100644 index 0000000..ea778de --- /dev/null +++ b/sdks/typescript/src/providers/types.ts @@ -0,0 +1,31 @@ +export interface SandboxProvider { + /** Provider name. Must match the prefix in sandbox IDs (for example "e2b"). */ + name: string; + + /** Provision a new sandbox and return the provider-specific ID. */ + create(): Promise; + + /** Permanently tear down a sandbox. */ + destroy(sandboxId: string): Promise; + + /** + * Return the sandbox-agent base URL for this sandbox. + * Providers that cannot expose a URL should implement `getFetch()` instead. + */ + getUrl?(sandboxId: string): Promise; + + /** + * Return a fetch implementation that routes requests to the sandbox. + * Providers that expose a URL can implement `getUrl()` instead. + */ + getFetch?(sandboxId: string): Promise; + + /** + * 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). + */ + ensureServer?(sandboxId: string): Promise; +} diff --git a/sdks/typescript/src/providers/vercel.ts b/sdks/typescript/src/providers/vercel.ts new file mode 100644 index 0000000..09d41cf --- /dev/null +++ b/sdks/typescript/src/providers/vercel.ts @@ -0,0 +1,65 @@ +import { Sandbox } from "@vercel/sandbox"; +import type { SandboxProvider } from "./types.ts"; +import { DEFAULT_AGENTS, SANDBOX_AGENT_INSTALL_SCRIPT } from "./shared.ts"; + +const DEFAULT_AGENT_PORT = 3000; + +export interface VercelProviderOptions { + create?: Record | (() => Record | Promise>); + agentPort?: number; +} + +async function resolveCreateOptions(value: VercelProviderOptions["create"], agentPort: number): Promise> { + const resolved = typeof value === "function" ? await value() : (value ?? {}); + return { + ports: [agentPort], + ...resolved, + }; +} + +async function runVercelCommand(sandbox: InstanceType, cmd: string, args: string[] = []): Promise { + const result = await sandbox.runCommand({ cmd, args }); + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + throw new Error(`vercel command failed: ${cmd} ${args.join(" ")}\n${stderr}`); + } +} + +export function vercel(options: VercelProviderOptions = {}): SandboxProvider { + const agentPort = options.agentPort ?? DEFAULT_AGENT_PORT; + + return { + name: "vercel", + async create(): Promise { + const sandbox = await Sandbox.create((await resolveCreateOptions(options.create, agentPort)) as Parameters[0]); + + await runVercelCommand(sandbox, "sh", ["-c", `curl -fsSL ${SANDBOX_AGENT_INSTALL_SCRIPT} | sh`]); + for (const agent of DEFAULT_AGENTS) { + await runVercelCommand(sandbox, "sandbox-agent", ["install-agent", agent]); + } + await sandbox.runCommand({ + cmd: "sandbox-agent", + args: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)], + detached: true, + }); + + return sandbox.sandboxId; + }, + async destroy(sandboxId: string): Promise { + const sandbox = await Sandbox.get({ sandboxId }); + await sandbox.stop(); + }, + async getUrl(sandboxId: string): Promise { + const sandbox = await Sandbox.get({ sandboxId }); + return sandbox.domain(agentPort); + }, + 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/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 6865690..f2a7af3 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -98,6 +98,7 @@ export interface SessionRecord { lastConnectionId: string; createdAt: number; destroyedAt?: number; + sandboxId?: string; sessionInit?: Omit; configOptions?: SessionConfigOption[]; modes?: SessionModeState | null; @@ -131,11 +132,11 @@ export interface ListEventsRequest extends ListPageRequest { } export interface SessionPersistDriver { - getSession(id: string): Promise; + getSession(id: string): Promise; listSessions(request?: ListPageRequest): Promise>; updateSession(session: SessionRecord): Promise; listEvents(request: ListEventsRequest): Promise>; - insertEvent(event: SessionEvent): Promise; + insertEvent(sessionId: string, event: SessionEvent): Promise; } export interface InMemorySessionPersistDriverOptions { @@ -158,9 +159,9 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver { this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION); } - async getSession(id: string): Promise { + async getSession(id: string): Promise { const session = this.sessions.get(id); - return session ? cloneSessionRecord(session) : null; + return session ? cloneSessionRecord(session) : undefined; } async listSessions(request: ListPageRequest = {}): Promise> { @@ -219,15 +220,15 @@ export class InMemorySessionPersistDriver implements SessionPersistDriver { }; } - async insertEvent(event: SessionEvent): Promise { - const events = this.eventsBySession.get(event.sessionId) ?? []; + async insertEvent(sessionId: string, event: SessionEvent): Promise { + const events = this.eventsBySession.get(sessionId) ?? []; events.push(cloneSessionEvent(event)); if (events.length > this.maxEventsPerSession) { events.splice(0, events.length - this.maxEventsPerSession); } - this.eventsBySession.set(event.sessionId, events); + this.eventsBySession.set(sessionId, events); } } diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 003b0dd..295e688 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -70,19 +70,19 @@ class StrictUniqueSessionPersistDriver implements SessionPersistDriver { return this.events.listEvents(request); } - async insertEvent(event: SessionEvent): Promise { + async insertEvent(sessionId: string, event: SessionEvent): Promise { await sleep(5); - const indexes = this.eventIndexesBySession.get(event.sessionId) ?? new Set(); + const indexes = this.eventIndexesBySession.get(sessionId) ?? new Set(); if (indexes.has(event.eventIndex)) { throw new Error("UNIQUE constraint failed: sandbox_agent_events.session_id, sandbox_agent_events.event_index"); } indexes.add(event.eventIndex); - this.eventIndexesBySession.set(event.sessionId, indexes); + this.eventIndexesBySession.set(sessionId, indexes); await sleep(5); - await this.events.insertEvent(event); + await this.events.insertEvent(sessionId, event); } } diff --git a/sdks/typescript/tests/providers.test.ts b/sdks/typescript/tests/providers.test.ts new file mode 100644 index 0000000..3376026 --- /dev/null +++ b/sdks/typescript/tests/providers.test.ts @@ -0,0 +1,417 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { createRequire } from "node:module"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +const _require = createRequire(import.meta.url); +import { InMemorySessionPersistDriver, SandboxAgent, type SandboxProvider } from "../src/index.ts"; +import { local } from "../src/providers/local.ts"; +import { docker } from "../src/providers/docker.ts"; +import { e2b } from "../src/providers/e2b.ts"; +import { daytona } from "../src/providers/daytona.ts"; +import { vercel } from "../src/providers/vercel.ts"; +import { modal } from "../src/providers/modal.ts"; +import { computesdk } from "../src/providers/computesdk.ts"; +import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function findBinary(): string | null { + if (process.env.SANDBOX_AGENT_BIN) { + return process.env.SANDBOX_AGENT_BIN; + } + + const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")]; + for (const candidate of cargoPaths) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +const BINARY_PATH = findBinary(); +if (!BINARY_PATH) { + throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN."); +} +if (!process.env.SANDBOX_AGENT_BIN) { + process.env.SANDBOX_AGENT_BIN = BINARY_PATH; +} + +function isModuleAvailable(name: string): boolean { + try { + _require.resolve(name); + return true; + } catch { + return false; + } +} + +function isDockerAvailable(): boolean { + try { + execSync("docker info", { stdio: "ignore", timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Provider registry — each entry defines how to create a provider and +// what preconditions are required for it to run. +// --------------------------------------------------------------------------- + +interface ProviderEntry { + name: string; + /** Human-readable reasons this provider can't run, or empty if ready. */ + skipReasons: string[]; + /** Return a fresh provider instance for a single test. */ + createProvider: () => SandboxProvider; + /** Optional per-provider setup (e.g. create temp dirs). Returns cleanup fn. */ + setup?: () => { cleanup: () => void }; + /** Agent to use for session tests. */ + agent: string; + /** Timeout for start() — remote providers need longer. */ + startTimeoutMs?: number; + /** Some providers (e.g. local) can verify the sandbox is gone after destroy. */ + canVerifyDestroyedSandbox?: boolean; + /** + * Whether session tests (createSession, prompt) should run. + * The mock agent only works with local provider (requires mock-acp process binary). + * Remote providers need a real agent (claude) which requires compatible server version + API keys. + */ + sessionTestsEnabled: boolean; +} + +function missingEnvVars(...vars: string[]): string[] { + const missing = vars.filter((v) => !process.env[v]); + return missing.length > 0 ? [`missing env: ${missing.join(", ")}`] : []; +} + +function missingModules(...modules: string[]): string[] { + const missing = modules.filter((m) => !isModuleAvailable(m)); + return missing.length > 0 ? [`missing npm packages: ${missing.join(", ")}`] : []; +} + +function collectApiKeys(): Record { + const keys: Record = {}; + if (process.env.ANTHROPIC_API_KEY) keys.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) keys.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return keys; +} + +function buildProviders(): ProviderEntry[] { + const entries: ProviderEntry[] = []; + + // --- local --- + // Uses the mock-acp process binary created by prepareMockAgentDataHome. + { + let dataHome: string | undefined; + entries.push({ + name: "local", + skipReasons: [], + agent: "mock", + canVerifyDestroyedSandbox: true, + sessionTestsEnabled: true, + setup() { + dataHome = mkdtempSync(join(tmpdir(), "sdk-provider-local-")); + return { + cleanup: () => { + if (dataHome) rmSync(dataHome, { recursive: true, force: true }); + }, + }; + }, + createProvider() { + return local({ + log: "silent", + env: prepareMockAgentDataHome(dataHome!), + }); + }, + }); + } + + // --- docker --- + // Requires SANDBOX_AGENT_DOCKER_IMAGE (e.g. "sandbox-agent-dev:local"). + // Session tests disabled: released server images use a different ACP protocol + // version than the current SDK branch, causing "Query closed before response + // received" errors on session creation. + { + entries.push({ + name: "docker", + skipReasons: [ + ...missingEnvVars("SANDBOX_AGENT_DOCKER_IMAGE"), + ...missingModules("dockerode", "get-port"), + ...(isDockerAvailable() ? [] : ["Docker daemon not available"]), + ], + agent: "claude", + startTimeoutMs: 180_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + const apiKeys = [ + process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", + process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", + ].filter(Boolean); + return docker({ + image: process.env.SANDBOX_AGENT_DOCKER_IMAGE, + env: apiKeys, + }); + }, + }); + } + + // --- e2b --- + // Session tests disabled: see docker comment above (ACP protocol mismatch). + { + entries.push({ + name: "e2b", + skipReasons: [...missingEnvVars("E2B_API_KEY"), ...missingModules("@e2b/code-interpreter")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + return e2b({ + create: { envs: collectApiKeys() }, + }); + }, + }); + } + + // --- daytona --- + // Session tests disabled: see docker comment above (ACP protocol mismatch). + { + entries.push({ + name: "daytona", + skipReasons: [...missingEnvVars("DAYTONA_API_KEY"), ...missingModules("@daytonaio/sdk")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + return daytona({ + create: { envVars: collectApiKeys() }, + }); + }, + }); + } + + // --- vercel --- + // Session tests disabled: see docker comment above (ACP protocol mismatch). + { + entries.push({ + name: "vercel", + skipReasons: [...missingEnvVars("VERCEL_ACCESS_TOKEN"), ...missingModules("@vercel/sandbox")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + return vercel({ + create: { env: collectApiKeys() }, + }); + }, + }); + } + + // --- modal --- + // Session tests disabled: see docker comment above (ACP protocol mismatch). + { + entries.push({ + name: "modal", + skipReasons: [...missingEnvVars("MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"), ...missingModules("modal")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + return modal({ + create: { secrets: collectApiKeys() }, + }); + }, + }); + } + + // --- computesdk --- + // Session tests disabled: see docker comment above (ACP protocol mismatch). + { + entries.push({ + name: "computesdk", + skipReasons: [...missingEnvVars("COMPUTESDK_API_KEY"), ...missingModules("computesdk")], + agent: "claude", + startTimeoutMs: 300_000, + canVerifyDestroyedSandbox: false, + sessionTestsEnabled: false, + createProvider() { + return computesdk({ + create: { envs: collectApiKeys() }, + }); + }, + }); + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Shared test suite — runs the same assertions against every provider. +// +// Provider lifecycle tests (start, sandboxId, reconnect, destroy) use only +// listAgents() and never create sessions — these work regardless of which +// agents are installed or whether API keys are present. +// +// Session tests (createSession, prompt) are only enabled for providers where +// the agent is known to work. For local, the mock-acp process binary is +// created by test setup. For remote providers, a real agent (claude) is used +// which requires ANTHROPIC_API_KEY and a compatible server version. +// --------------------------------------------------------------------------- + +function providerSuite(entry: ProviderEntry) { + const skip = entry.skipReasons.length > 0; + + const descFn = skip ? describe.skip : describe; + + descFn(`SandboxProvider: ${entry.name}`, () => { + let sdk: SandboxAgent | undefined; + let cleanupFn: (() => void) | undefined; + + if (skip) { + it.skip(`skipped — ${entry.skipReasons.join("; ")}`, () => {}); + return; + } + + beforeAll(() => { + const result = entry.setup?.(); + cleanupFn = result?.cleanup; + }); + + afterEach(async () => { + if (!sdk) return; + await sdk.destroySandbox().catch(async () => { + await sdk?.dispose().catch(() => {}); + }); + sdk = undefined; + }, 30_000); + + afterAll(() => { + cleanupFn?.(); + }); + + // -- lifecycle tests (no session creation) -- + + it( + "starts with a prefixed sandboxId and passes health", + async () => { + sdk = await SandboxAgent.start({ sandbox: entry.createProvider() }); + expect(sdk.sandboxId).toMatch(new RegExp(`^${entry.name}/`)); + + // listAgents() awaits the internal health gate, confirming the server is ready. + const agents = await sdk.listAgents(); + expect(agents.agents.length).toBeGreaterThan(0); + }, + entry.startTimeoutMs, + ); + + it("rejects mismatched sandboxId prefixes", async () => { + await expect( + SandboxAgent.start({ + sandbox: entry.createProvider(), + sandboxId: "wrong-provider/example", + }), + ).rejects.toThrow(/provider/i); + }); + + it( + "reconnects after dispose without destroying the sandbox", + async () => { + sdk = await SandboxAgent.start({ sandbox: entry.createProvider() }); + const sandboxId = sdk.sandboxId; + expect(sandboxId).toBeTruthy(); + + await sdk.dispose(); + + const reconnected = await SandboxAgent.start({ + sandbox: entry.createProvider(), + sandboxId, + }); + + const agents = await reconnected.listAgents(); + expect(agents.agents.length).toBeGreaterThan(0); + sdk = reconnected; + }, + entry.startTimeoutMs ? entry.startTimeoutMs * 2 : undefined, + ); + + it( + "destroySandbox tears the sandbox down", + async () => { + sdk = await SandboxAgent.start({ sandbox: entry.createProvider() }); + const sandboxId = sdk.sandboxId; + expect(sandboxId).toBeTruthy(); + + await sdk.destroySandbox(); + sdk = undefined; + + if (entry.canVerifyDestroyedSandbox) { + const reconnected = await SandboxAgent.start({ + sandbox: entry.createProvider(), + sandboxId, + skipHealthCheck: true, + }); + await expect(reconnected.listAgents()).rejects.toThrow(); + } + }, + entry.startTimeoutMs, + ); + + // -- session tests (require working agent) -- + + const sessionIt = entry.sessionTestsEnabled ? it : it.skip; + + sessionIt( + "creates sessions with persisted sandboxId", + async () => { + const persist = new InMemorySessionPersistDriver(); + sdk = await SandboxAgent.start({ sandbox: entry.createProvider(), persist }); + + const session = await sdk.createSession({ agent: entry.agent }); + const record = await persist.getSession(session.id); + + expect(record?.sandboxId).toBe(sdk.sandboxId); + }, + entry.startTimeoutMs, + ); + + sessionIt( + "sends a prompt and receives a response", + async () => { + sdk = await SandboxAgent.start({ sandbox: entry.createProvider() }); + + const session = await sdk.createSession({ agent: entry.agent }); + const events: unknown[] = []; + const off = session.onEvent((event) => { + events.push(event); + }); + + const result = await session.prompt([{ type: "text", text: "Say hello in one word." }]); + off(); + + expect(result.stopReason).toBe("end_turn"); + expect(events.length).toBeGreaterThan(0); + }, + entry.startTimeoutMs ? entry.startTimeoutMs * 2 : 30_000, + ); + }); +} + +// --------------------------------------------------------------------------- +// Register all providers +// --------------------------------------------------------------------------- + +for (const entry of buildProviders()) { + providerSuite(entry); +} diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts index faf3167..984eeb3 100644 --- a/sdks/typescript/tsup.config.ts +++ b/sdks/typescript/tsup.config.ts @@ -1,9 +1,20 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: [ + "src/index.ts", + "src/providers/local.ts", + "src/providers/e2b.ts", + "src/providers/daytona.ts", + "src/providers/docker.ts", + "src/providers/vercel.ts", + "src/providers/cloudflare.ts", + "src/providers/modal.ts", + "src/providers/computesdk.ts", + ], format: ["esm"], dts: true, clean: true, sourcemap: true, + external: ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@vercel/sandbox", "dockerode", "get-port", "modal", "computesdk"], }); diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts index 8676010..e83d10a 100644 --- a/sdks/typescript/vitest.config.ts +++ b/sdks/typescript/vitest.config.ts @@ -4,5 +4,7 @@ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30000, + teardownTimeout: 10000, + pool: "forks", }, }); 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) }