diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 368be2259..8e4975c2c 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -224,6 +224,14 @@ jobs: TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} TF_VAR_daytona_base_snapshot: ${{ secrets.DAYTONA_BASE_SNAPSHOT }} TF_VAR_daytona_target: ${{ secrets.DAYTONA_TARGET }} + TF_VAR_vercel_sandbox_token: ${{ secrets.VERCEL_SANDBOX_TOKEN }} + TF_VAR_vercel_sandbox_project_id: ${{ secrets.VERCEL_SANDBOX_PROJECT_ID }} + TF_VAR_vercel_sandbox_team_id: ${{ secrets.VERCEL_SANDBOX_TEAM_ID }} + TF_VAR_vercel_base_snapshot_id: ${{ secrets.VERCEL_BASE_SNAPSHOT_ID }} + TF_VAR_vercel_sandbox_runtime: "${{ secrets.VERCEL_SANDBOX_RUNTIME || 'node24' }}" + TF_VAR_vercel_runtime_repo_url: "${{ secrets.VERCEL_RUNTIME_REPO_URL || 'https://github.com/ColeMurray/background-agents.git' }}" + TF_VAR_vercel_runtime_repo_ref: "${{ secrets.VERCEL_RUNTIME_REPO_REF || 'main' }}" + TF_VAR_vercel_snapshot_expiration_ms: "${{ secrets.VERCEL_SNAPSHOT_EXPIRATION_MS || '0' }}" - name: Post Plan Results uses: actions/github-script@v8 @@ -264,7 +272,7 @@ jobs: apply: name: Apply runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 needs: [validate, check-secrets] if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && needs.check-secrets.outputs.has-secrets == 'true' environment: production @@ -301,6 +309,44 @@ jobs: with: terraform_version: ${{ env.TF_VERSION }} + - name: Build Vercel base snapshot + id: vercel_base_snapshot + run: | + if [ "${SANDBOX_PROVIDER}" != "vercel" ]; then + echo "snapshot_id=${VERCEL_BASE_SNAPSHOT_ID:-}" >> "$GITHUB_OUTPUT" + echo "Skipping Vercel base snapshot build because SANDBOX_PROVIDER=${SANDBOX_PROVIDER}" + exit 0 + fi + + if [ -z "${VERCEL_TOKEN:-}" ] || [ -z "${VERCEL_PROJECT_ID:-}" ]; then + echo "VERCEL_SANDBOX_TOKEN and VERCEL_SANDBOX_PROJECT_ID are required when SANDBOX_PROVIDER=vercel" + exit 1 + fi + + npm run build:vercel-base-snapshot -w @open-inspect/control-plane + + output_file="${RUNNER_TEMP}/vercel-base-snapshot-id" + node packages/control-plane/dist/vercel-base-snapshot.js --output "$output_file" + snapshot_id="$(tr -d '\r\n' < "$output_file")" + + if [ -z "$snapshot_id" ]; then + echo "Vercel base snapshot builder did not write a snapshot ID" + exit 1 + fi + + echo "Built Vercel base snapshot: $snapshot_id" + echo "snapshot_id=$snapshot_id" >> "$GITHUB_OUTPUT" + env: + SANDBOX_PROVIDER: "${{ secrets.SANDBOX_PROVIDER || 'modal' }}" + VERCEL_BASE_SNAPSHOT_ID: ${{ secrets.VERCEL_BASE_SNAPSHOT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_SANDBOX_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_SANDBOX_PROJECT_ID }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_SANDBOX_TEAM_ID }} + VERCEL_RUNTIME: "${{ secrets.VERCEL_SANDBOX_RUNTIME || 'node24' }}" + VERCEL_RUNTIME_REPO_URL: "${{ secrets.VERCEL_RUNTIME_REPO_URL || 'https://github.com/ColeMurray/background-agents.git' }}" + VERCEL_RUNTIME_REPO_REF: "${{ secrets.VERCEL_RUNTIME_REPO_REF || 'main' }}" + VERCEL_SANDBOX_API_BASE_URL: ${{ secrets.VERCEL_SANDBOX_API_BASE_URL }} + - name: Terraform Init run: | terraform init \ @@ -357,6 +403,14 @@ jobs: TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} TF_VAR_daytona_base_snapshot: ${{ secrets.DAYTONA_BASE_SNAPSHOT }} TF_VAR_daytona_target: ${{ secrets.DAYTONA_TARGET }} + TF_VAR_vercel_sandbox_token: ${{ secrets.VERCEL_SANDBOX_TOKEN }} + TF_VAR_vercel_sandbox_project_id: ${{ secrets.VERCEL_SANDBOX_PROJECT_ID }} + TF_VAR_vercel_sandbox_team_id: ${{ secrets.VERCEL_SANDBOX_TEAM_ID }} + TF_VAR_vercel_base_snapshot_id: ${{ steps.vercel_base_snapshot.outputs.snapshot_id || secrets.VERCEL_BASE_SNAPSHOT_ID }} + TF_VAR_vercel_sandbox_runtime: "${{ secrets.VERCEL_SANDBOX_RUNTIME || 'node24' }}" + TF_VAR_vercel_runtime_repo_url: "${{ secrets.VERCEL_RUNTIME_REPO_URL || 'https://github.com/ColeMurray/background-agents.git' }}" + TF_VAR_vercel_runtime_repo_ref: "${{ secrets.VERCEL_RUNTIME_REPO_REF || 'main' }}" + TF_VAR_vercel_snapshot_expiration_ms: "${{ secrets.VERCEL_SNAPSHOT_EXPIRATION_MS || '0' }}" MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f372b9bd8..3e58ed601 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -15,11 +15,11 @@ This guide walks you through deploying your own instance of Open-Inspect using T Open-Inspect uses Terraform to automate deployment across three cloud providers: -| Provider | Purpose | What Terraform Creates | -| -------------------------------------- | -------------------------------- | ----------------------------------------------------------------- | -| **Cloudflare** | Control plane, session state | Workers, KV namespaces, Durable Objects, D1 Database | -| **Vercel** _or_ **Cloudflare Workers** | Web application | Project + env vars (Vercel) _or_ Worker via OpenNext (Cloudflare) | -| **Modal** _or_ **Daytona** | Sandbox execution infrastructure | Modal app deployment _or_ control-plane config for Daytona API | +| Provider | Purpose | What Terraform Creates | +| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------ | +| **Cloudflare** | Control plane, session state | Workers, KV namespaces, Durable Objects, D1 Database | +| **Vercel** _or_ **Cloudflare Workers** | Web application | Project + env vars (Vercel) _or_ Worker via OpenNext (Cloudflare) | +| **Modal**, **Daytona**, _or_ **Vercel Sandboxes** | Sandbox execution infrastructure | Modal app deployment, Daytona API config, _or_ Vercel Sandbox API config | > **Web platform choice**: Set `web_platform` in your `terraform.tfvars` to `"vercel"` (default) or > `"cloudflare"`. The Cloudflare option deploys the Next.js app as a Cloudflare Worker using @@ -36,16 +36,17 @@ Open-Inspect uses Terraform to automate deployment across three cloud providers: Create accounts on these services before continuing: -| Service | Purpose | -| ------------------------------------------------ | -------------------------------------------------------------- | -| [Cloudflare](https://dash.cloudflare.com) | Control plane hosting (+ web app if using Cloudflare platform) | -| [Vercel](https://vercel.com) _(optional)_ | Web application hosting (only if `web_platform = "vercel"`) | -| [Modal](https://modal.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "modal"` | -| [Daytona](https://app.daytona.io) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "daytona"` | -| [GitHub](https://github.com/settings/developers) | OAuth + repository access | -| [Anthropic](https://console.anthropic.com) | Claude API | -| [Slack](https://api.slack.com/apps) _(optional)_ | Slack bot integration | -| GitHub App Webhooks _(optional)_ | GitHub bot (PR reviews) | +| Service | Purpose | +| --------------------------------------------------- | -------------------------------------------------------------- | +| [Cloudflare](https://dash.cloudflare.com) | Control plane hosting (+ web app if using Cloudflare platform) | +| [Vercel](https://vercel.com) _(optional)_ | Web application hosting (only if `web_platform = "vercel"`) | +| [Modal](https://modal.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "modal"` | +| [Daytona](https://app.daytona.io) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "daytona"` | +| [Vercel Sandboxes](https://vercel.com) _(optional)_ | Sandbox infrastructure when `sandbox_provider = "vercel"` | +| [GitHub](https://github.com/settings/developers) | OAuth + repository access | +| [Anthropic](https://console.anthropic.com) | Claude API | +| [Slack](https://api.slack.com/apps) _(optional)_ | Slack bot integration | +| GitHub App Webhooks _(optional)_ | GitHub bot (PR reviews) | ### Required Tools @@ -181,6 +182,32 @@ The control plane calls the Daytona REST API directly — no shim service to dep > sandboxes. If you plan to use Claude models, add `ANTHROPIC_API_KEY` as a **global secret** in > Settings > Secrets after deploying. See [Secrets Management](SECRETS.md) for details. +### Vercel Sandboxes + +> Only required when `sandbox_provider = "vercel"`. + +1. Create a [Vercel API token](https://vercel.com/account/tokens) that can access your sandbox + project. +2. Note the **Project ID** for the project that will own sandbox sessions. +3. Note the **Team/Account ID** if you use a Vercel team. Leave it unset for personal accounts where + the token can access the project directly. +4. Set `sandbox_provider = "vercel"` in `terraform.tfvars`. +5. Set `vercel_sandbox_token`, `vercel_sandbox_project_id`, and optionally `vercel_sandbox_team_id` + in `terraform.tfvars`. + +The control plane calls the Vercel Sandbox API directly from Cloudflare Workers. No Modal-style shim +service is deployed. Vercel supports filesystem snapshots and repo prebuilt images; if you have a +reusable base snapshot, set `vercel_base_snapshot_id` to skip runtime bootstrapping on every fresh +sandbox. + +When the Terraform GitHub Actions apply job runs with `SANDBOX_PROVIDER=vercel`, it builds a fresh +immutable Vercel base-runtime snapshot before `terraform apply` and passes the generated snapshot ID +into the Worker deployment. This keeps the Vercel base runtime aligned with the configured +`VERCEL_RUNTIME_REPO_URL`/`VERCEL_RUNTIME_REPO_REF`. The `vercel_base_snapshot_id` setting is still +available for local Terraform runs or as a manual fallback. See +[Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) for the full runtime, snapshot, and resource +configuration model. + ### Anthropic 1. Go to [Anthropic Console](https://console.anthropic.com) @@ -362,11 +389,20 @@ modal_workspace = "your-modal-workspace" modal_environment = "your-modal-environment" modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digits, dashes; empty for https://workspace--... endpoints +# Sandbox provider: "modal" (default), "daytona", or "vercel" +# sandbox_provider = "modal" + # Daytona (only required when sandbox_provider = "daytona") # daytona_api_url = "https://app.daytona.io/api" # daytona_api_key = "your-daytona-api-key" # daytona_base_snapshot = "your-snapshot-name" +# Vercel Sandboxes (only required when sandbox_provider = "vercel") +# vercel_sandbox_token = "your-vercel-token" +# vercel_sandbox_project_id = "prj_xxxxx" +# vercel_sandbox_team_id = "team_xxxxx" # Optional +# vercel_base_snapshot_id = "snapshot_xxxxx" # Optional manual fallback; CI usually generates this + # GitHub App (used for both OAuth and repository access) github_client_id = "Iv1.abc123..." # From GitHub App settings github_client_secret = "your-client-secret" # Generated in GitHub App settings @@ -680,6 +716,11 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `MODAL_WORKSPACE` | Modal workspace name | | `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | | `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | +| `SANDBOX_PROVIDER` | `modal`, `daytona`, or `vercel` | +| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | +| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; Terraform CI generates one for Vercel applies | | `GH_OAUTH_CLIENT_ID` | GitHub App OAuth client ID | | `GH_OAUTH_CLIENT_SECRET` | GitHub App OAuth client secret | | `GH_APP_ID` | GitHub App ID | diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index e05d016e3..cd5112119 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -148,10 +148,11 @@ Open-Inspect supports two backend patterns: - **Modal**: near-instant startup plus filesystem snapshot restore - **Daytona**: persistent stop/start sandboxes via direct REST API calls +- **Vercel Sandboxes**: filesystem snapshot restore and repo-image builds via the Vercel Sandbox API -Modal is still the only backend with repo-image builds and live filesystem snapshot restore. Daytona -uses persistent sandboxes instead: the control plane stops the sandbox on inactivity or stale -heartbeat, then resumes that same sandbox later with the same logical sandbox ID and auth token. +Modal and Vercel support repo-image builds and live filesystem snapshot restore. Daytona uses +persistent sandboxes instead: the control plane stops the sandbox on inactivity or stale heartbeat, +then resumes that same sandbox later with the same logical sandbox ID and auth token. ### Clients @@ -408,7 +409,7 @@ That's potentially minutes before the agent can start working. ### How Snapshots Solve This -Modal's filesystem snapshots let us capture a sandbox's state after setup: +Modal and Vercel filesystem snapshots let us capture a sandbox's state after setup: ``` First session: Clone ─▶ Install/Build ─▶ Start Runtime ─▶ [Snapshot] ─▶ Work @@ -420,6 +421,12 @@ Later sessions: [Restore Snapshot] ─▶ Quick sync ─▶ Start Runtime ─▶ The first session for a repo pays the setup cost. Subsequent sessions restore in seconds. +For Vercel, the Terraform GitHub Actions deploy builds a base-runtime snapshot before applying the +control plane and wires that generated snapshot ID into `VERCEL_BASE_SNAPSHOT_ID`. Fresh Vercel +sandboxes normally start from this managed runtime snapshot instead of cloning and installing the +sandbox runtime on every session. See [Vercel Sandbox Provider](VERCEL_SANDBOX_PROVIDER.md) for the +full provider flow. + ### Image Prebuilding For frequently-used repositories, images can be prebuilt on a schedule: diff --git a/docs/IMAGE_PREBUILD.md b/docs/IMAGE_PREBUILD.md index c93a45b2e..2248708d4 100644 --- a/docs/IMAGE_PREBUILD.md +++ b/docs/IMAGE_PREBUILD.md @@ -24,6 +24,10 @@ minutes of changes. ## Getting Started +Pre-built images are available when the deployment uses `sandbox_provider = "modal"` or +`sandbox_provider = "vercel"`. Daytona deployments use persistent sandboxes instead, so the Images +settings page is disabled for that backend. + ### Enable for a Repository 1. Open **Settings > Images** in the web dashboard diff --git a/docs/SECRETS.md b/docs/SECRETS.md index d315b806d..d464447cd 100644 --- a/docs/SECRETS.md +++ b/docs/SECRETS.md @@ -34,13 +34,13 @@ If you override a global key at the repo level, the global entry shows "(overrid Use global secrets for keys that every session needs regardless of which repository it runs against. The most common example: -| Key | Description | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Required for Claude models when using the **Daytona** sandbox provider (Modal injects this automatically via its own secrets mechanism) | +| Key | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Required for Claude models when using the **Daytona** or **Vercel** sandbox provider (Modal injects this automatically via its own secrets mechanism) | -> **Daytona users**: If you plan to use Claude models, you must add `ANTHROPIC_API_KEY` as a global -> secret after deploying. Without it, Claude sessions will fail with "Model not found." See -> [Getting Started — Daytona](GETTING_STARTED.md#daytona) for details. +> **Daytona and Vercel sandbox users**: If you plan to use Claude models, you must add +> `ANTHROPIC_API_KEY` as a global secret after deploying. Without it, Claude sessions will fail with +> "Model not found." See [Getting Started](GETTING_STARTED.md) for details. ### When to use repository secrets @@ -110,24 +110,24 @@ If you try to save a reserved key, the UI will show a validation error. ## Common Examples -| Key | Scope | Purpose | -| ---------------------------- | ------ | ----------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Global | Claude API access (required for Daytona provider) | -| `OPENAI_OAUTH_REFRESH_TOKEN` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) | -| `OPENAI_OAUTH_ACCOUNT_ID` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) | -| `DATABASE_URL` | Repo | Database connection string | -| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project | -| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project | +| Key | Scope | Purpose | +| ---------------------------- | ------ | ------------------------------------------------------------ | +| `ANTHROPIC_API_KEY` | Global | Claude API access (required for Daytona or Vercel sandboxes) | +| `OPENAI_OAUTH_REFRESH_TOKEN` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) | +| `OPENAI_OAUTH_ACCOUNT_ID` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) | +| `DATABASE_URL` | Repo | Database connection string | +| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project | +| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project | --- ## Troubleshooting -### "Model not found" errors (Daytona provider) +### "Model not found" errors (Daytona or Vercel sandbox provider) -If you're using `sandbox_provider = "daytona"` with Claude models and see "Model not found" errors, -add your `ANTHROPIC_API_KEY` as a global secret in Settings. Unlike Modal, the Daytona provider does -not inject LLM API keys automatically. +If you're using `sandbox_provider = "daytona"` or `sandbox_provider = "vercel"` with Claude models +and see "Model not found" errors, add your `ANTHROPIC_API_KEY` as a global secret in Settings. +Unlike Modal, these providers do not inject LLM API keys automatically. ### Secret not appearing in sandbox diff --git a/docs/VERCEL_SANDBOX_PROVIDER.md b/docs/VERCEL_SANDBOX_PROVIDER.md new file mode 100644 index 000000000..9b868a6a6 --- /dev/null +++ b/docs/VERCEL_SANDBOX_PROVIDER.md @@ -0,0 +1,120 @@ +# Vercel Sandbox Provider + +Open-Inspect can use Vercel Sandboxes as the data-plane provider for coding sessions. The control +plane talks directly to the Vercel Sandbox REST API from Cloudflare Workers; there is no separate +Modal-style shim service for this provider. + +## When to Use It + +Use `sandbox_provider = "vercel"` when you want sandbox sessions to run in Vercel Sandboxes while +keeping the same Open-Inspect control plane and web app deployment flow. Vercel supports filesystem +snapshots, so Open-Inspect can restore a base runtime snapshot, create repo-specific snapshots, and +resume user sessions from saved filesystem state. + +## Required Configuration + +For Terraform variables, set: + +```hcl +sandbox_provider = "vercel" +vercel_sandbox_token = "..." +vercel_sandbox_project_id = "prj_..." +# vercel_sandbox_team_id = "team_..." # optional for team projects +``` + +For GitHub Actions based deployment, configure the matching repository secrets: + +```text +SANDBOX_PROVIDER=vercel +VERCEL_SANDBOX_TOKEN +VERCEL_SANDBOX_PROJECT_ID +VERCEL_SANDBOX_TEAM_ID # optional +``` + +Optional runtime settings: + +```text +VERCEL_SANDBOX_RUNTIME=node24 +VERCEL_RUNTIME_REPO_URL=https://github.com/ColeMurray/background-agents.git +VERCEL_RUNTIME_REPO_REF=main +VERCEL_SNAPSHOT_EXPIRATION_MS=0 +``` + +`VERCEL_SNAPSHOT_EXPIRATION_MS` applies to repo/session snapshots created at runtime. `0` means no +expiration. The managed base-runtime snapshot is created without expiration. + +## Managed Base Runtime Snapshot + +When the Terraform GitHub Actions apply job runs with `SANDBOX_PROVIDER=vercel`, it builds a fresh +base-runtime snapshot before `terraform apply`: + +1. Create a temporary Vercel sandbox. +2. Run the Open-Inspect runtime bootstrap script inside that sandbox. +3. Clone `VERCEL_RUNTIME_REPO_URL` at `VERCEL_RUNTIME_REPO_REF`. +4. Install the sandbox runtime, OpenCode, code-server, ttyd, browser tooling, and credential helper. +5. Snapshot the prepared filesystem. +6. Stop the temporary sandbox. +7. Pass the generated snapshot ID into Terraform as `vercel_base_snapshot_id`. + +The deployed control plane receives that value as `VERCEL_BASE_SNAPSHOT_ID`. Fresh Vercel sessions +start from this snapshot, so they do not need to reinstall the base runtime every time. + +`vercel_base_snapshot_id` still exists as a manual fallback for local Terraform applies or emergency +pinning, but the normal CI path should generate it. + +## Runtime Source + +The runtime source is intentionally a repository/ref pair rather than files uploaded from the local +Terraform checkout. By default it uses: + +```text +https://github.com/ColeMurray/background-agents.git +main +``` + +This keeps the public deploy path simple and makes the base snapshot reproducible from a Git ref. If +you need a private fork or pinned release branch, set `VERCEL_RUNTIME_REPO_URL` and +`VERCEL_RUNTIME_REPO_REF` in GitHub Actions secrets before running Terraform apply. + +## Session Startup Sources + +Vercel sessions choose their source in this order: + +1. Repo image snapshot, when a repo-specific prebuild exists. +2. Managed base-runtime snapshot from `VERCEL_BASE_SNAPSHOT_ID`. +3. Fresh Vercel sandbox followed by runtime bootstrap, if no snapshot is configured. + +Repo image snapshots still take precedence over the base runtime snapshot because they contain both +the base runtime and repository-specific setup work. + +## Shutdown and Snapshots + +Vercel sandboxes are explicitly stopped by Open-Inspect when they should no longer run: + +- The temporary base-snapshot build sandbox is stopped after its snapshot is created. +- Inactive Vercel sessions are snapshotted and stopped by the lifecycle manager. +- Runtime-created snapshots use `VERCEL_SNAPSHOT_EXPIRATION_MS`; the base runtime snapshot does not + expire by default. + +Existing generated base snapshots are not automatically deleted. Treat them like deploy artifacts: +keep the current snapshot, and delete old snapshots manually if you need to reclaim quota. + +## CPU and Memory + +Open-Inspect does not currently send a Vercel `resources` setting when creating sandboxes. Vercel +therefore applies its default sandbox size. + +At the time this provider was added, Vercel documented the default as `2` vCPUs with memory tied to +CPU at `2 GB` per vCPU, which gives the observed default of `2 vCPU / 4 GB RAM`. Vercel also +documents `1`, `2`, `4`, and `8` vCPU options for standard sandbox configuration, with larger +Enterprise configurations available separately. + +If Open-Inspect needs to control this later, add a provider config value such as +`VERCEL_SANDBOX_VCPUS`, thread it into the Vercel create-sandbox request as `resources.vcpus`, and +let Vercel derive memory from that vCPU count. + +References: + +- [Vercel Sandbox pricing and limits](https://vercel.com/docs/vercel-sandbox/pricing) +- [Vercel Sandbox REST API](https://vercel.com/docs/vercel-sandbox) +- [Vercel Sandbox 32 vCPU / 64 GB RAM changelog](https://vercel.com/changelog/vercel-sandbox-now-supports-up-to-32-vcpu-64-gb-ram-configurations) diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 4e7f6f019..42b15a9dc 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=browser --target=es2022 --external:cloudflare:* --external:node:*", + "build:vercel-base-snapshot": "esbuild scripts/build-vercel-base-snapshot.ts --bundle --format=esm --platform=node --target=node22 --outfile=dist/vercel-base-snapshot.js", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", diff --git a/packages/control-plane/scripts/build-vercel-base-snapshot.ts b/packages/control-plane/scripts/build-vercel-base-snapshot.ts new file mode 100644 index 000000000..3bcc46daa --- /dev/null +++ b/packages/control-plane/scripts/build-vercel-base-snapshot.ts @@ -0,0 +1,53 @@ +import { writeFileSync } from "node:fs"; + +import { buildVercelBaseSnapshot } from "../src/sandbox/vercel-base-snapshot"; +import { createVercelSandboxClient } from "../src/sandbox/vercel-client"; + +function env(name: string, fallback = ""): string { + return process.env[name] || fallback; +} + +function requiredEnv(name: string): string { + const value = env(name); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function getArgValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +async function main(): Promise { + const outputPath = getArgValue("--output"); + const token = requiredEnv("VERCEL_TOKEN"); + const projectId = requiredEnv("VERCEL_PROJECT_ID"); + + const client = createVercelSandboxClient({ + token, + projectId, + teamId: env("VERCEL_TEAM_ID") || undefined, + apiBaseUrl: env("VERCEL_SANDBOX_API_BASE_URL") || undefined, + }); + + const result = await buildVercelBaseSnapshot(client, { + runtime: env("VERCEL_RUNTIME") || undefined, + runtimeRepoUrl: env("VERCEL_RUNTIME_REPO_URL") || undefined, + runtimeRepoRef: env("VERCEL_RUNTIME_REPO_REF") || undefined, + sourceVersion: env("GITHUB_SHA") || env("VERCEL_BASE_SNAPSHOT_SOURCE_VERSION") || undefined, + }); + + if (outputPath) { + writeFileSync(outputPath, `${result.snapshotId}\n`, { encoding: "utf8" }); + } else { + process.stdout.write(`${result.snapshotId}\n`); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/control-plane/src/routes/repo-images.ts b/packages/control-plane/src/routes/repo-images.ts index 98af799dc..22985cc84 100644 --- a/packages/control-plane/src/routes/repo-images.ts +++ b/packages/control-plane/src/routes/repo-images.ts @@ -14,7 +14,10 @@ import { GlobalSecretsStore } from "../db/global-secrets"; import { RepoSecretsStore } from "../db/repo-secrets"; import { mergeSecrets } from "../db/secrets-validation"; import { createModalClient } from "../sandbox/client"; -import { isModalSandboxBackend } from "../sandbox/provider-name"; +import { createVercelSandboxClient } from "../sandbox/vercel-client"; +import { createVercelProvider } from "../sandbox/providers/vercel-provider"; +import { resolveSandboxBackendName, supportsRepoImageBackend } from "../sandbox/provider-name"; +import { resolveScmProviderFromEnv } from "../source-control"; import { createLogger } from "../logger"; import type { Env } from "../types"; import { @@ -31,12 +34,47 @@ import { const logger = createLogger("router:repo-images"); -function requireModalRepoImages(env: Env): Response | null { - if (isModalSandboxBackend(env.SANDBOX_PROVIDER)) { +function requireRepoImages(env: Env): Response | null { + if (supportsRepoImageBackend(env.SANDBOX_PROVIDER)) { return null; } - return error("Repo images are only available when SANDBOX_PROVIDER=modal", 501); + return error("Repo images are only available when SANDBOX_PROVIDER=modal or vercel", 501); +} + +function getRepoImageBackend(env: Env): "modal" | "vercel" { + const backend = resolveSandboxBackendName(env.SANDBOX_PROVIDER); + if (backend !== "modal" && backend !== "vercel") { + throw new Error(`Repo images are not supported for SANDBOX_PROVIDER=${backend}`); + } + return backend; +} + +function createConfiguredVercelProvider(env: Env) { + if (!env.VERCEL_TOKEN || !env.VERCEL_PROJECT_ID) { + throw new Error("Vercel configuration not available"); + } + + const client = createVercelSandboxClient({ + token: env.VERCEL_TOKEN, + projectId: env.VERCEL_PROJECT_ID, + teamId: env.VERCEL_TEAM_ID, + apiBaseUrl: env.VERCEL_SANDBOX_API_BASE_URL, + }); + + return createVercelProvider(client, { + scmProvider: resolveScmProviderFromEnv(env.SCM_PROVIDER), + token: env.VERCEL_TOKEN, + teamId: env.VERCEL_TEAM_ID, + apiBaseUrl: env.VERCEL_SANDBOX_API_BASE_URL, + baseSnapshotId: env.VERCEL_BASE_SNAPSHOT_ID, + runtime: env.VERCEL_RUNTIME, + runtimeRepoUrl: env.VERCEL_RUNTIME_REPO_URL, + runtimeRepoRef: env.VERCEL_RUNTIME_REPO_REF, + snapshotExpirationMs: parseInt(env.VERCEL_SNAPSHOT_EXPIRATION_MS || "0", 10), + codeServerPasswordSecret: env.VERCEL_TOKEN, + internalCallbackSecret: env.INTERNAL_CALLBACK_SECRET, + }); } /** @@ -49,7 +87,7 @@ async function handleBuildComplete( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -92,17 +130,23 @@ async function handleBuildComplete( trace_id: ctx.trace_id, }); - // Fire-and-forget: delete the replaced provider image if one was replaced - if (result.replacedImageId && env.MODAL_API_SECRET && env.MODAL_WORKSPACE) { + // Fire-and-forget: delete the replaced provider image if one was replaced. + if (result.replacedImageId) { ctx.executionCtx?.waitUntil( (async () => { try { - const client = createModalClient( - env.MODAL_API_SECRET!, - env.MODAL_WORKSPACE!, - env.MODAL_ENVIRONMENT_WEB_SUFFIX - ); - await client.deleteProviderImage({ providerImageId: result.replacedImageId! }); + if (getRepoImageBackend(env) === "vercel") { + await createConfiguredVercelProvider(env).deleteProviderImage( + result.replacedImageId! + ); + } else if (env.MODAL_API_SECRET && env.MODAL_WORKSPACE) { + const client = createModalClient( + env.MODAL_API_SECRET, + env.MODAL_WORKSPACE, + env.MODAL_ENVIRONMENT_WEB_SUFFIX + ); + await client.deleteProviderImage({ providerImageId: result.replacedImageId! }); + } } catch (e) { logger.warn("repo_image.delete_old_failed", { provider_image_id: result.replacedImageId, @@ -135,7 +179,7 @@ async function handleBuildFailed( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -184,15 +228,12 @@ async function handleTriggerBuild( match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { return error("Database not configured", 503); } - if (!env.MODAL_API_SECRET || !env.MODAL_WORKSPACE) { - return error("Modal configuration not available", 503); - } if (!env.WORKER_URL) { return error("WORKER_URL not configured", 503); } @@ -264,23 +305,51 @@ async function handleTriggerBuild( } } - // Trigger build on Modal - const client = createModalClient( - env.MODAL_API_SECRET, - env.MODAL_WORKSPACE, - env.MODAL_ENVIRONMENT_WEB_SUFFIX - ); - await client.buildRepoImage( - { + const backend = getRepoImageBackend(env); + if (backend === "modal") { + if (!env.MODAL_API_SECRET || !env.MODAL_WORKSPACE) { + return error("Modal configuration not available", 503); + } + const client = createModalClient( + env.MODAL_API_SECRET, + env.MODAL_WORKSPACE, + env.MODAL_ENVIRONMENT_WEB_SUFFIX + ); + await client.buildRepoImage( + { + repoOwner: owner, + repoName: name, + defaultBranch: "main", + buildId, + callbackUrl, + userEnvVars, + }, + { trace_id: ctx.trace_id, request_id: ctx.request_id } + ); + } else { + let cloneToken: string | undefined; + try { + const provider = createRouteSourceControlProvider(env); + const auth = await provider.generateCredentialHelperAuth(); + cloneToken = auth.password; + } catch (e) { + logger.warn("repo_image.clone_token_failed", { + error: e instanceof Error ? e.message : String(e), + repo_owner: owner, + repo_name: name, + }); + } + await createConfiguredVercelProvider(env).triggerRepoImageBuild({ repoOwner: owner, repoName: name, defaultBranch: "main", buildId, callbackUrl, userEnvVars, - }, - { trace_id: ctx.trace_id, request_id: ctx.request_id } - ); + cloneToken, + correlation: { trace_id: ctx.trace_id, request_id: ctx.request_id }, + }); + } logger.info("repo_image.build_triggered", { build_id: buildId, @@ -313,7 +382,7 @@ async function handleGetStatus( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -355,7 +424,7 @@ async function handleMarkStale( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -405,7 +474,7 @@ async function handleCleanup( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -455,7 +524,7 @@ async function handleToggleImageBuild( match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { @@ -509,7 +578,7 @@ async function handleGetEnabledRepos( _match: RegExpMatchArray, ctx: RequestContext ): Promise { - const providerError = requireModalRepoImages(env); + const providerError = requireRepoImages(env); if (providerError) return providerError; if (!env.DB) { diff --git a/packages/control-plane/src/sandbox/index.ts b/packages/control-plane/src/sandbox/index.ts index 04db35bf7..e7572fd0d 100644 --- a/packages/control-plane/src/sandbox/index.ts +++ b/packages/control-plane/src/sandbox/index.ts @@ -34,6 +34,36 @@ export { // Modal provider export { ModalSandboxProvider, createModalProvider } from "./providers/modal-provider"; export { DaytonaSandboxProvider, createDaytonaProvider } from "./providers/daytona-provider"; +export { + VercelSandboxProvider, + createVercelProvider, + type VercelProviderConfig, + type TriggerVercelRepoImageBuildConfig, + type TriggerVercelRepoImageBuildResult, +} from "./providers/vercel-provider"; +export { + VercelSandboxClient, + VercelSandboxApiError, + createVercelSandboxClient, + type VercelSandboxClientConfig, + type VercelCreateSandboxRequest, + type VercelCreateSandboxResponse, + type VercelSandboxRoute, + type VercelSandboxSession, +} from "./vercel-client"; +export { + buildVercelBaseSnapshot, + buildBaseSnapshotSandboxName, + type BuildVercelBaseSnapshotConfig, + type BuildVercelBaseSnapshotResult, +} from "./vercel-base-snapshot"; +export { + DEFAULT_VERCEL_RUNTIME, + DEFAULT_VERCEL_RUNTIME_REPO_REF, + DEFAULT_VERCEL_RUNTIME_REPO_URL, + VERCEL_PYTHON_BIN, + buildVercelBootstrapScript, +} from "./vercel-bootstrap"; export { DaytonaRestClient, DaytonaNotFoundError, @@ -46,6 +76,7 @@ export { export { resolveSandboxBackendName, isModalSandboxBackend, + supportsRepoImageBackend, type SandboxBackendName, } from "./provider-name"; diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index a6dbf6c71..bffc8e5bc 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -28,6 +28,8 @@ import { type ResumeResult, type SnapshotConfig, type SnapshotResult, + type StopConfig, + type StopResult, } from "../provider"; import type { SandboxRow, SessionRow } from "../../session/types"; import type { SandboxStatus } from "../../types"; @@ -180,6 +182,12 @@ function createMockStorage( sandbox.code_server_password = null; } }), + clearSandboxCodeServerUrl: vi.fn(() => { + calls.push("clearSandboxCodeServerUrl"); + if (sandbox) { + sandbox.code_server_url = null; + } + }), updateSandboxTunnelUrls: vi.fn(async (urls: Record) => { calls.push(`updateSandboxTunnelUrls`); if (sandbox) { @@ -259,6 +267,7 @@ function createMockProvider( restoreFromSnapshot: (config: RestoreConfig) => Promise; resumeSandbox: (config: ResumeConfig) => Promise; takeSnapshot: (config: SnapshotConfig) => Promise; + stopSandbox: (config: StopConfig) => Promise; capabilities: Partial; }> = {} ): SandboxProvider { @@ -294,6 +303,9 @@ function createMockProvider( if (overrides.resumeSandbox) { provider.resumeSandbox = overrides.resumeSandbox; } + if (overrides.stopSandbox) { + provider.stopSandbox = overrides.stopSandbox; + } return provider; } @@ -1181,6 +1193,88 @@ describe("SandboxLifecycleManager", () => { expect(provider.takeSnapshot).toHaveBeenCalled(); }); + it("snapshots and explicitly stops non-resumable providers on inactivity timeout", async () => { + const now = Date.now(); + const sandbox = createMockSandbox({ + status: "ready", + last_heartbeat: now - 10000, + last_activity: now - 11 * 60 * 1000, + }); + const storage = createMockStorage(createMockSession(), sandbox); + const wsManager = createMockWebSocketManager(false, 0); + const stopSandbox = vi.fn(async () => ({ success: true })); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true, supportsPersistentResume: false }, + stopSandbox, + }); + + const manager = new SandboxLifecycleManager( + provider, + storage, + createMockBroadcaster(), + wsManager, + createMockAlarmScheduler(), + createMockIdGenerator(), + createTestConfig() + ); + + await manager.handleAlarm(); + + expect(provider.takeSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + providerObjectId: "modal-obj-123", + reason: "inactivity_timeout", + }) + ); + expect(stopSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + providerObjectId: "modal-obj-123", + reason: "inactivity_timeout", + }) + ); + expect(wsManager.sendToSandbox).toHaveBeenCalledWith({ type: "shutdown" }); + expect(storage.calls).toContain("clearSandboxCodeServer"); + }); + + it("stops resumable provider-managed sandboxes without snapshotting", async () => { + const now = Date.now(); + const sandbox = createMockSandbox({ + status: "ready", + last_heartbeat: now - 10000, + last_activity: now - 11 * 60 * 1000, + code_server_url: "https://code.test", + code_server_password: "encrypted-password", + }); + const storage = createMockStorage(createMockSession(), sandbox); + const stopSandbox = vi.fn(async () => ({ success: true })); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true, supportsPersistentResume: true }, + stopSandbox, + }); + + const manager = new SandboxLifecycleManager( + provider, + storage, + createMockBroadcaster(), + createMockWebSocketManager(false, 0), + createMockAlarmScheduler(), + createMockIdGenerator(), + createTestConfig() + ); + + await manager.handleAlarm(); + + expect(provider.takeSnapshot).not.toHaveBeenCalled(); + expect(stopSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + providerObjectId: "modal-obj-123", + reason: "inactivity_timeout", + }) + ); + expect(storage.calls).toContain("clearSandboxCodeServerUrl"); + expect(storage.calls).not.toContain("clearSandboxCodeServer"); + }); + it("calls onSandboxTerminating callback on heartbeat stale", async () => { const now = Date.now(); const sandbox = createMockSandbox({ diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 60e8a8b96..4eee2cd23 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -855,12 +855,19 @@ export class SandboxLifecycleManager { } /** - * Whether the active provider owns stop/resume of long-lived sandboxes. + * Whether the active provider can stop a sandbox via its API. */ - private usesProviderManagedStop(): boolean { + private canStopProviderSandbox(): boolean { return !!this.provider.capabilities.supportsExplicitStop && !!this.provider.stopSandbox; } + /** + * Whether stopping should preserve provider-owned state for in-place resume. + */ + private usesProviderManagedStop(): boolean { + return this.canStopProviderSandbox() && !!this.provider.capabilities.supportsPersistentResume; + } + /** * Clear preview URLs after a sandbox is no longer reachable. * @@ -949,7 +956,7 @@ export class SandboxLifecycleManager { await this.callbacks.onSandboxTerminating?.(); this.storage.updateSandboxStatus("failed"); this.clearSandboxAccessState(); - if (this.usesProviderManagedStop()) { + if (this.canStopProviderSandbox()) { try { await this.stopProviderSandbox("connecting_timeout"); } catch (error) { @@ -995,12 +1002,23 @@ export class SandboxLifecycleManager { }); } } else { - // Fire-and-forget snapshot so status broadcast isn't delayed. - this.triggerSnapshot("heartbeat_timeout").catch((e) => - this.log.error("Heartbeat snapshot failed", { - error: e instanceof Error ? e : String(e), - }) - ); + if (this.canStopProviderSandbox()) { + await this.triggerSnapshot("heartbeat_timeout"); + try { + await this.stopProviderSandbox("heartbeat_timeout"); + } catch (error) { + this.log.warn("Provider stop failed after heartbeat timeout", { + error: error instanceof Error ? error.message : String(error), + }); + } + } else { + // Fire-and-forget snapshot so status broadcast isn't delayed. + this.triggerSnapshot("heartbeat_timeout").catch((e) => + this.log.error("Heartbeat snapshot failed", { + error: e instanceof Error ? e : String(e), + }) + ); + } this.wsManager.sendToSandbox({ type: "shutdown" }); } @@ -1047,6 +1065,15 @@ export class SandboxLifecycleManager { } else { await this.triggerSnapshot("inactivity_timeout"); this.wsManager.sendToSandbox({ type: "shutdown" }); + if (this.canStopProviderSandbox()) { + try { + await this.stopProviderSandbox("inactivity_timeout"); + } catch (error) { + this.log.error("Provider stop failed after inactivity timeout", { + error: error instanceof Error ? error.message : String(error), + }); + } + } } this.wsManager.closeSandboxWebSocket(1000, "Inactivity timeout"); diff --git a/packages/control-plane/src/sandbox/provider-name.test.ts b/packages/control-plane/src/sandbox/provider-name.test.ts index 73afea528..91f3f2f50 100644 --- a/packages/control-plane/src/sandbox/provider-name.test.ts +++ b/packages/control-plane/src/sandbox/provider-name.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { resolveSandboxBackendName, isModalSandboxBackend } from "./provider-name"; +import { + resolveSandboxBackendName, + isModalSandboxBackend, + supportsRepoImageBackend, +} from "./provider-name"; describe("resolveSandboxBackendName", () => { it("defaults to modal when undefined", () => { @@ -22,15 +26,21 @@ describe("resolveSandboxBackendName", () => { expect(resolveSandboxBackendName("daytona")).toBe("daytona"); }); + it('returns "vercel" for "vercel"', () => { + expect(resolveSandboxBackendName("vercel")).toBe("vercel"); + }); + it("is case-insensitive", () => { expect(resolveSandboxBackendName("MODAL")).toBe("modal"); expect(resolveSandboxBackendName("Daytona")).toBe("daytona"); expect(resolveSandboxBackendName("DAYTONA")).toBe("daytona"); + expect(resolveSandboxBackendName("VERCEL")).toBe("vercel"); }); it("trims whitespace", () => { expect(resolveSandboxBackendName(" modal ")).toBe("modal"); expect(resolveSandboxBackendName(" daytona ")).toBe("daytona"); + expect(resolveSandboxBackendName(" vercel ")).toBe("vercel"); }); it("throws for unsupported provider", () => { @@ -51,4 +61,20 @@ describe("isModalSandboxBackend", () => { it("returns false for daytona", () => { expect(isModalSandboxBackend("daytona")).toBe(false); }); + + it("returns false for vercel", () => { + expect(isModalSandboxBackend("vercel")).toBe(false); + }); +}); + +describe("supportsRepoImageBackend", () => { + it("returns true for modal and vercel", () => { + expect(supportsRepoImageBackend("modal")).toBe(true); + expect(supportsRepoImageBackend("vercel")).toBe(true); + expect(supportsRepoImageBackend(undefined)).toBe(true); + }); + + it("returns false for daytona", () => { + expect(supportsRepoImageBackend("daytona")).toBe(false); + }); }); diff --git a/packages/control-plane/src/sandbox/provider-name.ts b/packages/control-plane/src/sandbox/provider-name.ts index 32234a137..8c3d4d17d 100644 --- a/packages/control-plane/src/sandbox/provider-name.ts +++ b/packages/control-plane/src/sandbox/provider-name.ts @@ -2,7 +2,7 @@ * Sandbox backend selection utilities. */ -export type SandboxBackendName = "modal" | "daytona"; +export type SandboxBackendName = "modal" | "daytona" | "vercel"; /** * Resolve the configured sandbox backend. @@ -20,9 +20,18 @@ export function resolveSandboxBackendName(value: string | undefined): SandboxBac return "daytona"; } + if (normalized === "vercel") { + return "vercel"; + } + throw new Error(`Unsupported SANDBOX_PROVIDER: ${value}`); } export function isModalSandboxBackend(value: string | undefined): boolean { return resolveSandboxBackendName(value) === "modal"; } + +export function supportsRepoImageBackend(value: string | undefined): boolean { + const backend = resolveSandboxBackendName(value); + return backend === "modal" || backend === "vercel"; +} diff --git a/packages/control-plane/src/sandbox/providers/vercel-provider.test.ts b/packages/control-plane/src/sandbox/providers/vercel-provider.test.ts new file mode 100644 index 000000000..c146b60ec --- /dev/null +++ b/packages/control-plane/src/sandbox/providers/vercel-provider.test.ts @@ -0,0 +1,421 @@ +/** + * Unit tests for VercelSandboxProvider. + */ + +import { describe, expect, it, vi } from "vitest"; +import { VercelSandboxProvider, type VercelProviderConfig } from "./vercel-provider"; +import { SandboxProviderError, type CreateSandboxConfig, type RestoreConfig } from "../provider"; +import type { + VercelCreateSandboxRequest, + VercelCreateSandboxResponse, + VercelRunCommandRequest, + VercelSandboxClient, + VercelSnapshotResponse, +} from "../vercel-client"; + +function createSessionResponse( + sessionId = "vercel-session-1", + routes: VercelCreateSandboxResponse["routes"] = [ + { port: 8080, subdomain: "code", url: "https://code.test" }, + { port: 7680, subdomain: "term", url: "https://term.test" }, + { port: 3000, subdomain: "app", url: "app.test" }, + ] +): VercelCreateSandboxResponse { + return { + sandbox: { + name: "sandbox-456", + currentSessionId: sessionId, + createdAt: 123, + status: "running", + }, + session: { + id: sessionId, + status: "running", + createdAt: 123, + cwd: "/workspace", + timeout: 7200000, + }, + routes, + }; +} + +function createMockClient( + overrides: Partial<{ + createSandbox: (request: VercelCreateSandboxRequest) => Promise; + runCommandAndWait: ( + request: VercelRunCommandRequest + ) => Promise<{ commandId: string; exitCode: number | null }>; + startCommand: ( + request: VercelRunCommandRequest + ) => Promise<{ commandId: string; exitCode: number | null }>; + snapshotSession: (sessionId: string) => Promise; + stopSession: (sessionId: string) => Promise; + deleteSnapshot: (snapshotId: string) => Promise; + }> = {} +): VercelSandboxClient { + return { + createSandbox: vi.fn(async () => createSessionResponse()), + runCommandAndWait: vi.fn(async () => ({ commandId: "cmd-1", exitCode: 0 })), + startCommand: vi.fn(async () => ({ commandId: "cmd-2", exitCode: null })), + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snapshot-1", status: "created", createdAt: 456 }, + session: createSessionResponse().session, + }) + ), + deleteSnapshot: vi.fn(async () => {}), + stopSession: vi.fn(async () => {}), + ...overrides, + } as unknown as VercelSandboxClient; +} + +const providerConfig: VercelProviderConfig = { + scmProvider: "github", + codeServerPasswordSecret: "code-secret", + internalCallbackSecret: "callback-secret", + token: "vercel-token", + teamId: "team-123", + apiBaseUrl: "https://vercel.test/api", + baseSnapshotId: "base-snapshot-1", +}; + +const baseCreateConfig: CreateSandboxConfig = { + sessionId: "session-123", + sandboxId: "sandbox-456", + repoOwner: "testowner", + repoName: "testrepo", + controlPlaneUrl: "https://control-plane.test", + sandboxAuthToken: "auth-token", + provider: "anthropic", + model: "anthropic/claude-sonnet-4-5", +}; + +const baseRestoreConfig: RestoreConfig = { + snapshotImageId: "snapshot-restore-1", + sessionId: "session-123", + sandboxId: "sandbox-456", + repoOwner: "testowner", + repoName: "testrepo", + controlPlaneUrl: "https://control-plane.test", + sandboxAuthToken: "auth-token", + provider: "anthropic", + model: "anthropic/claude-sonnet-4-5", +}; + +describe("VercelSandboxProvider", () => { + it("reports Vercel capabilities", () => { + const provider = new VercelSandboxProvider(createMockClient(), providerConfig); + + expect(provider.name).toBe("vercel"); + expect(provider.capabilities).toEqual({ + supportsSnapshots: true, + supportsRestore: true, + supportsWarm: true, + supportsPersistentResume: false, + supportsExplicitStop: true, + }); + }); + + it("creates a sandbox from the configured base snapshot and launches the entrypoint", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + const result = await provider.createSandbox({ + ...baseCreateConfig, + branch: "feature/vercel", + codeServerEnabled: true, + sandboxSettings: { terminalEnabled: true }, + userEnvVars: { USER_SECRET: "value", SANDBOX_ID: "user-override" }, + mcpServers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }], + agentSlackNotifyEnabled: true, + }); + + const createCall = vi.mocked(client.createSandbox).mock.calls[0][0]; + expect(createCall).toEqual( + expect.objectContaining({ + name: "sandbox-456", + runtime: "node24", + sourceSnapshotId: "base-snapshot-1", + ports: [8080, 7680], + tags: { + openinspect_framework: "open-inspect", + openinspect_session_id: "session-123", + openinspect_repo: "testowner/testrepo", + openinspect_expected_sandbox_id: "sandbox-456", + }, + }) + ); + expect(createCall.env).toEqual( + expect.objectContaining({ + USER_SECRET: "value", + SANDBOX_ID: "sandbox-456", + CONTROL_PLANE_URL: "https://control-plane.test", + SANDBOX_AUTH_TOKEN: "auth-token", + REPO_OWNER: "testowner", + REPO_NAME: "testrepo", + VCS_HOST: "github.com", + VCS_CLONE_USERNAME: "x-access-token", + CODE_SERVER_PASSWORD: expect.any(String), + TERMINAL_ENABLED: "true", + AGENT_SLACK_NOTIFY_ENABLED: "true", + }) + ); + expect(JSON.parse(createCall.env?.SESSION_CONFIG as string)).toEqual({ + session_id: "session-123", + repo_owner: "testowner", + repo_name: "testrepo", + provider: "anthropic", + model: "anthropic/claude-sonnet-4-5", + mcp_servers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }], + branch: "feature/vercel", + }); + expect(vi.mocked(client.runCommandAndWait)).not.toHaveBeenCalled(); + expect(vi.mocked(client.startCommand)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "vercel-session-1", + command: "sudo", + args: ["-E", "/usr/bin/python3.12", "-m", "sandbox_runtime.entrypoint"], + cwd: "/workspace", + }), + undefined + ); + expect(result).toEqual( + expect.objectContaining({ + sandboxId: "sandbox-456", + providerObjectId: "vercel-session-1", + status: "warming", + createdAt: 123, + codeServerUrl: "https://code.test", + codeServerPassword: expect.any(String), + ttydUrl: "https://term.test", + }) + ); + }); + + it("uses a repo image snapshot and writes tunnel URLs for extra exposed ports", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + const result = await provider.createSandbox({ + ...baseCreateConfig, + repoImageId: "repo-snapshot-1", + repoImageSha: "abc123", + codeServerEnabled: true, + sandboxSettings: { terminalEnabled: true, tunnelPorts: [8080, 3000, 5173] }, + }); + + const createCall = vi.mocked(client.createSandbox).mock.calls[0][0]; + expect(createCall.sourceSnapshotId).toBe("repo-snapshot-1"); + expect(createCall.ports).toEqual([8080, 7680, 3000, 5173]); + expect(createCall.env).toEqual( + expect.objectContaining({ + FROM_REPO_IMAGE: "true", + REPO_IMAGE_SHA: "abc123", + EXPECTED_TUNNEL_PORTS: "3000,5173", + }) + ); + expect(vi.mocked(client.runCommandAndWait)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "vercel-session-1", + command: "sudo", + args: expect.arrayContaining([ + "/usr/bin/python3.12", + "-c", + expect.stringContaining("TUNNEL_3000"), + ]), + }), + undefined + ); + expect(result.tunnelUrls).toEqual({ + "3000": "https://app.test", + }); + }); + + it("bootstraps the runtime when no source snapshot is available", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, { + ...providerConfig, + baseSnapshotId: undefined, + runtimeRepoUrl: "https://github.com/example/runtime.git", + runtimeRepoRef: "release", + }); + + await provider.createSandbox(baseCreateConfig); + + const bootstrapScript = vi.mocked(client.runCommandAndWait).mock.calls[0][0].args?.[1] ?? ""; + expect(vi.mocked(client.runCommandAndWait)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "vercel-session-1", + command: "bash", + args: ["-lc", expect.stringContaining("RUNTIME_REPO_REF='release'")], + }), + undefined + ); + expect(bootstrapScript).toContain( + "sudo dnf install -y dnf-plugins-core git gcc gcc-c++ make ca-certificates openssh-clients jq unzip tar gzip python3.12 python3.12-pip python3.12-devel" + ); + expect(bootstrapScript).toContain("sudo dnf install -y ffmpeg || true"); + expect(bootstrapScript).toContain("sudo ln -sf /usr/bin/python3.12 /usr/local/bin/python"); + expect(bootstrapScript).toContain("sudo /usr/bin/python3.12 -m ensurepip --upgrade"); + }); + + it("restores from a session snapshot and sets restore mode env vars", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + const result = await provider.restoreFromSnapshot({ + ...baseRestoreConfig, + codeServerEnabled: true, + }); + + const createCall = vi.mocked(client.createSandbox).mock.calls[0][0]; + expect(createCall.sourceSnapshotId).toBe("snapshot-restore-1"); + expect(createCall.env).toEqual(expect.objectContaining({ RESTORED_FROM_SNAPSHOT: "true" })); + expect(result).toEqual( + expect.objectContaining({ + success: true, + sandboxId: "sandbox-456", + providerObjectId: "vercel-session-1", + codeServerUrl: "https://code.test", + }) + ); + }); + + it("takes and deletes Vercel snapshots", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, { + ...providerConfig, + snapshotExpirationMs: 60_000, + }); + + const snapshot = await provider.takeSnapshot({ + providerObjectId: "vercel-session-1", + sessionId: "session-123", + reason: "inactivity_timeout", + }); + await provider.deleteProviderImage("snapshot-1"); + + expect(vi.mocked(client.snapshotSession)).toHaveBeenCalledWith( + "vercel-session-1", + { expirationMs: 60_000 }, + undefined + ); + expect(snapshot).toEqual({ success: true, imageId: "snapshot-1" }); + expect(vi.mocked(client.deleteSnapshot)).toHaveBeenCalledWith("snapshot-1"); + }); + + it("stops a Vercel sandbox session", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + const correlation = { + trace_id: "trace-1", + request_id: "request-1", + session_id: "session-123", + sandbox_id: "sandbox-456", + }; + + const result = await provider.stopSandbox({ + providerObjectId: "vercel-session-1", + sessionId: "session-123", + reason: "inactivity_timeout", + correlation, + }); + + expect(result).toEqual({ success: true }); + expect(vi.mocked(client.stopSession)).toHaveBeenCalledWith("vercel-session-1", correlation); + }); + + it("reports a failed snapshot status without throwing", async () => { + const client = createMockClient({ + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snapshot-1", status: "failed", createdAt: 456 }, + session: createSessionResponse().session, + }) + ), + }); + const provider = new VercelSandboxProvider(client, providerConfig); + + const result = await provider.takeSnapshot({ + providerObjectId: "vercel-session-1", + sessionId: "session-123", + reason: "execution_complete", + }); + + expect(result).toEqual({ success: false, error: "Snapshot status was failed" }); + }); + + it("triggers a repo image build sandbox and launches the build coordinator", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + const result = await provider.triggerRepoImageBuild({ + buildId: "build-123", + repoOwner: "testowner", + repoName: "testrepo", + defaultBranch: "main", + callbackUrl: "https://control-plane.test/repo-images/build-complete", + userEnvVars: { USER_SECRET: "value" }, + cloneToken: "clone-token", + }); + + const createCall = vi.mocked(client.createSandbox).mock.calls[0][0]; + expect(createCall).toEqual( + expect.objectContaining({ + runtime: "node24", + timeoutMs: 1800 * 1000, + sourceSnapshotId: "base-snapshot-1", + tags: { + openinspect_framework: "open-inspect", + openinspect_kind: "repo-image-build", + openinspect_build_id: "build-123", + openinspect_repo: "testowner/testrepo", + }, + }) + ); + expect(createCall.env).toEqual( + expect.objectContaining({ + USER_SECRET: "value", + IMAGE_BUILD_MODE: "true", + SESSION_CONFIG: JSON.stringify({ branch: "main" }), + OI_VERCEL_BUILD_ID: "build-123", + OI_VERCEL_CALLBACK_URL: "https://control-plane.test/repo-images/build-complete", + OI_INTERNAL_CALLBACK_SECRET: "callback-secret", + OI_VERCEL_TOKEN: "vercel-token", + OI_VERCEL_TEAM_ID: "team-123", + OI_VERCEL_API_BASE_URL: "https://vercel.test/api", + VCS_CLONE_TOKEN: "clone-token", + GITHUB_TOKEN: "clone-token", + GITHUB_APP_TOKEN: "clone-token", + OI_GITHUB_TOKEN_IS_FALLBACK: "1", + }) + ); + expect(vi.mocked(client.startCommand)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "vercel-session-1", + command: "/usr/bin/python3.12", + args: ["-c", expect.stringContaining("def snapshot_session")], + cwd: "/workspace", + env: { OI_VERCEL_SESSION_ID: "vercel-session-1" }, + }) + ); + expect(result).toEqual({ buildId: "build-123", status: "building" }); + }); + + it("requires an internal callback secret for repo image builds", async () => { + const provider = new VercelSandboxProvider(createMockClient(), { + ...providerConfig, + internalCallbackSecret: undefined, + }); + + await expect( + provider.triggerRepoImageBuild({ + buildId: "build-123", + repoOwner: "testowner", + repoName: "testrepo", + defaultBranch: "main", + callbackUrl: "https://control-plane.test/repo-images/build-complete", + }) + ).rejects.toThrow(SandboxProviderError); + }); +}); diff --git a/packages/control-plane/src/sandbox/providers/vercel-provider.ts b/packages/control-plane/src/sandbox/providers/vercel-provider.ts new file mode 100644 index 000000000..aabb262cc --- /dev/null +++ b/packages/control-plane/src/sandbox/providers/vercel-provider.ts @@ -0,0 +1,744 @@ +/** + * Vercel Sandbox provider implementation. + */ + +import { computeHmacHex, MAX_TUNNEL_PORTS, type SandboxSettings } from "@open-inspect/shared"; +import { createLogger } from "../../logger"; +import type { CorrelationContext } from "../../logger"; +import type { SourceControlProviderName } from "../../source-control"; +import { + DEFAULT_SANDBOX_TIMEOUT_SECONDS, + SandboxProviderError, + type CreateSandboxConfig, + type CreateSandboxResult, + type RestoreConfig, + type RestoreResult, + type SandboxProvider, + type SandboxProviderCapabilities, + type SnapshotConfig, + type SnapshotResult, + type StopConfig, + type StopResult, +} from "../provider"; +import type { + VercelCreateSandboxResponse, + VercelSandboxClient, + VercelSandboxRoute, +} from "../vercel-client"; +import { VercelSandboxApiError } from "../vercel-client"; +import { + DEFAULT_VERCEL_RUNTIME, + DEFAULT_VERCEL_RUNTIME_REPO_REF, + DEFAULT_VERCEL_RUNTIME_REPO_URL, + VERCEL_PYTHON_BIN, + buildVercelBootstrapScript, +} from "../vercel-bootstrap"; + +const log = createLogger("vercel-provider"); + +const CODE_SERVER_PORT = 8080; +const TTYD_PROXY_PORT = 7680; +const TUNNEL_ENV_FILE_PATH = "/workspace/.tunnels.env"; +const EXPECTED_TUNNEL_PORTS_ENV_VAR = "EXPECTED_TUNNEL_PORTS"; +const DEFAULT_SNAPSHOT_EXPIRATION_MS = 0; +const BUILD_TIMEOUT_SECONDS = 1800; + +export interface VercelProviderConfig { + scmProvider: SourceControlProviderName; + runtimeRepoUrl?: string; + runtimeRepoRef?: string; + baseSnapshotId?: string; + runtime?: string; + snapshotExpirationMs?: number; + codeServerPasswordSecret: string; + internalCallbackSecret?: string; + apiBaseUrl?: string; + token: string; + teamId?: string; +} + +export interface TriggerVercelRepoImageBuildConfig { + buildId: string; + repoOwner: string; + repoName: string; + defaultBranch: string; + callbackUrl: string; + userEnvVars?: Record; + cloneToken?: string; + correlation?: CorrelationContext; +} + +export interface TriggerVercelRepoImageBuildResult { + buildId: string; + status: string; +} + +export class VercelSandboxProvider implements SandboxProvider { + readonly name = "vercel"; + + readonly capabilities: SandboxProviderCapabilities = { + supportsSnapshots: true, + supportsRestore: true, + supportsWarm: true, + supportsPersistentResume: false, + supportsExplicitStop: true, + }; + + constructor( + private readonly client: VercelSandboxClient, + private readonly providerConfig: VercelProviderConfig + ) {} + + async createSandbox(config: CreateSandboxConfig): Promise { + try { + const env = await this.buildEnvVars(config, { + fromRepoImage: !!config.repoImageId, + repoImageSha: config.repoImageSha ?? undefined, + }); + const ports = collectExposedPorts( + config.codeServerEnabled, + config.sandboxSettings + ).allExposedPorts; + const sourceSnapshotId = config.repoImageId || this.providerConfig.baseSnapshotId; + + const created = await this.client.createSandbox( + { + name: config.sandboxId, + runtime: this.providerConfig.runtime || DEFAULT_VERCEL_RUNTIME, + timeoutMs: (config.timeoutSeconds ?? DEFAULT_SANDBOX_TIMEOUT_SECONDS) * 1000, + ports, + env, + tags: this.buildTags(config), + sourceSnapshotId: sourceSnapshotId || undefined, + }, + config.correlation + ); + + if (!sourceSnapshotId) { + await this.bootstrapRuntime(created.session.id, config.correlation); + } + + const access = await this.prepareSandboxAccess( + created, + config.sandboxId, + config.codeServerEnabled, + config.sandboxSettings, + config.correlation + ); + + await this.launchEntrypoint(created.session.id, {}, config.correlation); + + return { + sandboxId: config.sandboxId, + providerObjectId: created.session.id, + status: "warming", + createdAt: created.session.createdAt || Date.now(), + codeServerUrl: access.codeServerUrl, + codeServerPassword: access.codeServerPassword, + ttydUrl: access.ttydUrl, + tunnelUrls: access.tunnelUrls, + }; + } catch (error) { + throw this.classifyError("Failed to create Vercel sandbox", error); + } + } + + async restoreFromSnapshot(config: RestoreConfig): Promise { + try { + const env = await this.buildEnvVars(config, { restoredFromSnapshot: true }); + const ports = collectExposedPorts( + config.codeServerEnabled, + config.sandboxSettings + ).allExposedPorts; + + const created = await this.client.createSandbox( + { + name: config.sandboxId, + runtime: this.providerConfig.runtime || DEFAULT_VERCEL_RUNTIME, + timeoutMs: (config.timeoutSeconds ?? DEFAULT_SANDBOX_TIMEOUT_SECONDS) * 1000, + ports, + env, + tags: this.buildTags(config), + sourceSnapshotId: config.snapshotImageId, + }, + config.correlation + ); + + const access = await this.prepareSandboxAccess( + created, + config.sandboxId, + config.codeServerEnabled, + config.sandboxSettings, + config.correlation + ); + + await this.launchEntrypoint(created.session.id, {}, config.correlation); + + return { + success: true, + sandboxId: config.sandboxId, + providerObjectId: created.session.id, + codeServerUrl: access.codeServerUrl, + codeServerPassword: access.codeServerPassword, + ttydUrl: access.ttydUrl, + tunnelUrls: access.tunnelUrls, + }; + } catch (error) { + if (error instanceof SandboxProviderError) throw error; + throw this.classifyError("Failed to restore Vercel sandbox from snapshot", error); + } + } + + async takeSnapshot(config: SnapshotConfig): Promise { + try { + const snapshot = await this.client.snapshotSession( + config.providerObjectId, + { + expirationMs: this.providerConfig.snapshotExpirationMs ?? DEFAULT_SNAPSHOT_EXPIRATION_MS, + }, + config.correlation + ); + + if (snapshot.snapshot.status !== "created") { + return { + success: false, + error: `Snapshot status was ${snapshot.snapshot.status}`, + }; + } + + return { success: true, imageId: snapshot.snapshot.id }; + } catch (error) { + if (error instanceof SandboxProviderError) throw error; + throw this.classifyError("Failed to snapshot Vercel sandbox", error); + } + } + + async stopSandbox(config: StopConfig): Promise { + try { + await this.client.stopSession(config.providerObjectId, config.correlation); + return { success: true }; + } catch (error) { + if (error instanceof VercelSandboxApiError && error.status === 404) { + return { success: true }; + } + if (error instanceof SandboxProviderError) throw error; + throw this.classifyError("Failed to stop Vercel sandbox", error); + } + } + + async triggerRepoImageBuild( + config: TriggerVercelRepoImageBuildConfig + ): Promise { + if (!this.providerConfig.internalCallbackSecret) { + throw new SandboxProviderError( + "INTERNAL_CALLBACK_SECRET is required for Vercel repo image builds", + "permanent" + ); + } + + try { + const sandboxName = `build-${config.repoOwner}-${config.repoName}-${Date.now()}`; + const env = await this.buildBuildEnvVars(config); + const created = await this.client.createSandbox( + { + name: sandboxName, + runtime: this.providerConfig.runtime || DEFAULT_VERCEL_RUNTIME, + timeoutMs: BUILD_TIMEOUT_SECONDS * 1000, + env, + tags: { + openinspect_framework: "open-inspect", + openinspect_kind: "repo-image-build", + openinspect_build_id: config.buildId, + openinspect_repo: `${config.repoOwner}/${config.repoName}`, + }, + sourceSnapshotId: this.providerConfig.baseSnapshotId, + }, + config.correlation + ); + + if (!this.providerConfig.baseSnapshotId) { + await this.bootstrapRuntime(created.session.id, config.correlation); + } + + await this.launchBuildCoordinator(created.session.id, { + OI_VERCEL_SESSION_ID: created.session.id, + }); + + log.info("vercel.repo_image_build_triggered", { + build_id: config.buildId, + repo_owner: config.repoOwner, + repo_name: config.repoName, + session_id: created.session.id, + sandbox_name: sandboxName, + }); + + return { buildId: config.buildId, status: "building" }; + } catch (error) { + if (error instanceof SandboxProviderError) throw error; + throw this.classifyError("Failed to trigger Vercel repo image build", error); + } + } + + async deleteProviderImage(providerImageId: string): Promise { + try { + await this.client.deleteSnapshot(providerImageId); + } catch (error) { + throw this.classifyError("Failed to delete Vercel snapshot", error); + } + } + + private async buildEnvVars( + config: CreateSandboxConfig | RestoreConfig, + mode: { + restoredFromSnapshot?: boolean; + fromRepoImage?: boolean; + repoImageSha?: string; + } + ): Promise> { + const envVars: Record = { ...(config.userEnvVars ?? {}) }; + const sessionConfig: Record = { + session_id: config.sessionId, + repo_owner: config.repoOwner, + repo_name: config.repoName, + provider: config.provider, + model: config.model, + mcp_servers: config.mcpServers, + }; + if (config.branch) sessionConfig.branch = config.branch; + + Object.assign(envVars, { + HOME: "/root", + NODE_ENV: "development", + PATH: "/root/.bun/bin:/usr/local/bin:/usr/bin:/bin:/vercel/runtimes/node24/bin", + PYTHONPATH: "/app", + PYTHONUNBUFFERED: "1", + NODE_PATH: "/usr/lib/node_modules:/usr/local/lib/node_modules", + SANDBOX_ID: config.sandboxId, + CONTROL_PLANE_URL: config.controlPlaneUrl, + SANDBOX_AUTH_TOKEN: config.sandboxAuthToken, + REPO_OWNER: config.repoOwner, + REPO_NAME: config.repoName, + SESSION_CONFIG: JSON.stringify(sessionConfig), + }); + + this.injectScmEnvVars(envVars); + + if (mode.restoredFromSnapshot) envVars.RESTORED_FROM_SNAPSHOT = "true"; + if (mode.fromRepoImage) { + envVars.FROM_REPO_IMAGE = "true"; + envVars.REPO_IMAGE_SHA = mode.repoImageSha ?? ""; + } + if (config.codeServerEnabled) { + envVars.CODE_SERVER_PASSWORD = await this.deriveCodeServerPassword(config.sandboxId); + } + if (config.sandboxSettings?.terminalEnabled) { + envVars.TERMINAL_ENABLED = "true"; + } + if (config.agentSlackNotifyEnabled) { + envVars.AGENT_SLACK_NOTIFY_ENABLED = "true"; + } + + const tunnelPorts = collectExposedPorts( + config.codeServerEnabled, + config.sandboxSettings + ).extraTunnelPorts; + if (tunnelPorts.length > 0) { + envVars[EXPECTED_TUNNEL_PORTS_ENV_VAR] = tunnelPorts.join(","); + } + + return envVars; + } + + private async buildBuildEnvVars( + config: TriggerVercelRepoImageBuildConfig + ): Promise> { + const envVars: Record = { ...(config.userEnvVars ?? {}) }; + Object.assign(envVars, { + HOME: "/root", + NODE_ENV: "development", + PATH: "/root/.bun/bin:/usr/local/bin:/usr/bin:/bin:/vercel/runtimes/node24/bin", + PYTHONPATH: "/app", + PYTHONUNBUFFERED: "1", + NODE_PATH: "/usr/lib/node_modules:/usr/local/lib/node_modules", + SANDBOX_ID: `build-${config.repoOwner}-${config.repoName}`, + REPO_OWNER: config.repoOwner, + REPO_NAME: config.repoName, + IMAGE_BUILD_MODE: "true", + SESSION_CONFIG: JSON.stringify({ branch: config.defaultBranch }), + OI_VERCEL_BUILD_ID: config.buildId, + OI_VERCEL_CALLBACK_URL: config.callbackUrl, + OI_INTERNAL_CALLBACK_SECRET: this.providerConfig.internalCallbackSecret ?? "", + OI_VERCEL_TOKEN: this.providerConfig.token, + OI_VERCEL_TEAM_ID: this.providerConfig.teamId ?? "", + OI_VERCEL_API_BASE_URL: this.providerConfig.apiBaseUrl ?? "https://vercel.com/api", + OI_VERCEL_SNAPSHOT_EXPIRATION_MS: String( + this.providerConfig.snapshotExpirationMs ?? DEFAULT_SNAPSHOT_EXPIRATION_MS + ), + }); + + this.injectScmEnvVars(envVars, config.cloneToken); + return envVars; + } + + private injectScmEnvVars(envVars: Record, cloneToken?: string): void { + if (this.providerConfig.scmProvider === "gitlab") { + envVars.VCS_HOST = "gitlab.com"; + envVars.VCS_CLONE_USERNAME = "oauth2"; + } else if (this.providerConfig.scmProvider === "bitbucket") { + envVars.VCS_HOST = "bitbucket.org"; + envVars.VCS_CLONE_USERNAME = "x-token-auth"; + } else { + envVars.VCS_HOST = "github.com"; + envVars.VCS_CLONE_USERNAME = "x-access-token"; + } + + if (cloneToken) { + envVars.VCS_CLONE_TOKEN = cloneToken; + if (this.providerConfig.scmProvider === "github") { + const hasUserGithubCliToken = Boolean( + envVars.GH_TOKEN || envVars.GITHUB_TOKEN || envVars.GITHUB_APP_TOKEN + ); + if (!hasUserGithubCliToken) { + envVars.GITHUB_TOKEN = cloneToken; + envVars.GITHUB_APP_TOKEN = cloneToken; + envVars.OI_GITHUB_TOKEN_IS_FALLBACK = "1"; + } + } + } + } + + private buildTags(config: CreateSandboxConfig | RestoreConfig): Record { + return { + openinspect_framework: "open-inspect", + openinspect_session_id: config.sessionId, + openinspect_repo: `${config.repoOwner}/${config.repoName}`, + openinspect_expected_sandbox_id: config.sandboxId, + }; + } + + private async prepareSandboxAccess( + created: VercelCreateSandboxResponse, + logicalSandboxId: string, + codeServerEnabled: boolean | undefined, + sandboxSettings: SandboxSettings | undefined, + correlation?: CreateSandboxConfig["correlation"] + ): Promise<{ + codeServerUrl?: string; + codeServerPassword?: string; + ttydUrl?: string; + tunnelUrls?: Record; + }> { + const routeByPort = new Map(created.routes.map((route) => [route.port, route])); + const { extraTunnelPorts } = collectExposedPorts(codeServerEnabled, sandboxSettings); + const tunnelUrls: Record = {}; + + for (const port of extraTunnelPorts) { + const url = routeToUrl(routeByPort.get(port)); + if (url) tunnelUrls[String(port)] = url; + } + + if (Object.keys(tunnelUrls).length > 0) { + await this.writeTunnelEnvFile(created.session.id, tunnelUrls, correlation); + } + + const codeServerUrl = codeServerEnabled + ? routeToUrl(routeByPort.get(CODE_SERVER_PORT)) + : undefined; + const ttydUrl = sandboxSettings?.terminalEnabled + ? routeToUrl(routeByPort.get(TTYD_PROXY_PORT)) + : undefined; + + return { + codeServerUrl, + codeServerPassword: codeServerEnabled + ? await this.deriveCodeServerPassword(logicalSandboxId) + : undefined, + ttydUrl, + tunnelUrls: Object.keys(tunnelUrls).length > 0 ? tunnelUrls : undefined, + }; + } + + private async writeTunnelEnvFile( + sessionId: string, + tunnelUrls: Record, + correlation?: CreateSandboxConfig["correlation"] + ): Promise { + const content = + Object.entries(tunnelUrls) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([port, url]) => `TUNNEL_${port}=${url}`) + .join("\n") + "\n"; + + const script = [ + "from pathlib import Path", + `Path(${JSON.stringify(TUNNEL_ENV_FILE_PATH)}).write_text(${JSON.stringify(content)})`, + ].join("\n"); + + await this.client.runCommandAndWait( + { + sessionId, + command: "sudo", + args: ["-E", VERCEL_PYTHON_BIN, "-c", script], + timeoutMs: 30_000, + }, + correlation + ); + } + + private async bootstrapRuntime( + sessionId: string, + correlation?: CreateSandboxConfig["correlation"] + ): Promise { + const script = buildVercelBootstrapScript({ + runtimeRepoUrl: this.providerConfig.runtimeRepoUrl || DEFAULT_VERCEL_RUNTIME_REPO_URL, + runtimeRepoRef: this.providerConfig.runtimeRepoRef || DEFAULT_VERCEL_RUNTIME_REPO_REF, + }); + const result = await this.client.runCommandAndWait( + { + sessionId, + command: "bash", + args: ["-lc", script], + timeoutMs: 20 * 60 * 1000, + }, + correlation + ); + if (result.exitCode !== 0) { + throw new Error(`Vercel runtime bootstrap failed with exit code ${result.exitCode}`); + } + } + + private async launchEntrypoint( + sessionId: string, + env: Record, + correlation?: CreateSandboxConfig["correlation"] + ): Promise { + await this.client.startCommand( + { + sessionId, + command: "sudo", + args: ["-E", VERCEL_PYTHON_BIN, "-m", "sandbox_runtime.entrypoint"], + cwd: "/workspace", + env, + }, + correlation + ); + } + + private async launchBuildCoordinator( + sessionId: string, + env: Record + ): Promise { + await this.client.startCommand({ + sessionId, + command: VERCEL_PYTHON_BIN, + args: ["-c", buildCoordinatorScript()], + cwd: "/workspace", + env, + }); + } + + private async deriveCodeServerPassword(sandboxId: string): Promise { + const digest = await computeHmacHex( + `code-server:${sandboxId}`, + this.providerConfig.codeServerPasswordSecret + ); + return digest.slice(0, 32); + } + + private classifyError(message: string, error: unknown): SandboxProviderError { + if (error instanceof VercelSandboxApiError) { + return SandboxProviderError.fromFetchError( + `${message}: ${error.message}`, + error, + error.status + ); + } + return SandboxProviderError.fromFetchError( + `${message}: ${error instanceof Error ? error.message : String(error)}`, + error + ); + } +} + +function collectExposedPorts( + codeServerEnabled: boolean | undefined, + sandboxSettings: SandboxSettings | undefined +): { allExposedPorts: number[]; extraTunnelPorts: number[] } { + const reserved = new Set(); + const exposed: number[] = []; + + if (codeServerEnabled) { + exposed.push(CODE_SERVER_PORT); + reserved.add(CODE_SERVER_PORT); + } + if (sandboxSettings?.terminalEnabled) { + exposed.push(TTYD_PROXY_PORT); + reserved.add(TTYD_PROXY_PORT); + } + + const extraTunnelPorts = resolveTunnelPorts(sandboxSettings?.tunnelPorts).filter( + (port) => !reserved.has(port) + ); + exposed.push(...extraTunnelPorts); + + return { allExposedPorts: exposed, extraTunnelPorts }; +} + +function resolveTunnelPorts(rawPorts: number[] | undefined): number[] { + if (!rawPorts) return []; + const ports: number[] = []; + for (const value of rawPorts) { + if (Number.isInteger(value) && value >= 1 && value <= 65535) { + ports.push(value); + } + if (ports.length >= MAX_TUNNEL_PORTS) break; + } + return ports; +} + +function routeToUrl(route: VercelSandboxRoute | undefined): string | undefined { + if (!route) return undefined; + if (route.url) return route.url.startsWith("http") ? route.url : `https://${route.url}`; + return `https://${route.subdomain}.vercel.run`; +} + +function buildCoordinatorScript(): string { + return String.raw` +import hashlib +import hmac +import json +import os +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + + +def callback(path_suffix, payload): + callback_url = os.environ["OI_VERCEL_CALLBACK_URL"] + if path_suffix: + callback_url = callback_url.replace("/build-complete", path_suffix) + secret = os.environ["OI_INTERNAL_CALLBACK_SECRET"] + timestamp = str(int(time.time() * 1000)) + signature = hmac.new(secret.encode(), timestamp.encode(), hashlib.sha256).hexdigest() + body = json.dumps(payload).encode() + req = urllib.request.Request( + callback_url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {timestamp}.{signature}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + resp.read() + + +def snapshot_session(): + api_base = os.environ.get("OI_VERCEL_API_BASE_URL", "https://vercel.com/api").rstrip("/") + session_id = os.environ["OI_VERCEL_SESSION_ID"] + team_id = os.environ.get("OI_VERCEL_TEAM_ID", "") + token = os.environ["OI_VERCEL_TOKEN"] + expiration = int(os.environ.get("OI_VERCEL_SNAPSHOT_EXPIRATION_MS", "0")) + query = f"?teamId={urllib.parse.quote(team_id)}" if team_id else "" + body = json.dumps({"expiration": expiration}).encode() + req = urllib.request.Request( + f"{api_base}/v2/sandboxes/sessions/{urllib.parse.quote(session_id)}/snapshot{query}", + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "open-inspect/vercel-build-coordinator", + }, + ) + with urllib.request.urlopen(req, timeout=600) as resp: + data = json.loads(resp.read().decode() or "{}") + return data["snapshot"]["id"] + + +def read_head_sha(): + repo_name = os.environ.get("REPO_NAME", "") + if not repo_name: + return "" + try: + return subprocess.check_output( + ["git", "-C", f"/workspace/{repo_name}", "rev-parse", "HEAD"], + text=True, + stderr=subprocess.DEVNULL, + timeout=10, + ).strip() + except Exception: + return "" + + +def main(): + started = time.time() + build_id = os.environ["OI_VERCEL_BUILD_ID"] + head_sha = "" + last_error = "" + proc = subprocess.Popen( + ["sudo", "-E", "/usr/bin/python3.12", "-m", "sandbox_runtime.entrypoint"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=os.environ.copy(), + ) + try: + assert proc.stdout is not None + for line in proc.stdout: + print(line, end="", flush=True) + try: + entry = json.loads(line) + except Exception: + continue + event = entry.get("event") + if event == "git.sync_complete" and entry.get("head_sha"): + head_sha = entry["head_sha"] + elif event in {"setup.failed", "setup.timeout", "setup.error", "supervisor.error", "supervisor.fatal"}: + last_error = str(entry.get("output_tail") or entry.get("error_message") or entry.get("error") or event)[-500:] + elif event == "image_build.complete": + if not head_sha: + head_sha = read_head_sha() + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) + snapshot_id = snapshot_session() + callback("", { + "build_id": build_id, + "provider_image_id": snapshot_id, + "base_sha": head_sha, + "build_duration_seconds": round(time.time() - started, 3), + }) + return + exit_code = proc.wait() + callback("/build-failed", { + "build_id": build_id, + "error": last_error or f"build entrypoint exited before completion (exit_code={exit_code})", + }) + except Exception as exc: + try: + callback("/build-failed", {"build_id": build_id, "error": str(exc)[-500:]}) + finally: + raise + + +if __name__ == "__main__": + main() +`; +} + +export function createVercelProvider( + client: VercelSandboxClient, + providerConfig: VercelProviderConfig +): VercelSandboxProvider { + return new VercelSandboxProvider(client, providerConfig); +} diff --git a/packages/control-plane/src/sandbox/vercel-base-snapshot.test.ts b/packages/control-plane/src/sandbox/vercel-base-snapshot.test.ts new file mode 100644 index 000000000..30cc7d2be --- /dev/null +++ b/packages/control-plane/src/sandbox/vercel-base-snapshot.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildBaseSnapshotSandboxName, buildVercelBaseSnapshot } from "./vercel-base-snapshot"; +import type { + VercelCreateSandboxResponse, + VercelRunCommandRequest, + VercelSandboxClient, + VercelSnapshotResponse, +} from "./vercel-client"; + +function createSessionResponse(sessionId = "session-1"): VercelCreateSandboxResponse { + return { + sandbox: { + name: "base-build", + currentSessionId: sessionId, + createdAt: 123, + status: "running", + }, + session: { + id: sessionId, + status: "running", + createdAt: 123, + cwd: "/workspace", + timeout: 1800000, + }, + routes: [], + }; +} + +function createMockClient( + overrides: Partial<{ + createSandbox: () => Promise; + runCommandAndWait: ( + request: VercelRunCommandRequest + ) => Promise<{ commandId: string; exitCode: number | null }>; + snapshotSession: (sessionId: string) => Promise; + stopSession: (sessionId: string) => Promise; + }> = {} +): VercelSandboxClient { + return { + createSandbox: vi.fn(async () => createSessionResponse()), + runCommandAndWait: vi.fn(async () => ({ commandId: "cmd-1", exitCode: 0 })), + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snap-base-1", status: "created", createdAt: 456 }, + session: createSessionResponse().session, + }) + ), + stopSession: vi.fn(async () => {}), + ...overrides, + } as unknown as VercelSandboxClient; +} + +describe("buildVercelBaseSnapshot", () => { + it("bootstraps, snapshots, and stops a temporary Vercel sandbox", async () => { + const client = createMockClient(); + + const result = await buildVercelBaseSnapshot(client, { + runtime: "node24", + runtimeRepoUrl: "https://github.com/example/runtime.git", + runtimeRepoRef: "release", + sourceVersion: "abcdef1234567890", + now: 1780000000000, + }); + + expect(vi.mocked(client.createSandbox)).toHaveBeenCalledWith( + expect.objectContaining({ + name: "openinspect-base-abcdef123456-1780000000000", + runtime: "node24", + timeoutMs: 30 * 60 * 1000, + ports: [], + tags: expect.objectContaining({ + openinspect_framework: "open-inspect", + openinspect_kind: "base-runtime-build", + openinspect_runtime_ref: "release", + openinspect_source_version: "abcdef1234567890", + }), + }), + undefined + ); + expect(vi.mocked(client.runCommandAndWait)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + command: "bash", + args: ["-lc", expect.stringContaining("RUNTIME_REPO_REF='release'")], + timeoutMs: 20 * 60 * 1000, + }), + undefined + ); + expect(vi.mocked(client.snapshotSession)).toHaveBeenCalledWith( + "session-1", + { expirationMs: 0 }, + undefined + ); + expect(vi.mocked(client.stopSession)).toHaveBeenCalledWith("session-1", undefined); + expect(result).toEqual({ + snapshotId: "snap-base-1", + sandboxName: "openinspect-base-abcdef123456-1780000000000", + sessionId: "session-1", + }); + }); + + it("stops the temporary sandbox when bootstrap fails", async () => { + const client = createMockClient({ + runCommandAndWait: vi.fn(async () => ({ commandId: "cmd-1", exitCode: 1 })), + }); + + await expect(buildVercelBaseSnapshot(client)).rejects.toThrow( + "Vercel base runtime bootstrap failed" + ); + expect(vi.mocked(client.stopSession)).toHaveBeenCalledWith("session-1", undefined); + expect(vi.mocked(client.snapshotSession)).not.toHaveBeenCalled(); + }); +}); + +describe("buildBaseSnapshotSandboxName", () => { + it("normalizes dynamic build names", () => { + expect( + buildBaseSnapshotSandboxName({ + prefix: "Open Inspect Base", + sourceVersion: "Feature/ABC_def-1234567890", + now: 1780000000000, + }) + ).toBe("open-inspect-base-feature-abc-1780000000000"); + }); +}); diff --git a/packages/control-plane/src/sandbox/vercel-base-snapshot.ts b/packages/control-plane/src/sandbox/vercel-base-snapshot.ts new file mode 100644 index 000000000..46c2f482f --- /dev/null +++ b/packages/control-plane/src/sandbox/vercel-base-snapshot.ts @@ -0,0 +1,134 @@ +/** + * Managed Vercel base-runtime snapshot builder. + * + * This creates a temporary Vercel Sandbox, applies the shared runtime + * bootstrap, snapshots the resulting filesystem, and stops the temporary + * session. The returned immutable snapshot ID can be passed to Terraform as + * VERCEL_BASE_SNAPSHOT_ID for the control plane deployment. + */ + +import { createLogger } from "../logger"; +import type { CorrelationContext } from "../logger"; +import { + DEFAULT_VERCEL_RUNTIME, + DEFAULT_VERCEL_RUNTIME_REPO_REF, + DEFAULT_VERCEL_RUNTIME_REPO_URL, + buildVercelBootstrapScript, +} from "./vercel-bootstrap"; +import type { VercelSandboxClient } from "./vercel-client"; + +const log = createLogger("vercel-base-snapshot"); + +const DEFAULT_BASE_SNAPSHOT_NAME_PREFIX = "openinspect-base"; +const DEFAULT_BASE_SNAPSHOT_TIMEOUT_MS = 30 * 60 * 1000; +const BOOTSTRAP_TIMEOUT_MS = 20 * 60 * 1000; + +export interface BuildVercelBaseSnapshotConfig { + runtime?: string; + runtimeRepoUrl?: string; + runtimeRepoRef?: string; + sourceVersion?: string; + namePrefix?: string; + now?: number; + correlation?: CorrelationContext; +} + +export interface BuildVercelBaseSnapshotResult { + snapshotId: string; + sandboxName: string; + sessionId: string; +} + +export async function buildVercelBaseSnapshot( + client: VercelSandboxClient, + config: BuildVercelBaseSnapshotConfig = {} +): Promise { + const runtimeRepoUrl = config.runtimeRepoUrl || DEFAULT_VERCEL_RUNTIME_REPO_URL; + const runtimeRepoRef = config.runtimeRepoRef || DEFAULT_VERCEL_RUNTIME_REPO_REF; + const sandboxName = buildBaseSnapshotSandboxName({ + prefix: config.namePrefix || DEFAULT_BASE_SNAPSHOT_NAME_PREFIX, + sourceVersion: config.sourceVersion, + now: config.now ?? Date.now(), + }); + + const created = await client.createSandbox( + { + name: sandboxName, + runtime: config.runtime || DEFAULT_VERCEL_RUNTIME, + timeoutMs: DEFAULT_BASE_SNAPSHOT_TIMEOUT_MS, + ports: [], + tags: { + openinspect_framework: "open-inspect", + openinspect_kind: "base-runtime-build", + openinspect_runtime_ref: runtimeRepoRef, + ...(config.sourceVersion ? { openinspect_source_version: config.sourceVersion } : {}), + }, + }, + config.correlation + ); + + const sessionId = created.session.id; + try { + const result = await client.runCommandAndWait( + { + sessionId, + command: "bash", + args: ["-lc", buildVercelBootstrapScript({ runtimeRepoUrl, runtimeRepoRef })], + timeoutMs: BOOTSTRAP_TIMEOUT_MS, + }, + config.correlation + ); + + if (result.exitCode !== 0) { + throw new Error(`Vercel base runtime bootstrap failed with exit code ${result.exitCode}`); + } + + const snapshot = await client.snapshotSession( + sessionId, + { expirationMs: 0 }, + config.correlation + ); + + if (snapshot.snapshot.status !== "created") { + throw new Error(`Vercel base snapshot status was ${snapshot.snapshot.status}`); + } + + log.info("vercel_base_snapshot.created", { + snapshot_id: snapshot.snapshot.id, + sandbox_name: sandboxName, + session_id: sessionId, + runtime_repo_ref: runtimeRepoRef, + }); + + return { + snapshotId: snapshot.snapshot.id, + sandboxName, + sessionId, + }; + } finally { + try { + await client.stopSession(sessionId, config.correlation); + } catch (error) { + log.warn("vercel_base_snapshot.stop_failed", { + session_id: sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +export function buildBaseSnapshotSandboxName(params: { + prefix: string; + sourceVersion?: string; + now: number; +}): string { + const source = params.sourceVersion ? params.sourceVersion.slice(0, 12) : "manual"; + const raw = `${params.prefix}-${source}-${params.now}`; + const normalized = raw + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + return normalized.slice(0, 96) || `${DEFAULT_BASE_SNAPSHOT_NAME_PREFIX}-${params.now}`; +} diff --git a/packages/control-plane/src/sandbox/vercel-bootstrap.ts b/packages/control-plane/src/sandbox/vercel-bootstrap.ts new file mode 100644 index 000000000..b44ecd28e --- /dev/null +++ b/packages/control-plane/src/sandbox/vercel-bootstrap.ts @@ -0,0 +1,89 @@ +/** + * Shared Vercel Sandbox runtime bootstrap script. + * + * Used both by live fresh sandboxes when no source snapshot is configured and + * by CI when building the managed Vercel base-runtime snapshot. + */ + +export const VERCEL_PYTHON_BIN = "/usr/bin/python3.12"; +export const DEFAULT_VERCEL_RUNTIME = "node24"; +export const DEFAULT_VERCEL_RUNTIME_REPO_URL = + "https://github.com/ColeMurray/background-agents.git"; +export const DEFAULT_VERCEL_RUNTIME_REPO_REF = "main"; + +export function buildVercelBootstrapScript(params: { + runtimeRepoUrl: string; + runtimeRepoRef: string; +}): string { + return ` +set -euo pipefail + +RUNTIME_REPO_URL=${shellQuote(params.runtimeRepoUrl)} +RUNTIME_REPO_REF=${shellQuote(params.runtimeRepoRef)} +OPENCODE_VERSION="1.14.41" +CODE_SERVER_VERSION="4.109.5" +AGENT_BROWSER_VERSION="0.21.2" +TTYD_VERSION="1.7.7" +TTYD_SHA256="8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55" + +sudo mkdir -p /workspace /app /app/plugins /app/opencode-deps /tmp/opencode /root + +sudo dnf install -y dnf-plugins-core git gcc gcc-c++ make ca-certificates openssh-clients jq unzip tar gzip python3.12 python3.12-pip python3.12-devel +sudo dnf install -y libX11 libXcomposite libXdamage libXext libXfixes libXrandr libxcb libxkbcommon libdrm mesa-libgbm alsa-lib atk at-spi2-atk cups-libs pango cairo nspr nss || true +sudo dnf install -y ffmpeg || true +if ! command -v gh >/dev/null 2>&1; then + sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo || true + sudo dnf install -y gh || true +fi + +sudo ln -sf ${VERCEL_PYTHON_BIN} /usr/local/bin/python3 +sudo ln -sf ${VERCEL_PYTHON_BIN} /usr/local/bin/python +if ! ${VERCEL_PYTHON_BIN} -m pip --version >/dev/null 2>&1; then + sudo ${VERCEL_PYTHON_BIN} -m ensurepip --upgrade +fi +sudo ${VERCEL_PYTHON_BIN} -m pip install --break-system-packages uv httpx websockets 'pydantic>=2.0' 'PyJWT[crypto]' || sudo ${VERCEL_PYTHON_BIN} -m pip install uv httpx websockets 'pydantic>=2.0' 'PyJWT[crypto]' + +sudo npm install -g pnpm@latest opencode-ai@"$OPENCODE_VERSION" @opencode-ai/plugin@"$OPENCODE_VERSION" zod agent-browser@"$AGENT_BROWSER_VERSION" +if [ ! -x /root/.bun/bin/bun ]; then + curl -fsSL https://bun.sh/install | sudo -E bash || true +fi +sudo env PATH="/root/.bun/bin:$PATH" agent-browser install || true + +if ! command -v code-server >/dev/null 2>&1; then + curl -fsSL https://code-server.dev/install.sh | sudo sh -s -- --version "$CODE_SERVER_VERSION" || true +fi +if ! command -v ttyd >/dev/null 2>&1; then + curl -fsSL -o /tmp/ttyd "https://github.com/tsl0922/ttyd/releases/download/$TTYD_VERSION/ttyd.x86_64" + echo "$TTYD_SHA256 /tmp/ttyd" | sha256sum -c - + sudo mv /tmp/ttyd /usr/local/bin/ttyd + sudo chmod 0755 /usr/local/bin/ttyd +fi + +rm -rf /tmp/open-inspect-runtime +git clone --depth 1 "$RUNTIME_REPO_URL" /tmp/open-inspect-runtime +cd /tmp/open-inspect-runtime +git fetch --depth 1 origin "$RUNTIME_REPO_REF" || true +git checkout --detach FETCH_HEAD 2>/dev/null || git checkout "$RUNTIME_REPO_REF" + +sudo rm -rf /app/sandbox_runtime +sudo cp -a packages/sandbox-runtime/src/sandbox_runtime /app/sandbox_runtime +sudo chmod -R a+rX /app/sandbox_runtime +sudo ${VERCEL_PYTHON_BIN} -m pip install --break-system-packages -e packages/sandbox-runtime || sudo ${VERCEL_PYTHON_BIN} -m pip install -e packages/sandbox-runtime + +printf '%s\\n' '#!/bin/sh' 'exec ${VERCEL_PYTHON_BIN} -m sandbox_runtime.credentials.git_credential_helper "$@"' | sudo tee /usr/local/bin/oi-git-credentials >/dev/null +sudo chmod 0755 /usr/local/bin/oi-git-credentials +sudo git config --system credential.helper /usr/local/bin/oi-git-credentials || true +sudo git config --system credential.useHttpPath true || true + +cat > /tmp/opencode-deps-package.json <; + +beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createClient(): VercelSandboxClient { + return new VercelSandboxClient({ + token: "vercel-token", + projectId: "project-123", + teamId: "team-456", + apiBaseUrl: "https://vercel.test/api/", + }); +} + +function lastFetchInit(): RequestInit { + return fetchSpy.mock.calls.at(-1)?.[1] as RequestInit; +} + +function lastFetchBody(): Record { + return JSON.parse(lastFetchInit().body as string) as Record; +} + +describe("VercelSandboxClient", () => { + it("validates required configuration", () => { + expect(() => new VercelSandboxClient({ token: "", projectId: "project" })).toThrow( + "VERCEL_TOKEN" + ); + expect(() => new VercelSandboxClient({ token: "token", projectId: "" })).toThrow( + "VERCEL_PROJECT_ID" + ); + }); + + it("creates a sandbox with project id, team query, auth headers, and snapshot source", async () => { + fetchSpy.mockResolvedValue( + jsonResponse({ + sandbox: { + name: "sandbox-1", + currentSessionId: "session-1", + createdAt: 123, + status: "running", + }, + session: { + id: "session-1", + status: "running", + createdAt: 123, + cwd: "/workspace", + timeout: 7200000, + }, + routes: [{ port: 8080, subdomain: "code", url: "https://code.test" }], + }) + ); + + const result = await createClient().createSandbox( + { + name: "sandbox-1", + runtime: "node24", + timeoutMs: 7200000, + ports: [8080], + env: { FOO: "bar" }, + tags: { openinspect_framework: "open-inspect" }, + sourceSnapshotId: "snapshot-1", + }, + { + trace_id: "trace-1", + request_id: "request-1", + session_id: "session-logical", + sandbox_id: "sandbox-logical", + } + ); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://vercel.test/api/v2/sandboxes?teamId=team-456", + expect.objectContaining({ method: "POST" }) + ); + const init = lastFetchInit(); + const headers = init.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer vercel-token"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("x-trace-id")).toBe("trace-1"); + expect(headers.get("x-request-id")).toBe("request-1"); + expect(headers.get("x-session-id")).toBe("session-logical"); + expect(headers.get("x-sandbox-id")).toBe("sandbox-logical"); + expect(lastFetchBody()).toEqual({ + projectId: "project-123", + name: "sandbox-1", + runtime: "node24", + timeout: 7200000, + ports: [8080], + env: { FOO: "bar" }, + tags: { openinspect_framework: "open-inspect" }, + source: { type: "snapshot", snapshotId: "snapshot-1" }, + }); + expect(result.session.id).toBe("session-1"); + }); + + it("starts a command and maps the command id", async () => { + fetchSpy.mockResolvedValue(jsonResponse({ command: { id: "cmd-1", exitCode: null } })); + + const result = await createClient().startCommand({ + sessionId: "session/1", + command: "python3", + args: ["-m", "sandbox_runtime.entrypoint"], + cwd: "/workspace", + env: { FOO: "bar" }, + sudo: true, + timeoutMs: 1000, + }); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://vercel.test/api/v2/sandboxes/sessions/session%2F1/cmd?teamId=team-456", + expect.objectContaining({ method: "POST" }) + ); + expect(lastFetchBody()).toEqual({ + command: "python3", + args: ["-m", "sandbox_runtime.entrypoint"], + cwd: "/workspace", + env: { FOO: "bar" }, + sudo: true, + timeout: 1000, + }); + expect(result).toEqual({ commandId: "cmd-1", exitCode: null }); + }); + + it("parses NDJSON output from a waited command", async () => { + fetchSpy.mockResolvedValue( + new Response( + [ + JSON.stringify({ stream: { data: "installing" } }), + JSON.stringify({ command: { id: "cmd-1", exitCode: null } }), + JSON.stringify({ command: { id: "cmd-1", exitCode: 0 } }), + "", + ].join("\n"), + { status: 200 } + ) + ); + + const result = await createClient().runCommandAndWait({ + sessionId: "session-1", + command: "bash", + args: ["-lc", "true"], + }); + + expect(lastFetchBody()).toEqual({ + command: "bash", + args: ["-lc", "true"], + env: {}, + sudo: false, + wait: true, + }); + expect(result).toEqual({ commandId: "cmd-1", exitCode: 0 }); + }); + + it("throws when a waited command stream never includes a command id", async () => { + fetchSpy.mockResolvedValue(new Response(JSON.stringify({ stream: { data: "only logs" } }))); + + await expect( + createClient().runCommandAndWait({ sessionId: "session-1", command: "bash" }) + ).rejects.toThrow(VercelSandboxApiError); + }); + + it("creates and deletes snapshots with the expected endpoints", async () => { + fetchSpy + .mockResolvedValueOnce( + jsonResponse({ + snapshot: { id: "snapshot-1", status: "created", createdAt: 456 }, + session: { + id: "session-1", + status: "running", + createdAt: 123, + cwd: "/workspace", + timeout: 7200000, + }, + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + const snapshot = await createClient().snapshotSession("session-1", { expirationMs: 0 }); + await createClient().deleteSnapshot("snapshot-1"); + + expect(fetchSpy.mock.calls[0][0]).toBe( + "https://vercel.test/api/v2/sandboxes/sessions/session-1/snapshot?teamId=team-456" + ); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body as string)).toEqual({ expiration: 0 }); + expect(snapshot.snapshot.id).toBe("snapshot-1"); + expect(fetchSpy.mock.calls[1][0]).toBe( + "https://vercel.test/api/v2/sandboxes/snapshots/snapshot-1?teamId=team-456" + ); + expect(fetchSpy.mock.calls[1][1]).toEqual(expect.objectContaining({ method: "DELETE" })); + }); + + it("stops a sandbox session with the expected endpoint", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); + + await createClient().stopSession("session-1", { + trace_id: "trace-1", + request_id: "request-1", + session_id: "session-logical", + sandbox_id: "sandbox-logical", + }); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://vercel.test/api/v2/sandboxes/sessions/session-1/stop?teamId=team-456", + expect.objectContaining({ method: "POST" }) + ); + const headers = lastFetchInit().headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer vercel-token"); + expect(headers.get("x-trace-id")).toBe("trace-1"); + expect(headers.get("x-request-id")).toBe("request-1"); + expect(headers.get("x-session-id")).toBe("session-logical"); + expect(headers.get("x-sandbox-id")).toBe("sandbox-logical"); + }); + + it("wraps non-OK responses in VercelSandboxApiError", async () => { + fetchSpy.mockResolvedValue(new Response("unauthorized", { status: 401 })); + + try { + await createClient().deleteSnapshot("snapshot-1"); + expect.unreachable("expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(VercelSandboxApiError); + expect((error as VercelSandboxApiError).status).toBe(401); + expect((error as VercelSandboxApiError).responseText).toBe("unauthorized"); + } + }); +}); diff --git a/packages/control-plane/src/sandbox/vercel-client.ts b/packages/control-plane/src/sandbox/vercel-client.ts new file mode 100644 index 000000000..1644499c9 --- /dev/null +++ b/packages/control-plane/src/sandbox/vercel-client.ts @@ -0,0 +1,324 @@ +/** + * Worker-compatible Vercel Sandbox REST client. + * + * The published @vercel/sandbox SDK currently imports Node-only modules, so + * the Cloudflare Worker control plane talks to Vercel's documented Sandbox API + * with fetch directly. + */ + +import { createLogger } from "../logger"; +import type { CorrelationContext } from "../logger"; + +const log = createLogger("vercel-sandbox-client"); + +const DEFAULT_VERCEL_API_BASE_URL = "https://vercel.com/api"; +const USER_AGENT = "open-inspect/vercel-sandbox"; + +export interface VercelSandboxClientConfig { + token: string; + projectId: string; + teamId?: string; + apiBaseUrl?: string; +} + +export interface VercelSandboxRoute { + url?: string; + subdomain: string; + port: number; +} + +export interface VercelSandboxSession { + id: string; + status: "pending" | "running" | "stopping" | "stopped" | "failed" | "aborted" | "snapshotting"; + createdAt: number; + cwd: string; + timeout: number; +} + +export interface VercelSandboxMetadata { + name: string; + currentSessionId: string; + currentSnapshotId?: string; + createdAt: number; + status: VercelSandboxSession["status"]; +} + +export interface VercelCreateSandboxRequest { + name: string; + runtime?: string; + timeoutMs?: number; + ports?: number[]; + env?: Record; + tags?: Record; + sourceSnapshotId?: string; +} + +export interface VercelCreateSandboxResponse { + sandbox: VercelSandboxMetadata; + session: VercelSandboxSession; + routes: VercelSandboxRoute[]; +} + +export interface VercelRunCommandRequest { + sessionId: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + sudo?: boolean; + timeoutMs?: number; +} + +export interface VercelCommandResult { + commandId: string; + exitCode: number | null; +} + +export interface VercelSnapshotResponse { + snapshot: { + id: string; + status: "created" | "deleted" | "failed"; + createdAt: number; + }; + session: VercelSandboxSession; +} + +export class VercelSandboxApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly responseText?: string + ) { + super(message); + this.name = "VercelSandboxApiError"; + } +} + +export class VercelSandboxClient { + private readonly apiBaseUrl: string; + + constructor(private readonly config: VercelSandboxClientConfig) { + if (!config.token) throw new Error("VercelSandboxClient requires VERCEL_TOKEN"); + if (!config.projectId) throw new Error("VercelSandboxClient requires VERCEL_PROJECT_ID"); + this.apiBaseUrl = (config.apiBaseUrl || DEFAULT_VERCEL_API_BASE_URL).replace(/\/$/, ""); + } + + async createSandbox( + request: VercelCreateSandboxRequest, + correlation?: CorrelationContext + ): Promise { + const response = await this.request( + "/v2/sandboxes", + { + method: "POST", + body: JSON.stringify({ + projectId: this.config.projectId, + name: request.name, + runtime: request.runtime, + timeout: request.timeoutMs, + ports: request.ports ?? [], + env: request.env, + tags: request.tags, + source: request.sourceSnapshotId + ? { type: "snapshot", snapshotId: request.sourceSnapshotId } + : undefined, + }), + }, + correlation, + "createSandbox" + ); + + return response; + } + + async startCommand( + request: VercelRunCommandRequest, + correlation?: CorrelationContext + ): Promise { + const response = await this.request<{ command: { id: string; exitCode: number | null } }>( + `/v2/sandboxes/sessions/${encodeURIComponent(request.sessionId)}/cmd`, + { + method: "POST", + body: JSON.stringify({ + command: request.command, + args: request.args ?? [], + cwd: request.cwd, + env: request.env ?? {}, + sudo: request.sudo ?? false, + timeout: request.timeoutMs, + }), + }, + correlation, + "startCommand" + ); + + return { commandId: response.command.id, exitCode: response.command.exitCode }; + } + + async runCommandAndWait( + request: VercelRunCommandRequest, + correlation?: CorrelationContext + ): Promise { + const text = await this.requestText( + `/v2/sandboxes/sessions/${encodeURIComponent(request.sessionId)}/cmd`, + { + method: "POST", + body: JSON.stringify({ + command: request.command, + args: request.args ?? [], + cwd: request.cwd, + env: request.env ?? {}, + sudo: request.sudo ?? false, + wait: true, + timeout: request.timeoutMs, + }), + }, + correlation, + "runCommandAndWait" + ); + + return parseCommandNdjson(text); + } + + async snapshotSession( + sessionId: string, + opts: { expirationMs?: number } = {}, + correlation?: CorrelationContext + ): Promise { + const body = + opts.expirationMs === undefined + ? undefined + : JSON.stringify({ expiration: opts.expirationMs }); + return this.request( + `/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/snapshot`, + { method: "POST", body }, + correlation, + "snapshotSession" + ); + } + + async stopSession(sessionId: string, correlation?: CorrelationContext): Promise { + await this.request( + `/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/stop`, + { method: "POST" }, + correlation, + "stopSession" + ); + } + + async deleteSnapshot(snapshotId: string, correlation?: CorrelationContext): Promise { + await this.request( + `/v2/sandboxes/snapshots/${encodeURIComponent(snapshotId)}`, + { method: "DELETE" }, + correlation, + "deleteSnapshot" + ); + } + + private async request( + path: string, + init: RequestInit, + correlation: CorrelationContext | undefined, + endpoint: string + ): Promise { + const text = await this.requestText(path, init, correlation, endpoint); + try { + return JSON.parse(text || "{}") as T; + } catch (error) { + throw new VercelSandboxApiError( + `Vercel Sandbox API returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + 200, + text + ); + } + } + + private async requestText( + path: string, + init: RequestInit, + correlation: CorrelationContext | undefined, + endpoint: string + ): Promise { + const startTime = Date.now(); + let httpStatus: number | undefined; + let outcome: "success" | "error" = "error"; + + try { + const url = new URL(`${this.apiBaseUrl}${path}`); + if (this.config.teamId) { + url.searchParams.set("teamId", this.config.teamId); + } + + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${this.config.token}`); + headers.set("Content-Type", headers.get("Content-Type") || "application/json"); + headers.set("User-Agent", USER_AGENT); + if (correlation?.trace_id) headers.set("x-trace-id", correlation.trace_id); + if (correlation?.request_id) headers.set("x-request-id", correlation.request_id); + if (correlation?.session_id) headers.set("x-session-id", correlation.session_id); + if (correlation?.sandbox_id) headers.set("x-sandbox-id", correlation.sandbox_id); + + const response = await fetch(url.toString(), { ...init, headers }); + httpStatus = response.status; + const text = await response.text(); + if (!response.ok) { + throw new VercelSandboxApiError( + `Vercel Sandbox API error: ${response.status} ${text}`, + response.status, + text + ); + } + + outcome = "success"; + return text; + } finally { + log.info("vercel_sandbox.request", { + event: "vercel_sandbox.request", + endpoint, + trace_id: correlation?.trace_id, + request_id: correlation?.request_id, + http_status: httpStatus, + duration_ms: Date.now() - startTime, + outcome, + }); + } + } +} + +function parseCommandNdjson(text: string): VercelCommandResult { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + let commandId = ""; + let exitCode: number | null = null; + + for (const line of lines) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object" || !("command" in parsed)) continue; + const command = (parsed as { command?: { id?: unknown; exitCode?: unknown } }).command; + if (!command) continue; + if (typeof command.id === "string") commandId = command.id; + if (typeof command.exitCode === "number") exitCode = command.exitCode; + } + + if (!commandId) { + throw new VercelSandboxApiError( + "Vercel command stream did not include a command id", + 200, + text + ); + } + + return { commandId, exitCode }; +} + +export function createVercelSandboxClient(config: VercelSandboxClientConfig): VercelSandboxClient { + return new VercelSandboxClient(config); +} diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index db66629fe..e4e612d87 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -14,9 +14,11 @@ import { resolveAppName, timingSafeEqual } from "@open-inspect/shared"; import { generateId, hashToken, encryptToken, decryptToken } from "../auth/crypto"; import { buildModalSandboxDashboardUrl, createModalClient } from "../sandbox/client"; import { createDaytonaRestClient } from "../sandbox/daytona-rest-client"; +import { createVercelSandboxClient } from "../sandbox/vercel-client"; import { createModalProvider } from "../sandbox/providers/modal-provider"; import { createDaytonaProvider } from "../sandbox/providers/daytona-provider"; -import { resolveSandboxBackendName } from "../sandbox/provider-name"; +import { createVercelProvider } from "../sandbox/providers/vercel-provider"; +import { resolveSandboxBackendName, supportsRepoImageBackend } from "../sandbox/provider-name"; import { createLogger, parseLogLevel } from "../logger"; import type { Logger } from "../logger"; import { @@ -550,58 +552,86 @@ export class SessionDO extends DurableObject { private createLifecycleManager(): SandboxLifecycleManager { const sandboxBackend = resolveSandboxBackendName(this.env.SANDBOX_PROVIDER); - const provider = - sandboxBackend === "daytona" - ? (() => { - if ( - !this.env.DAYTONA_API_URL || - !this.env.DAYTONA_API_KEY || - !this.env.DAYTONA_BASE_SNAPSHOT - ) { - throw new Error( - "DAYTONA_API_URL, DAYTONA_API_KEY, and DAYTONA_BASE_SNAPSHOT are required when SANDBOX_PROVIDER=daytona" - ); - } - - const daytonaClient = createDaytonaRestClient({ - apiUrl: this.env.DAYTONA_API_URL, - apiKey: this.env.DAYTONA_API_KEY, - target: this.env.DAYTONA_TARGET, - baseSnapshot: this.env.DAYTONA_BASE_SNAPSHOT, - autoStopIntervalMinutes: parseInt( - this.env.DAYTONA_AUTO_STOP_INTERVAL_MINUTES || "120", - 10 - ), - autoArchiveIntervalMinutes: parseInt( - this.env.DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES || "10080", - 10 - ), - }); - - const scmProvider = resolveScmProviderFromEnv(this.env.SCM_PROVIDER); - - return createDaytonaProvider(daytonaClient, { - scmProvider, - gitlabAccessToken: this.env.GITLAB_ACCESS_TOKEN, - // Reuses API key as HMAC secret for code-server password derivation - // (distinct message prefix prevents collision with auth use) - codeServerPasswordSecret: this.env.DAYTONA_API_KEY, - }); - })() - : (() => { - if (!this.env.MODAL_API_SECRET || !this.env.MODAL_WORKSPACE) { - throw new Error( - "MODAL_API_SECRET and MODAL_WORKSPACE are required when SANDBOX_PROVIDER=modal" - ); - } - - const modalClient = createModalClient( - this.env.MODAL_API_SECRET, - this.env.MODAL_WORKSPACE, - this.env.MODAL_ENVIRONMENT_WEB_SUFFIX - ); - return createModalProvider(modalClient); - })(); + const provider = (() => { + if (sandboxBackend === "daytona") { + if ( + !this.env.DAYTONA_API_URL || + !this.env.DAYTONA_API_KEY || + !this.env.DAYTONA_BASE_SNAPSHOT + ) { + throw new Error( + "DAYTONA_API_URL, DAYTONA_API_KEY, and DAYTONA_BASE_SNAPSHOT are required when SANDBOX_PROVIDER=daytona" + ); + } + + const daytonaClient = createDaytonaRestClient({ + apiUrl: this.env.DAYTONA_API_URL, + apiKey: this.env.DAYTONA_API_KEY, + target: this.env.DAYTONA_TARGET, + baseSnapshot: this.env.DAYTONA_BASE_SNAPSHOT, + autoStopIntervalMinutes: parseInt( + this.env.DAYTONA_AUTO_STOP_INTERVAL_MINUTES || "120", + 10 + ), + autoArchiveIntervalMinutes: parseInt( + this.env.DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES || "10080", + 10 + ), + }); + + const scmProvider = resolveScmProviderFromEnv(this.env.SCM_PROVIDER); + + return createDaytonaProvider(daytonaClient, { + scmProvider, + gitlabAccessToken: this.env.GITLAB_ACCESS_TOKEN, + // Reuses API key as HMAC secret for code-server password derivation + // (distinct message prefix prevents collision with auth use) + codeServerPasswordSecret: this.env.DAYTONA_API_KEY, + }); + } + + if (sandboxBackend === "vercel") { + if (!this.env.VERCEL_TOKEN || !this.env.VERCEL_PROJECT_ID) { + throw new Error( + "VERCEL_TOKEN and VERCEL_PROJECT_ID are required when SANDBOX_PROVIDER=vercel" + ); + } + + const vercelClient = createVercelSandboxClient({ + token: this.env.VERCEL_TOKEN, + projectId: this.env.VERCEL_PROJECT_ID, + teamId: this.env.VERCEL_TEAM_ID, + apiBaseUrl: this.env.VERCEL_SANDBOX_API_BASE_URL, + }); + + return createVercelProvider(vercelClient, { + scmProvider: resolveScmProviderFromEnv(this.env.SCM_PROVIDER), + token: this.env.VERCEL_TOKEN, + teamId: this.env.VERCEL_TEAM_ID, + apiBaseUrl: this.env.VERCEL_SANDBOX_API_BASE_URL, + baseSnapshotId: this.env.VERCEL_BASE_SNAPSHOT_ID, + runtime: this.env.VERCEL_RUNTIME, + runtimeRepoUrl: this.env.VERCEL_RUNTIME_REPO_URL, + runtimeRepoRef: this.env.VERCEL_RUNTIME_REPO_REF, + snapshotExpirationMs: parseInt(this.env.VERCEL_SNAPSHOT_EXPIRATION_MS || "0", 10), + codeServerPasswordSecret: this.env.VERCEL_TOKEN, + internalCallbackSecret: this.env.INTERNAL_CALLBACK_SECRET, + }); + } + + if (!this.env.MODAL_API_SECRET || !this.env.MODAL_WORKSPACE) { + throw new Error( + "MODAL_API_SECRET and MODAL_WORKSPACE are required when SANDBOX_PROVIDER=modal" + ); + } + + const modalClient = createModalClient( + this.env.MODAL_API_SECRET, + this.env.MODAL_WORKSPACE, + this.env.MODAL_ENVIRONMENT_WEB_SUFFIX + ); + return createModalProvider(modalClient); + })(); // Storage adapter const storage: SandboxStorage = { @@ -731,9 +761,9 @@ export class SessionDO extends DurableObject { sandboxDashboardUrlBuilder, }; - // Create repo image lookup if D1 is available (Modal-only — Daytona doesn't use repo images) + // Create repo image lookup if D1 is available and the provider supports repo images. let repoImageLookup: RepoImageLookup | undefined; - if (this.env.DB && sandboxBackend === "modal") { + if (this.env.DB && supportsRepoImageBackend(sandboxBackend)) { const repoImageStore = new RepoImageStore(this.env.DB); repoImageLookup = { getLatestReady: (repoOwner, repoName, baseBranch) => diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index e2f57be71..88ce4db6d 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -61,6 +61,7 @@ export interface Env { MODAL_TOKEN_SECRET?: string; MODAL_API_SECRET?: string; // Shared secret for authenticating with Modal endpoints DAYTONA_API_KEY?: string; // Daytona REST API key (Bearer auth + HMAC derivation) + VERCEL_TOKEN?: string; // Vercel API access token for Sandbox API INTERNAL_CALLBACK_SECRET?: string; // For signing callbacks to slack-bot SLACK_BOT_TOKEN?: string; // Slack bot token for agent-initiated chat.postMessage calls @@ -80,7 +81,7 @@ export interface Env { WORKER_URL?: string; // Base URL for the worker (for callbacks) WEB_APP_URL?: string; // Base URL for the web app (for PR links) CF_ACCOUNT_ID?: string; // Cloudflare account ID - SANDBOX_PROVIDER?: string; // "modal" (default) or "daytona" + SANDBOX_PROVIDER?: string; // "modal" (default), "daytona", or "vercel" MODAL_WORKSPACE?: string; // Modal workspace name MODAL_ENVIRONMENT?: string; // Modal environment name for dashboard URLs MODAL_ENVIRONMENT_WEB_SUFFIX?: string; // Modal environment web suffix for endpoint URLs @@ -89,6 +90,14 @@ export interface Env { DAYTONA_AUTO_STOP_INTERVAL_MINUTES?: string; // Daytona idle stop interval in minutes DAYTONA_AUTO_ARCHIVE_INTERVAL_MINUTES?: string; // Daytona archive interval in minutes DAYTONA_TARGET?: string; // Optional Daytona target name + VERCEL_PROJECT_ID?: string; // Vercel project ID used for Sandbox API scope + VERCEL_TEAM_ID?: string; // Optional Vercel team ID used for Sandbox API scope + VERCEL_BASE_SNAPSHOT_ID?: string; // Optional prebuilt base snapshot with sandbox runtime + VERCEL_RUNTIME?: string; // Vercel sandbox runtime (default: node24) + VERCEL_RUNTIME_REPO_URL?: string; // Runtime source repo used when bootstrapping without base snapshot + VERCEL_RUNTIME_REPO_REF?: string; // Runtime source ref used when bootstrapping without base snapshot + VERCEL_SANDBOX_API_BASE_URL?: string; // Override for tests or non-default Vercel API base URL + VERCEL_SNAPSHOT_EXPIRATION_MS?: string; // Snapshot expiration in ms; 0 means no expiration // Sandbox lifecycle configuration SANDBOX_INACTIVITY_TIMEOUT_MS?: string; // Inactivity timeout in ms (default: 600000 = 10 min) diff --git a/packages/web/src/app/api/repo-images/[owner]/[name]/toggle/route.ts b/packages/web/src/app/api/repo-images/[owner]/[name]/toggle/route.ts index e1a5675ab..4c1c1421c 100644 --- a/packages/web/src/app/api/repo-images/[owner]/[name]/toggle/route.ts +++ b/packages/web/src/app/api/repo-images/[owner]/[name]/toggle/route.ts @@ -11,7 +11,7 @@ export async function PUT( ) { if (!supportsRepoImages()) { return NextResponse.json( - { error: "Repo images are only available when SANDBOX_PROVIDER=modal" }, + { error: "Repo images are only available when SANDBOX_PROVIDER=modal or vercel" }, { status: 501 } ); } diff --git a/packages/web/src/app/api/repo-images/[owner]/[name]/trigger/route.ts b/packages/web/src/app/api/repo-images/[owner]/[name]/trigger/route.ts index 51426da31..2d16e46b5 100644 --- a/packages/web/src/app/api/repo-images/[owner]/[name]/trigger/route.ts +++ b/packages/web/src/app/api/repo-images/[owner]/[name]/trigger/route.ts @@ -11,7 +11,7 @@ export async function POST( ) { if (!supportsRepoImages()) { return NextResponse.json( - { error: "Repo images are only available when SANDBOX_PROVIDER=modal" }, + { error: "Repo images are only available when SANDBOX_PROVIDER=modal or vercel" }, { status: 501 } ); } diff --git a/packages/web/src/app/api/repo-images/route.ts b/packages/web/src/app/api/repo-images/route.ts index 4c2c50226..c3f831bf5 100644 --- a/packages/web/src/app/api/repo-images/route.ts +++ b/packages/web/src/app/api/repo-images/route.ts @@ -7,7 +7,7 @@ import { supportsRepoImages } from "@/lib/sandbox-provider"; export async function GET() { if (!supportsRepoImages()) { return NextResponse.json( - { error: "Repo images are only available when SANDBOX_PROVIDER=modal" }, + { error: "Repo images are only available when SANDBOX_PROVIDER=modal or vercel" }, { status: 501 } ); } diff --git a/packages/web/src/components/settings/images-settings.tsx b/packages/web/src/components/settings/images-settings.tsx index de952e97e..156cb1c01 100644 --- a/packages/web/src/components/settings/images-settings.tsx +++ b/packages/web/src/components/settings/images-settings.tsx @@ -43,7 +43,8 @@ export function ImagesSettings() {

Pre-Built Images

- Pre-built images are only available when SANDBOX_PROVIDER=modal. + Pre-built images are only available when SANDBOX_PROVIDER=modal or{" "} + SANDBOX_PROVIDER=vercel.

); diff --git a/packages/web/src/lib/sandbox-provider.test.ts b/packages/web/src/lib/sandbox-provider.test.ts new file mode 100644 index 000000000..8a018e1f4 --- /dev/null +++ b/packages/web/src/lib/sandbox-provider.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("sandbox-provider", () => { + const originalPublicProvider = process.env.NEXT_PUBLIC_SANDBOX_PROVIDER; + const originalProvider = process.env.SANDBOX_PROVIDER; + + afterEach(() => { + vi.resetModules(); + if (originalPublicProvider === undefined) { + delete process.env.NEXT_PUBLIC_SANDBOX_PROVIDER; + } else { + process.env.NEXT_PUBLIC_SANDBOX_PROVIDER = originalPublicProvider; + } + if (originalProvider === undefined) { + delete process.env.SANDBOX_PROVIDER; + } else { + process.env.SANDBOX_PROVIDER = originalProvider; + } + }); + + async function loadProvider() { + vi.resetModules(); + return import("./sandbox-provider"); + } + + it("defaults to modal when no provider is configured", async () => { + delete process.env.NEXT_PUBLIC_SANDBOX_PROVIDER; + delete process.env.SANDBOX_PROVIDER; + + const { getPublicSandboxProvider, supportsRepoImages } = await loadProvider(); + + expect(getPublicSandboxProvider()).toBe("modal"); + expect(supportsRepoImages()).toBe(true); + }); + + it("uses the public provider value when present", async () => { + process.env.NEXT_PUBLIC_SANDBOX_PROVIDER = " vercel "; + process.env.SANDBOX_PROVIDER = "daytona"; + + const { getPublicSandboxProvider, supportsRepoImages } = await loadProvider(); + + expect(getPublicSandboxProvider()).toBe("vercel"); + expect(supportsRepoImages()).toBe(true); + }); + + it("disables repo images for daytona", async () => { + delete process.env.NEXT_PUBLIC_SANDBOX_PROVIDER; + process.env.SANDBOX_PROVIDER = "daytona"; + + const { getPublicSandboxProvider, supportsRepoImages } = await loadProvider(); + + expect(getPublicSandboxProvider()).toBe("daytona"); + expect(supportsRepoImages()).toBe(false); + }); + + it("throws for unsupported providers", async () => { + process.env.NEXT_PUBLIC_SANDBOX_PROVIDER = "fly"; + + const { getPublicSandboxProvider } = await loadProvider(); + + expect(() => getPublicSandboxProvider()).toThrow("Invalid sandbox provider: fly"); + }); +}); diff --git a/packages/web/src/lib/sandbox-provider.ts b/packages/web/src/lib/sandbox-provider.ts index b7a3bcb1d..b7d9769f7 100644 --- a/packages/web/src/lib/sandbox-provider.ts +++ b/packages/web/src/lib/sandbox-provider.ts @@ -2,7 +2,7 @@ * Public sandbox backend helpers for the web app. */ -export type PublicSandboxProvider = "modal" | "daytona"; +export type PublicSandboxProvider = "modal" | "daytona" | "vercel"; export function getPublicSandboxProvider(): PublicSandboxProvider { const rawValue = process.env.NEXT_PUBLIC_SANDBOX_PROVIDER ?? process.env.SANDBOX_PROVIDER; @@ -11,7 +11,7 @@ export function getPublicSandboxProvider(): PublicSandboxProvider { } const value = rawValue.trim().toLowerCase(); - if (value === "modal" || value === "daytona") { + if (value === "modal" || value === "daytona" || value === "vercel") { return value; } @@ -19,5 +19,6 @@ export function getPublicSandboxProvider(): PublicSandboxProvider { } export function supportsRepoImages(): boolean { - return getPublicSandboxProvider() === "modal"; + const provider = getPublicSandboxProvider(); + return provider === "modal" || provider === "vercel"; } diff --git a/terraform/README.md b/terraform/README.md index d418d08de..356bce383 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -10,7 +10,7 @@ The infrastructure spans three cloud providers: | Provider | Resources | Terraform Support | | -------------- | ---------------------------------------------------- | -------------------------------- | | **Cloudflare** | Workers, KV Namespaces, Durable Objects, D1 Database | Native provider | -| **Vercel** | Next.js Web App | Native provider | +| **Vercel** | Next.js Web App, optional sandbox sessions | Native provider | | **Modal** | Sandbox Infrastructure | CLI wrapper (no provider exists) | ## Directory Structure @@ -80,6 +80,11 @@ brew install node@22 1. **Create API Token** at [Vercel Account Settings](https://vercel.com/account/tokens) 2. **Note your Team ID** (found in team settings URL) +3. If using `sandbox_provider = "vercel"`, also note the Project ID for the Vercel project that will + own sandbox sessions. +4. The Terraform GitHub Actions apply job builds an immutable Vercel base-runtime snapshot before + deploy and passes the generated snapshot ID into the control-plane Worker. + `VERCEL_BASE_SNAPSHOT_ID` is only needed as a manual fallback or for local Terraform runs. ### 4. Modal Setup @@ -181,6 +186,13 @@ MODAL_WORKSPACE MODAL_ENVIRONMENT # Optional; defaults to main MODAL_ENVIRONMENT_WEB_SUFFIX # Optional; lowercase letters, digits, dashes; empty for workspace--... endpoints +# Vercel Sandboxes (only if SANDBOX_PROVIDER=vercel) +SANDBOX_PROVIDER +VERCEL_SANDBOX_TOKEN +VERCEL_SANDBOX_PROJECT_ID +VERCEL_SANDBOX_TEAM_ID # Optional +VERCEL_BASE_SNAPSHOT_ID # Optional manual fallback; CI usually generates this + # GitHub OAuth App GH_OAUTH_CLIENT_ID GH_OAUTH_CLIENT_SECRET diff --git a/terraform/environments/production/locals.tf b/terraform/environments/production/locals.tf index c7806cff1..d2bf3d34e 100644 --- a/terraform/environments/production/locals.tf +++ b/terraform/environments/production/locals.tf @@ -2,6 +2,7 @@ locals { name_suffix = var.deployment_name use_modal_backend = var.sandbox_provider == "modal" use_daytona_backend = var.sandbox_provider == "daytona" + use_vercel_backend = var.sandbox_provider == "vercel" # URLs for cross-service configuration control_plane_host = "open-inspect-control-plane-${local.name_suffix}.${var.cloudflare_worker_subdomain}.workers.dev" diff --git a/terraform/environments/production/outputs.tf b/terraform/environments/production/outputs.tf index d0514f615..6c9a5ce53 100644 --- a/terraform/environments/production/outputs.tf +++ b/terraform/environments/production/outputs.tf @@ -81,6 +81,11 @@ output "sandbox_provider" { value = var.sandbox_provider } +output "vercel_base_snapshot_id" { + description = "Vercel base runtime snapshot ID configured for sandbox creation" + value = local.use_vercel_backend ? var.vercel_base_snapshot_id : null +} + output "web_app_project_id" { description = "Vercel project ID (null when using Cloudflare)" value = var.web_platform == "vercel" ? module.web_app[0].project_id : null @@ -109,7 +114,7 @@ output "verification_commands" { curl ${module.control_plane_worker.worker_url}/health # 2. Health check sandbox backend - ${local.use_modal_backend ? "curl ${module.modal_app[0].api_health_url}" : "# Daytona sandboxes use the REST API directly — no health endpoint to check"} + ${local.use_modal_backend ? "curl ${module.modal_app[0].api_health_url}" : local.use_vercel_backend ? "# Vercel sandboxes use the Vercel Sandbox API directly. Base snapshot: ${var.vercel_base_snapshot_id != "" ? var.vercel_base_snapshot_id : "(none configured; fresh sandboxes bootstrap at start)"}" : "# Daytona sandboxes use the REST API directly — no health endpoint to check"} # 3. Verify web app deployment curl ${local.web_app_url} diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index 01f2b45c1..b3f978239 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -72,6 +72,18 @@ daytona_api_key = "" # Requires Sandboxes (Read/Write) + Snapshots (Re daytona_base_snapshot = "" # Name of the seeded Daytona snapshot # daytona_target = "" # Optional Daytona target name +# Vercel Sandbox API +# Only required when sandbox_provider = "vercel" +# Create a Vercel access token and use a project ID with Sandbox access. +vercel_sandbox_token = "" +vercel_sandbox_project_id = "" # e.g., "prj_xxxxxxxxxxxxxxxxxxxx" +# vercel_sandbox_team_id = "" # Optional team ID, e.g., "team_xxxxxxxxxxxxxxxxxxxx" +# vercel_base_snapshot_id = "" # Optional manual fallback; GitHub Actions normally generates this for Vercel applies +# vercel_sandbox_runtime = "node24" +# vercel_runtime_repo_url = "https://github.com/ColeMurray/background-agents.git" +# vercel_runtime_repo_ref = "main" +# vercel_snapshot_expiration_ms = 0 + # ============================================================================= # GitHub App Credentials # ============================================================================= @@ -189,6 +201,7 @@ nextauth_secret = "" # Sandbox backend for session execution # - "modal": existing Modal-based sandboxes with filesystem snapshot restore # - "daytona": direct Daytona REST API integration with stop/start resume +# - "vercel": Vercel Sandbox API with filesystem snapshot restore sandbox_provider = "modal" # Platform for the web app deployment diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 3bc0c31a0..82268edfc 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -316,6 +316,65 @@ variable "daytona_target" { default = "" } +variable "vercel_sandbox_token" { + description = "Vercel API token for the Vercel Sandbox API" + type = string + sensitive = true + default = "" + + validation { + condition = var.sandbox_provider != "vercel" || length(var.vercel_sandbox_token) > 0 + error_message = "vercel_sandbox_token must be set when sandbox_provider = 'vercel'." + } +} + +variable "vercel_sandbox_project_id" { + description = "Vercel project ID used to scope Sandbox API calls" + type = string + default = "" + + validation { + condition = var.sandbox_provider != "vercel" || length(var.vercel_sandbox_project_id) > 0 + error_message = "vercel_sandbox_project_id must be set when sandbox_provider = 'vercel'." + } +} + +variable "vercel_sandbox_team_id" { + description = "Optional Vercel team ID used to scope Sandbox API calls" + type = string + default = "" +} + +variable "vercel_base_snapshot_id" { + description = "Optional Vercel Sandbox snapshot ID containing the Open-Inspect base runtime" + type = string + default = "" +} + +variable "vercel_sandbox_runtime" { + description = "Vercel Sandbox runtime identifier" + type = string + default = "node24" +} + +variable "vercel_runtime_repo_url" { + description = "Repository URL used to bootstrap sandbox-runtime when no Vercel base snapshot is configured" + type = string + default = "https://github.com/ColeMurray/background-agents.git" +} + +variable "vercel_runtime_repo_ref" { + description = "Repository ref used to bootstrap sandbox-runtime when no Vercel base snapshot is configured" + type = string + default = "main" +} + +variable "vercel_snapshot_expiration_ms" { + description = "Vercel Sandbox snapshot expiration in milliseconds; 0 means no expiration" + type = number + default = 0 +} + variable "nextauth_secret" { description = "NextAuth.js secret (generate with: openssl rand -base64 32)" type = string @@ -327,13 +386,13 @@ variable "nextauth_secret" { # ============================================================================= variable "sandbox_provider" { - description = "Sandbox backend for session execution: 'modal' or 'daytona'" + description = "Sandbox backend for session execution: 'modal', 'daytona', or 'vercel'" type = string default = "modal" validation { - condition = contains(["modal", "daytona"], var.sandbox_provider) - error_message = "sandbox_provider must be 'modal' or 'daytona'." + condition = contains(["modal", "daytona", "vercel"], var.sandbox_provider) + error_message = "sandbox_provider must be 'modal', 'daytona', or 'vercel'." } } diff --git a/terraform/environments/production/workers-control-plane.tf b/terraform/environments/production/workers-control-plane.tf index 8d4b34feb..17c5fb412 100644 --- a/terraform/environments/production/workers-control-plane.tf +++ b/terraform/environments/production/workers-control-plane.tf @@ -81,6 +81,19 @@ module "control_plane_worker" { ] : [], local.use_daytona_backend && var.daytona_target != "" ? [ { name = "DAYTONA_TARGET", value = var.daytona_target }, + ] : [], + local.use_vercel_backend ? [ + { name = "VERCEL_PROJECT_ID", value = var.vercel_sandbox_project_id }, + { name = "VERCEL_RUNTIME", value = var.vercel_sandbox_runtime }, + { name = "VERCEL_RUNTIME_REPO_URL", value = var.vercel_runtime_repo_url }, + { name = "VERCEL_RUNTIME_REPO_REF", value = var.vercel_runtime_repo_ref }, + { name = "VERCEL_SNAPSHOT_EXPIRATION_MS", value = tostring(var.vercel_snapshot_expiration_ms) }, + ] : [], + local.use_vercel_backend && var.vercel_sandbox_team_id != "" ? [ + { name = "VERCEL_TEAM_ID", value = var.vercel_sandbox_team_id }, + ] : [], + local.use_vercel_backend && var.vercel_base_snapshot_id != "" ? [ + { name = "VERCEL_BASE_SNAPSHOT_ID", value = var.vercel_base_snapshot_id }, ] : [] ) @@ -103,6 +116,9 @@ module "control_plane_worker" { local.use_daytona_backend ? [ { name = "DAYTONA_API_KEY", value = var.daytona_api_key }, ] : [], + local.use_vercel_backend ? [ + { name = "VERCEL_TOKEN", value = var.vercel_sandbox_token }, + ] : [], # Slack bot token enables the agent-initiated `slack-notify` endpoint. # Shares the variable with the slack-bot worker; bound here so the same # token can authorize chat.postMessage from agent tool calls.