From 5b207ec2611d7281cc61ea7886109f7bcb7ecb8a Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 11:42:41 -0700 Subject: [PATCH 01/24] feat(vercel): add Stagehand code-mode MCP example --- packages/integrations/README.md | 4 ++ .../integrations/examples/vercel/README.md | 17 +++++ .../integrations/examples/vercel/package.json | 25 +++++++ .../integrations/examples/vercel/src/agent.ts | 66 +++++++++++++++++++ .../integrations/examples/vercel/src/e2e.ts | 25 +++++++ .../integrations/examples/vercel/src/smoke.ts | 52 +++++++++++++++ .../examples/vercel/tsconfig.json | 11 ++++ pnpm-lock.yaml | 41 ++++++++++++ pnpm-workspace.yaml | 2 + 9 files changed, 243 insertions(+) create mode 100644 packages/integrations/examples/vercel/README.md create mode 100644 packages/integrations/examples/vercel/package.json create mode 100644 packages/integrations/examples/vercel/src/agent.ts create mode 100644 packages/integrations/examples/vercel/src/e2e.ts create mode 100644 packages/integrations/examples/vercel/src/smoke.ts create mode 100644 packages/integrations/examples/vercel/tsconfig.json diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 3a5df2478a..f66aa85aa7 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -44,6 +44,10 @@ STAGEHAND_BROWSER=browserbase The process stays alive across calls and closes when its input stream ends. `SIGINT` and `SIGTERM` perform bounded graceful cleanup and preserve signal-style exit codes. If generated JavaScript blocks the JavaScript event loop, the server cannot run its cleanup handlers. The owner must terminate the entire process tree, escalate to `SIGKILL` after its own deadline, and start a new process before accepting more work. Killing only the Node process can leave its local browser child alive. +### Framework examples + +- [Vercel AI SDK](./examples/vercel) launches the stdio server through the AI SDK MCP client and keeps one process alive for the complete agent run. + ### Configuration `stagehandCodeConfigFromEnv()` recognizes: diff --git a/packages/integrations/examples/vercel/README.md b/packages/integrations/examples/vercel/README.md new file mode 100644 index 0000000000..84e55243cb --- /dev/null +++ b/packages/integrations/examples/vercel/README.md @@ -0,0 +1,17 @@ +# Vercel AI SDK + Stagehand code mode + +This example connects the Vercel AI SDK to the workspace's canonical Stagehand code-mode MCP server +over stdio. One MCP client remains open for the complete `generateText` call, so repeated +`code_execute` calls share the same browser context. + +The MCP child reads the normal code-mode startup environment. Set `STAGEHAND_BROWSER=local` for a +local headless browser or `STAGEHAND_BROWSER=browserbase` with Browserbase credentials for a remote +browser. + +```bash +pnpm --filter @browserbasehq/stagehand-integrations build +STAGEHAND_BROWSER=local pnpm --filter @browserbasehq/stagehand-integrations-example-vercel smoke +``` + +See [`src/agent.ts`](./src/agent.ts) for the reusable connection lifecycle and +[`src/e2e.ts`](./src/e2e.ts) for a real model-driven example. diff --git a/packages/integrations/examples/vercel/package.json b/packages/integrations/examples/vercel/package.json new file mode 100644 index 0000000000..9583d70029 --- /dev/null +++ b/packages/integrations/examples/vercel/package.json @@ -0,0 +1,25 @@ +{ + "name": "@browserbasehq/stagehand-integrations-example-vercel", + "version": "4.0.0", + "private": true, + "type": "module", + "scripts": { + "e2e": "tsx src/e2e.ts", + "smoke": "tsx src/smoke.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ai-sdk/groq": "catalog:", + "@ai-sdk/mcp": "catalog:", + "@browserbasehq/stagehand-integrations": "workspace:*", + "ai": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/integrations/examples/vercel/src/agent.ts b/packages/integrations/examples/vercel/src/agent.ts new file mode 100644 index 0000000000..d9c116152d --- /dev/null +++ b/packages/integrations/examples/vercel/src/agent.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from "node:url"; +import { createMCPClient, type MCPClient } from "@ai-sdk/mcp"; +import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; +import { STAGEHAND_CODEMODE_SKILL } from "@browserbasehq/stagehand-integrations/codemode"; +import { generateText, stepCountIs, type LanguageModel } from "ai"; + +const DEFAULT_STDIO_SERVER_PATH = fileURLToPath( + new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), +); + +export type StagehandMcpBinding = { + client: MCPClient; + tools: Awaited>; +}; + +export type StagehandAgentResult = { + text: string; + toolNames: string[]; +}; + +export async function createStagehandMcpBinding( + stdioServerPath = DEFAULT_STDIO_SERVER_PATH, +): Promise { + const client = await createMCPClient({ + transport: new Experimental_StdioMCPTransport({ + command: process.execPath, + args: [stdioServerPath], + env: definedEnvironment(), + }), + }); + + return { + client, + tools: await client.tools(), + }; +} + +export async function runStagehandAgent( + model: LanguageModel, + prompt: string, +): Promise { + const { client, tools } = await createStagehandMcpBinding(); + try { + const result = await generateText({ + model, + system: STAGEHAND_CODEMODE_SKILL, + prompt, + tools, + stopWhen: stepCountIs(8), + }); + return { + text: result.text, + toolNames: result.steps.flatMap((step) => step.toolCalls.map((call) => call.toolName)), + }; + } finally { + await client.close(); + } +} + +function definedEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); +} diff --git a/packages/integrations/examples/vercel/src/e2e.ts b/packages/integrations/examples/vercel/src/e2e.ts new file mode 100644 index 0000000000..43e93be13b --- /dev/null +++ b/packages/integrations/examples/vercel/src/e2e.ts @@ -0,0 +1,25 @@ +import { groq } from "@ai-sdk/groq"; +import { runStagehandAgent } from "./agent.js"; + +process.env.STAGEHAND_BROWSER ??= "local"; + +const result = await runStagehandAgent( + groq(process.env.VERCEL_STAGEHAND_MODEL ?? "openai/gpt-oss-120b"), + [ + "Use code_execute exactly twice.", + "First navigate to https://example.com, open one additional blank tab, then restore the Example Domain page as active.", + "Second return the active page title and total context page count.", + "Report the title and count in your final answer.", + ].join(" "), +); + +if ( + result.toolNames.length !== 2 || + result.toolNames.some((name) => name !== "code_execute") || + !result.text.includes("Example Domain") || + !result.text.includes("2") +) { + throw new Error(`Unexpected agent result: ${JSON.stringify(result)}`); +} + +process.stdout.write(`${JSON.stringify({ status: "PASS", ...result })}\n`); diff --git a/packages/integrations/examples/vercel/src/smoke.ts b/packages/integrations/examples/vercel/src/smoke.ts new file mode 100644 index 0000000000..274b9c3731 --- /dev/null +++ b/packages/integrations/examples/vercel/src/smoke.ts @@ -0,0 +1,52 @@ +import { createStagehandMcpBinding } from "./agent.js"; +import type { Tool } from "ai"; + +process.env.STAGEHAND_BROWSER ??= "local"; + +const { client, tools } = await createStagehandMcpBinding(); +try { + const names = Object.keys(tools); + if (names.length !== 1 || names[0] !== "code_execute") { + throw new Error(`Expected one code_execute tool, got ${names.join(", ")}`); + } + + const tool = tools.code_execute as Tool<{ code: string }, unknown>; + if (typeof tool?.execute !== "function") { + throw new Error("Vercel AI SDK did not expose code_execute as executable."); + } + const description = + typeof tool.description === "function" ? tool.description({ context: {} }) : tool.description; + if (!description?.includes("Stagehand V4 code-mode syntax")) { + throw new Error("code_execute did not include the canonical Stagehand guidance."); + } + + const first = await tool.execute( + { + code: ` + await page.goto("https://example.com", { waitUntil: "load" }); + await context.newPage(); + await context.setActivePage(page); + return { title: await page.title(), pages: (await context.pages()).length }; + `, + }, + { context: {}, messages: [], toolCallId: "vercel-smoke-1" }, + ); + const second = await tool.execute( + { + code: `return { title: await page.title(), pages: (await context.pages()).length };`, + }, + { context: {}, messages: [], toolCallId: "vercel-smoke-2" }, + ); + + const firstText = JSON.stringify(first); + const secondText = JSON.stringify(second); + if (!firstText.includes("Example Domain") || !secondText.includes('"pages":2')) { + throw new Error(`Expected browser state to persist across calls: ${firstText} ${secondText}`); + } + + process.stdout.write( + `${JSON.stringify({ status: "PASS", tools: names, statePersisted: true })}\n`, + ); +} finally { + await client.close(); +} diff --git a/packages/integrations/examples/vercel/tsconfig.json b/packages/integrations/examples/vercel/tsconfig.json new file mode 100644 index 0000000000..8f14c5759a --- /dev/null +++ b/packages/integrations/examples/vercel/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b54bc747f9..8a8104c19d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ catalogs: '@ai-sdk/groq': specifier: ^4.0.5 version: 4.0.5 + '@ai-sdk/mcp': + specifier: 2.0.8 + version: 2.0.8 '@ai-sdk/openai': specifier: ^4.0.8 version: 4.0.8 @@ -583,6 +586,31 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations/examples/vercel: + dependencies: + '@ai-sdk/groq': + specifier: 'catalog:' + version: 4.0.5(zod@4.4.3) + '@ai-sdk/mcp': + specifier: 'catalog:' + version: 2.0.8(zod@4.4.3) + '@browserbasehq/stagehand-integrations': + specifier: workspace:* + version: link:../.. + ai: + specifier: 'catalog:' + version: 7.0.16(zod@4.4.3) + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + tsx: + specifier: 'catalog:' + version: 4.23.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/protocol: dependencies: camelcase-keys: @@ -735,6 +763,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@2.0.8': + resolution: {integrity: sha512-9PmSc+ObIxGieXsSXL3ghvAYGXj83V2mFo+FuG/8F1Ujv8d3pVD+fTnh3JdY0gKAT05Ehrtz5FGH9tLJGbQnMw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mistral@2.0.40': resolution: {integrity: sha512-NNrF4+7bXqYwGYTxfWifw4P6HbtPasBFaSfhMhMQ/f0DrXNZhd9EaL4WktwB/3A8F3rMkSwoehHch2Ps6EJG0A==} engines: {node: '>=18'} @@ -7539,6 +7573,13 @@ snapshots: '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/mcp@2.0.8(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.2 + '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) + pkce-challenge: 5.0.1 + zod: 4.4.3 + '@ai-sdk/mistral@2.0.40(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ffc7f1bd4..c32214057b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,11 @@ packages: - packages/* + - packages/integrations/examples/* catalogMode: prefer catalog: + "@ai-sdk/mcp": 2.0.8 "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 From 85e9ac3bfdcf2b2e6773660c548b956209fda3d5 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 11:52:43 -0700 Subject: [PATCH 02/24] test(integrations): smoke Vercel code mode in CI --- .../workflows/codemode-framework-examples.yml | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/codemode-framework-examples.yml diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml new file mode 100644 index 0000000000..996ce8ee6c --- /dev/null +++ b/.github/workflows/codemode-framework-examples.yml @@ -0,0 +1,53 @@ +name: Code-mode framework examples + +on: + pull_request: + paths: + - ".github/workflows/codemode-framework-examples.yml" + - "packages/integrations/**" + - "packages/extension/**" + - "packages/protocol/**" + - "packages/sdk-ts/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + push: + branches: [main, v4-spike] + paths: + - ".github/workflows/codemode-framework-examples.yml" + - "packages/integrations/**" + - "packages/extension/**" + - "packages/protocol/**" + - "packages/sdk-ts/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + vercel: + name: Vercel AI SDK + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: ./.github/actions/setup-node-pnpm + with: + use-prebuilt-artifacts: "false" + + - uses: ./.github/actions/setup-chrome-verified + id: setup-chrome + + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations + - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel typecheck + - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel smoke + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + STAGEHAND_BROWSER: local From f95a3bd2acf2911b242293b25fd8a52844a0ac90 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 15:20:22 -0700 Subject: [PATCH 03/24] fix(vercel): use current agent instructions field --- packages/integrations/examples/vercel/src/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/examples/vercel/src/agent.ts b/packages/integrations/examples/vercel/src/agent.ts index d9c116152d..80aa97f81b 100644 --- a/packages/integrations/examples/vercel/src/agent.ts +++ b/packages/integrations/examples/vercel/src/agent.ts @@ -43,7 +43,7 @@ export async function runStagehandAgent( try { const result = await generateText({ model, - system: STAGEHAND_CODEMODE_SKILL, + instructions: STAGEHAND_CODEMODE_SKILL, prompt, tools, stopWhen: stepCountIs(8), From d41e0f9d5988c5410dd114f4adb585aa7f9a135e Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:04:31 -0700 Subject: [PATCH 04/24] feat: add sandboxed code-mode image --- .dockerignore | 21 ++++ .github/workflows/codemode-image.yml | 120 ++++++++++++++++++++++ Dockerfile.codemode | 41 ++++++++ packages/integrations/README.md | 4 + packages/integrations/codemode/SANDBOX.md | 115 +++++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/codemode-image.yml create mode 100644 Dockerfile.codemode create mode 100644 packages/integrations/codemode/SANDBOX.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..a9a3e0d747 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +* +!package.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!tsconfig.json +!turbo.json +!packages +!packages/extension +!packages/extension/** +!packages/integrations +!packages/integrations/** +!packages/protocol +!packages/protocol/** +!packages/sdk-ts +!packages/sdk-ts/** + +**/.turbo +**/dist +**/node_modules +**/tests +**/*.test.ts diff --git a/.github/workflows/codemode-image.yml b/.github/workflows/codemode-image.yml new file mode 100644 index 0000000000..8c6ff31042 --- /dev/null +++ b/.github/workflows/codemode-image.yml @@ -0,0 +1,120 @@ +name: Code-mode MCP image + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - ".dockerignore" + - "Dockerfile.codemode" + - ".github/workflows/codemode-image.yml" + - "packages/integrations/**" + - "packages/extension/**" + - "packages/protocol/**" + - "packages/sdk-ts/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + push: + tags: + - "stagehand-codemode-v*.*.*-*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ghcr.io/browserbase/stagehand-codemode + +jobs: + build: + name: Build unprivileged image + if: >- + github.event_name == 'pull_request' && + (github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'safe-to-test')) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build image without publishing + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: Dockerfile.codemode + load: true + platforms: linux/amd64 + push: false + tags: stagehand-codemode:ci + + - name: Discover MCP tools without network access + run: | + request='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"container-smoke","version":"1.0.0"}}}' + initialized='{"jsonrpc":"2.0","method":"notifications/initialized"}' + list_tools='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + output="$(printf '%s\n%s\n%s\n' "$request" "$initialized" "$list_tools" | docker run --rm -i --network none stagehand-codemode:ci)" + grep -qF '"name":"code_execute"' <<<"$output" + printf 'code_execute discovery PASS\n' + + publish: + name: Publish immutable image + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate immutable tags and OCI labels + id: metadata + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE_NAME }} + flavor: latest=false + tags: | + type=sha,format=long,prefix=sha- + type=match,pattern=stagehand-codemode-v(.*),group=1 + labels: | + org.opencontainers.image.source=https://github.com/browserbase/stagehand + org.opencontainers.image.description=Stagehand code-mode MCP stdio server + org.opencontainers.image.licenses=MIT + + - name: Build and publish image + id: publish + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: Dockerfile.codemode + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + + - name: Attest image provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.publish.outputs.digest }} + push-to-registry: true diff --git a/Dockerfile.codemode b/Dockerfile.codemode new file mode 100644 index 0000000000..6a3b1b12e9 --- /dev/null +++ b/Dockerfile.codemode @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24.19.0-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN npm install --global pnpm@11.10.0 + +WORKDIR /workspace + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json turbo.json ./ +COPY packages/protocol ./packages/protocol +COPY packages/extension ./packages/extension +COPY packages/sdk-ts ./packages/sdk-ts +COPY packages/integrations ./packages/integrations + +RUN pnpm install --frozen-lockfile +RUN pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations... +RUN pnpm --filter @browserbasehq/stagehand-integrations deploy \ + --prod \ + --legacy \ + /opt/stagehand-codemode + +FROM node:24.19.0-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS runtime + +LABEL org.opencontainers.image.source="https://github.com/browserbase/stagehand" \ + org.opencontainers.image.description="Stagehand code-mode MCP stdio server" \ + org.opencontainers.image.licenses="MIT" + +ENV NODE_ENV=production +ENV NODE_OPTIONS=--enable-source-maps + +WORKDIR /opt/stagehand-codemode + +COPY --from=build --chown=node:node /opt/stagehand-codemode ./ + +USER node + +CMD ["node", "dist/codemode/stdio-server.mjs"] diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 3a5df2478a..b73aa4084d 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -69,3 +69,7 @@ Native callers run generated JavaScript in their own process. An `AbortSignal` c ### Security boundary The code-mode executor does not provide a sandbox. Generated JavaScript runs in the host process and inherits that process's filesystem, network, and environment access. A framework may place the tool inside its own sandbox, container, or other isolation boundary. + +For untrusted generated code, use the OCI image and microVM architecture in +[`codemode/SANDBOX.md`](./codemode/SANDBOX.md). The image packages the stdio server; the sandbox +provider supplies the security boundary. diff --git a/packages/integrations/codemode/SANDBOX.md b/packages/integrations/codemode/SANDBOX.md new file mode 100644 index 0000000000..7a2b1e0f5d --- /dev/null +++ b/packages/integrations/codemode/SANDBOX.md @@ -0,0 +1,115 @@ +# Run Stagehand code mode inside a sandbox + +Stagehand code mode evaluates model-generated JavaScript. Run the MCP server inside an ephemeral +microVM or equivalent sandbox when that JavaScript is not fully trusted. + +The `ghcr.io/browserbase/stagehand-codemode` image is a reproducible package for the stdio server. +It is **not** the security boundary. A container shares its host kernel; the sandbox provider must +isolate the container or process from the agent application's filesystem, processes, credentials, +and network. + +## Architecture + +```text +Agent application + └─ authenticated Streamable HTTP MCP client + └─ sandbox provider gateway + └─ Firecracker microVM (security boundary) + └─ Stagehand code-mode MCP (stdio) + └─ generated JavaScript + Stagehand browser +``` + +Keep stdio inside the sandbox. Expose only the provider's authenticated MCP endpoint to a hosted +agent framework. Give the sandbox only the browser credentials it needs, restrict network egress +where the provider supports it, and destroy the complete sandbox when the agent run finishes or +times out. + +## Pull an immutable image + +The image is built from this repository on Node.js 24 and runs as the non-root `node` user. It starts +`dist/codemode/stdio-server.mjs` by default. + +```bash +docker pull ghcr.io/browserbase/stagehand-codemode@sha256: +``` + +GHCR publishes a `sha-<40-character-git-commit>` tag for every permitted publish event. A digest is +the strongest production pin. The workflow never publishes `latest`. + +## E2B template + +[E2B custom images](https://e2b.dev/docs/template/base-image) currently require a Debian derivative, +which this image uses. Consume the final published image instead of passing `Dockerfile.codemode` to +`fromDockerfile()` because E2B's Dockerfile parser does not support multi-stage Dockerfiles. + +```ts +import { Template, defaultBuildLogger, waitForTimeout } from "e2b"; + +const image = "ghcr.io/browserbase/stagehand-codemode@sha256:"; + +const template = Template() + .fromImage(image) + // Override the image entrypoint while E2B snapshots the template. Start the + // stdio server per agent run so it receives that run's short-lived secrets. + .setStartCmd("sleep infinity", waitForTimeout(1_000)); + +await Template.build(template, "stagehand-codemode", { + cpuCount: 2, + memoryMB: 2_048, + onBuildLogs: defaultBuildLogger(), +}); +``` + +For hosted frameworks, use an MCP gateway inside the same E2B microVM. The current +[custom-server gateway](https://e2b.dev/docs/mcp/custom-servers) launches a GitHub checkout over +stdio, then gives the outside client an authenticated Streamable HTTP URL. Until that gateway can +pre-pull arbitrary GHCR servers, use its source-checkout configuration for the bridge and use this +image for providers that accept an OCI root image directly. + +## Other sandbox providers + +- [Modal `Image.from_registry()`](https://modal.com/docs/reference/modal.Image#from_registry) can + consume the GHCR image. Publish and select `linux/amd64` because Modal requires that architecture. +- [Vercel Sandbox custom images](https://vercel.com/docs/sandbox) boot in a Firecracker microVM, but + currently pull custom root images from Vercel Container Registry. Mirror the pinned GHCR digest to + VCR, or run this image with Docker inside the microVM; do not run generated code in the agent host. + +## Codex and Claude Code devboxes + +Codex and Claude Code commonly run inside the devbox. In that layout the CLI agent and Stagehand +stdio server are sibling processes inside one sandbox, so no HTTP bridge is necessary: + +```text +Firecracker microVM / devbox (security boundary) + ├─ Codex or Claude Code + └─ Stagehand code-mode MCP (stdio child process) +``` + +After installing the CLI in the sandbox image or an E2B template layer, register the extracted +entrypoint from inside the sandbox: + +```bash +codex mcp add stagehand -- \ + node /opt/stagehand-codemode/dist/codemode/stdio-server.mjs + +claude mcp add --transport stdio stagehand -- \ + node /opt/stagehand-codemode/dist/codemode/stdio-server.mjs +``` + +See the official [Codex MCP configuration](https://developers.openai.com/codex/mcp/) and +[Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) references. +Inject only the required browser credentials into the short-lived devbox environment; never bake +them into the image or a checked-in MCP configuration. When the CLI exits it closes the child's +stdin, which triggers graceful Stagehand cleanup. The sandbox owner must still enforce a deadline, +kill the whole process tree if cleanup stalls, and destroy the microVM. + +## Security checklist + +- Pin the image by digest and verify its provenance attestation. +- Put the stdio process and generated JavaScript inside the sandbox boundary. +- Pass only `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID`, and an explicit Stagehand model key when + required; do not forward the agent host's complete environment. +- Authenticate the external MCP endpoint and pin the MCP protocol version required by the gateway. +- Apply provider network policy. The sandbox boundary protects the host but does not prevent a + malicious snippet from reading secrets inside the sandbox or using allowed network egress. +- Close the MCP client, terminate the server process tree, and destroy the sandbox in cleanup paths. From bd2c80094fa541067a9245bed4c4ff16b7f19416 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:30:09 -0700 Subject: [PATCH 05/24] feat(vercel): run code mode in an E2B sandbox --- .../workflows/codemode-framework-examples.yml | 19 +- .../integrations/examples/vercel/README.md | 77 ++++++-- .../integrations/examples/vercel/package.json | 5 +- .../integrations/examples/vercel/src/agent.ts | 186 +++++++++++++++--- .../integrations/examples/vercel/src/e2e.ts | 49 +++-- .../integrations/examples/vercel/src/smoke.ts | 72 ++++--- pnpm-lock.yaml | 184 ++++++++++++++++- pnpm-workspace.yaml | 3 +- 8 files changed, 495 insertions(+), 100 deletions(-) diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 996ce8ee6c..961aa9f900 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -2,6 +2,7 @@ name: Code-mode framework examples on: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - ".github/workflows/codemode-framework-examples.yml" - "packages/integrations/**" @@ -31,10 +32,20 @@ concurrency: cancel-in-progress: true jobs: - vercel: - name: Vercel AI SDK + framework: + name: ${{ matrix.name }} + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'safe-to-test') runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Vercel AI SDK + package: "@browserbasehq/stagehand-integrations-example-vercel" steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -46,8 +57,8 @@ jobs: id: setup-chrome - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel typecheck - - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel smoke + - run: pnpm --filter ${{ matrix.package }} typecheck + - run: pnpm --filter ${{ matrix.package }} smoke env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} STAGEHAND_BROWSER: local diff --git a/packages/integrations/examples/vercel/README.md b/packages/integrations/examples/vercel/README.md index 84e55243cb..ba3d28050b 100644 --- a/packages/integrations/examples/vercel/README.md +++ b/packages/integrations/examples/vercel/README.md @@ -1,17 +1,72 @@ -# Vercel AI SDK + Stagehand code mode +# Vercel AI SDK with sandboxed Stagehand code mode -This example connects the Vercel AI SDK to the workspace's canonical Stagehand code-mode MCP server -over stdio. One MCP client remains open for the complete `generateText` call, so repeated -`code_execute` calls share the same browser context. +This example runs the Stagehand code-mode MCP server over stdio **inside an E2B Firecracker +microVM**. The Vercel AI SDK stays outside the sandbox and connects through E2B's authenticated +Streamable HTTP gateway. -The MCP child reads the normal code-mode startup environment. Set `STAGEHAND_BROWSER=local` for a -local headless browser or `STAGEHAND_BROWSER=browserbase` with Browserbase credentials for a remote -browser. +```text +Vercel AI SDK + model + └─ authenticated Streamable HTTP + └─ E2B Firecracker microVM + └─ Stagehand MCP over stdio + └─ generated JavaScript + Browserbase browser +``` + +## Install and run + +Set these variables in your host application. `STAGEHAND_REVISION` must be a complete commit hash +that contains the code-mode MCP server. ```bash -pnpm --filter @browserbasehq/stagehand-integrations build -STAGEHAND_BROWSER=local pnpm --filter @browserbasehq/stagehand-integrations-example-vercel smoke +E2B_API_KEY= +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +ANTHROPIC_API_KEY= +STAGEHAND_REVISION=<40-character-git-commit> + +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel e2e +``` + +The host uses `E2B_API_KEY` to create the microVM and `ANTHROPIC_API_KEY` for +[`claude-opus-5`](https://platform.claude.com/docs/en/about-claude/models/whats-new-opus-5). +Only the two Browserbase credentials are passed into the sandbox by default. If generated code uses +Stagehand AI methods, pass one explicit Stagehand model name and key through +`StagehandSandboxOptions`; do not forward the host's complete environment. + +## Use the binding + +```ts +import { anthropic } from "@ai-sdk/anthropic"; +import { generateText, stepCountIs } from "ai"; +import { createStagehandMcpBinding } from "./src/agent.js"; + +const stagehand = await createStagehandMcpBinding({ + stagehandRevision: process.env.STAGEHAND_REVISION!, + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, +}); + +try { + const result = await generateText({ + model: anthropic("claude-opus-5"), + tools: stagehand.tools, + stopWhen: stepCountIs(8), + prompt: "Open example.com and return the page title.", + }); + console.log(result.text); +} finally { + await stagehand.close(); +} ``` -See [`src/agent.ts`](./src/agent.ts) for the reusable connection lifecycle and -[`src/e2e.ts`](./src/e2e.ts) for a real model-driven example. +E2B's current gateway requires MCP protocol `2025-06-18`, so the binding pins that version. The +gateway prefixes custom GitHub tool names; the example remaps the discovered tool to the +provider-safe name `stagehand_code_execute` before giving it to the model. + +`close()` closes the MCP client and kills the complete sandbox. Binding creation also cleans up both +resources if gateway readiness or tool discovery fails. Always apply an application deadline and +kill the microVM if generated code stops responding. + +The `smoke` script is a deterministic, no-secrets CI contract test against a trusted local browser. +It is not the production security pattern. See the shared +[`SANDBOX.md`](../../codemode/SANDBOX.md) for image, credential, network, and lifecycle guidance. diff --git a/packages/integrations/examples/vercel/package.json b/packages/integrations/examples/vercel/package.json index 9583d70029..7725791978 100644 --- a/packages/integrations/examples/vercel/package.json +++ b/packages/integrations/examples/vercel/package.json @@ -9,10 +9,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ai-sdk/groq": "catalog:", + "@ai-sdk/anthropic": "catalog:", "@ai-sdk/mcp": "catalog:", "@browserbasehq/stagehand-integrations": "workspace:*", - "ai": "catalog:" + "ai": "catalog:", + "e2b": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/integrations/examples/vercel/src/agent.ts b/packages/integrations/examples/vercel/src/agent.ts index 80aa97f81b..6ab8756b4e 100644 --- a/packages/integrations/examples/vercel/src/agent.ts +++ b/packages/integrations/examples/vercel/src/agent.ts @@ -1,66 +1,194 @@ -import { fileURLToPath } from "node:url"; import { createMCPClient, type MCPClient } from "@ai-sdk/mcp"; -import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; import { STAGEHAND_CODEMODE_SKILL } from "@browserbasehq/stagehand-integrations/codemode"; -import { generateText, stepCountIs, type LanguageModel } from "ai"; +import { generateText, stepCountIs, type LanguageModel, type ToolSet } from "ai"; +import { Sandbox } from "e2b"; -const DEFAULT_STDIO_SERVER_PATH = fileURLToPath( - new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), -); +const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; +const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; +export const STAGEHAND_TOOL_NAME = "stagehand_code_execute"; + +export type StagehandSandboxOptions = { + stagehandRevision: string; + browserbaseApiKey: string; + browserbaseProjectId: string; + stagehandModelName?: string; + stagehandModelApiKey?: string; + readinessTimeoutMs?: number; + sandboxTimeoutMs?: number; +}; export type StagehandMcpBinding = { client: MCPClient; - tools: Awaited>; + sandbox: Sandbox; + tools: ToolSet; + close: () => Promise; }; export type StagehandAgentResult = { text: string; toolNames: string[]; + toolOutputs: unknown[]; }; export async function createStagehandMcpBinding( - stdioServerPath = DEFAULT_STDIO_SERVER_PATH, + options: StagehandSandboxOptions, ): Promise { - const client = await createMCPClient({ - transport: new Experimental_StdioMCPTransport({ - command: process.execPath, - args: [stdioServerPath], - env: definedEnvironment(), - }), - }); - - return { - client, - tools: await client.tools(), - }; + assertCommitHash(options.stagehandRevision); + const sandboxEnvironment = stagehandEnvironment(options); + let sandbox: Sandbox | undefined; + let client: MCPClient | undefined; + + try { + sandbox = await Sandbox.create({ + timeoutMs: options.sandboxTimeoutMs ?? 20 * 60_000, + envs: sandboxEnvironment, + mcp: { + [E2B_STAGEHAND_SERVER]: { + installCmd: [ + `git checkout --detach ${options.stagehandRevision}`, + "pnpm install --frozen-lockfile", + "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", + ].join(" && "), + runCmd: "node packages/integrations/dist/codemode/stdio-server.mjs", + }, + }, + }); + + const token = await sandbox.getMcpToken(); + if (!token) throw new Error("E2B did not return an MCP gateway token"); + + const connected = await connectWhenReady( + sandbox.getMcpUrl(), + token, + options.readinessTimeoutMs ?? 12 * 60_000, + ); + client = connected.client; + + return { + client, + sandbox, + // The MCP package and AI SDK expose structurally compatible tools through + // separate provider type versions. Keep the cast at this adapter boundary. + tools: { [STAGEHAND_TOOL_NAME]: connected.codeExecute } as ToolSet, + close: () => closeResources(client, sandbox), + }; + } catch (error) { + await closeResources(client, sandbox).catch(() => undefined); + throw error; + } } export async function runStagehandAgent( model: LanguageModel, prompt: string, + options: StagehandSandboxOptions, ): Promise { - const { client, tools } = await createStagehandMcpBinding(); + const binding = await createStagehandMcpBinding(options); + let primaryError: unknown; + try { const result = await generateText({ model, instructions: STAGEHAND_CODEMODE_SKILL, prompt, - tools, + tools: binding.tools, stopWhen: stepCountIs(8), }); return { text: result.text, toolNames: result.steps.flatMap((step) => step.toolCalls.map((call) => call.toolName)), + toolOutputs: result.steps.flatMap((step) => + step.toolResults.map((toolResult) => toolResult.output), + ), }; + } catch (error) { + primaryError = error; + throw error; } finally { - await client.close(); + try { + await binding.close(); + } catch (cleanupError) { + if (primaryError === undefined) throw cleanupError; + } + } +} + +async function connectWhenReady( + url: string, + token: string, + timeoutMs: number, +): Promise<{ + client: MCPClient; + codeExecute: Awaited>[string]; +}> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + let candidate: MCPClient | undefined; + try { + candidate = await createMCPClient({ + clientName: "stagehand-e2b-vercel", + transport: { + type: "http", + url, + headers: { Authorization: `Bearer ${token}` }, + // E2B's current MCP gateway rejects the newer default protocol version. + initialProtocolVersion: E2B_MCP_PROTOCOL_VERSION, + }, + }); + const remoteTools = await candidate.tools(); + const entries = Object.entries(remoteTools).filter(([name]) => name.endsWith("code_execute")); + if (entries.length !== 1 || !entries[0]?.[1]) { + throw new Error( + `Expected one Stagehand code_execute tool, received: ${Object.keys(remoteTools).join(", ") || "none"}`, + ); + } + return { client: candidate, codeExecute: entries[0][1] }; + } catch (error) { + lastError = error; + await candidate?.close().catch(() => undefined); + await delay(5_000); + } + } + + throw new Error("Timed out waiting for the Stagehand MCP server in E2B", { cause: lastError }); +} + +function stagehandEnvironment(options: StagehandSandboxOptions): Record { + const environment: Record = { + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: options.browserbaseApiKey, + BROWSERBASE_PROJECT_ID: options.browserbaseProjectId, + }; + if (options.stagehandModelName) environment.STAGEHAND_MODEL_NAME = options.stagehandModelName; + if (options.stagehandModelApiKey) { + environment.STAGEHAND_MODEL_API_KEY = options.stagehandModelApiKey; + } + return environment; +} + +async function closeResources(client?: MCPClient, sandbox?: Sandbox): Promise { + const errors: unknown[] = []; + try { + await client?.close(); + } catch (error) { + errors.push(error); + } + try { + await sandbox?.kill(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) throw new AggregateError(errors, "Could not close the Stagehand sandbox"); +} + +function assertCommitHash(revision: string): void { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); } } -function definedEnvironment(): Record { - return Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ), - ); +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/packages/integrations/examples/vercel/src/e2e.ts b/packages/integrations/examples/vercel/src/e2e.ts index 43e93be13b..b2dc8dcadd 100644 --- a/packages/integrations/examples/vercel/src/e2e.ts +++ b/packages/integrations/examples/vercel/src/e2e.ts @@ -1,25 +1,46 @@ -import { groq } from "@ai-sdk/groq"; -import { runStagehandAgent } from "./agent.js"; +import { strict as assert } from "node:assert"; -process.env.STAGEHAND_BROWSER ??= "local"; +import { anthropic } from "@ai-sdk/anthropic"; + +import { runStagehandAgent, STAGEHAND_TOOL_NAME } from "./agent.js"; const result = await runStagehandAgent( - groq(process.env.VERCEL_STAGEHAND_MODEL ?? "openai/gpt-oss-120b"), + anthropic(process.env.VERCEL_STAGEHAND_MODEL ?? "claude-opus-5"), [ - "Use code_execute exactly twice.", + `Use ${STAGEHAND_TOOL_NAME} exactly twice.`, "First navigate to https://example.com, open one additional blank tab, then restore the Example Domain page as active.", - "Second return the active page title and total context page count.", + "Second return an object with the active page title and total context page count.", "Report the title and count in your final answer.", ].join(" "), + { + stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), + browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), + browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), + }, +); + +assert.deepEqual(result.toolNames, [STAGEHAND_TOOL_NAME, STAGEHAND_TOOL_NAME]); +assert.ok( + result.toolOutputs.some((output) => containsBrowserState(output, "Example Domain", 2)), + `Expected structured title/page-count evidence: ${JSON.stringify(result.toolOutputs)}`, ); -if ( - result.toolNames.length !== 2 || - result.toolNames.some((name) => name !== "code_execute") || - !result.text.includes("Example Domain") || - !result.text.includes("2") -) { - throw new Error(`Unexpected agent result: ${JSON.stringify(result)}`); +process.stdout.write( + `${JSON.stringify({ status: "PASS", toolNames: result.toolNames, state: { title: "Example Domain", pages: 2 } })}\n`, +); + +function containsBrowserState(value: unknown, title: string, pages: number): boolean { + if (Array.isArray(value)) return value.some((entry) => containsBrowserState(entry, title, pages)); + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (record.title === title && (record.pages === pages || record.pageCount === pages)) { + return true; + } + return Object.values(record).some((entry) => containsBrowserState(entry, title, pages)); } -process.stdout.write(`${JSON.stringify({ status: "PASS", ...result })}\n`); +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} diff --git a/packages/integrations/examples/vercel/src/smoke.ts b/packages/integrations/examples/vercel/src/smoke.ts index 274b9c3731..642f4ecb01 100644 --- a/packages/integrations/examples/vercel/src/smoke.ts +++ b/packages/integrations/examples/vercel/src/smoke.ts @@ -1,26 +1,29 @@ -import { createStagehandMcpBinding } from "./agent.js"; +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; + +import { createMCPClient, type MCPClient } from "@ai-sdk/mcp"; +import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; import type { Tool } from "ai"; -process.env.STAGEHAND_BROWSER ??= "local"; +const stdioServerPath = fileURLToPath( + new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), +); +let client: MCPClient | undefined; -const { client, tools } = await createStagehandMcpBinding(); try { - const names = Object.keys(tools); - if (names.length !== 1 || names[0] !== "code_execute") { - throw new Error(`Expected one code_execute tool, got ${names.join(", ")}`); - } + client = await createMCPClient({ + transport: new Experimental_StdioMCPTransport({ + command: process.execPath, + args: [stdioServerPath], + env: localSmokeEnvironment(), + }), + }); + const tools = await client.tools(); + assert.deepEqual(Object.keys(tools), ["code_execute"]); const tool = tools.code_execute as Tool<{ code: string }, unknown>; - if (typeof tool?.execute !== "function") { - throw new Error("Vercel AI SDK did not expose code_execute as executable."); - } - const description = - typeof tool.description === "function" ? tool.description({ context: {} }) : tool.description; - if (!description?.includes("Stagehand V4 code-mode syntax")) { - throw new Error("code_execute did not include the canonical Stagehand guidance."); - } - - const first = await tool.execute( + assert.equal(typeof tool?.execute, "function"); + const first = await tool.execute!( { code: ` await page.goto("https://example.com", { waitUntil: "load" }); @@ -31,22 +34,33 @@ try { }, { context: {}, messages: [], toolCallId: "vercel-smoke-1" }, ); - const second = await tool.execute( - { - code: `return { title: await page.title(), pages: (await context.pages()).length };`, - }, + const second = await tool.execute!( + { code: `return { title: await page.title(), pages: (await context.pages()).length };` }, { context: {}, messages: [], toolCallId: "vercel-smoke-2" }, ); - const firstText = JSON.stringify(first); - const secondText = JSON.stringify(second); - if (!firstText.includes("Example Domain") || !secondText.includes('"pages":2')) { - throw new Error(`Expected browser state to persist across calls: ${firstText} ${secondText}`); - } - + assert.ok(containsBrowserState(first, "Example Domain", 2), JSON.stringify(first)); + assert.ok(containsBrowserState(second, "Example Domain", 2), JSON.stringify(second)); process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: names, statePersisted: true })}\n`, + `${JSON.stringify({ status: "PASS", tools: ["code_execute"], statePersisted: true })}\n`, ); } finally { - await client.close(); + await client?.close().catch(() => undefined); +} + +function localSmokeEnvironment(): Record { + const environment: Record = { STAGEHAND_BROWSER: "local" }; + for (const name of ["CHROME_PATH", "HOME", "PATH", "TMPDIR"]) { + const value = process.env[name]; + if (value) environment[name] = value; + } + return environment; +} + +function containsBrowserState(value: unknown, title: string, pages: number): boolean { + if (Array.isArray(value)) return value.some((entry) => containsBrowserState(entry, title, pages)); + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (record.title === title && record.pages === pages) return true; + return Object.values(record).some((entry) => containsBrowserState(entry, title, pages)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a87be92090..3116a23667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,8 +218,8 @@ catalogs: specifier: ^4.0.5 version: 4.0.5 '@ai-sdk/mcp': - specifier: 2.0.8 - version: 2.0.8 + specifier: 2.0.21 + version: 2.0.21 '@ai-sdk/openai': specifier: ^4.0.8 version: 4.0.8 @@ -289,6 +289,9 @@ catalogs: dotenv: specifier: ^17.4.2 version: 17.4.2 + e2b: + specifier: 2.37.0 + version: 2.37.0 esbuild: specifier: 0.28.1 version: 0.28.1 @@ -594,18 +597,21 @@ importers: packages/integrations/examples/vercel: dependencies: - '@ai-sdk/groq': + '@ai-sdk/anthropic': specifier: 'catalog:' - version: 4.0.5(zod@4.4.3) + version: 4.0.8(zod@4.4.3) '@ai-sdk/mcp': specifier: 'catalog:' - version: 2.0.8(zod@4.4.3) + version: 2.0.21(zod@4.4.3) '@browserbasehq/stagehand-integrations': specifier: workspace:* version: link:../.. ai: specifier: 'catalog:' version: 7.0.16(zod@4.4.3) + e2b: + specifier: 'catalog:' + version: 2.37.0 devDependencies: '@types/node': specifier: 'catalog:' @@ -769,8 +775,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/mcp@2.0.8': - resolution: {integrity: sha512-9PmSc+ObIxGieXsSXL3ghvAYGXj83V2mFo+FuG/8F1Ujv8d3pVD+fTnh3JdY0gKAT05Ehrtz5FGH9tLJGbQnMw==} + '@ai-sdk/mcp@2.0.21': + resolution: {integrity: sha512-IC7mhtIX551SGp3jyFveNpwWpvzju/2ah4+Wsmsj4OMXOVCBChdwNpanZTGGfTaugqUoqtRgsOZ7dS2jJ4ix7Q==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -817,6 +823,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.17': + resolution: {integrity: sha512-U3h3xgaga1OOELBxhtwTfgfr0z++kHCD4YSFm4pfcqzUWolntINS0/21ktZ/5k2A1ozAiRPYqkCg90iwiYrwyw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.5': resolution: {integrity: sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==} engines: {node: '>=22'} @@ -835,6 +847,10 @@ packages: resolution: {integrity: sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==} engines: {node: '>=22'} + '@ai-sdk/provider@4.0.4': + resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==} + engines: {node: '>=22'} + '@ai-sdk/togetherai@1.0.49': resolution: {integrity: sha512-g4BpEatN7flh3GZ0CN9KvAUX6uLPmIqGSrKKFvAmC3HZdnF940zl+ChXs3atdbtpr6+cwirxM5RACbUzr0uYhA==} engines: {node: '>=18'} @@ -1084,6 +1100,9 @@ packages: puppeteer-core: optional: true + '@bufbuild/protobuf@2.13.0': + resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} + '@canvas/image-data@1.1.0': resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} @@ -1148,6 +1167,17 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@connectrpc/connect-web@2.1.2': + resolution: {integrity: sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.2 + + '@connectrpc/connect@2.1.2': + resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -3718,6 +3748,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -3969,6 +4002,9 @@ packages: resolution: {integrity: sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==} engines: {node: '>=6'} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4007,6 +4043,10 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + e2b@2.37.0: + resolution: {integrity: sha512-OWmTHwgQlPmTyCMUrcFReq9zhqsuwJn14p2AcCgYIcJifFnl1sJOrm/vZBCXe82SU7ZCgbzB3sIj7iFsZwewXw==} + engines: {node: '>=20.18.1 <21 || >=22'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4516,6 +4556,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -5216,6 +5260,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -5846,9 +5894,15 @@ packages: zod: optional: true + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} @@ -5997,6 +6051,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -6057,6 +6115,9 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + playwright-core@1.56.1: resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} engines: {node: '>=18'} @@ -6873,6 +6934,10 @@ packages: resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} engines: {node: '>=18'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -7076,6 +7141,14 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.8.0: + resolution: {integrity: sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==} + engines: {node: '>=22.19.0'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7285,6 +7358,12 @@ packages: jsdom: optional: true + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -7579,10 +7658,10 @@ snapshots: '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/mcp@2.0.8(zod@4.4.3)': + '@ai-sdk/mcp@2.0.21(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.2 - '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) + '@ai-sdk/provider': 4.0.4 + '@ai-sdk/provider-utils': 5.0.17(zod@4.4.3) pkce-challenge: 5.0.1 zod: 4.4.3 @@ -7633,6 +7712,15 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.4.3 + '@ai-sdk/provider-utils@5.0.17(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.4 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + undici: 7.29.0 + zod: 4.4.3 + '@ai-sdk/provider-utils@5.0.5(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.2 @@ -7653,6 +7741,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@4.0.4': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/togetherai@1.0.49(zod@4.4.3)': dependencies: '@ai-sdk/openai-compatible': 1.0.46(zod@4.4.3) @@ -7951,6 +8043,8 @@ snapshots: - supports-color - utf-8-validate + '@bufbuild/protobuf@2.13.0': {} + '@canvas/image-data@1.1.0': {} '@changesets/apply-release-plan@7.1.1': @@ -8111,6 +8205,15 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@connectrpc/connect-web@2.1.2(@bufbuild/protobuf@2.13.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0))': + dependencies: + '@bufbuild/protobuf': 2.13.0 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.13.0) + + '@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0)': + dependencies: + '@bufbuild/protobuf': 2.13.0 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -10685,6 +10788,8 @@ snapshots: commander@8.3.0: {} + compare-versions@6.1.1: {} + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -10897,6 +11002,11 @@ snapshots: dependencies: dns-packet: 5.6.1 + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -10929,6 +11039,22 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + e2b@2.37.0: + dependencies: + '@bufbuild/protobuf': 2.13.0 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.13.0) + '@connectrpc/connect-web': 2.1.2(@bufbuild/protobuf@2.13.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0)) + chalk: 5.6.2 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 13.0.6 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.22 + undici: 7.29.0 + optionalDependencies: + undici8: undici@8.8.0 + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -11642,6 +11768,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.1.6: dependencies: fs.realpath: 1.0.0 @@ -12449,6 +12581,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lru-cache@7.18.3: {} magic-string@0.30.21: @@ -13251,8 +13385,14 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0) zod: 4.4.3 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + openapi-types@12.1.3: {} + openapi-typescript-helpers@0.0.15: {} + openid-client@6.8.2: dependencies: jose: 6.2.4 @@ -13439,6 +13579,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@0.1.13: {} path-to-regexp@8.4.2: {} @@ -13503,6 +13648,8 @@ snapshots: pkce-challenge@5.0.1: {} + platform@1.3.6: {} + playwright-core@1.56.1: {} playwright@1.56.1: @@ -14709,6 +14856,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + teex@1.0.1: dependencies: streamx: 2.28.0 @@ -14916,6 +15071,11 @@ snapshots: undici-types@7.24.6: {} + undici@7.29.0: {} + + undici@8.8.0: + optional: true + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15147,6 +15307,10 @@ snapshots: transitivePeerDependencies: - msw + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.18.0: {} + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c32214057b..1e83175820 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,8 @@ packages: catalogMode: prefer catalog: - "@ai-sdk/mcp": 2.0.8 + "@ai-sdk/mcp": 2.0.21 + e2b: 2.37.0 "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 From e4bbe74da75455afbe371bb7cf4175699abc415d Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:30:49 -0700 Subject: [PATCH 06/24] fix: cover tsconfig changes in image CI --- .github/workflows/codemode-image.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codemode-image.yml b/.github/workflows/codemode-image.yml index 8c6ff31042..7f91729dc9 100644 --- a/.github/workflows/codemode-image.yml +++ b/.github/workflows/codemode-image.yml @@ -14,6 +14,7 @@ on: - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "tsconfig.json" - "turbo.json" push: tags: From df3959db4164abd2959725713eb4023f0cc52a5c Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:45:01 -0700 Subject: [PATCH 07/24] feat: add a source-installed E2B MCP boundary --- .dockerignore | 21 -- .../workflows/codemode-framework-examples.yml | 12 +- .github/workflows/codemode-image.yml | 121 ----------- Dockerfile.codemode | 41 ---- packages/integrations/README.md | 7 +- packages/integrations/codemode/SANDBOX.md | 115 ----------- packages/integrations/examples/e2b/README.md | 98 +++++++++ .../examples/{vercel => e2b}/package.json | 9 +- packages/integrations/examples/e2b/src/e2e.ts | 117 +++++++++++ .../integrations/examples/e2b/src/sandbox.ts | 144 +++++++++++++ .../integrations/examples/e2b/src/smoke.ts | 72 +++++++ .../examples/{vercel => e2b}/tsconfig.json | 0 .../integrations/examples/vercel/README.md | 72 ------- .../integrations/examples/vercel/src/agent.ts | 194 ------------------ .../integrations/examples/vercel/src/e2e.ts | 46 ----- .../integrations/examples/vercel/src/smoke.ts | 66 ------ pnpm-lock.yaml | 51 +---- pnpm-workspace.yaml | 1 - 18 files changed, 450 insertions(+), 737 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/codemode-image.yml delete mode 100644 Dockerfile.codemode delete mode 100644 packages/integrations/codemode/SANDBOX.md create mode 100644 packages/integrations/examples/e2b/README.md rename packages/integrations/examples/{vercel => e2b}/package.json (73%) create mode 100644 packages/integrations/examples/e2b/src/e2e.ts create mode 100644 packages/integrations/examples/e2b/src/sandbox.ts create mode 100644 packages/integrations/examples/e2b/src/smoke.ts rename packages/integrations/examples/{vercel => e2b}/tsconfig.json (100%) delete mode 100644 packages/integrations/examples/vercel/README.md delete mode 100644 packages/integrations/examples/vercel/src/agent.ts delete mode 100644 packages/integrations/examples/vercel/src/e2e.ts delete mode 100644 packages/integrations/examples/vercel/src/smoke.ts diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a9a3e0d747..0000000000 --- a/.dockerignore +++ /dev/null @@ -1,21 +0,0 @@ -* -!package.json -!pnpm-lock.yaml -!pnpm-workspace.yaml -!tsconfig.json -!turbo.json -!packages -!packages/extension -!packages/extension/** -!packages/integrations -!packages/integrations/** -!packages/protocol -!packages/protocol/** -!packages/sdk-ts -!packages/sdk-ts/** - -**/.turbo -**/dist -**/node_modules -**/tests -**/*.test.ts diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 961aa9f900..6ef21e4611 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -1,4 +1,4 @@ -name: Code-mode framework examples +name: Code-mode sandbox examples on: pull_request: @@ -9,8 +9,10 @@ on: - "packages/extension/**" - "packages/protocol/**" - "packages/sdk-ts/**" + - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "tsconfig.json" - "turbo.json" push: branches: [main, v4-spike] @@ -20,8 +22,10 @@ on: - "packages/extension/**" - "packages/protocol/**" - "packages/sdk-ts/**" + - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "tsconfig.json" - "turbo.json" permissions: @@ -32,7 +36,7 @@ concurrency: cancel-in-progress: true jobs: - framework: + example: name: ${{ matrix.name }} if: >- github.event_name == 'push' || @@ -44,8 +48,8 @@ jobs: fail-fast: false matrix: include: - - name: Vercel AI SDK - package: "@browserbasehq/stagehand-integrations-example-vercel" + - name: E2B source-installed MCP + package: "@browserbasehq/stagehand-integrations-example-e2b" steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 diff --git a/.github/workflows/codemode-image.yml b/.github/workflows/codemode-image.yml deleted file mode 100644 index 7f91729dc9..0000000000 --- a/.github/workflows/codemode-image.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Code-mode MCP image - -on: - pull_request: - types: [opened, synchronize, reopened, labeled] - paths: - - ".dockerignore" - - "Dockerfile.codemode" - - ".github/workflows/codemode-image.yml" - - "packages/integrations/**" - - "packages/extension/**" - - "packages/protocol/**" - - "packages/sdk-ts/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "tsconfig.json" - - "turbo.json" - push: - tags: - - "stagehand-codemode-v*.*.*-*" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - IMAGE_NAME: ghcr.io/browserbase/stagehand-codemode - -jobs: - build: - name: Build unprivileged image - if: >- - github.event_name == 'pull_request' && - (github.event.pull_request.head.repo.full_name == github.repository || - contains(github.event.pull_request.labels.*.name, 'safe-to-test')) - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Build image without publishing - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: Dockerfile.codemode - load: true - platforms: linux/amd64 - push: false - tags: stagehand-codemode:ci - - - name: Discover MCP tools without network access - run: | - request='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"container-smoke","version":"1.0.0"}}}' - initialized='{"jsonrpc":"2.0","method":"notifications/initialized"}' - list_tools='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' - output="$(printf '%s\n%s\n%s\n' "$request" "$initialized" "$list_tools" | docker run --rm -i --network none stagehand-codemode:ci)" - grep -qF '"name":"code_execute"' <<<"$output" - printf 'code_execute discovery PASS\n' - - publish: - name: Publish immutable image - if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - packages: write - attestations: write - id-token: write - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate immutable tags and OCI labels - id: metadata - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ${{ env.IMAGE_NAME }} - flavor: latest=false - tags: | - type=sha,format=long,prefix=sha- - type=match,pattern=stagehand-codemode-v(.*),group=1 - labels: | - org.opencontainers.image.source=https://github.com/browserbase/stagehand - org.opencontainers.image.description=Stagehand code-mode MCP stdio server - org.opencontainers.image.licenses=MIT - - - name: Build and publish image - id: publish - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: Dockerfile.codemode - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - - - name: Attest image provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-name: ${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.publish.outputs.digest }} - push-to-registry: true diff --git a/Dockerfile.codemode b/Dockerfile.codemode deleted file mode 100644 index 6a3b1b12e9..0000000000 --- a/Dockerfile.codemode +++ /dev/null @@ -1,41 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM node:24.19.0-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build - -ENV PNPM_HOME=/pnpm -ENV PATH=$PNPM_HOME:$PATH -ENV TURBO_TELEMETRY_DISABLED=1 - -RUN npm install --global pnpm@11.10.0 - -WORKDIR /workspace - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json turbo.json ./ -COPY packages/protocol ./packages/protocol -COPY packages/extension ./packages/extension -COPY packages/sdk-ts ./packages/sdk-ts -COPY packages/integrations ./packages/integrations - -RUN pnpm install --frozen-lockfile -RUN pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations... -RUN pnpm --filter @browserbasehq/stagehand-integrations deploy \ - --prod \ - --legacy \ - /opt/stagehand-codemode - -FROM node:24.19.0-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS runtime - -LABEL org.opencontainers.image.source="https://github.com/browserbase/stagehand" \ - org.opencontainers.image.description="Stagehand code-mode MCP stdio server" \ - org.opencontainers.image.licenses="MIT" - -ENV NODE_ENV=production -ENV NODE_OPTIONS=--enable-source-maps - -WORKDIR /opt/stagehand-codemode - -COPY --from=build --chown=node:node /opt/stagehand-codemode ./ - -USER node - -CMD ["node", "dist/codemode/stdio-server.mjs"] diff --git a/packages/integrations/README.md b/packages/integrations/README.md index a594136e08..39254b76d3 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -46,7 +46,7 @@ The process stays alive across calls and closes when its input stream ends. `SIG ### Framework examples -- [Vercel AI SDK](./examples/vercel) launches the stdio server through the AI SDK MCP client and keeps one process alive for the complete agent run. +- [E2B sandbox](./examples/e2b) source-installs the stdio server inside a Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection. ### Configuration @@ -74,6 +74,5 @@ Native callers run generated JavaScript in their own process. An `AbortSignal` c The code-mode executor does not provide a sandbox. Generated JavaScript runs in the host process and inherits that process's filesystem, network, and environment access. A framework may place the tool inside its own sandbox, container, or other isolation boundary. -For untrusted generated code, use the OCI image and microVM architecture in -[`codemode/SANDBOX.md`](./codemode/SANDBOX.md). The image packages the stdio server; the sandbox -provider supplies the security boundary. +For untrusted generated code, use the source-installed microVM architecture in the +[E2B sandbox example](./examples/e2b). The sandbox provider supplies the security boundary. diff --git a/packages/integrations/codemode/SANDBOX.md b/packages/integrations/codemode/SANDBOX.md deleted file mode 100644 index 7a2b1e0f5d..0000000000 --- a/packages/integrations/codemode/SANDBOX.md +++ /dev/null @@ -1,115 +0,0 @@ -# Run Stagehand code mode inside a sandbox - -Stagehand code mode evaluates model-generated JavaScript. Run the MCP server inside an ephemeral -microVM or equivalent sandbox when that JavaScript is not fully trusted. - -The `ghcr.io/browserbase/stagehand-codemode` image is a reproducible package for the stdio server. -It is **not** the security boundary. A container shares its host kernel; the sandbox provider must -isolate the container or process from the agent application's filesystem, processes, credentials, -and network. - -## Architecture - -```text -Agent application - └─ authenticated Streamable HTTP MCP client - └─ sandbox provider gateway - └─ Firecracker microVM (security boundary) - └─ Stagehand code-mode MCP (stdio) - └─ generated JavaScript + Stagehand browser -``` - -Keep stdio inside the sandbox. Expose only the provider's authenticated MCP endpoint to a hosted -agent framework. Give the sandbox only the browser credentials it needs, restrict network egress -where the provider supports it, and destroy the complete sandbox when the agent run finishes or -times out. - -## Pull an immutable image - -The image is built from this repository on Node.js 24 and runs as the non-root `node` user. It starts -`dist/codemode/stdio-server.mjs` by default. - -```bash -docker pull ghcr.io/browserbase/stagehand-codemode@sha256: -``` - -GHCR publishes a `sha-<40-character-git-commit>` tag for every permitted publish event. A digest is -the strongest production pin. The workflow never publishes `latest`. - -## E2B template - -[E2B custom images](https://e2b.dev/docs/template/base-image) currently require a Debian derivative, -which this image uses. Consume the final published image instead of passing `Dockerfile.codemode` to -`fromDockerfile()` because E2B's Dockerfile parser does not support multi-stage Dockerfiles. - -```ts -import { Template, defaultBuildLogger, waitForTimeout } from "e2b"; - -const image = "ghcr.io/browserbase/stagehand-codemode@sha256:"; - -const template = Template() - .fromImage(image) - // Override the image entrypoint while E2B snapshots the template. Start the - // stdio server per agent run so it receives that run's short-lived secrets. - .setStartCmd("sleep infinity", waitForTimeout(1_000)); - -await Template.build(template, "stagehand-codemode", { - cpuCount: 2, - memoryMB: 2_048, - onBuildLogs: defaultBuildLogger(), -}); -``` - -For hosted frameworks, use an MCP gateway inside the same E2B microVM. The current -[custom-server gateway](https://e2b.dev/docs/mcp/custom-servers) launches a GitHub checkout over -stdio, then gives the outside client an authenticated Streamable HTTP URL. Until that gateway can -pre-pull arbitrary GHCR servers, use its source-checkout configuration for the bridge and use this -image for providers that accept an OCI root image directly. - -## Other sandbox providers - -- [Modal `Image.from_registry()`](https://modal.com/docs/reference/modal.Image#from_registry) can - consume the GHCR image. Publish and select `linux/amd64` because Modal requires that architecture. -- [Vercel Sandbox custom images](https://vercel.com/docs/sandbox) boot in a Firecracker microVM, but - currently pull custom root images from Vercel Container Registry. Mirror the pinned GHCR digest to - VCR, or run this image with Docker inside the microVM; do not run generated code in the agent host. - -## Codex and Claude Code devboxes - -Codex and Claude Code commonly run inside the devbox. In that layout the CLI agent and Stagehand -stdio server are sibling processes inside one sandbox, so no HTTP bridge is necessary: - -```text -Firecracker microVM / devbox (security boundary) - ├─ Codex or Claude Code - └─ Stagehand code-mode MCP (stdio child process) -``` - -After installing the CLI in the sandbox image or an E2B template layer, register the extracted -entrypoint from inside the sandbox: - -```bash -codex mcp add stagehand -- \ - node /opt/stagehand-codemode/dist/codemode/stdio-server.mjs - -claude mcp add --transport stdio stagehand -- \ - node /opt/stagehand-codemode/dist/codemode/stdio-server.mjs -``` - -See the official [Codex MCP configuration](https://developers.openai.com/codex/mcp/) and -[Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) references. -Inject only the required browser credentials into the short-lived devbox environment; never bake -them into the image or a checked-in MCP configuration. When the CLI exits it closes the child's -stdin, which triggers graceful Stagehand cleanup. The sandbox owner must still enforce a deadline, -kill the whole process tree if cleanup stalls, and destroy the microVM. - -## Security checklist - -- Pin the image by digest and verify its provenance attestation. -- Put the stdio process and generated JavaScript inside the sandbox boundary. -- Pass only `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID`, and an explicit Stagehand model key when - required; do not forward the agent host's complete environment. -- Authenticate the external MCP endpoint and pin the MCP protocol version required by the gateway. -- Apply provider network policy. The sandbox boundary protects the host but does not prevent a - malicious snippet from reading secrets inside the sandbox or using allowed network egress. -- Close the MCP client, terminate the server process tree, and destroy the sandbox in cleanup paths. diff --git a/packages/integrations/examples/e2b/README.md b/packages/integrations/examples/e2b/README.md new file mode 100644 index 0000000000..370bbc0f04 --- /dev/null +++ b/packages/integrations/examples/e2b/README.md @@ -0,0 +1,98 @@ +# Run Stagehand code mode in an E2B sandbox + +Use this example when an agent framework runs on your host but Stagehand code mode must execute +untrusted JavaScript behind a microVM boundary. + +```text +Your MCP client + └─ E2B bearer-authenticated Streamable HTTP + └─ E2B Firecracker microVM + └─ Stagehand MCP over stdio + └─ generated JavaScript + Browserbase browser +``` + +The E2B package is a private workspace example that exports one framework-neutral contract: + +```ts +type StagehandSandboxConnection = { + url: URL; + token: string; + close: () => Promise; +}; +``` + +`createStagehandSandbox()` asks E2B's custom MCP gateway to clone a complete Stagehand commit, +build the code-mode package from source, and start its stdio server. It waits for exactly one +`code_execute` tool, applies the runtime egress policy, and only then returns the HTTP connection. +It does not depend on the Stagehand OCI image. + +## Install and run + +Set these variables on the host. `BROWSERBASE_PROJECT_ID` is optional. The default CDP allowlist is +the US West host observed in the live proof; set `BROWSERBASE_CDP_HOSTS` to the comma-separated CDP +hostnames returned for your Browserbase region. + +```bash +E2B_API_KEY= +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= +BROWSERBASE_CDP_HOSTS=connect.usw2.browserbase.com +STAGEHAND_REVISION=<40-character-git-commit> + +pnpm --filter @browserbasehq/stagehand-integrations-example-e2b e2e +``` + +## Connect an MCP client + +This raw [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) +example is the adapter boundary that agent frameworks build on: + +```ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createStagehandSandbox } from "@browserbasehq/stagehand-integrations-example-e2b"; + +const stagehand = await createStagehandSandbox({ + stagehandRevision: process.env.STAGEHAND_REVISION!, + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID, + browserbaseCdpHosts: ["connect.usw2.browserbase.com"], +}); +const client = new Client({ name: "my-agent", version: "1.0.0" }); +const transport = new StreamableHTTPClientTransport(stagehand.url, { + requestInit: { headers: { Authorization: `Bearer ${stagehand.token}` } }, +}); +transport.setProtocolVersion("2025-06-18"); + +try { + await client.connect(transport); + const tools = await client.listTools(); + console.log(tools); +} finally { + await client.close(); + await stagehand.close(); +} +``` + +E2B's current gateway requires MCP protocol `2025-06-18`. Authentication is the bearer token from +E2B's built-in MCP gateway; this example does not add a second proxy or application-defined secret. + +## Network and credential boundary + +The source checkout and dependency build need normal package-network access. After readiness succeeds, +`sandbox.updateNetwork()` atomically replaces that permissive setup with an allowlist containing only +`api.browserbase.com` and the configured Browserbase CDP hostnames. In E2B, setting `allowOut` makes +all unlisted egress denied by default. The live proof checks that Browserbase still works while an +unrelated host is blocked. + +Browserbase-only egress is the default. AI-backed Stagehand methods require a separately scoped model +credential **and** the model provider's exact API hostname added to the allowlist. Do not forward the +outer agent's model key into the microVM or broaden egress implicitly. + +Only the Browserbase key and optional project ID cross the sandbox boundary by default. A complete +commit hash prevents the source install from silently following a moving branch. Always close the MCP +client and call `close()`; the latter kills the complete microVM. Apply a host-side deadline and kill +the microVM when untrusted code stops responding. + +See [E2B custom MCP servers](https://e2b.dev/docs/mcp/custom-servers) for gateway and source-install +details. diff --git a/packages/integrations/examples/vercel/package.json b/packages/integrations/examples/e2b/package.json similarity index 73% rename from packages/integrations/examples/vercel/package.json rename to packages/integrations/examples/e2b/package.json index 7725791978..dadfb77002 100644 --- a/packages/integrations/examples/vercel/package.json +++ b/packages/integrations/examples/e2b/package.json @@ -1,18 +1,19 @@ { - "name": "@browserbasehq/stagehand-integrations-example-vercel", + "name": "@browserbasehq/stagehand-integrations-example-e2b", "version": "4.0.0", "private": true, "type": "module", + "exports": { + ".": "./src/sandbox.ts" + }, "scripts": { "e2e": "tsx src/e2e.ts", "smoke": "tsx src/smoke.ts", "typecheck": "tsc --noEmit" }, "dependencies": { - "@ai-sdk/anthropic": "catalog:", - "@ai-sdk/mcp": "catalog:", "@browserbasehq/stagehand-integrations": "workspace:*", - "ai": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", "e2b": "catalog:" }, "devDependencies": { diff --git a/packages/integrations/examples/e2b/src/e2e.ts b/packages/integrations/examples/e2b/src/e2e.ts new file mode 100644 index 0000000000..cc666981f6 --- /dev/null +++ b/packages/integrations/examples/e2b/src/e2e.ts @@ -0,0 +1,117 @@ +import { strict as assert } from "node:assert"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +import { createStagehandSandbox, stagehandTransport } from "./sandbox.js"; + +const markerPath = `/tmp/stagehand-e2b-source-proof-${randomUUID()}`; +const connection = await createStagehandSandbox({ + stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), + browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID, + browserbaseCdpHosts: (process.env.BROWSERBASE_CDP_HOSTS ?? "connect.usw2.browserbase.com") + .split(",") + .map((hostname) => hostname.trim()) + .filter(Boolean), +}); +const client = new Client({ name: "stagehand-e2b-proof", version: "1.0.0" }); +let primaryError: unknown; + +try { + await client.connect(stagehandTransport(connection.url, connection.token)); + const { tools } = await client.listTools(); + const codeTools = tools.filter((tool) => tool.name.endsWith("code_execute")); + assert.equal(tools.length, 1, tools.map((tool) => tool.name).join(", ")); + assert.equal(codeTools.length, 1, tools.map((tool) => tool.name).join(", ")); + const toolName = codeTools[0]!.name; + + const first = await client.callTool({ + name: toolName, + arguments: { + code: ` + const fs = await import("node:fs/promises"); + await fs.writeFile(${JSON.stringify(markerPath)}, "inside-e2b"); + await page.goto("https://example.com", { waitUntil: "load" }); + await context.newPage(); + await context.setActivePage(page); + let unrelatedEgressBlocked = false; + try { + await fetch("https://example.org", { signal: AbortSignal.timeout(5_000) }); + } catch { + unrelatedEgressBlocked = true; + } + return { + title: await page.title(), + pages: (await context.pages()).length, + marker: await fs.readFile(${JSON.stringify(markerPath)}, "utf8"), + hostname: (await fs.readFile("/etc/hostname", "utf8")).trim(), + unrelatedEgressBlocked, + }; + `, + }, + }); + const second = await client.callTool({ + name: toolName, + arguments: { + code: ` + const fs = await import("node:fs/promises"); + return { + title: await page.title(), + pages: (await context.pages()).length, + marker: await fs.readFile(${JSON.stringify(markerPath)}, "utf8"), + }; + `, + }, + }); + + assert.ok( + containsState(first.structuredContent, { + title: "Example Domain", + pages: 2, + marker: "inside-e2b", + unrelatedEgressBlocked: true, + }), + JSON.stringify(first.structuredContent), + ); + assert.ok( + containsState(second.structuredContent, { + title: "Example Domain", + pages: 2, + marker: "inside-e2b", + }), + JSON.stringify(second.structuredContent), + ); + assert.equal(existsSync(markerPath), false, "sandbox marker escaped to the host filesystem"); + + process.stdout.write( + `${JSON.stringify({ status: "PASS", tools: [toolName], statePersisted: true, unrelatedEgressBlocked: true, hostMarkerPresent: false })}\n`, + ); +} catch (error) { + primaryError = error; + throw error; +} finally { + const cleanupErrors: unknown[] = []; + await client.close().catch((error: unknown) => cleanupErrors.push(error)); + await connection.close().catch((error: unknown) => cleanupErrors.push(error)); + if (primaryError === undefined && cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Could not close the MCP client and E2B sandbox"); + } +} + +function containsState(value: unknown, expected: Record): boolean { + if (Array.isArray(value)) return value.some((entry) => containsState(entry, expected)); + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (Object.entries(expected).every(([key, expectedValue]) => record[key] === expectedValue)) { + return true; + } + return Object.values(record).some((entry) => containsState(entry, expected)); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts new file mode 100644 index 0000000000..059c10fb3b --- /dev/null +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -0,0 +1,144 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Sandbox } from "e2b"; + +const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; +const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; +const BROWSERBASE_API_HOST = "api.browserbase.com"; +const DEFAULT_BROWSERBASE_CDP_HOSTS = ["connect.usw2.browserbase.com"]; + +export type StagehandSandboxOptions = { + stagehandRevision: string; + browserbaseApiKey: string; + browserbaseProjectId?: string; + browserbaseCdpHosts?: string[]; + readinessTimeoutMs?: number; + sandboxTimeoutMs?: number; +}; + +export type StagehandSandboxConnection = { + url: URL; + token: string; + close: () => Promise; +}; + +/** + * Start Stagehand's stdio MCP server inside E2B, wait for it to become ready, + * then switch the running microVM to a Browserbase-only egress allowlist. + */ +export async function createStagehandSandbox( + options: StagehandSandboxOptions, +): Promise { + assertCommitHash(options.stagehandRevision); + const cdpHosts = options.browserbaseCdpHosts ?? DEFAULT_BROWSERBASE_CDP_HOSTS; + if (cdpHosts.length === 0) throw new Error("browserbaseCdpHosts must contain at least one host"); + const allowedHosts = [ + BROWSERBASE_API_HOST, + ...cdpHosts.map((hostname) => assertHostname(hostname.trim())), + ]; + let sandbox: Sandbox | undefined; + + try { + sandbox = await Sandbox.create({ + timeoutMs: options.sandboxTimeoutMs ?? 20 * 60_000, + envs: { + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: options.browserbaseApiKey, + ...(options.browserbaseProjectId + ? { BROWSERBASE_PROJECT_ID: options.browserbaseProjectId } + : {}), + }, + mcp: { + [E2B_STAGEHAND_SERVER]: { + installCmd: [ + `git checkout --detach ${options.stagehandRevision}`, + "corepack prepare pnpm@11.10.0 --activate", + "pnpm install --frozen-lockfile", + "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", + ].join(" && "), + runCmd: "node packages/integrations/dist/codemode/stdio-server.mjs", + }, + }, + }); + + const token = await sandbox.getMcpToken(); + if (!token) throw new Error("E2B did not return an MCP gateway token"); + const url = new URL(sandbox.getMcpUrl()); + + await waitForStagehand(url, token, options.readinessTimeoutMs ?? 12 * 60_000); + + // Supplying allowOut changes E2B egress from allow-all to default-deny. + // Do this only after the source checkout and build have completed. + await sandbox.updateNetwork({ allowOut: [...new Set(allowedHosts)] }); + + let closed = false; + return { + url, + token, + close: async () => { + if (closed) return; + await sandbox.kill(); + closed = true; + }, + }; + } catch (error) { + await sandbox?.kill().catch(() => undefined); + throw error; + } +} + +async function waitForStagehand(url: URL, token: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + const client = new Client({ name: "stagehand-e2b-readiness", version: "1.0.0" }); + const transport = stagehandTransport(url, token); + try { + await client.connect(transport); + const { tools } = await client.listTools(); + if (tools.length !== 1 || !tools[0]?.name.endsWith("code_execute")) { + throw new Error(`Expected one Stagehand code_execute tool, received: ${toolNames(tools)}`); + } + await client.close(); + return; + } catch (error) { + lastError = error; + await client.close().catch(() => undefined); + await delay(5_000); + } + } + + throw new Error("Timed out waiting for the Stagehand MCP server in E2B", { cause: lastError }); +} + +export function stagehandTransport(url: URL, token: string): StreamableHTTPClientTransport { + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }); + // E2B's current gateway requires this protocol version on gateway requests. + transport.setProtocolVersion(E2B_MCP_PROTOCOL_VERSION); + return transport; +} + +function assertCommitHash(revision: string): void { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); + } +} + +function assertHostname(hostname: string): string { + const parsed = new URL(`https://${hostname}`); + if (parsed.hostname !== hostname || parsed.port || parsed.pathname !== "/") { + throw new Error(`Expected a hostname without a scheme, port, or path: ${hostname}`); + } + return hostname; +} + +function toolNames(tools: Array<{ name: string }>): string { + return tools.map((tool) => tool.name).join(", ") || "none"; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/integrations/examples/e2b/src/smoke.ts b/packages/integrations/examples/e2b/src/smoke.ts new file mode 100644 index 0000000000..d7d37980a4 --- /dev/null +++ b/packages/integrations/examples/e2b/src/smoke.ts @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import { fileURLToPath } from "node:url"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const stdioServerPath = fileURLToPath( + new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), +); +const client = new Client({ name: "stagehand-stdio-smoke", version: "1.0.0" }); + +try { + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [stdioServerPath], + env: localSmokeEnvironment(), + stderr: "inherit", + }), + ); + const { tools } = await client.listTools(); + assert.deepEqual( + tools.map((tool) => tool.name), + ["code_execute"], + ); + + const first = await client.callTool({ + name: "code_execute", + arguments: { + code: ` + await page.goto("https://example.com", { waitUntil: "load" }); + await context.newPage(); + await context.setActivePage(page); + return { title: await page.title(), pages: (await context.pages()).length }; + `, + }, + }); + const second = await client.callTool({ + name: "code_execute", + arguments: { + code: `return { title: await page.title(), pages: (await context.pages()).length };`, + }, + }); + + assert.ok(containsBrowserState(first.structuredContent), JSON.stringify(first.structuredContent)); + assert.ok( + containsBrowserState(second.structuredContent), + JSON.stringify(second.structuredContent), + ); + process.stdout.write( + `${JSON.stringify({ status: "PASS", tools: ["code_execute"], statePersisted: true })}\n`, + ); +} finally { + await client.close().catch(() => undefined); +} + +function localSmokeEnvironment(): Record { + const environment: Record = { STAGEHAND_BROWSER: "local" }; + for (const name of ["CHROME_PATH", "CI", "HOME", "PATH", "TMPDIR"]) { + const value = process.env[name]; + if (value) environment[name] = value; + } + return environment; +} + +function containsBrowserState(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsBrowserState); + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (record.title === "Example Domain" && record.pages === 2) return true; + return Object.values(record).some(containsBrowserState); +} diff --git a/packages/integrations/examples/vercel/tsconfig.json b/packages/integrations/examples/e2b/tsconfig.json similarity index 100% rename from packages/integrations/examples/vercel/tsconfig.json rename to packages/integrations/examples/e2b/tsconfig.json diff --git a/packages/integrations/examples/vercel/README.md b/packages/integrations/examples/vercel/README.md deleted file mode 100644 index ba3d28050b..0000000000 --- a/packages/integrations/examples/vercel/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Vercel AI SDK with sandboxed Stagehand code mode - -This example runs the Stagehand code-mode MCP server over stdio **inside an E2B Firecracker -microVM**. The Vercel AI SDK stays outside the sandbox and connects through E2B's authenticated -Streamable HTTP gateway. - -```text -Vercel AI SDK + model - └─ authenticated Streamable HTTP - └─ E2B Firecracker microVM - └─ Stagehand MCP over stdio - └─ generated JavaScript + Browserbase browser -``` - -## Install and run - -Set these variables in your host application. `STAGEHAND_REVISION` must be a complete commit hash -that contains the code-mode MCP server. - -```bash -E2B_API_KEY= -BROWSERBASE_API_KEY= -BROWSERBASE_PROJECT_ID= -ANTHROPIC_API_KEY= -STAGEHAND_REVISION=<40-character-git-commit> - -pnpm --filter @browserbasehq/stagehand-integrations-example-vercel e2e -``` - -The host uses `E2B_API_KEY` to create the microVM and `ANTHROPIC_API_KEY` for -[`claude-opus-5`](https://platform.claude.com/docs/en/about-claude/models/whats-new-opus-5). -Only the two Browserbase credentials are passed into the sandbox by default. If generated code uses -Stagehand AI methods, pass one explicit Stagehand model name and key through -`StagehandSandboxOptions`; do not forward the host's complete environment. - -## Use the binding - -```ts -import { anthropic } from "@ai-sdk/anthropic"; -import { generateText, stepCountIs } from "ai"; -import { createStagehandMcpBinding } from "./src/agent.js"; - -const stagehand = await createStagehandMcpBinding({ - stagehandRevision: process.env.STAGEHAND_REVISION!, - browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, - browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, -}); - -try { - const result = await generateText({ - model: anthropic("claude-opus-5"), - tools: stagehand.tools, - stopWhen: stepCountIs(8), - prompt: "Open example.com and return the page title.", - }); - console.log(result.text); -} finally { - await stagehand.close(); -} -``` - -E2B's current gateway requires MCP protocol `2025-06-18`, so the binding pins that version. The -gateway prefixes custom GitHub tool names; the example remaps the discovered tool to the -provider-safe name `stagehand_code_execute` before giving it to the model. - -`close()` closes the MCP client and kills the complete sandbox. Binding creation also cleans up both -resources if gateway readiness or tool discovery fails. Always apply an application deadline and -kill the microVM if generated code stops responding. - -The `smoke` script is a deterministic, no-secrets CI contract test against a trusted local browser. -It is not the production security pattern. See the shared -[`SANDBOX.md`](../../codemode/SANDBOX.md) for image, credential, network, and lifecycle guidance. diff --git a/packages/integrations/examples/vercel/src/agent.ts b/packages/integrations/examples/vercel/src/agent.ts deleted file mode 100644 index 6ab8756b4e..0000000000 --- a/packages/integrations/examples/vercel/src/agent.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { createMCPClient, type MCPClient } from "@ai-sdk/mcp"; -import { STAGEHAND_CODEMODE_SKILL } from "@browserbasehq/stagehand-integrations/codemode"; -import { generateText, stepCountIs, type LanguageModel, type ToolSet } from "ai"; -import { Sandbox } from "e2b"; - -const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; -const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; -export const STAGEHAND_TOOL_NAME = "stagehand_code_execute"; - -export type StagehandSandboxOptions = { - stagehandRevision: string; - browserbaseApiKey: string; - browserbaseProjectId: string; - stagehandModelName?: string; - stagehandModelApiKey?: string; - readinessTimeoutMs?: number; - sandboxTimeoutMs?: number; -}; - -export type StagehandMcpBinding = { - client: MCPClient; - sandbox: Sandbox; - tools: ToolSet; - close: () => Promise; -}; - -export type StagehandAgentResult = { - text: string; - toolNames: string[]; - toolOutputs: unknown[]; -}; - -export async function createStagehandMcpBinding( - options: StagehandSandboxOptions, -): Promise { - assertCommitHash(options.stagehandRevision); - const sandboxEnvironment = stagehandEnvironment(options); - let sandbox: Sandbox | undefined; - let client: MCPClient | undefined; - - try { - sandbox = await Sandbox.create({ - timeoutMs: options.sandboxTimeoutMs ?? 20 * 60_000, - envs: sandboxEnvironment, - mcp: { - [E2B_STAGEHAND_SERVER]: { - installCmd: [ - `git checkout --detach ${options.stagehandRevision}`, - "pnpm install --frozen-lockfile", - "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", - ].join(" && "), - runCmd: "node packages/integrations/dist/codemode/stdio-server.mjs", - }, - }, - }); - - const token = await sandbox.getMcpToken(); - if (!token) throw new Error("E2B did not return an MCP gateway token"); - - const connected = await connectWhenReady( - sandbox.getMcpUrl(), - token, - options.readinessTimeoutMs ?? 12 * 60_000, - ); - client = connected.client; - - return { - client, - sandbox, - // The MCP package and AI SDK expose structurally compatible tools through - // separate provider type versions. Keep the cast at this adapter boundary. - tools: { [STAGEHAND_TOOL_NAME]: connected.codeExecute } as ToolSet, - close: () => closeResources(client, sandbox), - }; - } catch (error) { - await closeResources(client, sandbox).catch(() => undefined); - throw error; - } -} - -export async function runStagehandAgent( - model: LanguageModel, - prompt: string, - options: StagehandSandboxOptions, -): Promise { - const binding = await createStagehandMcpBinding(options); - let primaryError: unknown; - - try { - const result = await generateText({ - model, - instructions: STAGEHAND_CODEMODE_SKILL, - prompt, - tools: binding.tools, - stopWhen: stepCountIs(8), - }); - return { - text: result.text, - toolNames: result.steps.flatMap((step) => step.toolCalls.map((call) => call.toolName)), - toolOutputs: result.steps.flatMap((step) => - step.toolResults.map((toolResult) => toolResult.output), - ), - }; - } catch (error) { - primaryError = error; - throw error; - } finally { - try { - await binding.close(); - } catch (cleanupError) { - if (primaryError === undefined) throw cleanupError; - } - } -} - -async function connectWhenReady( - url: string, - token: string, - timeoutMs: number, -): Promise<{ - client: MCPClient; - codeExecute: Awaited>[string]; -}> { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - - while (Date.now() < deadline) { - let candidate: MCPClient | undefined; - try { - candidate = await createMCPClient({ - clientName: "stagehand-e2b-vercel", - transport: { - type: "http", - url, - headers: { Authorization: `Bearer ${token}` }, - // E2B's current MCP gateway rejects the newer default protocol version. - initialProtocolVersion: E2B_MCP_PROTOCOL_VERSION, - }, - }); - const remoteTools = await candidate.tools(); - const entries = Object.entries(remoteTools).filter(([name]) => name.endsWith("code_execute")); - if (entries.length !== 1 || !entries[0]?.[1]) { - throw new Error( - `Expected one Stagehand code_execute tool, received: ${Object.keys(remoteTools).join(", ") || "none"}`, - ); - } - return { client: candidate, codeExecute: entries[0][1] }; - } catch (error) { - lastError = error; - await candidate?.close().catch(() => undefined); - await delay(5_000); - } - } - - throw new Error("Timed out waiting for the Stagehand MCP server in E2B", { cause: lastError }); -} - -function stagehandEnvironment(options: StagehandSandboxOptions): Record { - const environment: Record = { - STAGEHAND_BROWSER: "browserbase", - BROWSERBASE_API_KEY: options.browserbaseApiKey, - BROWSERBASE_PROJECT_ID: options.browserbaseProjectId, - }; - if (options.stagehandModelName) environment.STAGEHAND_MODEL_NAME = options.stagehandModelName; - if (options.stagehandModelApiKey) { - environment.STAGEHAND_MODEL_API_KEY = options.stagehandModelApiKey; - } - return environment; -} - -async function closeResources(client?: MCPClient, sandbox?: Sandbox): Promise { - const errors: unknown[] = []; - try { - await client?.close(); - } catch (error) { - errors.push(error); - } - try { - await sandbox?.kill(); - } catch (error) { - errors.push(error); - } - if (errors.length > 0) throw new AggregateError(errors, "Could not close the Stagehand sandbox"); -} - -function assertCommitHash(revision: string): void { - if (!/^[0-9a-f]{40}$/.test(revision)) { - throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); - } -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} diff --git a/packages/integrations/examples/vercel/src/e2e.ts b/packages/integrations/examples/vercel/src/e2e.ts deleted file mode 100644 index b2dc8dcadd..0000000000 --- a/packages/integrations/examples/vercel/src/e2e.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { strict as assert } from "node:assert"; - -import { anthropic } from "@ai-sdk/anthropic"; - -import { runStagehandAgent, STAGEHAND_TOOL_NAME } from "./agent.js"; - -const result = await runStagehandAgent( - anthropic(process.env.VERCEL_STAGEHAND_MODEL ?? "claude-opus-5"), - [ - `Use ${STAGEHAND_TOOL_NAME} exactly twice.`, - "First navigate to https://example.com, open one additional blank tab, then restore the Example Domain page as active.", - "Second return an object with the active page title and total context page count.", - "Report the title and count in your final answer.", - ].join(" "), - { - stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), - browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), - browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), - }, -); - -assert.deepEqual(result.toolNames, [STAGEHAND_TOOL_NAME, STAGEHAND_TOOL_NAME]); -assert.ok( - result.toolOutputs.some((output) => containsBrowserState(output, "Example Domain", 2)), - `Expected structured title/page-count evidence: ${JSON.stringify(result.toolOutputs)}`, -); - -process.stdout.write( - `${JSON.stringify({ status: "PASS", toolNames: result.toolNames, state: { title: "Example Domain", pages: 2 } })}\n`, -); - -function containsBrowserState(value: unknown, title: string, pages: number): boolean { - if (Array.isArray(value)) return value.some((entry) => containsBrowserState(entry, title, pages)); - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - if (record.title === title && (record.pages === pages || record.pageCount === pages)) { - return true; - } - return Object.values(record).some((entry) => containsBrowserState(entry, title, pages)); -} - -function requiredEnvironment(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Missing ${name}`); - return value; -} diff --git a/packages/integrations/examples/vercel/src/smoke.ts b/packages/integrations/examples/vercel/src/smoke.ts deleted file mode 100644 index 642f4ecb01..0000000000 --- a/packages/integrations/examples/vercel/src/smoke.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { strict as assert } from "node:assert"; -import { fileURLToPath } from "node:url"; - -import { createMCPClient, type MCPClient } from "@ai-sdk/mcp"; -import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio"; -import type { Tool } from "ai"; - -const stdioServerPath = fileURLToPath( - new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), -); -let client: MCPClient | undefined; - -try { - client = await createMCPClient({ - transport: new Experimental_StdioMCPTransport({ - command: process.execPath, - args: [stdioServerPath], - env: localSmokeEnvironment(), - }), - }); - const tools = await client.tools(); - assert.deepEqual(Object.keys(tools), ["code_execute"]); - - const tool = tools.code_execute as Tool<{ code: string }, unknown>; - assert.equal(typeof tool?.execute, "function"); - const first = await tool.execute!( - { - code: ` - await page.goto("https://example.com", { waitUntil: "load" }); - await context.newPage(); - await context.setActivePage(page); - return { title: await page.title(), pages: (await context.pages()).length }; - `, - }, - { context: {}, messages: [], toolCallId: "vercel-smoke-1" }, - ); - const second = await tool.execute!( - { code: `return { title: await page.title(), pages: (await context.pages()).length };` }, - { context: {}, messages: [], toolCallId: "vercel-smoke-2" }, - ); - - assert.ok(containsBrowserState(first, "Example Domain", 2), JSON.stringify(first)); - assert.ok(containsBrowserState(second, "Example Domain", 2), JSON.stringify(second)); - process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: ["code_execute"], statePersisted: true })}\n`, - ); -} finally { - await client?.close().catch(() => undefined); -} - -function localSmokeEnvironment(): Record { - const environment: Record = { STAGEHAND_BROWSER: "local" }; - for (const name of ["CHROME_PATH", "HOME", "PATH", "TMPDIR"]) { - const value = process.env[name]; - if (value) environment[name] = value; - } - return environment; -} - -function containsBrowserState(value: unknown, title: string, pages: number): boolean { - if (Array.isArray(value)) return value.some((entry) => containsBrowserState(entry, title, pages)); - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - if (record.title === title && record.pages === pages) return true; - return Object.values(record).some((entry) => containsBrowserState(entry, title, pages)); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3116a23667..0510d40bc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,9 +217,6 @@ catalogs: '@ai-sdk/groq': specifier: ^4.0.5 version: 4.0.5 - '@ai-sdk/mcp': - specifier: 2.0.21 - version: 2.0.21 '@ai-sdk/openai': specifier: ^4.0.8 version: 4.0.8 @@ -595,20 +592,14 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - packages/integrations/examples/vercel: + packages/integrations/examples/e2b: dependencies: - '@ai-sdk/anthropic': - specifier: 'catalog:' - version: 4.0.8(zod@4.4.3) - '@ai-sdk/mcp': - specifier: 'catalog:' - version: 2.0.21(zod@4.4.3) '@browserbasehq/stagehand-integrations': specifier: workspace:* version: link:../.. - ai: + '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 7.0.16(zod@4.4.3) + version: 1.29.0(zod@4.4.3) e2b: specifier: 'catalog:' version: 2.37.0 @@ -775,12 +766,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/mcp@2.0.21': - resolution: {integrity: sha512-IC7mhtIX551SGp3jyFveNpwWpvzju/2ah4+Wsmsj4OMXOVCBChdwNpanZTGGfTaugqUoqtRgsOZ7dS2jJ4ix7Q==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/mistral@2.0.40': resolution: {integrity: sha512-NNrF4+7bXqYwGYTxfWifw4P6HbtPasBFaSfhMhMQ/f0DrXNZhd9EaL4WktwB/3A8F3rMkSwoehHch2Ps6EJG0A==} engines: {node: '>=18'} @@ -823,12 +808,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.17': - resolution: {integrity: sha512-U3h3xgaga1OOELBxhtwTfgfr0z++kHCD4YSFm4pfcqzUWolntINS0/21ktZ/5k2A1ozAiRPYqkCg90iwiYrwyw==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.5': resolution: {integrity: sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==} engines: {node: '>=22'} @@ -847,10 +826,6 @@ packages: resolution: {integrity: sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==} engines: {node: '>=22'} - '@ai-sdk/provider@4.0.4': - resolution: {integrity: sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==} - engines: {node: '>=22'} - '@ai-sdk/togetherai@1.0.49': resolution: {integrity: sha512-g4BpEatN7flh3GZ0CN9KvAUX6uLPmIqGSrKKFvAmC3HZdnF940zl+ChXs3atdbtpr6+cwirxM5RACbUzr0uYhA==} engines: {node: '>=18'} @@ -7658,13 +7633,6 @@ snapshots: '@ai-sdk/provider-utils': 5.0.5(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/mcp@2.0.21(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.4 - '@ai-sdk/provider-utils': 5.0.17(zod@4.4.3) - pkce-challenge: 5.0.1 - zod: 4.4.3 - '@ai-sdk/mistral@2.0.40(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 @@ -7712,15 +7680,6 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.17(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.4 - '@standard-schema/spec': 1.1.0 - '@workflow/serde': 4.1.0 - eventsource-parser: 3.1.0 - undici: 7.29.0 - zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.5(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.2 @@ -7741,10 +7700,6 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@4.0.4': - dependencies: - json-schema: 0.4.0 - '@ai-sdk/togetherai@1.0.49(zod@4.4.3)': dependencies: '@ai-sdk/openai-compatible': 1.0.46(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1e83175820..b81c76db17 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,6 @@ packages: catalogMode: prefer catalog: - "@ai-sdk/mcp": 2.0.21 e2b: 2.37.0 "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 From a804d841f1cdee26d7614939463d058670b38c45 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:46:45 -0700 Subject: [PATCH 08/24] fix: make E2B egress default deny --- packages/integrations/examples/e2b/README.md | 8 ++++---- packages/integrations/examples/e2b/src/sandbox.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/integrations/examples/e2b/README.md b/packages/integrations/examples/e2b/README.md index 370bbc0f04..f1a8d6bb74 100644 --- a/packages/integrations/examples/e2b/README.md +++ b/packages/integrations/examples/e2b/README.md @@ -80,10 +80,10 @@ E2B's built-in MCP gateway; this example does not add a second proxy or applicat ## Network and credential boundary The source checkout and dependency build need normal package-network access. After readiness succeeds, -`sandbox.updateNetwork()` atomically replaces that permissive setup with an allowlist containing only -`api.browserbase.com` and the configured Browserbase CDP hostnames. In E2B, setting `allowOut` makes -all unlisted egress denied by default. The live proof checks that Browserbase still works while an -unrelated host is blocked. +`sandbox.updateNetwork()` atomically replaces that permissive setup with `allowOut` containing only +`api.browserbase.com` and the configured Browserbase CDP hostnames, plus E2B's required +`denyOut: [ALL_TRAFFIC]`. The live proof checks that Browserbase still works while an unrelated host +is blocked. Browserbase-only egress is the default. AI-backed Stagehand methods require a separately scoped model credential **and** the model provider's exact API hostname added to the allowlist. Do not forward the diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts index 059c10fb3b..775e3b434e 100644 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { Sandbox } from "e2b"; +import { ALL_TRAFFIC, Sandbox } from "e2b"; const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; @@ -67,9 +67,12 @@ export async function createStagehandSandbox( await waitForStagehand(url, token, options.readinessTimeoutMs ?? 12 * 60_000); - // Supplying allowOut changes E2B egress from allow-all to default-deny. + // E2B requires ALL_TRAFFIC in denyOut when allowOut contains domains. // Do this only after the source checkout and build have completed. - await sandbox.updateNetwork({ allowOut: [...new Set(allowedHosts)] }); + await sandbox.updateNetwork({ + allowOut: [...new Set(allowedHosts)], + denyOut: [ALL_TRAFFIC], + }); let closed = false; return { From 8155c034f0f5b7243598c75653226446604af7bb Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:54:45 -0700 Subject: [PATCH 09/24] fix: keep MCP session setup offline --- packages/integrations/examples/e2b/README.md | 19 +++++-- .../integrations/examples/e2b/src/sandbox.ts | 55 ++++++++++++++++--- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/integrations/examples/e2b/README.md b/packages/integrations/examples/e2b/README.md index f1a8d6bb74..0e92668b28 100644 --- a/packages/integrations/examples/e2b/README.md +++ b/packages/integrations/examples/e2b/README.md @@ -23,8 +23,9 @@ type StagehandSandboxConnection = { `createStagehandSandbox()` asks E2B's custom MCP gateway to clone a complete Stagehand commit, build the code-mode package from source, and start its stdio server. It waits for exactly one -`code_execute` tool, applies the runtime egress policy, and only then returns the HTTP connection. -It does not depend on the Stagehand OCI image. +`code_execute` tool, creates a local bare mirror and warm pnpm store for later MCP client sessions, +applies the runtime egress policy, and only then returns the HTTP connection. It does not depend on +the Stagehand OCI image. ## Install and run @@ -83,16 +84,22 @@ The source checkout and dependency build need normal package-network access. Aft `sandbox.updateNetwork()` atomically replaces that permissive setup with `allowOut` containing only `api.browserbase.com` and the configured Browserbase CDP hostnames, plus E2B's required `denyOut: [ALL_TRAFFIC]`. The live proof checks that Browserbase still works while an unrelated host -is blocked. +is blocked. E2B starts a GitHub custom server for each new MCP session, so the helper rewrites that +repository URL to the in-microVM mirror and makes subsequent dependency installs offline before it +removes GitHub and package registries from egress. Browserbase-only egress is the default. AI-backed Stagehand methods require a separately scoped model credential **and** the model provider's exact API hostname added to the allowlist. Do not forward the outer agent's model key into the microVM or broaden egress implicitly. Only the Browserbase key and optional project ID cross the sandbox boundary by default. A complete -commit hash prevents the source install from silently following a moving branch. Always close the MCP -client and call `close()`; the latter kills the complete microVM. Apply a host-side deadline and kill -the microVM when untrusted code stops responding. +commit hash prevents the source install from silently following a moving branch. The helper makes the +bare source mirror read-only, but the hard lifecycle boundary is **one framework MCP session per +sandbox**. Readiness finishes before any untrusted tool call. After generated code runs, destroy the +microVM instead of reconnecting or reusing its guest filesystem and caches. + +Always close the MCP client and call `close()`; the latter kills the complete microVM. Apply a +host-side deadline and kill the microVM when untrusted code stops responding. See [E2B custom MCP servers](https://e2b.dev/docs/mcp/custom-servers) for gateway and source-install details. diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts index 775e3b434e..45c34fdaa0 100644 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -6,6 +6,10 @@ const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; const BROWSERBASE_API_HOST = "api.browserbase.com"; const DEFAULT_BROWSERBASE_CDP_HOSTS = ["connect.usw2.browserbase.com"]; +const STAGEHAND_GITHUB_URL_PREFIX = "https://github.com/browserbase/stagehand"; +const STAGEHAND_OFFLINE_MARKER = "/opt/stagehand-codemode-offline"; +const STAGEHAND_OFFLINE_MIRROR = "/opt/stagehand-codemode.git"; +const STAGEHAND_PNPM_STORE = "/opt/stagehand-pnpm-store"; export type StagehandSandboxOptions = { stagehandRevision: string; @@ -50,12 +54,7 @@ export async function createStagehandSandbox( }, mcp: { [E2B_STAGEHAND_SERVER]: { - installCmd: [ - `git checkout --detach ${options.stagehandRevision}`, - "corepack prepare pnpm@11.10.0 --activate", - "pnpm install --frozen-lockfile", - "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", - ].join(" && "), + installCmd: stagehandInstallCommand(options.stagehandRevision), runCmd: "node packages/integrations/dist/codemode/stdio-server.mjs", }, }, @@ -65,10 +64,11 @@ export async function createStagehandSandbox( if (!token) throw new Error("E2B did not return an MCP gateway token"); const url = new URL(sandbox.getMcpUrl()); + await waitForOfflineSource(sandbox, options.readinessTimeoutMs ?? 12 * 60_000); await waitForStagehand(url, token, options.readinessTimeoutMs ?? 12 * 60_000); // E2B requires ALL_TRAFFIC in denyOut when allowOut contains domains. - // Do this only after the source checkout and build have completed. + // Do this after trusted readiness, but before returning an untrusted session. await sandbox.updateNetwork({ allowOut: [...new Set(allowedHosts)], denyOut: [ALL_TRAFFIC], @@ -90,6 +90,18 @@ export async function createStagehandSandbox( } } +async function waitForOfflineSource(sandbox: Sandbox, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await sandbox.commands.run(`test -f ${STAGEHAND_OFFLINE_MARKER}`, { + timeoutMs: 5_000, + }); + if (result.exitCode === 0) return; + await delay(2_000); + } + throw new Error("Timed out waiting for the trusted Stagehand source build in E2B"); +} + async function waitForStagehand(url: URL, token: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -115,6 +127,35 @@ async function waitForStagehand(url: URL, token: string, timeoutMs: number): Pro throw new Error("Timed out waiting for the Stagehand MCP server in E2B", { cause: lastError }); } +function stagehandInstallCommand(revision: string): string { + const install = [ + `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]`, + `then pnpm install --offline --frozen-lockfile --store-dir ${STAGEHAND_PNPM_STORE}`, + `else corepack prepare pnpm@11.10.0 --activate && pnpm install --frozen-lockfile --store-dir ${STAGEHAND_PNPM_STORE}`, + "fi", + ].join("; "); + const prepareOfflineSource = [ + `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]; then true; else`, + [ + `rm -rf ${STAGEHAND_OFFLINE_MIRROR}.tmp`, + `git clone --bare . ${STAGEHAND_OFFLINE_MIRROR}.tmp`, + `mv ${STAGEHAND_OFFLINE_MIRROR}.tmp ${STAGEHAND_OFFLINE_MIRROR}`, + "git config --system protocol.file.allow always", + `git config --system url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL_PREFIX}`, + `chmod -R a-w ${STAGEHAND_OFFLINE_MIRROR}`, + `touch ${STAGEHAND_OFFLINE_MARKER}`, + ].join(" && "), + "fi", + ].join(" "); + + return [ + `git checkout --detach ${revision}`, + install, + "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", + prepareOfflineSource, + ].join(" && "); +} + export function stagehandTransport(url: URL, token: string): StreamableHTTPClientTransport { const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: `Bearer ${token}` } }, From 8cf5b40acd23e426a37f40b3b150d1e5f541c0dd Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:55:28 -0700 Subject: [PATCH 10/24] fix: poll E2B build readiness safely --- packages/integrations/examples/e2b/src/sandbox.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts index 45c34fdaa0..9d1041a7bb 100644 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -93,10 +93,11 @@ export async function createStagehandSandbox( async function waitForOfflineSource(sandbox: Sandbox, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const result = await sandbox.commands.run(`test -f ${STAGEHAND_OFFLINE_MARKER}`, { - timeoutMs: 5_000, - }); - if (result.exitCode === 0) return; + const result = await sandbox.commands.run( + `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]; then echo ready; else echo waiting; fi`, + { timeoutMs: 5_000 }, + ); + if (result.stdout.trim() === "ready") return; await delay(2_000); } throw new Error("Timed out waiting for the trusted Stagehand source build in E2B"); From 3a54a90ac228412cdc53d5af788e05ab7a413be4 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:58:29 -0700 Subject: [PATCH 11/24] fix: terminate the offline setup branch --- packages/integrations/examples/e2b/src/sandbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts index 9d1041a7bb..4972f9c9ed 100644 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -146,7 +146,7 @@ function stagehandInstallCommand(revision: string): string { `chmod -R a-w ${STAGEHAND_OFFLINE_MIRROR}`, `touch ${STAGEHAND_OFFLINE_MARKER}`, ].join(" && "), - "fi", + "; fi", ].join(" "); return [ From bc92188af8d59d19e848fa7706b53c3b3949fdd4 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 18:03:28 -0700 Subject: [PATCH 12/24] fix: validate the offline source mirror --- packages/integrations/examples/e2b/src/e2e.ts | 18 +++++++++--------- .../integrations/examples/e2b/src/sandbox.ts | 7 ++++++- .../integrations/examples/e2b/src/smoke.ts | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/integrations/examples/e2b/src/e2e.ts b/packages/integrations/examples/e2b/src/e2e.ts index cc666981f6..a887dab87c 100644 --- a/packages/integrations/examples/e2b/src/e2e.ts +++ b/packages/integrations/examples/e2b/src/e2e.ts @@ -1,4 +1,4 @@ -import { strict as assert } from "node:assert"; +import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; @@ -90,14 +90,14 @@ try { ); } catch (error) { primaryError = error; - throw error; -} finally { - const cleanupErrors: unknown[] = []; - await client.close().catch((error: unknown) => cleanupErrors.push(error)); - await connection.close().catch((error: unknown) => cleanupErrors.push(error)); - if (primaryError === undefined && cleanupErrors.length > 0) { - throw new AggregateError(cleanupErrors, "Could not close the MCP client and E2B sandbox"); - } +} + +const cleanupErrors: unknown[] = []; +await client.close().catch((error: unknown) => cleanupErrors.push(error)); +await connection.close().catch((error: unknown) => cleanupErrors.push(error)); +if (primaryError !== undefined) throw primaryError; +if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Could not close the MCP client and E2B sandbox"); } function containsState(value: unknown, expected: Record): boolean { diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts index 4972f9c9ed..8f7085ce87 100644 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ b/packages/integrations/examples/e2b/src/sandbox.ts @@ -7,6 +7,7 @@ const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; const BROWSERBASE_API_HOST = "api.browserbase.com"; const DEFAULT_BROWSERBASE_CDP_HOSTS = ["connect.usw2.browserbase.com"]; const STAGEHAND_GITHUB_URL_PREFIX = "https://github.com/browserbase/stagehand"; +const STAGEHAND_GITHUB_URL = `${STAGEHAND_GITHUB_URL_PREFIX}.git`; const STAGEHAND_OFFLINE_MARKER = "/opt/stagehand-codemode-offline"; const STAGEHAND_OFFLINE_MIRROR = "/opt/stagehand-codemode.git"; const STAGEHAND_PNPM_STORE = "/opt/stagehand-pnpm-store"; @@ -141,8 +142,12 @@ function stagehandInstallCommand(revision: string): string { `rm -rf ${STAGEHAND_OFFLINE_MIRROR}.tmp`, `git clone --bare . ${STAGEHAND_OFFLINE_MIRROR}.tmp`, `mv ${STAGEHAND_OFFLINE_MIRROR}.tmp ${STAGEHAND_OFFLINE_MIRROR}`, + `git --git-dir ${STAGEHAND_OFFLINE_MIRROR} update-ref refs/heads/stagehand-codemode ${revision}`, + `git --git-dir ${STAGEHAND_OFFLINE_MIRROR} symbolic-ref HEAD refs/heads/stagehand-codemode`, "git config --system protocol.file.allow always", - `git config --system url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL_PREFIX}`, + `git config --system --add url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL}`, + `git config --system --add url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL_PREFIX}`, + `git -c http.proxy=http://127.0.0.1:1 ls-remote ${STAGEHAND_GITHUB_URL} refs/heads/stagehand-codemode`, `chmod -R a-w ${STAGEHAND_OFFLINE_MIRROR}`, `touch ${STAGEHAND_OFFLINE_MARKER}`, ].join(" && "), diff --git a/packages/integrations/examples/e2b/src/smoke.ts b/packages/integrations/examples/e2b/src/smoke.ts index d7d37980a4..e3ca82033c 100644 --- a/packages/integrations/examples/e2b/src/smoke.ts +++ b/packages/integrations/examples/e2b/src/smoke.ts @@ -1,4 +1,4 @@ -import { strict as assert } from "node:assert"; +import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; From f8c6e80d38d7b2b66652e99b5fb2d3b2776918b8 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 18:09:13 -0700 Subject: [PATCH 13/24] docs: bound E2B custom servers to one tool call --- packages/integrations/examples/e2b/README.md | 10 +++++--- packages/integrations/examples/e2b/src/e2e.ts | 23 +------------------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/packages/integrations/examples/e2b/README.md b/packages/integrations/examples/e2b/README.md index 0e92668b28..d29b6afe91 100644 --- a/packages/integrations/examples/e2b/README.md +++ b/packages/integrations/examples/e2b/README.md @@ -94,9 +94,13 @@ outer agent's model key into the microVM or broaden egress implicitly. Only the Browserbase key and optional project ID cross the sandbox boundary by default. A complete commit hash prevents the source install from silently following a moving branch. The helper makes the -bare source mirror read-only, but the hard lifecycle boundary is **one framework MCP session per -sandbox**. Readiness finishes before any untrusted tool call. After generated code runs, destroy the -microVM instead of reconnecting or reusing its guest filesystem and caches. +bare source mirror read-only. Readiness finishes before any untrusted tool call. + +E2B's current custom-server gateway starts a fresh stdio process for each tool invocation, so it does +not preserve Stagehand browser state across separate `code_execute` calls. Treat one call as one +complete job: batch all dependent browser work into that call, then destroy the sandbox. Do not +reconnect or reuse the guest filesystem and caches after generated code runs. Framework adapters must +cap the agent at one tool call unless E2B adds a documented long-lived custom-server mode. Always close the MCP client and call `close()`; the latter kills the complete microVM. Apply a host-side deadline and kill the microVM when untrusted code stops responding. diff --git a/packages/integrations/examples/e2b/src/e2e.ts b/packages/integrations/examples/e2b/src/e2e.ts index a887dab87c..4db86f626c 100644 --- a/packages/integrations/examples/e2b/src/e2e.ts +++ b/packages/integrations/examples/e2b/src/e2e.ts @@ -52,19 +52,6 @@ try { `, }, }); - const second = await client.callTool({ - name: toolName, - arguments: { - code: ` - const fs = await import("node:fs/promises"); - return { - title: await page.title(), - pages: (await context.pages()).length, - marker: await fs.readFile(${JSON.stringify(markerPath)}, "utf8"), - }; - `, - }, - }); assert.ok( containsState(first.structuredContent, { @@ -75,18 +62,10 @@ try { }), JSON.stringify(first.structuredContent), ); - assert.ok( - containsState(second.structuredContent, { - title: "Example Domain", - pages: 2, - marker: "inside-e2b", - }), - JSON.stringify(second.structuredContent), - ); assert.equal(existsSync(markerPath), false, "sandbox marker escaped to the host filesystem"); process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: [toolName], statePersisted: true, unrelatedEgressBlocked: true, hostMarkerPresent: false })}\n`, + `${JSON.stringify({ status: "PASS", tools: [toolName], toolCalls: 1, unrelatedEgressBlocked: true, hostMarkerPresent: false })}\n`, ); } catch (error) { primaryError = error; From a6c79546a1a38989c1fbebbe1756741d41a58b64 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 18:49:37 -0700 Subject: [PATCH 14/24] feat: run code-mode MCP in Vercel Sandbox --- .../workflows/codemode-framework-examples.yml | 4 +- packages/integrations/README.md | 5 +- packages/integrations/examples/e2b/README.md | 109 ----- packages/integrations/examples/e2b/src/e2e.ts | 96 ---- .../integrations/examples/e2b/src/sandbox.ts | 194 -------- .../examples/vercel-sandbox/README.md | 139 ++++++ .../{e2b => vercel-sandbox}/package.json | 9 +- .../examples/vercel-sandbox/src/e2e.ts | 217 +++++++++ .../vercel-sandbox/src/guest/auth-proxy.mjs | 70 +++ .../src/guest/stdio-wrapper.mjs | 20 + .../examples/vercel-sandbox/src/lease.ts | 71 +++ .../examples/vercel-sandbox/src/sandbox.ts | 418 ++++++++++++++++++ .../{e2b => vercel-sandbox}/src/smoke.ts | 34 +- .../{e2b => vercel-sandbox}/tsconfig.json | 1 + pnpm-lock.yaml | 269 ++++++----- pnpm-workspace.yaml | 3 +- 16 files changed, 1104 insertions(+), 555 deletions(-) delete mode 100644 packages/integrations/examples/e2b/README.md delete mode 100644 packages/integrations/examples/e2b/src/e2e.ts delete mode 100644 packages/integrations/examples/e2b/src/sandbox.ts create mode 100644 packages/integrations/examples/vercel-sandbox/README.md rename packages/integrations/examples/{e2b => vercel-sandbox}/package.json (67%) create mode 100644 packages/integrations/examples/vercel-sandbox/src/e2e.ts create mode 100644 packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs create mode 100644 packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs create mode 100644 packages/integrations/examples/vercel-sandbox/src/lease.ts create mode 100644 packages/integrations/examples/vercel-sandbox/src/sandbox.ts rename packages/integrations/examples/{e2b => vercel-sandbox}/src/smoke.ts (60%) rename packages/integrations/examples/{e2b => vercel-sandbox}/tsconfig.json (85%) diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 6ef21e4611..92b020260d 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -48,8 +48,8 @@ jobs: fail-fast: false matrix: include: - - name: E2B source-installed MCP - package: "@browserbasehq/stagehand-integrations-example-e2b" + - name: Vercel Sandbox source-installed MCP + package: "@browserbasehq/stagehand-integrations-example-vercel-sandbox" steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 39254b76d3..4472b2f751 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -46,7 +46,7 @@ The process stays alive across calls and closes when its input stream ends. `SIG ### Framework examples -- [E2B sandbox](./examples/e2b) source-installs the stdio server inside a Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection. +- [Vercel Sandbox](./examples/vercel-sandbox) source-installs the stdio server inside a Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection. ### Configuration @@ -75,4 +75,5 @@ Native callers run generated JavaScript in their own process. An `AbortSignal` c The code-mode executor does not provide a sandbox. Generated JavaScript runs in the host process and inherits that process's filesystem, network, and environment access. A framework may place the tool inside its own sandbox, container, or other isolation boundary. For untrusted generated code, use the source-installed microVM architecture in the -[E2B sandbox example](./examples/e2b). The sandbox provider supplies the security boundary. +[Vercel Sandbox example](./examples/vercel-sandbox). The sandbox provider supplies the security +boundary. diff --git a/packages/integrations/examples/e2b/README.md b/packages/integrations/examples/e2b/README.md deleted file mode 100644 index d29b6afe91..0000000000 --- a/packages/integrations/examples/e2b/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Run Stagehand code mode in an E2B sandbox - -Use this example when an agent framework runs on your host but Stagehand code mode must execute -untrusted JavaScript behind a microVM boundary. - -```text -Your MCP client - └─ E2B bearer-authenticated Streamable HTTP - └─ E2B Firecracker microVM - └─ Stagehand MCP over stdio - └─ generated JavaScript + Browserbase browser -``` - -The E2B package is a private workspace example that exports one framework-neutral contract: - -```ts -type StagehandSandboxConnection = { - url: URL; - token: string; - close: () => Promise; -}; -``` - -`createStagehandSandbox()` asks E2B's custom MCP gateway to clone a complete Stagehand commit, -build the code-mode package from source, and start its stdio server. It waits for exactly one -`code_execute` tool, creates a local bare mirror and warm pnpm store for later MCP client sessions, -applies the runtime egress policy, and only then returns the HTTP connection. It does not depend on -the Stagehand OCI image. - -## Install and run - -Set these variables on the host. `BROWSERBASE_PROJECT_ID` is optional. The default CDP allowlist is -the US West host observed in the live proof; set `BROWSERBASE_CDP_HOSTS` to the comma-separated CDP -hostnames returned for your Browserbase region. - -```bash -E2B_API_KEY= -BROWSERBASE_API_KEY= -BROWSERBASE_PROJECT_ID= -BROWSERBASE_CDP_HOSTS=connect.usw2.browserbase.com -STAGEHAND_REVISION=<40-character-git-commit> - -pnpm --filter @browserbasehq/stagehand-integrations-example-e2b e2e -``` - -## Connect an MCP client - -This raw [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) -example is the adapter boundary that agent frameworks build on: - -```ts -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { createStagehandSandbox } from "@browserbasehq/stagehand-integrations-example-e2b"; - -const stagehand = await createStagehandSandbox({ - stagehandRevision: process.env.STAGEHAND_REVISION!, - browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, - browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID, - browserbaseCdpHosts: ["connect.usw2.browserbase.com"], -}); -const client = new Client({ name: "my-agent", version: "1.0.0" }); -const transport = new StreamableHTTPClientTransport(stagehand.url, { - requestInit: { headers: { Authorization: `Bearer ${stagehand.token}` } }, -}); -transport.setProtocolVersion("2025-06-18"); - -try { - await client.connect(transport); - const tools = await client.listTools(); - console.log(tools); -} finally { - await client.close(); - await stagehand.close(); -} -``` - -E2B's current gateway requires MCP protocol `2025-06-18`. Authentication is the bearer token from -E2B's built-in MCP gateway; this example does not add a second proxy or application-defined secret. - -## Network and credential boundary - -The source checkout and dependency build need normal package-network access. After readiness succeeds, -`sandbox.updateNetwork()` atomically replaces that permissive setup with `allowOut` containing only -`api.browserbase.com` and the configured Browserbase CDP hostnames, plus E2B's required -`denyOut: [ALL_TRAFFIC]`. The live proof checks that Browserbase still works while an unrelated host -is blocked. E2B starts a GitHub custom server for each new MCP session, so the helper rewrites that -repository URL to the in-microVM mirror and makes subsequent dependency installs offline before it -removes GitHub and package registries from egress. - -Browserbase-only egress is the default. AI-backed Stagehand methods require a separately scoped model -credential **and** the model provider's exact API hostname added to the allowlist. Do not forward the -outer agent's model key into the microVM or broaden egress implicitly. - -Only the Browserbase key and optional project ID cross the sandbox boundary by default. A complete -commit hash prevents the source install from silently following a moving branch. The helper makes the -bare source mirror read-only. Readiness finishes before any untrusted tool call. - -E2B's current custom-server gateway starts a fresh stdio process for each tool invocation, so it does -not preserve Stagehand browser state across separate `code_execute` calls. Treat one call as one -complete job: batch all dependent browser work into that call, then destroy the sandbox. Do not -reconnect or reuse the guest filesystem and caches after generated code runs. Framework adapters must -cap the agent at one tool call unless E2B adds a documented long-lived custom-server mode. - -Always close the MCP client and call `close()`; the latter kills the complete microVM. Apply a -host-side deadline and kill the microVM when untrusted code stops responding. - -See [E2B custom MCP servers](https://e2b.dev/docs/mcp/custom-servers) for gateway and source-install -details. diff --git a/packages/integrations/examples/e2b/src/e2e.ts b/packages/integrations/examples/e2b/src/e2e.ts deleted file mode 100644 index 4db86f626c..0000000000 --- a/packages/integrations/examples/e2b/src/e2e.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; - -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; - -import { createStagehandSandbox, stagehandTransport } from "./sandbox.js"; - -const markerPath = `/tmp/stagehand-e2b-source-proof-${randomUUID()}`; -const connection = await createStagehandSandbox({ - stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), - browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), - browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID, - browserbaseCdpHosts: (process.env.BROWSERBASE_CDP_HOSTS ?? "connect.usw2.browserbase.com") - .split(",") - .map((hostname) => hostname.trim()) - .filter(Boolean), -}); -const client = new Client({ name: "stagehand-e2b-proof", version: "1.0.0" }); -let primaryError: unknown; - -try { - await client.connect(stagehandTransport(connection.url, connection.token)); - const { tools } = await client.listTools(); - const codeTools = tools.filter((tool) => tool.name.endsWith("code_execute")); - assert.equal(tools.length, 1, tools.map((tool) => tool.name).join(", ")); - assert.equal(codeTools.length, 1, tools.map((tool) => tool.name).join(", ")); - const toolName = codeTools[0]!.name; - - const first = await client.callTool({ - name: toolName, - arguments: { - code: ` - const fs = await import("node:fs/promises"); - await fs.writeFile(${JSON.stringify(markerPath)}, "inside-e2b"); - await page.goto("https://example.com", { waitUntil: "load" }); - await context.newPage(); - await context.setActivePage(page); - let unrelatedEgressBlocked = false; - try { - await fetch("https://example.org", { signal: AbortSignal.timeout(5_000) }); - } catch { - unrelatedEgressBlocked = true; - } - return { - title: await page.title(), - pages: (await context.pages()).length, - marker: await fs.readFile(${JSON.stringify(markerPath)}, "utf8"), - hostname: (await fs.readFile("/etc/hostname", "utf8")).trim(), - unrelatedEgressBlocked, - }; - `, - }, - }); - - assert.ok( - containsState(first.structuredContent, { - title: "Example Domain", - pages: 2, - marker: "inside-e2b", - unrelatedEgressBlocked: true, - }), - JSON.stringify(first.structuredContent), - ); - assert.equal(existsSync(markerPath), false, "sandbox marker escaped to the host filesystem"); - - process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: [toolName], toolCalls: 1, unrelatedEgressBlocked: true, hostMarkerPresent: false })}\n`, - ); -} catch (error) { - primaryError = error; -} - -const cleanupErrors: unknown[] = []; -await client.close().catch((error: unknown) => cleanupErrors.push(error)); -await connection.close().catch((error: unknown) => cleanupErrors.push(error)); -if (primaryError !== undefined) throw primaryError; -if (cleanupErrors.length > 0) { - throw new AggregateError(cleanupErrors, "Could not close the MCP client and E2B sandbox"); -} - -function containsState(value: unknown, expected: Record): boolean { - if (Array.isArray(value)) return value.some((entry) => containsState(entry, expected)); - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - if (Object.entries(expected).every(([key, expectedValue]) => record[key] === expectedValue)) { - return true; - } - return Object.values(record).some((entry) => containsState(entry, expected)); -} - -function requiredEnvironment(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Missing ${name}`); - return value; -} diff --git a/packages/integrations/examples/e2b/src/sandbox.ts b/packages/integrations/examples/e2b/src/sandbox.ts deleted file mode 100644 index 8f7085ce87..0000000000 --- a/packages/integrations/examples/e2b/src/sandbox.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { ALL_TRAFFIC, Sandbox } from "e2b"; - -const E2B_MCP_PROTOCOL_VERSION = "2025-06-18"; -const E2B_STAGEHAND_SERVER = "github/browserbase/stagehand"; -const BROWSERBASE_API_HOST = "api.browserbase.com"; -const DEFAULT_BROWSERBASE_CDP_HOSTS = ["connect.usw2.browserbase.com"]; -const STAGEHAND_GITHUB_URL_PREFIX = "https://github.com/browserbase/stagehand"; -const STAGEHAND_GITHUB_URL = `${STAGEHAND_GITHUB_URL_PREFIX}.git`; -const STAGEHAND_OFFLINE_MARKER = "/opt/stagehand-codemode-offline"; -const STAGEHAND_OFFLINE_MIRROR = "/opt/stagehand-codemode.git"; -const STAGEHAND_PNPM_STORE = "/opt/stagehand-pnpm-store"; - -export type StagehandSandboxOptions = { - stagehandRevision: string; - browserbaseApiKey: string; - browserbaseProjectId?: string; - browserbaseCdpHosts?: string[]; - readinessTimeoutMs?: number; - sandboxTimeoutMs?: number; -}; - -export type StagehandSandboxConnection = { - url: URL; - token: string; - close: () => Promise; -}; - -/** - * Start Stagehand's stdio MCP server inside E2B, wait for it to become ready, - * then switch the running microVM to a Browserbase-only egress allowlist. - */ -export async function createStagehandSandbox( - options: StagehandSandboxOptions, -): Promise { - assertCommitHash(options.stagehandRevision); - const cdpHosts = options.browserbaseCdpHosts ?? DEFAULT_BROWSERBASE_CDP_HOSTS; - if (cdpHosts.length === 0) throw new Error("browserbaseCdpHosts must contain at least one host"); - const allowedHosts = [ - BROWSERBASE_API_HOST, - ...cdpHosts.map((hostname) => assertHostname(hostname.trim())), - ]; - let sandbox: Sandbox | undefined; - - try { - sandbox = await Sandbox.create({ - timeoutMs: options.sandboxTimeoutMs ?? 20 * 60_000, - envs: { - STAGEHAND_BROWSER: "browserbase", - BROWSERBASE_API_KEY: options.browserbaseApiKey, - ...(options.browserbaseProjectId - ? { BROWSERBASE_PROJECT_ID: options.browserbaseProjectId } - : {}), - }, - mcp: { - [E2B_STAGEHAND_SERVER]: { - installCmd: stagehandInstallCommand(options.stagehandRevision), - runCmd: "node packages/integrations/dist/codemode/stdio-server.mjs", - }, - }, - }); - - const token = await sandbox.getMcpToken(); - if (!token) throw new Error("E2B did not return an MCP gateway token"); - const url = new URL(sandbox.getMcpUrl()); - - await waitForOfflineSource(sandbox, options.readinessTimeoutMs ?? 12 * 60_000); - await waitForStagehand(url, token, options.readinessTimeoutMs ?? 12 * 60_000); - - // E2B requires ALL_TRAFFIC in denyOut when allowOut contains domains. - // Do this after trusted readiness, but before returning an untrusted session. - await sandbox.updateNetwork({ - allowOut: [...new Set(allowedHosts)], - denyOut: [ALL_TRAFFIC], - }); - - let closed = false; - return { - url, - token, - close: async () => { - if (closed) return; - await sandbox.kill(); - closed = true; - }, - }; - } catch (error) { - await sandbox?.kill().catch(() => undefined); - throw error; - } -} - -async function waitForOfflineSource(sandbox: Sandbox, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const result = await sandbox.commands.run( - `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]; then echo ready; else echo waiting; fi`, - { timeoutMs: 5_000 }, - ); - if (result.stdout.trim() === "ready") return; - await delay(2_000); - } - throw new Error("Timed out waiting for the trusted Stagehand source build in E2B"); -} - -async function waitForStagehand(url: URL, token: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - - while (Date.now() < deadline) { - const client = new Client({ name: "stagehand-e2b-readiness", version: "1.0.0" }); - const transport = stagehandTransport(url, token); - try { - await client.connect(transport); - const { tools } = await client.listTools(); - if (tools.length !== 1 || !tools[0]?.name.endsWith("code_execute")) { - throw new Error(`Expected one Stagehand code_execute tool, received: ${toolNames(tools)}`); - } - await client.close(); - return; - } catch (error) { - lastError = error; - await client.close().catch(() => undefined); - await delay(5_000); - } - } - - throw new Error("Timed out waiting for the Stagehand MCP server in E2B", { cause: lastError }); -} - -function stagehandInstallCommand(revision: string): string { - const install = [ - `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]`, - `then pnpm install --offline --frozen-lockfile --store-dir ${STAGEHAND_PNPM_STORE}`, - `else corepack prepare pnpm@11.10.0 --activate && pnpm install --frozen-lockfile --store-dir ${STAGEHAND_PNPM_STORE}`, - "fi", - ].join("; "); - const prepareOfflineSource = [ - `if [ -f ${STAGEHAND_OFFLINE_MARKER} ]; then true; else`, - [ - `rm -rf ${STAGEHAND_OFFLINE_MIRROR}.tmp`, - `git clone --bare . ${STAGEHAND_OFFLINE_MIRROR}.tmp`, - `mv ${STAGEHAND_OFFLINE_MIRROR}.tmp ${STAGEHAND_OFFLINE_MIRROR}`, - `git --git-dir ${STAGEHAND_OFFLINE_MIRROR} update-ref refs/heads/stagehand-codemode ${revision}`, - `git --git-dir ${STAGEHAND_OFFLINE_MIRROR} symbolic-ref HEAD refs/heads/stagehand-codemode`, - "git config --system protocol.file.allow always", - `git config --system --add url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL}`, - `git config --system --add url.file://${STAGEHAND_OFFLINE_MIRROR}.insteadOf ${STAGEHAND_GITHUB_URL_PREFIX}`, - `git -c http.proxy=http://127.0.0.1:1 ls-remote ${STAGEHAND_GITHUB_URL} refs/heads/stagehand-codemode`, - `chmod -R a-w ${STAGEHAND_OFFLINE_MIRROR}`, - `touch ${STAGEHAND_OFFLINE_MARKER}`, - ].join(" && "), - "; fi", - ].join(" "); - - return [ - `git checkout --detach ${revision}`, - install, - "pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations...", - prepareOfflineSource, - ].join(" && "); -} - -export function stagehandTransport(url: URL, token: string): StreamableHTTPClientTransport { - const transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers: { Authorization: `Bearer ${token}` } }, - }); - // E2B's current gateway requires this protocol version on gateway requests. - transport.setProtocolVersion(E2B_MCP_PROTOCOL_VERSION); - return transport; -} - -function assertCommitHash(revision: string): void { - if (!/^[0-9a-f]{40}$/.test(revision)) { - throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); - } -} - -function assertHostname(hostname: string): string { - const parsed = new URL(`https://${hostname}`); - if (parsed.hostname !== hostname || parsed.port || parsed.pathname !== "/") { - throw new Error(`Expected a hostname without a scheme, port, or path: ${hostname}`); - } - return hostname; -} - -function toolNames(tools: Array<{ name: string }>): string { - return tools.map((tool) => tool.name).join(", ") || "none"; -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md new file mode 100644 index 0000000000..464f734ffe --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -0,0 +1,139 @@ +# Run Stagehand code mode in Vercel Sandbox + +Use this example when an agent framework runs on your host but Stagehand code mode must execute +untrusted JavaScript behind a microVM boundary. + +```text +Your MCP client + └─ bearer-authenticated Streamable HTTP + └─ Vercel Sandbox exposed port + └─ SHA-256 auth proxy (stagehand-proxy user) + └─ stateful HTTP-to-stdio bridge (stagehand-mcp user) + └─ Stagehand MCP over stdio + └─ generated JavaScript + Browserbase browser +``` + +The private workspace package exports one framework-neutral contract: + +```ts +type StagehandSandboxConnection = { + url: URL; + token: string; + close: () => Promise; +}; +``` + +`createStagehandSandbox()` creates a fresh Vercel Firecracker microVM with open setup egress, checks +out a complete Stagehand commit, installs its frozen lockfile, builds code mode from source, and +installs the pinned HTTP-to-stdio bridge. Before the MCP server starts, it replaces setup egress with +an allowlist containing only Browserbase's API and the regional CDP hostname discovered for the +configured project. + +## Install and run + +Authenticate the host for [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox), then set: + +```bash +STAGEHAND_REVISION=<40-character-stagehand-commit> +BROWSERBASE_API_KEY= +BROWSERBASE_PROJECT_ID= + +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox e2e +``` + +The revision must be a full commit hash. This prevents the trusted install from following a moving +branch or tag. Vercel's credential-brokering header transforms are currently available on Pro and +Enterprise plans. Check the +[credential-brokering announcement](https://vercel.com/changelog/safely-inject-credentials-in-http-headers-with-vercel-sandbox) +before relying on this example with another plan. + +Vercel credentials authenticate the host to the Sandbox control plane so it can create, update, +stop, and delete the microVM. They are separate from the random application bearer returned by this +helper, which protects only the MCP port exposed by this sandbox. + +## Connect an MCP client + +This raw [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) +example is the adapter boundary that agent frameworks build on: + +```ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + createStagehandSandbox, + stagehandTransport, +} from "@browserbasehq/stagehand-integrations-example-vercel-sandbox"; + +const stagehand = await createStagehandSandbox({ + stagehandRevision: process.env.STAGEHAND_REVISION!, + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, +}); +const client = new Client({ name: "my-agent", version: "1.0.0" }); + +try { + await client.connect(stagehandTransport(stagehand)); + const tools = await client.listTools(); + console.log(tools); +} finally { + await client.close(); + await stagehand.close(); +} +``` + +Create one external MCP client and one MCP session per sandbox. The bridge holds one Stagehand stdio +process for that session, so browser pages, DOM changes, cookies, and guest files survive across tool +calls. Destroy the sandbox after the agent run; generated JavaScript can mutate its guest filesystem, +so reconnecting or reusing that VM would cross a trust boundary. + +The authenticated `/mcp` endpoint accepts POST and DELETE. It returns `405 Method Not Allowed` for +the optional standalone GET event stream because Vercel's public edge buffers an idle SSE response +and Stagehand does not send server-initiated notifications. MCP calls still stream their responses +over POST. + +## Use the cross-language lease + +Python and other non-Node adapters can launch the same provider implementation without copying its +setup or network-policy logic: + +```bash +node packages/integrations/examples/vercel-sandbox/src/lease.ts +``` + +The launcher writes exactly one JSON line to stdout: + +```json +{ "url": "https:///mcp", "token": "" } +``` + +It then holds stdin open as the sandbox lease. Keep the process and stdin pipe alive for the entire +MCP session. Close stdin for normal cleanup; `SIGINT` and `SIGTERM` trigger bounded cleanup and retain +signal-style exit semantics. Spawn it with an explicit environment allowlist containing only the +runtime variables it needs: `PATH`, the relevant Vercel authentication variables, +`STAGEHAND_REVISION`, `BROWSERBASE_API_KEY`, and `BROWSERBASE_PROJECT_ID`. The token is emitted once +over the trusted parent pipe and is never placed in command arguments or environment variables. + +## Security boundary + +[Vercel Sandbox](https://vercel.com/sandbox) supplies the microVM boundary. Process users are an +additional defense inside that VM, not a substitute for it: + +- `stagehand-mcp` runs supergateway, the Stagehand stdio server, and generated JavaScript without + sudo. +- `stagehand-proxy` runs only the exposed-port auth proxy without sudo. Its bootstrap environment + receives the SHA-256 digest of a random 32-byte bearer, not the raw bearer. +- The host retains the raw bearer and Browserbase key. The guest MCP process receives a fixed + placeholder key. Vercel's [credential-brokering transform](https://vercel.com/changelog/safely-inject-credentials-in-http-headers-with-vercel-sandbox) + overwrites the Browserbase API header at the network boundary. +- The host discovers and validates the exact regional Browserbase CDP hostname before lockdown. The + running VM allows only that hostname and `api.browserbase.com`; all other egress is denied. +- Source, dependencies, and bridge code become root-owned and read-only before untrusted code runs. +- The only published guest port is the authenticated proxy. The stateful bridge listens on guest + loopback. + +Credential brokering prevents key disclosure, but it still grants the sandbox the Browserbase API +capabilities of that key. Use a separately scoped project/key and host-side timeouts. AI-backed +Stagehand methods require a separately scoped model credential and an exact provider-host policy; +this example intentionally does not forward outer-agent model keys or broaden egress. + +`close()` is idempotent and attempts both stop and permanent delete even when one cleanup operation +fails. The lease adds a bounded fallback. Always close the MCP client first, then the connection. diff --git a/packages/integrations/examples/e2b/package.json b/packages/integrations/examples/vercel-sandbox/package.json similarity index 67% rename from packages/integrations/examples/e2b/package.json rename to packages/integrations/examples/vercel-sandbox/package.json index dadfb77002..4968e2ee97 100644 --- a/packages/integrations/examples/e2b/package.json +++ b/packages/integrations/examples/vercel-sandbox/package.json @@ -1,20 +1,23 @@ { - "name": "@browserbasehq/stagehand-integrations-example-e2b", + "name": "@browserbasehq/stagehand-integrations-example-vercel-sandbox", "version": "4.0.0", "private": true, "type": "module", "exports": { - ".": "./src/sandbox.ts" + ".": "./src/sandbox.ts", + "./lease": "./src/lease.ts" }, "scripts": { "e2e": "tsx src/e2e.ts", + "lease": "node src/lease.ts", "smoke": "tsx src/smoke.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@browserbasehq/stagehand-integrations": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "e2b": "catalog:" + "@vercel/sandbox": "catalog:", + "supergateway": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/integrations/examples/vercel-sandbox/src/e2e.ts b/packages/integrations/examples/vercel-sandbox/src/e2e.ts new file mode 100644 index 0000000000..8da4680bb0 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/e2e.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +import { createStagehandSandbox, stagehandTransport } from "./sandbox.js"; + +const hostMarker = `host-${randomUUID()}`; +const stateMarker = `state-${randomUUID()}`; +const markerPath = `/tmp/stagehand-vercel-proof-${randomUUID()}.json`; +process.env.HOST_ONLY_MARKER = hostMarker; +assert.equal(existsSync(markerPath), false); + +const connection = await createStagehandSandbox({ + stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), + browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), + browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), +}); +const client = new Client({ name: "stagehand-vercel-sandbox-e2e", version: "1.0.0" }); +let primaryError: unknown; + +try { + const unauthorized = await fetch(connection.url); + assert.equal(unauthorized.status, 401); + const authorizedHealth = await fetch(new URL("/healthz", connection.url), { + headers: { Authorization: `Bearer ${connection.token}` }, + }); + assert.equal(authorizedHealth.status, 200); + const optionalGetStream = await fetch(connection.url, { + headers: { Authorization: `Bearer ${connection.token}` }, + }); + assert.equal(optionalGetStream.status, 405); + assert.equal(optionalGetStream.headers.get("allow"), "POST, DELETE"); + + await client.connect(stagehandTransport(connection)); + const { tools } = await client.listTools(); + assert.deepEqual( + tools.map((tool) => tool.name), + ["code_execute"], + ); + + const first = await client.callTool({ + name: "code_execute", + arguments: { + code: ` + const fs = await import("node:fs/promises"); + await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); + await page.evaluate((marker) => { + document.documentElement.dataset.vercelStagehandState = marker; + }, ${JSON.stringify(stateMarker)}); + await fs.writeFile( + ${JSON.stringify(markerPath)}, + JSON.stringify({ marker: ${JSON.stringify(stateMarker)}, pageId: page.pageId }), + ); + let unrelatedEgressBlocked = false; + try { + await fetch("https://example.org", { signal: AbortSignal.timeout(5_000) }); + } catch { + unrelatedEgressBlocked = true; + } + return { + title: await page.title(), + pageId: page.pageId, + domMarker: await page.evaluate( + () => document.documentElement.dataset.vercelStagehandState, + ), + boundary: process.env.STAGEHAND_SANDBOX_BOUNDARY, + browserbaseCredentialInProcess: process.env.BROWSERBASE_API_KEY, + hostMarker: process.env.HOST_ONLY_MARKER ?? null, + bridgeTokenVisible: process.env.BRIDGE_TOKEN ?? null, + bridgeTokenDigestVisible: process.env.BRIDGE_TOKEN_SHA256 ?? null, + unrelatedEgressBlocked, + }; + `, + }, + }); + const firstValue = successfulValue(first, "first code_execute"); + + const second = await client.callTool({ + name: "code_execute", + arguments: { + code: ` + const fs = await import("node:fs/promises"); + const persisted = JSON.parse( + await fs.readFile(${JSON.stringify(markerPath)}, "utf8"), + ); + const procEntries = (await fs.readdir("/proc")).filter((entry) => /^\\d+$/.test(entry)); + let proxyPid = null; + let proxyUid = null; + let rawTokenEnvSeen = false; + let digestEnvSeen = false; + for (const pid of procEntries) { + const cmdline = await fs.readFile(\`/proc/\${pid}/cmdline\`, "utf8").catch(() => ""); + if (cmdline.includes("auth-proxy.mjs")) { + proxyPid = Number(pid); + const status = await fs.readFile(\`/proc/\${pid}/status\`, "utf8"); + proxyUid = Number(/^Uid:\\s+(\\d+)/m.exec(status)?.[1]); + } + const environ = await fs.readFile(\`/proc/\${pid}/environ\`, "utf8").catch(() => ""); + rawTokenEnvSeen ||= environ + .split("\\0") + .some((entry) => entry.startsWith("BRIDGE_TOKEN=")); + digestEnvSeen ||= environ + .split("\\0") + .some((entry) => entry.startsWith("BRIDGE_TOKEN_SHA256=")); + } + if (proxyPid === null) throw new Error("Auth proxy process was not found"); + const proxyEnvironReadable = await fs + .readFile(\`/proc/\${proxyPid}/environ\`) + .then(() => true, () => false); + const proxyMemoryReadable = await fs + .open(\`/proc/\${proxyPid}/mem\`, "r") + .then(async (handle) => { + await handle.close(); + return true; + }, () => false); + let proxySignalAllowed = true; + try { + process.kill(proxyPid, 0); + } catch { + proxySignalAllowed = false; + } + const { execFile } = await import("node:child_process"); + const sudoAllowed = await new Promise((resolve) => { + execFile("sudo", ["-n", "true"], (error) => resolve(error === null)); + }); + return { + title: await page.title(), + pageId: page.pageId, + domMarker: await page.evaluate( + () => document.documentElement.dataset.vercelStagehandState, + ), + fileMarker: persisted.marker, + filePageId: persisted.pageId, + hostMarker: process.env.HOST_ONLY_MARKER ?? null, + rawTokenEnvSeen, + digestEnvSeen, + proxyRunsAsDifferentUser: proxyUid !== process.getuid(), + proxyEnvironReadable, + proxyMemoryReadable, + proxySignalAllowed, + sudoAllowed, + }; + `, + }, + }); + const secondValue = successfulValue(second, "second code_execute"); + + assert.equal(firstValue.title, "Example Domain"); + assert.equal(secondValue.title, "Example Domain"); + assert.equal(firstValue.pageId, secondValue.pageId); + assert.equal(firstValue.domMarker, stateMarker); + assert.equal(secondValue.domMarker, stateMarker); + assert.equal(secondValue.fileMarker, stateMarker); + assert.equal(secondValue.filePageId, firstValue.pageId); + assert.equal(firstValue.boundary, "vercel-firecracker-microvm"); + assert.equal(firstValue.browserbaseCredentialInProcess, "bb_brokered_by_vercel"); + assert.equal(firstValue.hostMarker, null); + assert.equal(secondValue.hostMarker, null); + assert.equal(firstValue.bridgeTokenVisible, null); + assert.equal(firstValue.bridgeTokenDigestVisible, null); + assert.equal(firstValue.unrelatedEgressBlocked, true); + assert.equal(secondValue.rawTokenEnvSeen, false); + assert.equal(secondValue.digestEnvSeen, false); + assert.equal(secondValue.proxyRunsAsDifferentUser, true); + assert.equal(secondValue.proxyEnvironReadable, false); + assert.equal(secondValue.proxyMemoryReadable, false); + assert.equal(secondValue.proxySignalAllowed, false); + assert.equal(secondValue.sudoAllowed, false); + assert.equal(existsSync(markerPath), false, "sandbox marker escaped to the host filesystem"); + + process.stdout.write( + `${JSON.stringify({ + status: "PASS", + tools: ["code_execute"], + calls: 2, + statePersisted: true, + unrelatedEgressBlocked: true, + publicAuth: { unauthorized: 401, authorized: 200 }, + optionalGetStream: 405, + credentialBrokered: true, + hostIsolated: true, + proxyUserIsolated: true, + })}\n`, + ); +} catch (error) { + primaryError = error; +} + +const cleanupErrors: unknown[] = []; +await client.close().catch((error: unknown) => cleanupErrors.push(error)); +await connection.close().catch((error: unknown) => cleanupErrors.push(error)); +if (primaryError !== undefined && cleanupErrors.length > 0) { + throw new AggregateError( + [primaryError, ...cleanupErrors], + "Vercel Sandbox E2E failed and cleanup also failed", + ); +} +if (primaryError !== undefined) throw primaryError; +if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Could not close the MCP client and Vercel Sandbox"); +} + +function successfulValue(result: Awaited>, label: string) { + const structured = result.structuredContent as { ok?: unknown; value?: unknown } | undefined; + assert.equal(result.isError ?? false, false, `${label} returned an MCP error`); + assert.equal(structured?.ok, true, `${label} returned a code error`); + assert.equal(typeof structured?.value, "object", `${label} returned no value`); + return structured.value as Record; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs new file mode 100644 index 0000000000..f425ab3ae4 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs @@ -0,0 +1,70 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import http from "node:http"; + +const digestHex = process.env.BRIDGE_TOKEN_SHA256; +if (!digestHex || !/^[0-9a-f]{64}$/i.test(digestHex)) { + throw new Error("BRIDGE_TOKEN_SHA256 is required"); +} +const expectedDigest = Buffer.from(digestHex, "hex"); +const passthroughHeaders = [ + "accept", + "content-type", + "content-length", + "last-event-id", + "mcp-session-id", + "mcp-protocol-version", +]; + +function authorized(value) { + if (typeof value !== "string" || !value.startsWith("Bearer ")) return false; + const providedDigest = createHash("sha256").update(value.slice("Bearer ".length)).digest(); + return timingSafeEqual(providedDigest, expectedDigest); +} + +http + .createServer((request, response) => { + if (!authorized(request.headers.authorization)) { + response.writeHead(401, { "content-type": "text/plain" }); + response.end("Unauthorized\n"); + return; + } + + // Vercel's public edge buffers an otherwise idle SSE response. Stagehand + // sends no server-initiated notifications, so reject the optional GET + // stream and keep MCP request/response traffic on POST and DELETE. + const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (request.method === "GET" && pathname === "/mcp") { + response.writeHead(405, { + allow: "POST, DELETE", + "content-type": "text/plain", + }); + response.end("Method Not Allowed\n"); + return; + } + + const headers = { host: "127.0.0.1:8000" }; + for (const name of passthroughHeaders) { + const value = request.headers[name]; + if (value !== undefined) headers[name] = value; + } + + const upstream = http.request( + { + host: "127.0.0.1", + port: 8000, + method: request.method, + path: request.url, + headers, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502); + response.end("Upstream unavailable\n"); + }); + request.pipe(upstream); + }) + .listen(3000, "0.0.0.0"); diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs new file mode 100644 index 0000000000..be208ea186 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs @@ -0,0 +1,20 @@ +import { spawn } from "node:child_process"; + +const child = spawn( + process.execPath, + ["/vercel/sandbox/stagehand/packages/integrations/dist/codemode/stdio-server.mjs"], + { + cwd: "/vercel/sandbox/stagehand/packages/integrations", + env: process.env, + stdio: ["inherit", "inherit", "inherit"], + }, +); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); +} + +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts new file mode 100644 index 0000000000..896f44f268 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { createStagehandSandbox } from "./sandbox.ts"; + +const SHUTDOWN_FALLBACK_MS = 35_000; + +type LeaseEnd = { signal?: NodeJS.Signals }; + +try { + const leaseEnd = waitForLeaseEnd(); + const connection = await createStagehandSandbox({ + stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), + browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), + browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), + }); + + process.stdout.write( + `${JSON.stringify({ url: connection.url.toString(), token: connection.token })}\n`, + ); + + const { signal } = await leaseEnd; + const fallback = signal + ? setTimeout(() => forwardSignal(signal), SHUTDOWN_FALLBACK_MS) + : undefined; + try { + await connection.close(); + } catch (error) { + process.stderr.write(`Stagehand sandbox lease cleanup failed: ${safeMessage(error)}\n`); + if (!signal) process.exitCode = 1; + } finally { + clearTimeout(fallback); + } + + if (signal) forwardSignal(signal); +} catch (error) { + process.stderr.write(`Stagehand sandbox lease failed: ${safeMessage(error)}\n`); + process.exitCode = 1; +} + +function waitForLeaseEnd(): Promise { + return new Promise((resolve) => { + let finished = false; + const finish = (end: LeaseEnd = {}) => { + if (finished) return; + finished = true; + resolve(end); + }; + + process.once("SIGINT", () => finish({ signal: "SIGINT" })); + process.once("SIGTERM", () => finish({ signal: "SIGTERM" })); + process.stdin.once("end", () => finish()); + process.stdin.once("close", () => finish()); + process.stdin.resume(); + }); +} + +function forwardSignal(signal: NodeJS.Signals): never { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + throw new Error(`Could not forward ${signal}`); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function safeMessage(error: unknown): string { + return error instanceof Error ? error.message : "unknown error"; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts new file mode 100644 index 0000000000..29dd706820 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -0,0 +1,418 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Sandbox, type SandboxUser } from "@vercel/sandbox"; + +const BROWSERBASE_API_HOST = "api.browserbase.com"; +const BROWSERBASE_API_KEY_PLACEHOLDER = "bb_brokered_by_vercel"; +const BRIDGE_PORT = 3000; +const GATEWAY_PORT = 8000; +const MCP_PROTOCOL_VERSION = "2025-11-25"; +const MCP_USER = "stagehand-mcp"; +const PROXY_USER = "stagehand-proxy"; +const SANDBOX_ROOT = "/vercel/sandbox"; +const STAGEHAND_ROOT = `${SANDBOX_ROOT}/stagehand`; +const GATEWAY_ROOT = `${SANDBOX_ROOT}/gateway`; +const GATEWAY_BIN = `${GATEWAY_ROOT}/node_modules/.bin/supergateway`; +const AUTH_PROXY_PATH = `${SANDBOX_ROOT}/auth-proxy.mjs`; +const STDIO_WRAPPER_PATH = `${SANDBOX_ROOT}/stdio-wrapper.mjs`; + +export type StagehandSandboxOptions = { + stagehandRevision: string; + browserbaseApiKey: string; + browserbaseProjectId: string; + readinessTimeoutMs?: number; + sandboxTimeoutMs?: number; + cleanupTimeoutMs?: number; +}; + +export type StagehandSandboxConnection = { + url: URL; + token: string; + close: () => Promise; +}; + +/** + * Build Stagehand from an exact revision inside a Vercel Sandbox, replace the + * setup network with Browserbase-only egress, and expose its stdio MCP server + * through an authenticated, stateful Streamable HTTP bridge. + */ +export async function createStagehandSandbox( + options: StagehandSandboxOptions, +): Promise { + assertCommitHash(options.stagehandRevision); + assertNonEmpty(options.browserbaseApiKey, "browserbaseApiKey"); + assertNonEmpty(options.browserbaseProjectId, "browserbaseProjectId"); + + const cdpHost = await discoverBrowserbaseCdpHost(options); + const sandbox = await Sandbox.create({ + runtime: "node24", + resources: { vcpus: 4 }, + timeout: options.sandboxTimeoutMs ?? 40 * 60_000, + ports: [BRIDGE_PORT], + persistent: false, + networkPolicy: "allow-all", + tags: { purpose: "stagehand-codemode-mcp" }, + }); + const close = sandboxCloser(sandbox, options.cleanupTimeoutMs ?? 30_000); + + try { + await installStagehand(sandbox, options.stagehandRevision); + await installGateway(sandbox); + await sandbox.writeFiles([ + { + path: AUTH_PROXY_PATH, + content: await readFile(new URL("./guest/auth-proxy.mjs", import.meta.url)), + mode: 0o555, + }, + { + path: STDIO_WRAPPER_PATH, + content: await readFile(new URL("./guest/stdio-wrapper.mjs", import.meta.url)), + mode: 0o555, + }, + ]); + + const mcpUser = await sandbox.createUser(MCP_USER); + const proxyUser = await sandbox.createUser(PROXY_USER); + await assertUnprivileged(mcpUser, MCP_USER); + await assertUnprivileged(proxyUser, PROXY_USER); + await protectRuntimeFiles(sandbox); + + // This update is the trust transition: everything above is trusted setup; + // everything below may eventually execute model-generated JavaScript. + await sandbox.update({ + networkPolicy: { + allow: { + [BROWSERBASE_API_HOST]: [ + { + transform: [{ headers: { "X-BB-API-Key": options.browserbaseApiKey } }], + }, + ], + [cdpHost]: [], + }, + }, + }); + + const token = randomBytes(32).toString("base64url"); + const tokenDigest = createHash("sha256").update(token).digest("hex"); + await startGateway(mcpUser, options.browserbaseProjectId); + await startAuthProxy(proxyUser, tokenDigest); + + const origin = new URL(sandbox.domain(BRIDGE_PORT)); + await waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000); + const unauthorized = await fetch(new URL("/healthz", origin)); + if (unauthorized.status !== 401) { + throw new Error( + `Expected unauthenticated bridge health to return 401, received ${unauthorized.status}`, + ); + } + + return { + url: new URL("/mcp", origin), + token, + close, + }; + } catch (error) { + try { + await close(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Stagehand sandbox setup failed and cleanup also failed", + ); + } + throw error; + } +} + +export function stagehandTransport( + connection: Pick, +): StreamableHTTPClientTransport { + const transport = new StreamableHTTPClientTransport(connection.url, { + requestInit: { + headers: { Authorization: `Bearer ${connection.token}` }, + }, + }); + transport.setProtocolVersion(MCP_PROTOCOL_VERSION); + return transport; +} + +async function installStagehand(sandbox: Sandbox, revision: string): Promise { + await run(sandbox, "initialize Stagehand checkout", "git", ["init", STAGEHAND_ROOT]); + await run(sandbox, "add Stagehand remote", "git", [ + "-C", + STAGEHAND_ROOT, + "remote", + "add", + "origin", + "https://github.com/browserbase/stagehand.git", + ]); + await run(sandbox, "fetch Stagehand revision", "git", [ + "-C", + STAGEHAND_ROOT, + "fetch", + "--depth=1", + "origin", + revision, + ]); + await run(sandbox, "checkout Stagehand revision", "git", [ + "-C", + STAGEHAND_ROOT, + "checkout", + "--detach", + "FETCH_HEAD", + ]); + const resolved = await run(sandbox, "resolve Stagehand revision", "git", [ + "-C", + STAGEHAND_ROOT, + "rev-parse", + "HEAD", + ]); + if (resolved.trim() !== revision) { + throw new Error(`Stagehand checkout resolved to an unexpected revision: ${resolved.trim()}`); + } + + await run(sandbox, "activate pnpm", "corepack", ["prepare", "pnpm@11.10.0", "--activate"]); + await run( + sandbox, + "install Stagehand dependencies", + "pnpm", + ["install", "--frozen-lockfile"], + STAGEHAND_ROOT, + ); + await run( + sandbox, + "build Stagehand extension", + "pnpm", + ["--filter", "@browserbasehq/stagehand-extension", "build"], + STAGEHAND_ROOT, + ); + await run( + sandbox, + "build Stagehand integrations", + "pnpm", + ["--filter", "@browserbasehq/stagehand-integrations...", "build"], + STAGEHAND_ROOT, + ); +} + +async function installGateway(sandbox: Sandbox): Promise { + await run(sandbox, "install the MCP HTTP bridge", "npm", [ + "install", + "--prefix", + GATEWAY_ROOT, + "--ignore-scripts", + "--no-audit", + "--no-fund", + "supergateway@3.4.3", + ]); +} + +async function protectRuntimeFiles(sandbox: Sandbox): Promise { + await run(sandbox.asUser("root"), "protect the trusted runtime", "bash", [ + "-lc", + [ + `chown -R root:root ${STAGEHAND_ROOT} ${GATEWAY_ROOT}`, + `chown root:root ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, + `chmod -R a-w ${STAGEHAND_ROOT} ${GATEWAY_ROOT}`, + `chmod 0555 ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, + ].join(" && "), + ]); +} + +async function startGateway(user: SandboxUser, browserbaseProjectId: string): Promise { + await user.runCommand({ + cmd: GATEWAY_BIN, + args: [ + "--stdio", + `node ${STDIO_WRAPPER_PATH}`, + "--outputTransport", + "streamableHttp", + "--stateful", + "--sessionTimeout", + "600000", + "--protocolVersion", + MCP_PROTOCOL_VERSION, + "--port", + String(GATEWAY_PORT), + "--healthEndpoint", + "/healthz", + "--logLevel", + "none", + ], + detached: true, + env: { + BROWSERBASE_API_KEY: BROWSERBASE_API_KEY_PLACEHOLDER, + BROWSERBASE_PROJECT_ID: browserbaseProjectId, + STAGEHAND_BROWSER: "browserbase", + STAGEHAND_SANDBOX_BOUNDARY: "vercel-firecracker-microvm", + }, + }); +} + +async function startAuthProxy(user: SandboxUser, tokenDigest: string): Promise { + await user.runCommand({ + cmd: "node", + args: [AUTH_PROXY_PATH], + detached: true, + env: { BRIDGE_TOKEN_SHA256: tokenDigest }, + }); +} + +async function assertUnprivileged(user: SandboxUser, name: string): Promise { + const sudoProbe = await user.runCommand({ cmd: "sudo", args: ["-n", "true"] }); + if (sudoProbe.exitCode === 0) throw new Error(`${name} unexpectedly has sudo access`); +} + +async function waitForHealth(origin: URL, token: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastStatus: number | undefined; + while (Date.now() < deadline) { + const response = await fetch(new URL("/healthz", origin), { + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => undefined); + if (response?.ok) return; + lastStatus = response?.status; + await delay(250); + } + throw new Error( + `Authenticated Stagehand bridge readiness timed out (last status ${lastStatus ?? "unreachable"})`, + ); +} + +async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Promise { + let sessionId: string | undefined; + let discoveredHost: string | undefined; + let primaryError: unknown; + + try { + const response = await fetch(`https://${BROWSERBASE_API_HOST}/v1/sessions`, { + method: "POST", + headers: { + "content-type": "application/json", + "X-BB-API-Key": options.browserbaseApiKey, + }, + body: JSON.stringify({ projectId: options.browserbaseProjectId }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error(`Browserbase CDP host discovery returned ${response.status}`); + } + const session = (await response.json()) as { id?: unknown; connectUrl?: unknown }; + if (typeof session.id !== "string" || typeof session.connectUrl !== "string") { + throw new Error("Browserbase CDP host discovery returned an invalid session"); + } + sessionId = session.id; + discoveredHost = assertBrowserbaseCdpHost(new URL(session.connectUrl).hostname); + } catch (error) { + primaryError = error; + } + + let cleanupError: unknown; + if (sessionId) { + try { + const response = await fetch( + `https://${BROWSERBASE_API_HOST}/v1/sessions/${encodeURIComponent(sessionId)}`, + { + method: "POST", + headers: { + "content-type": "application/json", + "X-BB-API-Key": options.browserbaseApiKey, + }, + body: JSON.stringify({ status: "REQUEST_RELEASE" }), + signal: AbortSignal.timeout(30_000), + }, + ); + if (!response.ok) + throw new Error(`Browserbase discovery-session release returned ${response.status}`); + } catch (error) { + cleanupError = error; + } + } + + if (primaryError !== undefined && cleanupError !== undefined) { + throw new AggregateError( + [primaryError, cleanupError], + "Browserbase CDP host discovery and session release both failed", + ); + } + if (primaryError !== undefined) throw primaryError; + if (cleanupError !== undefined) throw cleanupError; + if (!discoveredHost) throw new Error("Browserbase CDP host discovery returned no hostname"); + return discoveredHost; +} + +function sandboxCloser(sandbox: Sandbox, timeoutMs: number): () => Promise { + let closePromise: Promise | undefined; + return () => { + closePromise ??= disposeSandbox(sandbox, timeoutMs); + return closePromise; + }; +} + +async function disposeSandbox(sandbox: Sandbox, timeoutMs: number): Promise { + const errors: unknown[] = []; + await withTimeout(sandbox.stop(), timeoutMs, "Vercel Sandbox stop").catch((error: unknown) => { + errors.push(error); + }); + await withTimeout(sandbox.delete(), timeoutMs, "Vercel Sandbox delete").catch( + (error: unknown) => { + errors.push(error); + }, + ); + if (errors.length > 0) + throw new AggregateError(errors, "Could not stop and delete Vercel Sandbox"); +} + +async function run( + target: Pick | SandboxUser, + label: string, + cmd: string, + args: string[], + cwd?: string, +): Promise { + const result = await target.runCommand({ cmd, args, cwd }); + const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]); + if (result.exitCode !== 0) { + const detail = stderr.trim() || stdout.trim() || "no command output"; + throw new Error(`${label} failed with exit ${result.exitCode}: ${detail.slice(-2_000)}`); + } + return stdout; +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeout: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + clearTimeout(timeout); + } +} + +function assertCommitHash(revision: string): void { + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); + } +} + +function assertNonEmpty(value: string, name: string): void { + if (!value.trim()) throw new Error(`${name} must not be empty`); +} + +function assertBrowserbaseCdpHost(hostname: string): string { + if (!/^connect(?:\.[a-z0-9-]+)?\.browserbase\.com$/.test(hostname)) { + throw new Error(`Browserbase returned an unexpected CDP hostname: ${hostname}`); + } + return hostname; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/integrations/examples/e2b/src/smoke.ts b/packages/integrations/examples/vercel-sandbox/src/smoke.ts similarity index 60% rename from packages/integrations/examples/e2b/src/smoke.ts rename to packages/integrations/examples/vercel-sandbox/src/smoke.ts index e3ca82033c..b05083aa44 100644 --- a/packages/integrations/examples/e2b/src/smoke.ts +++ b/packages/integrations/examples/vercel-sandbox/src/smoke.ts @@ -7,7 +7,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" const stdioServerPath = fileURLToPath( new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), ); -const client = new Client({ name: "stagehand-stdio-smoke", version: "1.0.0" }); +const client = new Client({ name: "stagehand-vercel-sandbox-smoke", version: "1.0.0" }); try { await client.connect( @@ -29,26 +29,32 @@ try { arguments: { code: ` await page.goto("https://example.com", { waitUntil: "load" }); - await context.newPage(); - await context.setActivePage(page); - return { title: await page.title(), pages: (await context.pages()).length }; + await page.evaluate(() => { document.documentElement.dataset.smoke = "persisted"; }); + return { title: await page.title() }; `, }, }); const second = await client.callTool({ name: "code_execute", arguments: { - code: `return { title: await page.title(), pages: (await context.pages()).length };`, + code: ` + return { + title: await page.title(), + marker: await page.evaluate(() => document.documentElement.dataset.smoke), + }; + `, }, }); - assert.ok(containsBrowserState(first.structuredContent), JSON.stringify(first.structuredContent)); + assert.ok(containsState(first.structuredContent, { title: "Example Domain" })); assert.ok( - containsBrowserState(second.structuredContent), - JSON.stringify(second.structuredContent), + containsState(second.structuredContent, { + title: "Example Domain", + marker: "persisted", + }), ); process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: ["code_execute"], statePersisted: true })}\n`, + `${JSON.stringify({ status: "PASS", tools: ["code_execute"], calls: 2, statePersisted: true })}\n`, ); } finally { await client.close().catch(() => undefined); @@ -63,10 +69,12 @@ function localSmokeEnvironment(): Record { return environment; } -function containsBrowserState(value: unknown): boolean { - if (Array.isArray(value)) return value.some(containsBrowserState); +function containsState(value: unknown, expected: Record): boolean { + if (Array.isArray(value)) return value.some((entry) => containsState(entry, expected)); if (typeof value !== "object" || value === null) return false; const record = value as Record; - if (record.title === "Example Domain" && record.pages === 2) return true; - return Object.values(record).some(containsBrowserState); + if (Object.entries(expected).every(([key, expectedValue]) => record[key] === expectedValue)) { + return true; + } + return Object.values(record).some((entry) => containsState(entry, expected)); } diff --git a/packages/integrations/examples/e2b/tsconfig.json b/packages/integrations/examples/vercel-sandbox/tsconfig.json similarity index 85% rename from packages/integrations/examples/e2b/tsconfig.json rename to packages/integrations/examples/vercel-sandbox/tsconfig.json index 8f14c5759a..07e24666ce 100644 --- a/packages/integrations/examples/e2b/tsconfig.json +++ b/packages/integrations/examples/vercel-sandbox/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../../../tsconfig.json", "compilerOptions": { + "allowImportingTsExtensions": true, "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0510d40bc6..dcb6b2619c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ catalogs: '@types/node': specifier: ^24 version: 24.13.2 + '@vercel/sandbox': + specifier: 2.9.2 + version: 2.9.2 ai: specifier: ^7.0.16 version: 7.0.16 @@ -286,9 +289,6 @@ catalogs: dotenv: specifier: ^17.4.2 version: 17.4.2 - e2b: - specifier: 2.37.0 - version: 2.37.0 esbuild: specifier: 0.28.1 version: 0.28.1 @@ -319,6 +319,9 @@ catalogs: snakecase-keys: specifier: ^9.0.2 version: 9.0.2 + supergateway: + specifier: 3.4.3 + version: 3.4.3 tsdown: specifier: 0.22.3 version: 0.22.3 @@ -592,7 +595,7 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - packages/integrations/examples/e2b: + packages/integrations/examples/vercel-sandbox: dependencies: '@browserbasehq/stagehand-integrations': specifier: workspace:* @@ -600,9 +603,12 @@ importers: '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(zod@4.4.3) - e2b: + '@vercel/sandbox': specifier: 'catalog:' - version: 2.37.0 + version: 2.9.2 + supergateway: + specifier: 'catalog:' + version: 3.4.3(bufferutil@4.1.0) devDependencies: '@types/node': specifier: 'catalog:' @@ -1075,9 +1081,6 @@ packages: puppeteer-core: optional: true - '@bufbuild/protobuf@2.13.0': - resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} - '@canvas/image-data@1.1.0': resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} @@ -1142,17 +1145,6 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@connectrpc/connect-web@2.1.2': - resolution: {integrity: sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==} - peerDependencies: - '@bufbuild/protobuf': ^2.7.0 - '@connectrpc/connect': 2.1.2 - - '@connectrpc/connect@2.1.2': - resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} - peerDependencies: - '@bufbuild/protobuf': ^2.7.0 - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -3121,6 +3113,9 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/sandbox@2.9.2': + resolution: {integrity: sha512-tnPtCNL6MZKM9eRYvmyL0geA2Nifq+PSxVXBNhk/lQNeCWgMp/EYzCDOotKEWR/VH+c0Yv9HG4qk0w2I65xnLA==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -3153,6 +3148,9 @@ packages: '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3360,6 +3358,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -3723,9 +3724,6 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - compare-versions@6.1.1: - resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} - compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -3977,9 +3975,6 @@ packages: resolution: {integrity: sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==} engines: {node: '>=6'} - dockerfile-ast@0.7.1: - resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4018,10 +4013,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - e2b@2.37.0: - resolution: {integrity: sha512-OWmTHwgQlPmTyCMUrcFReq9zhqsuwJn14p2AcCgYIcJifFnl1sJOrm/vZBCXe82SU7ZCgbzB3sIj7iFsZwewXw==} - engines: {node: '>=20.18.1 <21 || >=22'} - eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4531,10 +4522,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -5019,6 +5006,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} @@ -5077,6 +5067,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsonpath-plus@10.4.0: resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} engines: {node: '>=18.0.0'} @@ -5235,10 +5228,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.2: - resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} - engines: {node: 20 || >=22} - lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -5869,21 +5858,19 @@ packages: zod: optional: true - openapi-fetch@0.14.1: - resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} - openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - openapi-typescript-helpers@0.0.15: - resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} - openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -6026,10 +6013,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -6090,9 +6073,6 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - playwright-core@1.56.1: resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} engines: {node: '>=18'} @@ -6871,6 +6851,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supergateway@3.4.3: + resolution: {integrity: sha512-lidAbuX84K8gRp+TU+KLGqEu5ne8Ihef53y7+h5L0cFkL8yY1MDPbIGHw1gkPQuaTlsqsikLimt1cc2lt7yVuQ==} + hasBin: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -6902,6 +6886,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -6909,10 +6896,6 @@ packages: resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} engines: {node: '>=18'} - tar@7.5.22: - resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} - engines: {node: '>=18'} - teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -7120,10 +7103,6 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} - undici@8.8.0: - resolution: {integrity: sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==} - engines: {node: '>=22.19.0'} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7333,12 +7312,6 @@ packages: jsdom: optional: true - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.18.0: - resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -7424,6 +7397,14 @@ packages: utf-8-validate: optional: true + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} @@ -7998,8 +7979,6 @@ snapshots: - supports-color - utf-8-validate - '@bufbuild/protobuf@2.13.0': {} - '@canvas/image-data@1.1.0': {} '@changesets/apply-release-plan@7.1.1': @@ -8160,15 +8139,6 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 - '@connectrpc/connect-web@2.1.2(@bufbuild/protobuf@2.13.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0))': - dependencies: - '@bufbuild/protobuf': 2.13.0 - '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.13.0) - - '@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0)': - dependencies: - '@bufbuild/protobuf': 2.13.0 - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -9093,6 +9063,28 @@ snapshots: - supports-color - typescript + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.15(hono@4.12.32) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.32 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.15(hono@4.12.32) @@ -10078,6 +10070,23 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vercel/sandbox@2.9.2': + dependencies: + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.29.0 + xdg-app-paths: 5.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -10129,6 +10138,8 @@ snapshots: '@workflow/serde@4.1.0': {} + '@workflow/serde@4.1.0-beta.2': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -10334,6 +10345,10 @@ snapshots: async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + async@3.2.6: {} asynckit@0.4.0: {} @@ -10743,8 +10758,6 @@ snapshots: commander@8.3.0: {} - compare-versions@6.1.1: {} - compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -10957,11 +10970,6 @@ snapshots: dependencies: dns-packet: 5.6.1 - dockerfile-ast@0.7.1: - dependencies: - vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.18.0 - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -10994,22 +11002,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - e2b@2.37.0: - dependencies: - '@bufbuild/protobuf': 2.13.0 - '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.13.0) - '@connectrpc/connect-web': 2.1.2(@bufbuild/protobuf@2.13.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.13.0)) - chalk: 5.6.2 - compare-versions: 6.1.1 - dockerfile-ast: 0.7.1 - glob: 13.0.6 - openapi-fetch: 0.14.1 - platform: 1.3.6 - tar: 7.5.22 - undici: 7.29.0 - optionalDependencies: - undici8: undici@8.8.0 - eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: @@ -11723,12 +11715,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - glob@7.1.6: dependencies: fs.realpath: 1.0.0 @@ -12357,6 +12343,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.3: {} + jose@6.2.4: {} joycon@3.1.1: {} @@ -12407,6 +12395,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + jsonpath-plus@10.4.0: dependencies: '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) @@ -12536,8 +12526,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.2: {} - lru-cache@7.18.3: {} magic-string@0.30.21: @@ -13340,14 +13328,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0) zod: 4.4.3 - openapi-fetch@0.14.1: - dependencies: - openapi-typescript-helpers: 0.0.15 - openapi-types@12.1.3: {} - openapi-typescript-helpers@0.0.15: {} - openid-client@6.8.2: dependencies: jose: 6.2.4 @@ -13355,6 +13337,8 @@ snapshots: orderedmap@2.1.1: {} + os-paths@4.4.0: {} + outdent@0.5.0: {} own-keys@1.0.2: @@ -13534,11 +13518,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.2 - minipass: 7.1.3 - path-to-regexp@0.1.13: {} path-to-regexp@8.4.2: {} @@ -13603,8 +13582,6 @@ snapshots: pkce-challenge@5.0.1: {} - platform@1.3.6: {} - playwright-core@1.56.1: {} playwright@1.56.1: @@ -14724,6 +14701,22 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + supergateway@3.4.3(bufferutil@4.1.0): + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + body-parser: 1.20.6 + cors: 2.8.6 + express: 4.22.0 + uuid: 11.1.1 + ws: 8.21.0(bufferutil@4.1.0) + yargs: 17.7.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -14792,6 +14785,15 @@ snapshots: readable-stream: 3.6.2 optional: true + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -14811,14 +14813,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.22: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - teex@1.0.1: dependencies: streamx: 2.28.0 @@ -15028,9 +15022,6 @@ snapshots: undici@7.29.0: {} - undici@8.8.0: - optional: true - unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15262,10 +15253,6 @@ snapshots: transitivePeerDependencies: - msw - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.18.0: {} - web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} @@ -15369,6 +15356,14 @@ snapshots: optionalDependencies: bufferutil: 4.1.0 + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-naming@0.3.0: {} xml2js@0.6.2: @@ -15430,6 +15425,10 @@ snapshots: dependencies: zod: 3.24.0 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b81c76db17..870b832305 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,8 +5,8 @@ packages: catalogMode: prefer catalog: - e2b: 2.37.0 "@modelcontextprotocol/sdk": 1.29.0 + "@vercel/sandbox": 2.9.2 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 "@ast-grep/napi": 0.44.1 @@ -50,6 +50,7 @@ catalog: mint: 4.2.742 publint: ^0.3.8 smol-toml: 1.7.0 + supergateway: 3.4.3 overrides: vite: "catalog:" allowBuilds: From ec7a7ee1cf00d88c3b9a7b5998cf5c63a9e87407 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 18:54:28 -0700 Subject: [PATCH 15/24] fix: use the locked sandbox gateway --- .../examples/vercel-sandbox/README.md | 4 ++++ .../examples/vercel-sandbox/src/sandbox.ts | 22 +++++-------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md index 464f734ffe..8ce2a0f7e2 100644 --- a/packages/integrations/examples/vercel-sandbox/README.md +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -135,5 +135,9 @@ capabilities of that key. Use a separately scoped project/key and host-side time Stagehand methods require a separately scoped model credential and an exact provider-host policy; this example intentionally does not forward outer-agent model keys or broaden egress. +The Vercel policy constrains network requests made by guest processes. The browser itself runs +remotely on Browserbase, so this policy does not restrict which URLs that browser can navigate to. +Apply separate browser-navigation controls when the agent must stay within an approved site set. + `close()` is idempotent and attempts both stop and permanent delete even when one cleanup operation fails. The lease adds a bounded fallback. Always close the MCP client first, then the connection. diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index 29dd706820..f256b0f6bb 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -13,8 +13,8 @@ const MCP_USER = "stagehand-mcp"; const PROXY_USER = "stagehand-proxy"; const SANDBOX_ROOT = "/vercel/sandbox"; const STAGEHAND_ROOT = `${SANDBOX_ROOT}/stagehand`; -const GATEWAY_ROOT = `${SANDBOX_ROOT}/gateway`; -const GATEWAY_BIN = `${GATEWAY_ROOT}/node_modules/.bin/supergateway`; +const EXAMPLE_ROOT = `${STAGEHAND_ROOT}/packages/integrations/examples/vercel-sandbox`; +const GATEWAY_BIN = `${EXAMPLE_ROOT}/node_modules/.bin/supergateway`; const AUTH_PROXY_PATH = `${SANDBOX_ROOT}/auth-proxy.mjs`; const STDIO_WRAPPER_PATH = `${SANDBOX_ROOT}/stdio-wrapper.mjs`; @@ -59,7 +59,6 @@ export async function createStagehandSandbox( try { await installStagehand(sandbox, options.stagehandRevision); - await installGateway(sandbox); await sandbox.writeFiles([ { path: AUTH_PROXY_PATH, @@ -197,25 +196,14 @@ async function installStagehand(sandbox: Sandbox, revision: string): Promise { - await run(sandbox, "install the MCP HTTP bridge", "npm", [ - "install", - "--prefix", - GATEWAY_ROOT, - "--ignore-scripts", - "--no-audit", - "--no-fund", - "supergateway@3.4.3", - ]); -} - async function protectRuntimeFiles(sandbox: Sandbox): Promise { await run(sandbox.asUser("root"), "protect the trusted runtime", "bash", [ "-lc", [ - `chown -R root:root ${STAGEHAND_ROOT} ${GATEWAY_ROOT}`, + `test -x ${GATEWAY_BIN}`, + `chown -R root:root ${STAGEHAND_ROOT}`, `chown root:root ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, - `chmod -R a-w ${STAGEHAND_ROOT} ${GATEWAY_ROOT}`, + `chmod -R a-w ${STAGEHAND_ROOT}`, `chmod 0555 ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, ].join(" && "), ]); From e9c6682e570c8be1f1addaa4950ad9ce705b15f5 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 02:42:55 +0000 Subject: [PATCH 16/24] fix(vercel): harden sandbox lifecycle boundary --- .../workflows/codemode-framework-examples.yml | 1 + .../examples/vercel-sandbox/README.md | 2 +- .../examples/vercel-sandbox/package.json | 3 +- .../examples/vercel-sandbox/src/e2e.ts | 38 +++-- .../vercel-sandbox/src/guest/auth-proxy.mjs | 17 +- .../src/guest/auth-proxy.test.mjs | 148 ++++++++++++++++++ .../src/guest/stdio-wrapper.mjs | 15 +- .../vercel-sandbox/src/lease.test.mjs | 45 ++++++ .../examples/vercel-sandbox/src/lease.ts | 12 +- .../examples/vercel-sandbox/src/sandbox.ts | 124 ++++++++++----- .../examples/vercel-sandbox/src/smoke.ts | 23 ++- .../examples/vercel-sandbox/tsconfig.json | 1 - 12 files changed, 356 insertions(+), 73 deletions(-) create mode 100644 packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs create mode 100644 packages/integrations/examples/vercel-sandbox/src/lease.test.mjs diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 92b020260d..3f1e2ec017 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -62,6 +62,7 @@ jobs: - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - run: pnpm --filter ${{ matrix.package }} typecheck + - run: pnpm --filter ${{ matrix.package }} test:contract - run: pnpm --filter ${{ matrix.package }} smoke env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md index 8ce2a0f7e2..d1663a47ad 100644 --- a/packages/integrations/examples/vercel-sandbox/README.md +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -96,7 +96,7 @@ Python and other non-Node adapters can launch the same provider implementation w setup or network-policy logic: ```bash -node packages/integrations/examples/vercel-sandbox/src/lease.ts +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox lease ``` The launcher writes exactly one JSON line to stdout: diff --git a/packages/integrations/examples/vercel-sandbox/package.json b/packages/integrations/examples/vercel-sandbox/package.json index 4968e2ee97..1dbd18c98c 100644 --- a/packages/integrations/examples/vercel-sandbox/package.json +++ b/packages/integrations/examples/vercel-sandbox/package.json @@ -9,8 +9,9 @@ }, "scripts": { "e2e": "tsx src/e2e.ts", - "lease": "node src/lease.ts", + "lease": "tsx src/lease.ts", "smoke": "tsx src/smoke.ts", + "test:contract": "node --test src/*.test.mjs src/guest/*.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/integrations/examples/vercel-sandbox/src/e2e.ts b/packages/integrations/examples/vercel-sandbox/src/e2e.ts index 8da4680bb0..ac2c20b8b9 100644 --- a/packages/integrations/examples/vercel-sandbox/src/e2e.ts +++ b/packages/integrations/examples/vercel-sandbox/src/e2e.ts @@ -6,6 +6,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { createStagehandSandbox, stagehandTransport } from "./sandbox.js"; +const HTTP_REQUEST_TIMEOUT_MS = 15_000; const hostMarker = `host-${randomUUID()}`; const stateMarker = `state-${randomUUID()}`; const markerPath = `/tmp/stagehand-vercel-proof-${randomUUID()}.json`; @@ -21,14 +22,18 @@ const client = new Client({ name: "stagehand-vercel-sandbox-e2e", version: "1.0. let primaryError: unknown; try { - const unauthorized = await fetch(connection.url); + const unauthorized = await fetch(connection.url, { + signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT_MS), + }); assert.equal(unauthorized.status, 401); const authorizedHealth = await fetch(new URL("/healthz", connection.url), { headers: { Authorization: `Bearer ${connection.token}` }, + signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT_MS), }); assert.equal(authorizedHealth.status, 200); const optionalGetStream = await fetch(connection.url, { headers: { Authorization: `Bearer ${connection.token}` }, + signal: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT_MS), }); assert.equal(optionalGetStream.status, 405); assert.equal(optionalGetStream.headers.get("allow"), "POST, DELETE"); @@ -169,21 +174,6 @@ try { assert.equal(secondValue.proxySignalAllowed, false); assert.equal(secondValue.sudoAllowed, false); assert.equal(existsSync(markerPath), false, "sandbox marker escaped to the host filesystem"); - - process.stdout.write( - `${JSON.stringify({ - status: "PASS", - tools: ["code_execute"], - calls: 2, - statePersisted: true, - unrelatedEgressBlocked: true, - publicAuth: { unauthorized: 401, authorized: 200 }, - optionalGetStream: 405, - credentialBrokered: true, - hostIsolated: true, - proxyUserIsolated: true, - })}\n`, - ); } catch (error) { primaryError = error; } @@ -202,11 +192,27 @@ if (cleanupErrors.length > 0) { throw new AggregateError(cleanupErrors, "Could not close the MCP client and Vercel Sandbox"); } +process.stdout.write( + `${JSON.stringify({ + status: "PASS", + tools: ["code_execute"], + calls: 2, + statePersisted: true, + unrelatedEgressBlocked: true, + publicAuth: { unauthorized: 401, authorized: 200 }, + optionalGetStream: 405, + credentialBrokered: true, + hostIsolated: true, + proxyUserIsolated: true, + })}\n`, +); + function successfulValue(result: Awaited>, label: string) { const structured = result.structuredContent as { ok?: unknown; value?: unknown } | undefined; assert.equal(result.isError ?? false, false, `${label} returned an MCP error`); assert.equal(structured?.ok, true, `${label} returned a code error`); assert.equal(typeof structured?.value, "object", `${label} returned no value`); + assert.notEqual(structured.value, null, `${label} returned no value`); return structured.value as Record; } diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs index f425ab3ae4..1b6381345f 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs @@ -16,8 +16,10 @@ const passthroughHeaders = [ ]; function authorized(value) { - if (typeof value !== "string" || !value.startsWith("Bearer ")) return false; - const providedDigest = createHash("sha256").update(value.slice("Bearer ".length)).digest(); + if (typeof value !== "string") return false; + const match = /^Bearer\s+(.+)$/i.exec(value); + if (!match) return false; + const providedDigest = createHash("sha256").update(match[1]).digest(); return timingSafeEqual(providedDigest, expectedDigest); } @@ -33,7 +35,7 @@ http // sends no server-initiated notifications, so reject the optional GET // stream and keep MCP request/response traffic on POST and DELETE. const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; - if (request.method === "GET" && pathname === "/mcp") { + if (pathname === "/mcp" && request.method !== "POST" && request.method !== "DELETE") { response.writeHead(405, { allow: "POST, DELETE", "content-type": "text/plain", @@ -41,6 +43,11 @@ http response.end("Method Not Allowed\n"); return; } + if (pathname !== "/mcp" && !(pathname === "/healthz" && request.method === "GET")) { + response.writeHead(404, { "content-type": "text/plain" }); + response.end("Not Found\n"); + return; + } const headers = { host: "127.0.0.1:8000" }; for (const name of passthroughHeaders) { @@ -65,6 +72,10 @@ http if (!response.headersSent) response.writeHead(502); response.end("Upstream unavailable\n"); }); + request.once("aborted", () => upstream.destroy()); + response.once("close", () => { + if (!response.writableEnded) upstream.destroy(); + }); request.pipe(upstream); }) .listen(3000, "0.0.0.0"); diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs new file mode 100644 index 0000000000..0ec2e2c9ab --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import http from "node:http"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const TOKEN = "auth-proxy-contract-token"; +const AUTHORIZATION = `Bearer ${TOKEN}`; +const PROXY_ORIGIN = "http://127.0.0.1:3000"; +const REQUEST_TIMEOUT_MS = 2_000; + +test("auth proxy restricts ingress and closes abandoned upstream requests", async (context) => { + let upstreamRequests = 0; + let releaseStalledResponse; + const stalledResponseClosed = new Promise((resolve) => { + releaseStalledResponse = resolve; + }); + const bridge = http.createServer((request, response) => { + upstreamRequests += 1; + if (request.url === "/mcp?stall=1") { + response.once("close", releaseStalledResponse); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ path: request.url, method: request.method })); + }); + await listen(bridge, 8000); + context.after(() => closeServer(bridge)); + + const proxy = spawn( + process.execPath, + [fileURLToPath(new URL("./auth-proxy.mjs", import.meta.url))], + { + env: { + ...process.env, + BRIDGE_TOKEN_SHA256: createHash("sha256").update(TOKEN).digest("hex"), + }, + stdio: ["ignore", "ignore", "inherit"], + }, + ); + context.after(() => stopProcess(proxy)); + await waitForProxy(); + + assert.equal((await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`)).status, 401); + assert.equal( + ( + await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`, { + headers: { Authorization: `bEaReR ${TOKEN}` }, + }) + ).status, + 200, + ); + assert.equal( + ( + await fetchWithTimeout(`${PROXY_ORIGIN}/private`, { + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 404, + ); + assert.equal( + ( + await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + method: "PUT", + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 405, + ); + assert.equal( + ( + await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 405, + ); + const forwarded = await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + method: "POST", + headers: { Authorization: AUTHORIZATION, "content-type": "application/json" }, + body: "{}", + }); + assert.equal(forwarded.status, 200); + assert.deepEqual(await forwarded.json(), { path: "/mcp", method: "POST" }); + const deleted = await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + method: "DELETE", + headers: { Authorization: AUTHORIZATION }, + }); + assert.equal(deleted.status, 200); + assert.deepEqual(await deleted.json(), { path: "/mcp", method: "DELETE" }); + assert.equal(upstreamRequests, 3, "rejected routes must not reach the bridge"); + + const abandoned = http.request(`${PROXY_ORIGIN}/mcp?stall=1`, { + method: "POST", + headers: { Authorization: AUTHORIZATION, "content-type": "application/json" }, + }); + abandoned.on("error", () => undefined); + abandoned.end("{}"); + await waitFor(() => upstreamRequests === 4); + abandoned.destroy(); + await withTimeout(stalledResponseClosed, "proxy did not close the abandoned upstream request"); +}); + +async function waitForProxy() { + await waitFor(async () => { + const response = await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`).catch(() => undefined); + return response?.status === 401; + }); +} + +async function waitFor(predicate) { + const deadline = Date.now() + REQUEST_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for auth-proxy contract state"); +} + +async function fetchWithTimeout(url, init = {}) { + return fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); +} + +function withTimeout(promise, message) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(message)), REQUEST_TIMEOUT_MS)), + ]); +} + +function listen(server, port) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); +} + +async function closeServer(server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); +} + +async function stopProcess(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); +} diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs index be208ea186..3b9ff49bff 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs @@ -10,11 +10,20 @@ const child = spawn( }, ); +const signalHandlers = new Map(); for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, () => child.kill(signal)); + const handler = () => child.kill(signal); + signalHandlers.set(signal, handler); + process.on(signal, handler); } child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); + for (const [handledSignal, handler] of signalHandlers) { + process.removeListener(handledSignal, handler); + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); }); diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs new file mode 100644 index 0000000000..8115105d8a --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const EXIT_TIMEOUT_MS = 2_000; + +test("lease setup failure exits while the parent keeps stdin open", async () => { + const environment = { ...process.env }; + delete environment.STAGEHAND_REVISION; + delete environment.BROWSERBASE_API_KEY; + delete environment.BROWSERBASE_PROJECT_ID; + + const child = spawn( + process.execPath, + [ + fileURLToPath(import.meta.resolve("tsx/cli")), + fileURLToPath(new URL("./lease.ts", import.meta.url)), + ], + { + env: environment, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const stdout = []; + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + + const exit = await Promise.race([ + new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal }))), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Lease did not exit after setup failure")), + EXIT_TIMEOUT_MS, + ), + ), + ]).finally(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); + + assert.deepEqual(exit, { code: 1, signal: null }); + assert.equal(Buffer.concat(stdout).length, 0); + assert.match(Buffer.concat(stderr).toString(), /^Stagehand sandbox lease failed: Missing /); +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts index 896f44f268..394c2e7cbc 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.ts +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -1,17 +1,20 @@ #!/usr/bin/env node -import { createStagehandSandbox } from "./sandbox.ts"; +import { createStagehandSandbox } from "./sandbox.js"; const SHUTDOWN_FALLBACK_MS = 35_000; type LeaseEnd = { signal?: NodeJS.Signals }; try { + const stagehandRevision = requiredEnvironment("STAGEHAND_REVISION"); + const browserbaseApiKey = requiredEnvironment("BROWSERBASE_API_KEY"); + const browserbaseProjectId = requiredEnvironment("BROWSERBASE_PROJECT_ID"); const leaseEnd = waitForLeaseEnd(); const connection = await createStagehandSandbox({ - stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), - browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), - browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), + stagehandRevision, + browserbaseApiKey, + browserbaseProjectId, }); process.stdout.write( @@ -33,6 +36,7 @@ try { if (signal) forwardSignal(signal); } catch (error) { + process.stdin.pause(); process.stderr.write(`Stagehand sandbox lease failed: ${safeMessage(error)}\n`); process.exitCode = 1; } diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index f256b0f6bb..6e9ed7498b 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -17,6 +17,47 @@ const EXAMPLE_ROOT = `${STAGEHAND_ROOT}/packages/integrations/examples/vercel-sa const GATEWAY_BIN = `${EXAMPLE_ROOT}/node_modules/.bin/supergateway`; const AUTH_PROXY_PATH = `${SANDBOX_ROOT}/auth-proxy.mjs`; const STDIO_WRAPPER_PATH = `${SANDBOX_ROOT}/stdio-wrapper.mjs`; +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; + +class StagehandSandboxSetupError extends Error { + override readonly name = "StagehandSandboxSetupError"; + + constructor() { + super("Stagehand sandbox setup failed."); + } +} + +class StagehandSandboxHealthError extends Error { + override readonly name = "StagehandSandboxHealthError"; + + constructor() { + super("Stagehand sandbox health verification failed."); + } +} + +class StagehandCdpDiscoveryError extends Error { + override readonly name = "StagehandCdpDiscoveryError"; + + constructor() { + super("Browserbase CDP host discovery failed."); + } +} + +class StagehandSandboxDisposeError extends Error { + override readonly name = "StagehandSandboxDisposeError"; + + constructor() { + super("Could not stop and delete the Stagehand sandbox."); + } +} + +class StagehandSandboxCommandError extends Error { + override readonly name = "StagehandSandboxCommandError"; + + constructor(label: string, exitCode: number) { + super(`${label} failed inside the trusted sandbox (exit ${exitCode}).`); + } +} export type StagehandSandboxOptions = { stagehandRevision: string; @@ -46,15 +87,20 @@ export async function createStagehandSandbox( assertNonEmpty(options.browserbaseProjectId, "browserbaseProjectId"); const cdpHost = await discoverBrowserbaseCdpHost(options); - const sandbox = await Sandbox.create({ - runtime: "node24", - resources: { vcpus: 4 }, - timeout: options.sandboxTimeoutMs ?? 40 * 60_000, - ports: [BRIDGE_PORT], - persistent: false, - networkPolicy: "allow-all", - tags: { purpose: "stagehand-codemode-mcp" }, - }); + let sandbox: Sandbox; + try { + sandbox = await Sandbox.create({ + runtime: "node24", + resources: { vcpus: 4 }, + timeout: options.sandboxTimeoutMs ?? 40 * 60_000, + ports: [BRIDGE_PORT], + persistent: false, + networkPolicy: "allow-all", + tags: { purpose: "stagehand-codemode-mcp" }, + }); + } catch { + throw new StagehandSandboxSetupError(); + } const close = sandboxCloser(sandbox, options.cleanupTimeoutMs ?? 30_000); try { @@ -100,11 +146,11 @@ export async function createStagehandSandbox( const origin = new URL(sandbox.domain(BRIDGE_PORT)); await waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000); - const unauthorized = await fetch(new URL("/healthz", origin)); - if (unauthorized.status !== 401) { - throw new Error( - `Expected unauthenticated bridge health to return 401, received ${unauthorized.status}`, - ); + const unauthorized = await fetch(new URL("/healthz", origin), { + signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), + }).catch(() => undefined); + if (unauthorized?.status !== 401) { + throw new StagehandSandboxHealthError(); } return { @@ -115,13 +161,17 @@ export async function createStagehandSandbox( } catch (error) { try { await close(); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - "Stagehand sandbox setup failed and cleanup also failed", - ); + } catch { + throw new StagehandSandboxSetupError(); + } + if ( + error instanceof StagehandSandboxSetupError || + error instanceof StagehandSandboxHealthError || + error instanceof StagehandSandboxCommandError + ) { + throw error; } - throw error; + throw new StagehandSandboxSetupError(); } } @@ -255,18 +305,19 @@ async function assertUnprivileged(user: SandboxUser, name: string): Promise { const deadline = Date.now() + timeoutMs; - let lastStatus: number | undefined; while (Date.now() < deadline) { + const requestTimeoutMs = Math.max( + 1, + Math.min(HEALTH_REQUEST_TIMEOUT_MS, deadline - Date.now()), + ); const response = await fetch(new URL("/healthz", origin), { headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(requestTimeoutMs), }).catch(() => undefined); if (response?.ok) return; - lastStatus = response?.status; - await delay(250); + await delay(Math.min(250, Math.max(0, deadline - Date.now()))); } - throw new Error( - `Authenticated Stagehand bridge readiness timed out (last status ${lastStatus ?? "unreachable"})`, - ); + throw new StagehandSandboxHealthError(); } async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Promise { @@ -288,10 +339,10 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro throw new Error(`Browserbase CDP host discovery returned ${response.status}`); } const session = (await response.json()) as { id?: unknown; connectUrl?: unknown }; - if (typeof session.id !== "string" || typeof session.connectUrl !== "string") { + if (typeof session.id === "string") sessionId = session.id; + if (!sessionId || typeof session.connectUrl !== "string") { throw new Error("Browserbase CDP host discovery returned an invalid session"); } - sessionId = session.id; discoveredHost = assertBrowserbaseCdpHost(new URL(session.connectUrl).hostname); } catch (error) { primaryError = error; @@ -319,15 +370,9 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro } } - if (primaryError !== undefined && cleanupError !== undefined) { - throw new AggregateError( - [primaryError, cleanupError], - "Browserbase CDP host discovery and session release both failed", - ); + if (primaryError !== undefined || cleanupError !== undefined || !discoveredHost) { + throw new StagehandCdpDiscoveryError(); } - if (primaryError !== undefined) throw primaryError; - if (cleanupError !== undefined) throw cleanupError; - if (!discoveredHost) throw new Error("Browserbase CDP host discovery returned no hostname"); return discoveredHost; } @@ -349,8 +394,7 @@ async function disposeSandbox(sandbox: Sandbox, timeoutMs: number): Promise 0) - throw new AggregateError(errors, "Could not stop and delete Vercel Sandbox"); + if (errors.length > 0) throw new StagehandSandboxDisposeError(); } async function run( @@ -363,8 +407,8 @@ async function run( const result = await target.runCommand({ cmd, args, cwd }); const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]); if (result.exitCode !== 0) { - const detail = stderr.trim() || stdout.trim() || "no command output"; - throw new Error(`${label} failed with exit ${result.exitCode}: ${detail.slice(-2_000)}`); + void stderr; + throw new StagehandSandboxCommandError(label, result.exitCode); } return stdout; } diff --git a/packages/integrations/examples/vercel-sandbox/src/smoke.ts b/packages/integrations/examples/vercel-sandbox/src/smoke.ts index b05083aa44..e9ff80d251 100644 --- a/packages/integrations/examples/vercel-sandbox/src/smoke.ts +++ b/packages/integrations/examples/vercel-sandbox/src/smoke.ts @@ -8,6 +8,7 @@ const stdioServerPath = fileURLToPath( new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), ); const client = new Client({ name: "stagehand-vercel-sandbox-smoke", version: "1.0.0" }); +let primaryError: unknown; try { await client.connect( @@ -53,12 +54,26 @@ try { marker: "persisted", }), ); - process.stdout.write( - `${JSON.stringify({ status: "PASS", tools: ["code_execute"], calls: 2, statePersisted: true })}\n`, +} catch (error) { + primaryError = error; +} + +let cleanupError: unknown; +await client.close().catch((error: unknown) => { + cleanupError = error; +}); +if (primaryError !== undefined && cleanupError !== undefined) { + throw new AggregateError( + [primaryError, cleanupError], + "Stagehand sandbox smoke failed and MCP client cleanup also failed", ); -} finally { - await client.close().catch(() => undefined); } +if (primaryError !== undefined) throw primaryError; +if (cleanupError !== undefined) throw cleanupError; + +process.stdout.write( + `${JSON.stringify({ status: "PASS", tools: ["code_execute"], calls: 2, statePersisted: true })}\n`, +); function localSmokeEnvironment(): Record { const environment: Record = { STAGEHAND_BROWSER: "local" }; diff --git a/packages/integrations/examples/vercel-sandbox/tsconfig.json b/packages/integrations/examples/vercel-sandbox/tsconfig.json index 07e24666ce..8f14c5759a 100644 --- a/packages/integrations/examples/vercel-sandbox/tsconfig.json +++ b/packages/integrations/examples/vercel-sandbox/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../../../tsconfig.json", "compilerOptions": { - "allowImportingTsExtensions": true, "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, From 938efe158b73be3ef44cdc26946f063de85b0130 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 03:00:43 +0000 Subject: [PATCH 17/24] test(vercel): make sandbox contracts deterministic --- .../vercel-sandbox/src/guest/auth-proxy.mjs | 124 ++++++++++-------- .../src/guest/auth-proxy.test.mjs | 84 ++++++++---- .../vercel-sandbox/src/lease.test.mjs | 12 +- .../examples/vercel-sandbox/src/smoke.ts | 11 +- 4 files changed, 145 insertions(+), 86 deletions(-) diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs index 1b6381345f..105481c149 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs @@ -6,6 +6,8 @@ if (!digestHex || !/^[0-9a-f]{64}$/i.test(digestHex)) { throw new Error("BRIDGE_TOKEN_SHA256 is required"); } const expectedDigest = Buffer.from(digestHex, "hex"); +const bridgePort = configuredPort("BRIDGE_PORT", 8000, false); +const proxyPort = configuredPort("PROXY_PORT", 3000, true); const passthroughHeaders = [ "accept", "content-type", @@ -23,59 +25,75 @@ function authorized(value) { return timingSafeEqual(providedDigest, expectedDigest); } -http - .createServer((request, response) => { - if (!authorized(request.headers.authorization)) { - response.writeHead(401, { "content-type": "text/plain" }); - response.end("Unauthorized\n"); - return; - } +const server = http.createServer((request, response) => { + if (!authorized(request.headers.authorization)) { + response.writeHead(401, { "content-type": "text/plain" }); + response.end("Unauthorized\n"); + return; + } - // Vercel's public edge buffers an otherwise idle SSE response. Stagehand - // sends no server-initiated notifications, so reject the optional GET - // stream and keep MCP request/response traffic on POST and DELETE. - const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; - if (pathname === "/mcp" && request.method !== "POST" && request.method !== "DELETE") { - response.writeHead(405, { - allow: "POST, DELETE", - "content-type": "text/plain", - }); - response.end("Method Not Allowed\n"); - return; - } - if (pathname !== "/mcp" && !(pathname === "/healthz" && request.method === "GET")) { - response.writeHead(404, { "content-type": "text/plain" }); - response.end("Not Found\n"); - return; - } + // Vercel's public edge buffers an otherwise idle SSE response. Stagehand + // sends no server-initiated notifications, so reject the optional GET + // stream and keep MCP request/response traffic on POST and DELETE. + const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (pathname === "/mcp" && request.method !== "POST" && request.method !== "DELETE") { + response.writeHead(405, { + allow: "POST, DELETE", + "content-type": "text/plain", + }); + response.end("Method Not Allowed\n"); + return; + } + if (pathname !== "/mcp" && !(pathname === "/healthz" && request.method === "GET")) { + response.writeHead(404, { "content-type": "text/plain" }); + response.end("Not Found\n"); + return; + } - const headers = { host: "127.0.0.1:8000" }; - for (const name of passthroughHeaders) { - const value = request.headers[name]; - if (value !== undefined) headers[name] = value; - } + const headers = { host: `127.0.0.1:${bridgePort}` }; + for (const name of passthroughHeaders) { + const value = request.headers[name]; + if (value !== undefined) headers[name] = value; + } - const upstream = http.request( - { - host: "127.0.0.1", - port: 8000, - method: request.method, - path: request.url, - headers, - }, - (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.pipe(response); - }, - ); - upstream.on("error", () => { - if (!response.headersSent) response.writeHead(502); - response.end("Upstream unavailable\n"); - }); - request.once("aborted", () => upstream.destroy()); - response.once("close", () => { - if (!response.writableEnded) upstream.destroy(); - }); - request.pipe(upstream); - }) - .listen(3000, "0.0.0.0"); + const upstream = http.request( + { + host: "127.0.0.1", + port: bridgePort, + method: request.method, + path: request.url, + headers, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + }, + ); + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502); + response.end("Upstream unavailable\n"); + }); + request.once("aborted", () => upstream.destroy()); + response.once("close", () => { + if (!response.writableEnded) upstream.destroy(); + }); + request.pipe(upstream); +}); + +server.listen(proxyPort, "0.0.0.0", () => { + const address = server.address(); + if (process.send && typeof address === "object" && address !== null) { + process.send({ port: address.port }); + } +}); + +function configuredPort(name, fallback, allowEphemeral) { + const value = process.env[name]; + if (value === undefined) return fallback; + if (!/^\d+$/.test(value)) throw new Error(`${name} must be a valid port`); + const port = Number(value); + if (port > 65_535 || (!allowEphemeral && port === 0)) { + throw new Error(`${name} must be a valid port`); + } + return port; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs index 0ec2e2c9ab..6fe16208a7 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs @@ -7,7 +7,6 @@ import { fileURLToPath } from "node:url"; const TOKEN = "auth-proxy-contract-token"; const AUTHORIZATION = `Bearer ${TOKEN}`; -const PROXY_ORIGIN = "http://127.0.0.1:3000"; const REQUEST_TIMEOUT_MS = 2_000; test("auth proxy restricts ingress and closes abandoned upstream requests", async (context) => { @@ -25,7 +24,7 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ path: request.url, method: request.method })); }); - await listen(bridge, 8000); + const bridgePort = await listen(bridge, 0); context.after(() => closeServer(bridge)); const proxy = spawn( @@ -35,17 +34,20 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn env: { ...process.env, BRIDGE_TOKEN_SHA256: createHash("sha256").update(TOKEN).digest("hex"), + BRIDGE_PORT: String(bridgePort), + PROXY_PORT: "0", }, - stdio: ["ignore", "ignore", "inherit"], + stdio: ["ignore", "ignore", "inherit", "ipc"], }, ); context.after(() => stopProcess(proxy)); - await waitForProxy(); + const proxyPort = await waitForProxyPort(proxy); + const proxyOrigin = `http://127.0.0.1:${proxyPort}`; - assert.equal((await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`)).status, 401); + assert.equal((await fetchWithTimeout(`${proxyOrigin}/healthz`)).status, 401); assert.equal( ( - await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`, { + await fetchWithTimeout(`${proxyOrigin}/healthz`, { headers: { Authorization: `bEaReR ${TOKEN}` }, }) ).status, @@ -53,7 +55,7 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn ); assert.equal( ( - await fetchWithTimeout(`${PROXY_ORIGIN}/private`, { + await fetchWithTimeout(`${proxyOrigin}/private`, { headers: { Authorization: AUTHORIZATION }, }) ).status, @@ -61,7 +63,7 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn ); assert.equal( ( - await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + await fetchWithTimeout(`${proxyOrigin}/mcp`, { method: "PUT", headers: { Authorization: AUTHORIZATION }, }) @@ -70,20 +72,20 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn ); assert.equal( ( - await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + await fetchWithTimeout(`${proxyOrigin}/mcp`, { headers: { Authorization: AUTHORIZATION }, }) ).status, 405, ); - const forwarded = await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + const forwarded = await fetchWithTimeout(`${proxyOrigin}/mcp`, { method: "POST", headers: { Authorization: AUTHORIZATION, "content-type": "application/json" }, body: "{}", }); assert.equal(forwarded.status, 200); assert.deepEqual(await forwarded.json(), { path: "/mcp", method: "POST" }); - const deleted = await fetchWithTimeout(`${PROXY_ORIGIN}/mcp`, { + const deleted = await fetchWithTimeout(`${proxyOrigin}/mcp`, { method: "DELETE", headers: { Authorization: AUTHORIZATION }, }); @@ -91,7 +93,7 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn assert.deepEqual(await deleted.json(), { path: "/mcp", method: "DELETE" }); assert.equal(upstreamRequests, 3, "rejected routes must not reach the bridge"); - const abandoned = http.request(`${PROXY_ORIGIN}/mcp?stall=1`, { + const abandoned = http.request(`${proxyOrigin}/mcp?stall=1`, { method: "POST", headers: { Authorization: AUTHORIZATION, "content-type": "application/json" }, }); @@ -102,11 +104,33 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn await withTimeout(stalledResponseClosed, "proxy did not close the abandoned upstream request"); }); -async function waitForProxy() { - await waitFor(async () => { - const response = await fetchWithTimeout(`${PROXY_ORIGIN}/healthz`).catch(() => undefined); - return response?.status === 401; - }); +function waitForProxyPort(child) { + return withTimeout( + new Promise((resolve, reject) => { + const onMessage = (message) => { + if (!message || typeof message !== "object" || typeof message.port !== "number") return; + cleanup(); + resolve(message.port); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const onExit = (code, signal) => { + cleanup(); + reject(new Error(`auth proxy exited before listening (${signal ?? code ?? "unknown"})`)); + }; + const cleanup = () => { + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + }; + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + }), + "Timed out waiting for auth proxy to listen", + ); } async function waitFor(predicate) { @@ -122,17 +146,31 @@ async function fetchWithTimeout(url, init = {}) { return fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); } -function withTimeout(promise, message) { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(message)), REQUEST_TIMEOUT_MS)), - ]); +async function withTimeout(promise, message) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), REQUEST_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timeout); + } } function listen(server, port) { return new Promise((resolve, reject) => { server.once("error", reject); - server.listen(port, "127.0.0.1", resolve); + server.listen(port, "127.0.0.1", () => { + const address = server.address(); + if (typeof address !== "object" || address === null) { + reject(new Error("Test bridge did not bind a TCP port")); + return; + } + resolve(address.port); + }); }); } diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs index 8115105d8a..bc84506f72 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -27,15 +27,17 @@ test("lease setup failure exits while the parent keeps stdin open", async () => child.stdout.on("data", (chunk) => stdout.push(chunk)); child.stderr.on("data", (chunk) => stderr.push(chunk)); + let timeout; const exit = await Promise.race([ - new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal }))), - new Promise((_, reject) => - setTimeout( + new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))), + new Promise((_, reject) => { + timeout = setTimeout( () => reject(new Error("Lease did not exit after setup failure")), EXIT_TIMEOUT_MS, - ), - ), + ); + }), ]).finally(() => { + clearTimeout(timeout); if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }); diff --git a/packages/integrations/examples/vercel-sandbox/src/smoke.ts b/packages/integrations/examples/vercel-sandbox/src/smoke.ts index e9ff80d251..6b984c3211 100644 --- a/packages/integrations/examples/vercel-sandbox/src/smoke.ts +++ b/packages/integrations/examples/vercel-sandbox/src/smoke.ts @@ -8,7 +8,8 @@ const stdioServerPath = fileURLToPath( new URL("../../../dist/codemode/stdio-server.mjs", import.meta.url), ); const client = new Client({ name: "stagehand-vercel-sandbox-smoke", version: "1.0.0" }); -let primaryError: unknown; +const NO_ERROR = Symbol("no error"); +let primaryError: unknown = NO_ERROR; try { await client.connect( @@ -58,18 +59,18 @@ try { primaryError = error; } -let cleanupError: unknown; +let cleanupError: unknown = NO_ERROR; await client.close().catch((error: unknown) => { cleanupError = error; }); -if (primaryError !== undefined && cleanupError !== undefined) { +if (primaryError !== NO_ERROR && cleanupError !== NO_ERROR) { throw new AggregateError( [primaryError, cleanupError], "Stagehand sandbox smoke failed and MCP client cleanup also failed", ); } -if (primaryError !== undefined) throw primaryError; -if (cleanupError !== undefined) throw cleanupError; +if (primaryError !== NO_ERROR) throw primaryError; +if (cleanupError !== NO_ERROR) throw cleanupError; process.stdout.write( `${JSON.stringify({ status: "PASS", tools: ["code_execute"], calls: 2, statePersisted: true })}\n`, From 4678772d9a6be07293b6c66845d530bc23d5bffa Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 03:17:39 +0000 Subject: [PATCH 18/24] refactor(vercel): install exact code-mode package artifacts --- .../workflows/codemode-framework-examples.yml | 14 +- .gitignore | 1 + .../examples/vercel-sandbox/README.md | 42 ++-- .../examples/vercel-sandbox/package.json | 5 +- .../vercel-sandbox/scripts/pack-artifacts.mjs | 110 +++++++++ .../examples/vercel-sandbox/src/e2e.ts | 20 +- .../src/guest/stdio-wrapper.mjs | 14 +- .../vercel-sandbox/src/lease.test.mjs | 2 +- .../examples/vercel-sandbox/src/lease.ts | 15 +- .../vercel-sandbox/src/sandbox.test.ts | 18 ++ .../examples/vercel-sandbox/src/sandbox.ts | 219 ++++++++++++------ pnpm-lock.yaml | 2 +- 12 files changed, 355 insertions(+), 107 deletions(-) create mode 100644 packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs create mode 100644 packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 3f1e2ec017..12032af522 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -48,7 +48,7 @@ jobs: fail-fast: false matrix: include: - - name: Vercel Sandbox source-installed MCP + - name: Vercel Sandbox package-installed MCP package: "@browserbasehq/stagehand-integrations-example-vercel-sandbox" steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -60,10 +60,20 @@ jobs: - uses: ./.github/actions/setup-chrome-verified id: setup-chrome - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode - run: pnpm --filter ${{ matrix.package }} typecheck - run: pnpm --filter ${{ matrix.package }} test:contract + - run: pnpm --filter ${{ matrix.package }} pack:artifacts - run: pnpm --filter ${{ matrix.package }} smoke env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} STAGEHAND_BROWSER: local + - run: pnpm --filter ${{ matrix.package }} e2e + env: + STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/.gitignore b/.gitignore index bbaf26057d..dd887654d4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules dist dist-ssr packages/extension/artifacts +packages/integrations/examples/vercel-sandbox/.artifacts/ .pnpm-store *.local *.tgz diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md index d1663a47ad..bd0975c190 100644 --- a/packages/integrations/examples/vercel-sandbox/README.md +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -23,33 +23,40 @@ type StagehandSandboxConnection = { }; ``` -`createStagehandSandbox()` creates a fresh Vercel Firecracker microVM with open setup egress, checks -out a complete Stagehand commit, installs its frozen lockfile, builds code mode from source, and -installs the pinned HTTP-to-stdio bridge. Before the MCP server starts, it replaces setup egress with -an allowlist containing only Browserbase's API and the regional CDP hostname discovered for the -configured project. +`createStagehandSandbox()` creates a fresh Vercel Firecracker microVM with open setup egress, uploads +the exact packed Stagehand and code-mode artifacts supplied by the trusted host, verifies their +SHA-256 digests in the guest, and installs them with the pinned HTTP-to-stdio bridge. Before the MCP +server starts, it replaces setup egress with an allowlist containing only Browserbase's API and the +regional CDP hostname discovered for the configured project. ## Install and run Authenticate the host for [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox), then set: ```bash -STAGEHAND_REVISION=<40-character-stagehand-commit> +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts + +STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" BROWSERBASE_API_KEY= BROWSERBASE_PROJECT_ID= pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox e2e ``` -The revision must be a full commit hash. This prevents the trusted install from following a moving -branch or tag. Vercel's credential-brokering header transforms are currently available on Pro and -Enterprise plans. Check the +The artifact directory path must be absolute. The pack step builds the exact checkout under review, +produces stable local tarball names, and records an integrity-pinned npm installation lock; no +moving branch, tag, registry package version, or guest-side repository checkout participates in the +proof. Vercel's credential-brokering header transforms are currently available on Pro and Enterprise +plans. Check the [credential-brokering announcement](https://vercel.com/changelog/safely-inject-credentials-in-http-headers-with-vercel-sandbox) before relying on this example with another plan. Vercel credentials authenticate the host to the Sandbox control plane so it can create, update, stop, and delete the microVM. They are separate from the random application bearer returned by this -helper, which protects only the MCP port exposed by this sandbox. +helper, which protects only the MCP port exposed by this sandbox. The SDK uses +`VERCEL_OIDC_TOKEN` when available. Outside Vercel, pass `{ teamId, projectId, token }` as +`vercelCredentials`; the E2E and lease derive it from `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and +`VERCEL_TOKEN` when the token is present. ## Connect an MCP client @@ -64,9 +71,14 @@ import { } from "@browserbasehq/stagehand-integrations-example-vercel-sandbox"; const stagehand = await createStagehandSandbox({ - stagehandRevision: process.env.STAGEHAND_REVISION!, + packageArtifactsPath: process.env.STAGEHAND_SANDBOX_ARTIFACTS!, browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, + vercelCredentials: { + teamId: process.env.VERCEL_TEAM_ID!, + projectId: process.env.VERCEL_PROJECT_ID!, + token: process.env.VERCEL_TOKEN!, + }, }); const client = new Client({ name: "my-agent", version: "1.0.0" }); @@ -109,8 +121,9 @@ It then holds stdin open as the sandbox lease. Keep the process and stdin pipe a MCP session. Close stdin for normal cleanup; `SIGINT` and `SIGTERM` trigger bounded cleanup and retain signal-style exit semantics. Spawn it with an explicit environment allowlist containing only the runtime variables it needs: `PATH`, the relevant Vercel authentication variables, -`STAGEHAND_REVISION`, `BROWSERBASE_API_KEY`, and `BROWSERBASE_PROJECT_ID`. The token is emitted once -over the trusted parent pipe and is never placed in command arguments or environment variables. +`STAGEHAND_SANDBOX_ARTIFACTS`, `BROWSERBASE_API_KEY`, and `BROWSERBASE_PROJECT_ID`. The token is +emitted once over the trusted parent pipe and is never placed in command arguments or environment +variables. ## Security boundary @@ -126,7 +139,8 @@ additional defense inside that VM, not a substitute for it: overwrites the Browserbase API header at the network boundary. - The host discovers and validates the exact regional Browserbase CDP hostname before lockdown. The running VM allows only that hostname and `api.browserbase.com`; all other egress is denied. -- Source, dependencies, and bridge code become root-owned and read-only before untrusted code runs. +- Installed package artifacts, dependencies, and bridge code become root-owned and read-only before + untrusted code runs. - The only published guest port is the authenticated proxy. The stateful bridge listens on guest loopback. diff --git a/packages/integrations/examples/vercel-sandbox/package.json b/packages/integrations/examples/vercel-sandbox/package.json index 1dbd18c98c..b1ab73127a 100644 --- a/packages/integrations/examples/vercel-sandbox/package.json +++ b/packages/integrations/examples/vercel-sandbox/package.json @@ -10,12 +10,13 @@ "scripts": { "e2e": "tsx src/e2e.ts", "lease": "tsx src/lease.ts", + "pack:artifacts": "node scripts/pack-artifacts.mjs", "smoke": "tsx src/smoke.ts", - "test:contract": "node --test src/*.test.mjs src/guest/*.test.mjs", + "test:contract": "tsx --test src/*.test.ts src/*.test.mjs src/guest/*.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { - "@browserbasehq/stagehand-integrations": "workspace:*", + "@browserbasehq/stagehand-codemode": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@vercel/sandbox": "catalog:", "supergateway": "catalog:" diff --git a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs new file mode 100644 index 0000000000..ef3f76b093 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs @@ -0,0 +1,110 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const exampleRoot = fileURLToPath(new URL("..", import.meta.url)); +const repositoryRoot = path.resolve(exampleRoot, "../../../.."); +const sdkRoot = path.join(repositoryRoot, "packages", "sdk-ts"); +const codeModeRoot = path.join(repositoryRoot, "packages", "integrations"); +const artifactRoot = path.join(exampleRoot, ".artifacts"); +const packageRoot = path.join(artifactRoot, "packages"); +const runtimeRoot = path.join(artifactRoot, "runtime"); +const publicRegistry = "https://registry.npmjs.org"; + +await rm(artifactRoot, { force: true, recursive: true }); +await Promise.all([ + mkdir(packageRoot, { recursive: true }), + mkdir(runtimeRoot, { recursive: true }), +]); +await execFileAsync( + "pnpm", + ["exec", "turbo", "run", "build", "--filter", "@browserbasehq/stagehand-codemode"], + { cwd: repositoryRoot }, +); +await execFileAsync("pnpm", ["pack", "--pack-destination", packageRoot], { cwd: sdkRoot }); +await execFileAsync("pnpm", ["pack", "--pack-destination", packageRoot], { + cwd: codeModeRoot, +}); + +const packed = await readdir(packageRoot); +const stagehandSource = requiredArtifact(packed, /^browserbasehq-stagehand-(?!codemode-).+\.tgz$/); +const codeModeSource = requiredArtifact(packed, /^browserbasehq-stagehand-codemode-.+\.tgz$/); +const stagehandPath = path.join(packageRoot, "stagehand.tgz"); +const codeModePath = path.join(packageRoot, "stagehand-codemode.tgz"); +await rename(path.join(packageRoot, stagehandSource), stagehandPath); +await rename(path.join(packageRoot, codeModeSource), codeModePath); + +const runtimeManifest = { + name: "stagehand-sandbox-runtime", + private: true, + version: "0.0.0", + dependencies: { + "@browserbasehq/stagehand": "file:../packages/stagehand.tgz", + "@browserbasehq/stagehand-codemode": "file:../packages/stagehand-codemode.tgz", + supergateway: "3.4.3", + }, +}; +await writeFile( + path.join(runtimeRoot, "package.json"), + `${JSON.stringify(runtimeManifest, null, 2)}\n`, +); +await execFileAsync( + "npm", + [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + `--registry=${publicRegistry}`, + ], + { cwd: runtimeRoot }, +); +await assertPublicLock(path.join(runtimeRoot, "package-lock.json")); + +process.stdout.write( + `${JSON.stringify({ + status: "PASS", + artifacts: { + stagehand: await artifactSummary(stagehandPath), + codeMode: await artifactSummary(codeModePath), + runtimeManifest: await artifactSummary(path.join(runtimeRoot, "package.json")), + runtimeLock: await artifactSummary(path.join(runtimeRoot, "package-lock.json")), + }, + })}\n`, +); + +function requiredArtifact(files, pattern) { + const matches = files.filter((file) => pattern.test(file)); + if (matches.length !== 1) { + throw new Error(`Expected exactly one packed artifact matching ${pattern}`); + } + return matches[0]; +} + +async function artifactSummary(artifactPath) { + const content = await readFile(artifactPath); + return { + path: artifactPath, + bytes: (await stat(artifactPath)).size, + sha256: createHash("sha256").update(content).digest("hex"), + }; +} + +async function assertPublicLock(lockPath) { + const lock = JSON.parse(await readFile(lockPath, "utf8")); + for (const entry of Object.values(lock.packages ?? {})) { + const resolved = entry?.resolved; + if ( + typeof resolved === "string" && + !resolved.startsWith("file:") && + !resolved.startsWith(`${publicRegistry}/`) + ) { + throw new Error(`Sandbox runtime lock contains a non-public source: ${resolved}`); + } + } +} diff --git a/packages/integrations/examples/vercel-sandbox/src/e2e.ts b/packages/integrations/examples/vercel-sandbox/src/e2e.ts index ac2c20b8b9..183f040322 100644 --- a/packages/integrations/examples/vercel-sandbox/src/e2e.ts +++ b/packages/integrations/examples/vercel-sandbox/src/e2e.ts @@ -10,16 +10,18 @@ const HTTP_REQUEST_TIMEOUT_MS = 15_000; const hostMarker = `host-${randomUUID()}`; const stateMarker = `state-${randomUUID()}`; const markerPath = `/tmp/stagehand-vercel-proof-${randomUUID()}.json`; +const NO_ERROR = Symbol("no error"); process.env.HOST_ONLY_MARKER = hostMarker; assert.equal(existsSync(markerPath), false); const connection = await createStagehandSandbox({ - stagehandRevision: requiredEnvironment("STAGEHAND_REVISION"), + packageArtifactsPath: requiredEnvironment("STAGEHAND_SANDBOX_ARTIFACTS"), browserbaseApiKey: requiredEnvironment("BROWSERBASE_API_KEY"), browserbaseProjectId: requiredEnvironment("BROWSERBASE_PROJECT_ID"), + vercelCredentials: vercelCredentialsFromEnvironment(), }); const client = new Client({ name: "stagehand-vercel-sandbox-e2e", version: "1.0.0" }); -let primaryError: unknown; +let primaryError: unknown = NO_ERROR; try { const unauthorized = await fetch(connection.url, { @@ -181,13 +183,13 @@ try { const cleanupErrors: unknown[] = []; await client.close().catch((error: unknown) => cleanupErrors.push(error)); await connection.close().catch((error: unknown) => cleanupErrors.push(error)); -if (primaryError !== undefined && cleanupErrors.length > 0) { +if (primaryError !== NO_ERROR && cleanupErrors.length > 0) { throw new AggregateError( [primaryError, ...cleanupErrors], "Vercel Sandbox E2E failed and cleanup also failed", ); } -if (primaryError !== undefined) throw primaryError; +if (primaryError !== NO_ERROR) throw primaryError; if (cleanupErrors.length > 0) { throw new AggregateError(cleanupErrors, "Could not close the MCP client and Vercel Sandbox"); } @@ -221,3 +223,13 @@ function requiredEnvironment(name: string): string { if (!value) throw new Error(`Missing ${name}`); return value; } + +function vercelCredentialsFromEnvironment() { + const token = process.env.VERCEL_TOKEN; + if (!token) return undefined; + return { + teamId: requiredEnvironment("VERCEL_TEAM_ID"), + projectId: requiredEnvironment("VERCEL_PROJECT_ID"), + token, + }; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs index 3b9ff49bff..06e1b6639d 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs @@ -1,14 +1,10 @@ import { spawn } from "node:child_process"; -const child = spawn( - process.execPath, - ["/vercel/sandbox/stagehand/packages/integrations/dist/codemode/stdio-server.mjs"], - { - cwd: "/vercel/sandbox/stagehand/packages/integrations", - env: process.env, - stdio: ["inherit", "inherit", "inherit"], - }, -); +const child = spawn("/vercel/sandbox/stagehand-runtime/node_modules/.bin/stagehand-codemode", [], { + cwd: "/vercel/sandbox/stagehand-runtime", + env: process.env, + stdio: ["inherit", "inherit", "inherit"], +}); const signalHandlers = new Map(); for (const signal of ["SIGINT", "SIGTERM"]) { diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs index bc84506f72..5780eb200b 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -7,7 +7,7 @@ const EXIT_TIMEOUT_MS = 2_000; test("lease setup failure exits while the parent keeps stdin open", async () => { const environment = { ...process.env }; - delete environment.STAGEHAND_REVISION; + delete environment.STAGEHAND_SANDBOX_ARTIFACTS; delete environment.BROWSERBASE_API_KEY; delete environment.BROWSERBASE_PROJECT_ID; diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts index 394c2e7cbc..c5a5bdb00a 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.ts +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -7,14 +7,15 @@ const SHUTDOWN_FALLBACK_MS = 35_000; type LeaseEnd = { signal?: NodeJS.Signals }; try { - const stagehandRevision = requiredEnvironment("STAGEHAND_REVISION"); + const packageArtifactsPath = requiredEnvironment("STAGEHAND_SANDBOX_ARTIFACTS"); const browserbaseApiKey = requiredEnvironment("BROWSERBASE_API_KEY"); const browserbaseProjectId = requiredEnvironment("BROWSERBASE_PROJECT_ID"); const leaseEnd = waitForLeaseEnd(); const connection = await createStagehandSandbox({ - stagehandRevision, + packageArtifactsPath, browserbaseApiKey, browserbaseProjectId, + vercelCredentials: vercelCredentialsFromEnvironment(), }); process.stdout.write( @@ -70,6 +71,16 @@ function requiredEnvironment(name: string): string { return value; } +function vercelCredentialsFromEnvironment() { + const token = process.env.VERCEL_TOKEN; + if (!token) return undefined; + return { + teamId: requiredEnvironment("VERCEL_TEAM_ID"), + projectId: requiredEnvironment("VERCEL_PROJECT_ID"), + token, + }; +} + function safeMessage(error: unknown): string { return error instanceof Error ? error.message : "unknown error"; } diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts new file mode 100644 index 0000000000..580d28473b --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createStagehandSandbox } from "./sandbox.js"; + +void test("invalid package artifacts fail before any sandbox is created", async () => { + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: "relative-artifacts", + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + }), + { + name: "StagehandPackageArtifactError", + message: "Stagehand package artifact is invalid.", + }, + ); +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index 6e9ed7498b..becf7cf242 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; +import path from "node:path"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Sandbox, type SandboxUser } from "@vercel/sandbox"; @@ -12,12 +13,19 @@ const MCP_PROTOCOL_VERSION = "2025-11-25"; const MCP_USER = "stagehand-mcp"; const PROXY_USER = "stagehand-proxy"; const SANDBOX_ROOT = "/vercel/sandbox"; -const STAGEHAND_ROOT = `${SANDBOX_ROOT}/stagehand`; -const EXAMPLE_ROOT = `${STAGEHAND_ROOT}/packages/integrations/examples/vercel-sandbox`; -const GATEWAY_BIN = `${EXAMPLE_ROOT}/node_modules/.bin/supergateway`; +const RUNTIME_ROOT = `${SANDBOX_ROOT}/stagehand-runtime`; +const PACKAGE_ROOT = `${SANDBOX_ROOT}/packages`; +const STAGEHAND_PACKAGE_PATH = `${PACKAGE_ROOT}/stagehand.tgz`; +const CODEMODE_PACKAGE_PATH = `${PACKAGE_ROOT}/stagehand-codemode.tgz`; +const RUNTIME_MANIFEST_PATH = `${RUNTIME_ROOT}/package.json`; +const RUNTIME_LOCK_PATH = `${RUNTIME_ROOT}/package-lock.json`; +const GATEWAY_BIN = `${RUNTIME_ROOT}/node_modules/.bin/supergateway`; +const CODEMODE_BIN = `${RUNTIME_ROOT}/node_modules/.bin/stagehand-codemode`; const AUTH_PROXY_PATH = `${SANDBOX_ROOT}/auth-proxy.mjs`; const STDIO_WRAPPER_PATH = `${SANDBOX_ROOT}/stdio-wrapper.mjs`; const HEALTH_REQUEST_TIMEOUT_MS = 5_000; +const SUPERGATEWAY_VERSION = "3.4.3"; +const NO_ERROR = Symbol("no error"); class StagehandSandboxSetupError extends Error { override readonly name = "StagehandSandboxSetupError"; @@ -59,10 +67,23 @@ class StagehandSandboxCommandError extends Error { } } +class StagehandPackageArtifactError extends Error { + override readonly name = "StagehandPackageArtifactError"; + + constructor() { + super("Stagehand package artifact is invalid."); + } +} + export type StagehandSandboxOptions = { - stagehandRevision: string; + packageArtifactsPath: string; browserbaseApiKey: string; browserbaseProjectId: string; + vercelCredentials?: { + teamId: string; + projectId: string; + token: string; + }; readinessTimeoutMs?: number; sandboxTimeoutMs?: number; cleanupTimeoutMs?: number; @@ -75,16 +96,16 @@ export type StagehandSandboxConnection = { }; /** - * Build Stagehand from an exact revision inside a Vercel Sandbox, replace the - * setup network with Browserbase-only egress, and expose its stdio MCP server - * through an authenticated, stateful Streamable HTTP bridge. + * Install exact Stagehand package artifacts inside a Vercel Sandbox, replace + * setup egress with a Browserbase-only policy, and expose the code-mode stdio + * server through an authenticated, stateful Streamable HTTP bridge. */ export async function createStagehandSandbox( options: StagehandSandboxOptions, ): Promise { - assertCommitHash(options.stagehandRevision); assertNonEmpty(options.browserbaseApiKey, "browserbaseApiKey"); assertNonEmpty(options.browserbaseProjectId, "browserbaseProjectId"); + const artifacts = await loadPackageArtifacts(options); const cdpHost = await discoverBrowserbaseCdpHost(options); let sandbox: Sandbox; @@ -97,6 +118,7 @@ export async function createStagehandSandbox( persistent: false, networkPolicy: "allow-all", tags: { purpose: "stagehand-codemode-mcp" }, + ...options.vercelCredentials, }); } catch { throw new StagehandSandboxSetupError(); @@ -104,7 +126,7 @@ export async function createStagehandSandbox( const close = sandboxCloser(sandbox, options.cleanupTimeoutMs ?? 30_000); try { - await installStagehand(sandbox, options.stagehandRevision); + await installStagehandPackages(sandbox, artifacts); await sandbox.writeFiles([ { path: AUTH_PROXY_PATH, @@ -187,62 +209,31 @@ export function stagehandTransport( return transport; } -async function installStagehand(sandbox: Sandbox, revision: string): Promise { - await run(sandbox, "initialize Stagehand checkout", "git", ["init", STAGEHAND_ROOT]); - await run(sandbox, "add Stagehand remote", "git", [ - "-C", - STAGEHAND_ROOT, - "remote", - "add", - "origin", - "https://github.com/browserbase/stagehand.git", +async function installStagehandPackages( + sandbox: Sandbox, + artifacts: PackageArtifacts, +): Promise { + await run(sandbox, "create package install directories", "mkdir", [ + "-p", + PACKAGE_ROOT, + RUNTIME_ROOT, ]); - await run(sandbox, "fetch Stagehand revision", "git", [ - "-C", - STAGEHAND_ROOT, - "fetch", - "--depth=1", - "origin", - revision, + await sandbox.writeFiles([ + { path: STAGEHAND_PACKAGE_PATH, content: artifacts.stagehand.content, mode: 0o444 }, + { path: CODEMODE_PACKAGE_PATH, content: artifacts.codeMode.content, mode: 0o444 }, + { path: RUNTIME_MANIFEST_PATH, content: artifacts.runtimeManifest.content, mode: 0o444 }, + { path: RUNTIME_LOCK_PATH, content: artifacts.runtimeLock.content, mode: 0o444 }, ]); - await run(sandbox, "checkout Stagehand revision", "git", [ - "-C", - STAGEHAND_ROOT, - "checkout", - "--detach", - "FETCH_HEAD", - ]); - const resolved = await run(sandbox, "resolve Stagehand revision", "git", [ - "-C", - STAGEHAND_ROOT, - "rev-parse", - "HEAD", - ]); - if (resolved.trim() !== revision) { - throw new Error(`Stagehand checkout resolved to an unexpected revision: ${resolved.trim()}`); - } - - await run(sandbox, "activate pnpm", "corepack", ["prepare", "pnpm@11.10.0", "--activate"]); + await verifyArtifact(sandbox, STAGEHAND_PACKAGE_PATH, artifacts.stagehand.sha256); + await verifyArtifact(sandbox, CODEMODE_PACKAGE_PATH, artifacts.codeMode.sha256); + await verifyArtifact(sandbox, RUNTIME_MANIFEST_PATH, artifacts.runtimeManifest.sha256); + await verifyArtifact(sandbox, RUNTIME_LOCK_PATH, artifacts.runtimeLock.sha256); await run( sandbox, - "install Stagehand dependencies", - "pnpm", - ["install", "--frozen-lockfile"], - STAGEHAND_ROOT, - ); - await run( - sandbox, - "build Stagehand extension", - "pnpm", - ["--filter", "@browserbasehq/stagehand-extension", "build"], - STAGEHAND_ROOT, - ); - await run( - sandbox, - "build Stagehand integrations", - "pnpm", - ["--filter", "@browserbasehq/stagehand-integrations...", "build"], - STAGEHAND_ROOT, + "install Stagehand package artifacts", + "npm", + ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], + RUNTIME_ROOT, ); } @@ -251,9 +242,10 @@ async function protectRuntimeFiles(sandbox: Sandbox): Promise { "-lc", [ `test -x ${GATEWAY_BIN}`, - `chown -R root:root ${STAGEHAND_ROOT}`, + `test -x ${CODEMODE_BIN}`, + `chown -R root:root ${RUNTIME_ROOT} ${PACKAGE_ROOT}`, `chown root:root ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, - `chmod -R a-w ${STAGEHAND_ROOT}`, + `chmod -R a-w ${RUNTIME_ROOT} ${PACKAGE_ROOT}`, `chmod 0555 ${AUTH_PROXY_PATH} ${STDIO_WRAPPER_PATH}`, ].join(" && "), ]); @@ -323,7 +315,7 @@ async function waitForHealth(origin: URL, token: string, timeoutMs: number): Pro async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Promise { let sessionId: string | undefined; let discoveredHost: string | undefined; - let primaryError: unknown; + let primaryError: unknown = NO_ERROR; try { const response = await fetch(`https://${BROWSERBASE_API_HOST}/v1/sessions`, { @@ -348,7 +340,7 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro primaryError = error; } - let cleanupError: unknown; + let cleanupError: unknown = NO_ERROR; if (sessionId) { try { const response = await fetch( @@ -370,7 +362,7 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro } } - if (primaryError !== undefined || cleanupError !== undefined || !discoveredHost) { + if (primaryError !== NO_ERROR || cleanupError !== NO_ERROR || !discoveredHost) { throw new StagehandCdpDiscoveryError(); } return discoveredHost; @@ -428,12 +420,6 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function assertCommitHash(revision: string): void { - if (!/^[0-9a-f]{40}$/.test(revision)) { - throw new Error("stagehandRevision must be a complete 40-character Git commit hash"); - } -} - function assertNonEmpty(value: string, name: string): void { if (!value.trim()) throw new Error(`${name} must not be empty`); } @@ -448,3 +434,92 @@ function assertBrowserbaseCdpHost(hostname: string): string { function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } + +type PackageArtifact = { content: Buffer; sha256: string }; +type PackageArtifacts = { + stagehand: PackageArtifact; + codeMode: PackageArtifact; + runtimeManifest: PackageArtifact; + runtimeLock: PackageArtifact; +}; + +async function loadPackageArtifacts(options: StagehandSandboxOptions): Promise { + try { + if (!path.isAbsolute(options.packageArtifactsPath)) { + throw new StagehandPackageArtifactError(); + } + const packageRoot = path.join(options.packageArtifactsPath, "packages"); + const runtimeRoot = path.join(options.packageArtifactsPath, "runtime"); + const runtimeManifest = await loadPackageArtifact( + path.join(runtimeRoot, "package.json"), + false, + ); + assertRuntimeManifest(runtimeManifest.content); + const runtimeLock = await loadPackageArtifact( + path.join(runtimeRoot, "package-lock.json"), + false, + ); + assertRuntimeLock(runtimeLock.content); + return { + stagehand: await loadPackageArtifact(path.join(packageRoot, "stagehand.tgz"), true), + codeMode: await loadPackageArtifact(path.join(packageRoot, "stagehand-codemode.tgz"), true), + runtimeManifest, + runtimeLock, + }; + } catch { + throw new StagehandPackageArtifactError(); + } +} + +async function loadPackageArtifact( + artifactPath: string, + compressed: boolean, +): Promise { + const content = await readFile(artifactPath); + if (content.length === 0 || (compressed && (content[0] !== 0x1f || content[1] !== 0x8b))) { + throw new StagehandPackageArtifactError(); + } + return { + content, + sha256: createHash("sha256").update(content).digest("hex"), + }; +} + +function assertRuntimeManifest(content: Buffer): void { + const manifest = JSON.parse(content.toString()) as { dependencies?: Record }; + const dependencies = manifest.dependencies; + if ( + dependencies?.["@browserbasehq/stagehand"] !== "file:../packages/stagehand.tgz" || + dependencies["@browserbasehq/stagehand-codemode"] !== + "file:../packages/stagehand-codemode.tgz" || + dependencies.supergateway !== SUPERGATEWAY_VERSION || + Object.keys(dependencies).length !== 3 + ) { + throw new StagehandPackageArtifactError(); + } +} + +function assertRuntimeLock(content: Buffer): void { + const lock = JSON.parse(content.toString()) as { + lockfileVersion?: unknown; + packages?: Record }>; + }; + const dependencies = lock.packages?.[""]?.dependencies; + if (lock.lockfileVersion !== 3 || !dependencies) { + throw new StagehandPackageArtifactError(); + } + assertRuntimeManifest(Buffer.from(JSON.stringify({ dependencies }))); +} + +async function verifyArtifact( + sandbox: Sandbox, + artifactPath: string, + expectedSha256: string, +): Promise { + const actual = await run(sandbox, "verify uploaded package artifact", "sha256sum", [ + artifactPath, + ]); + if (actual.split(/\s+/, 1)[0] !== expectedSha256) { + throw new StagehandPackageArtifactError(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2212dd9a..4d6adbff2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -600,7 +600,7 @@ importers: packages/integrations/examples/vercel-sandbox: dependencies: - '@browserbasehq/stagehand-integrations': + '@browserbasehq/stagehand-codemode': specifier: workspace:* version: link:../.. '@modelcontextprotocol/sdk': From 245c2bb9d8aaa9ebf577e2d8b5dd76cd28c578bf Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 03:29:01 +0000 Subject: [PATCH 19/24] test(vercel): cover invalid proxy port configuration --- .../src/guest/auth-proxy.test.mjs | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs index 6fe16208a7..a12218b7ba 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs @@ -8,6 +8,31 @@ import { fileURLToPath } from "node:url"; const TOKEN = "auth-proxy-contract-token"; const AUTHORIZATION = `Bearer ${TOKEN}`; const REQUEST_TIMEOUT_MS = 2_000; +const PROXY_PATH = fileURLToPath(new URL("./auth-proxy.mjs", import.meta.url)); + +for (const scenario of [ + { + name: "a non-decimal bridge port", + environment: { BRIDGE_PORT: "8000.5", PROXY_PORT: "0" }, + expectedError: /BRIDGE_PORT must be a valid port/, + }, + { + name: "an out-of-range proxy port", + environment: { BRIDGE_PORT: "8000", PROXY_PORT: "65536" }, + expectedError: /PROXY_PORT must be a valid port/, + }, + { + name: "an ephemeral bridge port", + environment: { BRIDGE_PORT: "0", PROXY_PORT: "0" }, + expectedError: /BRIDGE_PORT must be a valid port/, + }, +]) { + test(`auth proxy rejects ${scenario.name}`, async () => { + const result = await runSparseProxy(scenario.environment); + assert.notEqual(result.code, 0); + assert.match(result.stderr, scenario.expectedError); + }); +} test("auth proxy restricts ingress and closes abandoned upstream requests", async (context) => { let upstreamRequests = 0; @@ -27,19 +52,15 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn const bridgePort = await listen(bridge, 0); context.after(() => closeServer(bridge)); - const proxy = spawn( - process.execPath, - [fileURLToPath(new URL("./auth-proxy.mjs", import.meta.url))], - { - env: { - ...process.env, - BRIDGE_TOKEN_SHA256: createHash("sha256").update(TOKEN).digest("hex"), - BRIDGE_PORT: String(bridgePort), - PROXY_PORT: "0", - }, - stdio: ["ignore", "ignore", "inherit", "ipc"], + const proxy = spawn(process.execPath, [PROXY_PATH], { + env: { + ...process.env, + BRIDGE_TOKEN_SHA256: createHash("sha256").update(TOKEN).digest("hex"), + BRIDGE_PORT: String(bridgePort), + PROXY_PORT: "0", }, - ); + stdio: ["ignore", "ignore", "inherit", "ipc"], + }); context.after(() => stopProcess(proxy)); const proxyPort = await waitForProxyPort(proxy); const proxyOrigin = `http://127.0.0.1:${proxyPort}`; @@ -104,6 +125,30 @@ test("auth proxy restricts ingress and closes abandoned upstream requests", asyn await withTimeout(stalledResponseClosed, "proxy did not close the abandoned upstream request"); }); +function runSparseProxy(environment) { + return withTimeout( + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [PROXY_PATH], { + env: { + BRIDGE_TOKEN_SHA256: createHash("sha256").update(TOKEN).digest("hex"), + ...environment, + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code, signal) => { + resolve({ code, signal, stderr }); + }); + }), + "Timed out waiting for invalid auth-proxy configuration to fail", + ); +} + function waitForProxyPort(child) { return withTimeout( new Promise((resolve, reject) => { From 75aa26c1d3fdd8e6a370b375b5e86b6a1113ebd2 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:00:51 +0000 Subject: [PATCH 20/24] fix(vercel): harden package-installed sandbox setup --- .../workflows/codemode-framework-examples.yml | 20 ++++++++- .../examples/vercel-sandbox/README.md | 14 +++--- .../examples/vercel-sandbox/package.json | 1 + .../vercel-sandbox/scripts/pack-artifacts.mjs | 19 ++++---- .../src/guest/stdio-wrapper.mjs | 15 +++++-- .../src/guest/stdio-wrapper.test.mjs | 24 +++++++++++ .../examples/vercel-sandbox/src/lease.ts | 3 +- .../vercel-sandbox/src/sandbox.test.ts | 43 +++++++++++++++++++ .../examples/vercel-sandbox/src/sandbox.ts | 21 ++++++++- 9 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.test.mjs diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 12032af522..805bbdcd69 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -68,7 +68,25 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} STAGEHAND_BROWSER: local - - run: pnpm --filter ${{ matrix.package }} e2e + - name: Detect live test credentials + id: live-credentials + env: + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" ]] && \ + [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - name: Run live package-installed sandbox proof + if: steps.live-credentials.outputs.available == 'true' + run: pnpm --filter ${{ matrix.package }} e2e env: STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md index bd0975c190..ccf185d06c 100644 --- a/packages/integrations/examples/vercel-sandbox/README.md +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -74,11 +74,15 @@ const stagehand = await createStagehandSandbox({ packageArtifactsPath: process.env.STAGEHAND_SANDBOX_ARTIFACTS!, browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, - vercelCredentials: { - teamId: process.env.VERCEL_TEAM_ID!, - projectId: process.env.VERCEL_PROJECT_ID!, - token: process.env.VERCEL_TOKEN!, - }, + ...(process.env.VERCEL_TOKEN + ? { + vercelCredentials: { + teamId: process.env.VERCEL_TEAM_ID!, + projectId: process.env.VERCEL_PROJECT_ID!, + token: process.env.VERCEL_TOKEN, + }, + } + : {}), }); const client = new Client({ name: "my-agent", version: "1.0.0" }); diff --git a/packages/integrations/examples/vercel-sandbox/package.json b/packages/integrations/examples/vercel-sandbox/package.json index b1ab73127a..13bfe39d59 100644 --- a/packages/integrations/examples/vercel-sandbox/package.json +++ b/packages/integrations/examples/vercel-sandbox/package.json @@ -8,6 +8,7 @@ "./lease": "./src/lease.ts" }, "scripts": { + "build": "tsc --noEmit", "e2e": "tsx src/e2e.ts", "lease": "tsx src/lease.ts", "pack:artifacts": "node scripts/pack-artifacts.mjs", diff --git a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs index ef3f76b093..6cfce804ac 100644 --- a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +const commandMaxBuffer = 16 * 1024 * 1024; const exampleRoot = fileURLToPath(new URL("..", import.meta.url)); const repositoryRoot = path.resolve(exampleRoot, "../../../.."); const sdkRoot = path.join(repositoryRoot, "packages", "sdk-ts"); @@ -20,15 +21,13 @@ await Promise.all([ mkdir(packageRoot, { recursive: true }), mkdir(runtimeRoot, { recursive: true }), ]); -await execFileAsync( +await run( "pnpm", ["exec", "turbo", "run", "build", "--filter", "@browserbasehq/stagehand-codemode"], - { cwd: repositoryRoot }, + repositoryRoot, ); -await execFileAsync("pnpm", ["pack", "--pack-destination", packageRoot], { cwd: sdkRoot }); -await execFileAsync("pnpm", ["pack", "--pack-destination", packageRoot], { - cwd: codeModeRoot, -}); +await run("pnpm", ["pack", "--pack-destination", packageRoot], sdkRoot); +await run("pnpm", ["pack", "--pack-destination", packageRoot], codeModeRoot); const packed = await readdir(packageRoot); const stagehandSource = requiredArtifact(packed, /^browserbasehq-stagehand-(?!codemode-).+\.tgz$/); @@ -52,7 +51,7 @@ await writeFile( path.join(runtimeRoot, "package.json"), `${JSON.stringify(runtimeManifest, null, 2)}\n`, ); -await execFileAsync( +await run( "npm", [ "install", @@ -62,7 +61,7 @@ await execFileAsync( "--no-fund", `--registry=${publicRegistry}`, ], - { cwd: runtimeRoot }, + runtimeRoot, ); await assertPublicLock(path.join(runtimeRoot, "package-lock.json")); @@ -86,6 +85,10 @@ function requiredArtifact(files, pattern) { return matches[0]; } +async function run(file, args, cwd) { + return execFileAsync(file, args, { cwd, maxBuffer: commandMaxBuffer }); +} + async function artifactSummary(artifactPath) { const content = await readFile(artifactPath); return { diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs index 06e1b6639d..8b4782dcb1 100644 --- a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs @@ -7,16 +7,25 @@ const child = spawn("/vercel/sandbox/stagehand-runtime/node_modules/.bin/stageha }); const signalHandlers = new Map(); +const removeSignalHandlers = () => { + for (const [handledSignal, handler] of signalHandlers) { + process.removeListener(handledSignal, handler); + } +}; for (const signal of ["SIGINT", "SIGTERM"]) { const handler = () => child.kill(signal); signalHandlers.set(signal, handler); process.on(signal, handler); } +child.on("error", () => { + removeSignalHandlers(); + process.stderr.write("Stagehand code-mode process failed to start.\n"); + process.exitCode = 1; +}); + child.on("exit", (code, signal) => { - for (const [handledSignal, handler] of signalHandlers) { - process.removeListener(handledSignal, handler); - } + removeSignalHandlers(); if (signal) { process.kill(process.pid, signal); return; diff --git a/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.test.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.test.mjs new file mode 100644 index 0000000000..0c7fda0751 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +void test("stdio wrapper reports a controlled startup failure", async () => { + const child = spawn( + process.execPath, + [fileURLToPath(new URL("./stdio-wrapper.mjs", import.meta.url))], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + const result = await new Promise((resolve) => { + child.on("close", (code, signal) => resolve({ code, signal })); + }); + + assert.deepEqual(result, { code: 1, signal: null }); + assert.equal(stderr, "Stagehand code-mode process failed to start.\n"); +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts index c5a5bdb00a..96fb9d7956 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.ts +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -10,12 +10,13 @@ try { const packageArtifactsPath = requiredEnvironment("STAGEHAND_SANDBOX_ARTIFACTS"); const browserbaseApiKey = requiredEnvironment("BROWSERBASE_API_KEY"); const browserbaseProjectId = requiredEnvironment("BROWSERBASE_PROJECT_ID"); + const vercelCredentials = vercelCredentialsFromEnvironment(); const leaseEnd = waitForLeaseEnd(); const connection = await createStagehandSandbox({ packageArtifactsPath, browserbaseApiKey, browserbaseProjectId, - vercelCredentials: vercelCredentialsFromEnvironment(), + vercelCredentials, }); process.stdout.write( diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts index 580d28473b..7c4b01d7d4 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { createStagehandSandbox } from "./sandbox.js"; @@ -16,3 +19,43 @@ void test("invalid package artifacts fail before any sandbox is created", async }, ); }); + +void test("runtime lock rejects dependency sources outside file and the npm registry", async () => { + const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-artifacts-")); + try { + const runtimeRoot = path.join(artifactRoot, "runtime"); + await mkdir(runtimeRoot); + const dependencies = { + "@browserbasehq/stagehand": "file:../packages/stagehand.tgz", + "@browserbasehq/stagehand-codemode": "file:../packages/stagehand-codemode.tgz", + supergateway: "3.4.3", + }; + await writeFile(path.join(runtimeRoot, "package.json"), JSON.stringify({ dependencies })); + await writeFile( + path.join(runtimeRoot, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + "node_modules/supergateway": { + resolved: "https://packages.example.test/supergateway.tgz", + }, + }, + }), + ); + + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: artifactRoot, + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + }), + { + name: "StagehandPackageArtifactError", + message: "Stagehand package artifact is invalid.", + }, + ); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index becf7cf242..4a2fb82f5a 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -110,6 +110,7 @@ export async function createStagehandSandbox( const cdpHost = await discoverBrowserbaseCdpHost(options); let sandbox: Sandbox; try { + const vercelCredentials = options.vercelCredentials; sandbox = await Sandbox.create({ runtime: "node24", resources: { vcpus: 4 }, @@ -118,7 +119,13 @@ export async function createStagehandSandbox( persistent: false, networkPolicy: "allow-all", tags: { purpose: "stagehand-codemode-mcp" }, - ...options.vercelCredentials, + ...(vercelCredentials + ? { + teamId: vercelCredentials.teamId, + projectId: vercelCredentials.projectId, + token: vercelCredentials.token, + } + : {}), }); } catch { throw new StagehandSandboxSetupError(); @@ -502,12 +509,22 @@ function assertRuntimeManifest(content: Buffer): void { function assertRuntimeLock(content: Buffer): void { const lock = JSON.parse(content.toString()) as { lockfileVersion?: unknown; - packages?: Record }>; + packages?: Record; resolved?: unknown }>; }; const dependencies = lock.packages?.[""]?.dependencies; if (lock.lockfileVersion !== 3 || !dependencies) { throw new StagehandPackageArtifactError(); } + for (const entry of Object.values(lock.packages ?? {})) { + const resolved = entry.resolved; + if ( + resolved !== undefined && + (typeof resolved !== "string" || + (!resolved.startsWith("file:") && !resolved.startsWith("https://registry.npmjs.org/"))) + ) { + throw new StagehandPackageArtifactError(); + } + } assertRuntimeManifest(Buffer.from(JSON.stringify({ dependencies }))); } From 6a01338a888daf3da83c65ffc147fc29748b4b77 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:03:32 +0000 Subject: [PATCH 21/24] fix(ci): register sandbox build task --- turbo.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/turbo.json b/turbo.json index 774686a13c..887ec97b79 100644 --- a/turbo.json +++ b/turbo.json @@ -31,6 +31,9 @@ "inputs": ["$TURBO_DEFAULT$", "!dist/**"], "outputs": ["dist/**"] }, + "@browserbasehq/stagehand-integrations-example-vercel-sandbox#build": { + "dependsOn": ["^build"] + }, "@browserbasehq/stagehand-evals#build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**"], From 63ddf585175a5e7bd49f85ec10ee3bae0bf1cfec Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:29:09 +0000 Subject: [PATCH 22/24] fix(vercel): close remaining setup review gaps --- .../vercel-sandbox/scripts/pack-artifacts.mjs | 14 +- .../vercel-sandbox/src/sandbox.test.ts | 131 +++++++++++++++--- 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs index 6cfce804ac..3c150f84d7 100644 --- a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs @@ -16,6 +16,14 @@ const packageRoot = path.join(artifactRoot, "packages"); const runtimeRoot = path.join(artifactRoot, "runtime"); const publicRegistry = "https://registry.npmjs.org"; +class StagehandArtifactPackCommandError extends Error { + name = "StagehandArtifactPackCommandError"; + + constructor() { + super("Stagehand sandbox artifact preparation failed."); + } +} + await rm(artifactRoot, { force: true, recursive: true }); await Promise.all([ mkdir(packageRoot, { recursive: true }), @@ -86,7 +94,11 @@ function requiredArtifact(files, pattern) { } async function run(file, args, cwd) { - return execFileAsync(file, args, { cwd, maxBuffer: commandMaxBuffer }); + try { + return await execFileAsync(file, args, { cwd, maxBuffer: commandMaxBuffer }); + } catch { + throw new StagehandArtifactPackCommandError(); + } } async function artifactSummary(artifactPath) { diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts index 7c4b01d7d4..e61f8e01f9 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -2,10 +2,17 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import test from "node:test"; +import test, { mock } from "node:test"; +import { Sandbox } from "@vercel/sandbox"; import { createStagehandSandbox } from "./sandbox.js"; +const dependencies = { + "@browserbasehq/stagehand": "file:../packages/stagehand.tgz", + "@browserbasehq/stagehand-codemode": "file:../packages/stagehand-codemode.tgz", + supergateway: "3.4.3", +}; + void test("invalid package artifacts fail before any sandbox is created", async () => { await assert.rejects( createStagehandSandbox({ @@ -21,29 +28,8 @@ void test("invalid package artifacts fail before any sandbox is created", async }); void test("runtime lock rejects dependency sources outside file and the npm registry", async () => { - const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-artifacts-")); + const artifactRoot = await writeArtifacts("https://packages.example.test/supergateway.tgz"); try { - const runtimeRoot = path.join(artifactRoot, "runtime"); - await mkdir(runtimeRoot); - const dependencies = { - "@browserbasehq/stagehand": "file:../packages/stagehand.tgz", - "@browserbasehq/stagehand-codemode": "file:../packages/stagehand-codemode.tgz", - supergateway: "3.4.3", - }; - await writeFile(path.join(runtimeRoot, "package.json"), JSON.stringify({ dependencies })); - await writeFile( - path.join(runtimeRoot, "package-lock.json"), - JSON.stringify({ - lockfileVersion: 3, - packages: { - "": { dependencies }, - "node_modules/supergateway": { - resolved: "https://packages.example.test/supergateway.tgz", - }, - }, - }), - ); - await assert.rejects( createStagehandSandbox({ packageArtifactsPath: artifactRoot, @@ -59,3 +45,102 @@ void test("runtime lock rejects dependency sources outside file and the npm regi await rm(artifactRoot, { force: true, recursive: true }); } }); + +void test("runtime lock rejects non-string resolved values", async () => { + for (const resolved of [null, 42, { source: "registry" }]) { + const artifactRoot = await writeArtifacts(resolved); + try { + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: artifactRoot, + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + }), + { + name: "StagehandPackageArtifactError", + message: "Stagehand package artifact is invalid.", + }, + ); + } finally { + await rm(artifactRoot, { force: true, recursive: true }); + } + } +}); + +void test("Sandbox.create receives only allowlisted Vercel credentials", async () => { + const artifactRoot = await writeArtifacts("https://registry.npmjs.org/supergateway.tgz"); + let createOptions: Parameters[0] | undefined; + const fetchMock = mock.method(globalThis, "fetch", async (input) => { + const url = input.toString(); + if (url.endsWith("/v1/sessions")) { + return { + ok: true, + json: async () => ({ + id: "discovery-session", + connectUrl: "wss://connect.browserbase.com/devtools/browser/test", + }), + } as Response; + } + return { ok: true } as Response; + }); + const createMock = mock.method(Sandbox, "create", async (options) => { + createOptions = options; + throw new Error("stop after inspecting create options"); + }); + + try { + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: artifactRoot, + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + vercelCredentials: { + teamId: "expected-team", + projectId: "expected-project", + token: "expected-token", + networkPolicy: "deny-all", + } as NonNullable[0]["vercelCredentials"]>, + }), + { name: "StagehandSandboxSetupError" }, + ); + + assert.ok(createOptions); + const forwardedOptions = createOptions as typeof createOptions & { + projectId: string; + teamId: string; + token: string; + }; + assert.equal(forwardedOptions.teamId, "expected-team"); + assert.equal(forwardedOptions.projectId, "expected-project"); + assert.equal(forwardedOptions.token, "expected-token"); + assert.equal(createOptions.networkPolicy, "allow-all"); + assert.deepEqual(createOptions.tags, { purpose: "stagehand-codemode-mcp" }); + } finally { + createMock.mock.restore(); + fetchMock.mock.restore(); + await rm(artifactRoot, { force: true, recursive: true }); + } +}); + +async function writeArtifacts(resolved: unknown): Promise { + const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-artifacts-")); + const packageRoot = path.join(artifactRoot, "packages"); + const runtimeRoot = path.join(artifactRoot, "runtime"); + await Promise.all([mkdir(packageRoot), mkdir(runtimeRoot)]); + await Promise.all([ + writeFile(path.join(packageRoot, "stagehand.tgz"), Buffer.from([0x1f, 0x8b])), + writeFile(path.join(packageRoot, "stagehand-codemode.tgz"), Buffer.from([0x1f, 0x8b])), + writeFile(path.join(runtimeRoot, "package.json"), JSON.stringify({ dependencies })), + writeFile( + path.join(runtimeRoot, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + "node_modules/supergateway": { resolved }, + }, + }), + ), + ]); + return artifactRoot; +} From 35c15787524ff71c8ad31901e5d96a2353d63763 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:34:59 +0000 Subject: [PATCH 23/24] test(vercel): lock credential and pack failure branches --- .../vercel-sandbox/scripts/pack-artifacts.mjs | 29 ++--------- .../vercel-sandbox/scripts/pack-command.mjs | 21 ++++++++ .../vercel-sandbox/src/pack-command.test.mjs | 24 +++++++++ .../vercel-sandbox/src/sandbox.test.ts | 49 ++++++++++++------- 4 files changed, 81 insertions(+), 42 deletions(-) create mode 100644 packages/integrations/examples/vercel-sandbox/scripts/pack-command.mjs create mode 100644 packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs diff --git a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs index 3c150f84d7..4282be3490 100644 --- a/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs @@ -1,12 +1,9 @@ -import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +import { runArtifactPackCommand } from "./pack-command.mjs"; -const execFileAsync = promisify(execFile); -const commandMaxBuffer = 16 * 1024 * 1024; const exampleRoot = fileURLToPath(new URL("..", import.meta.url)); const repositoryRoot = path.resolve(exampleRoot, "../../../.."); const sdkRoot = path.join(repositoryRoot, "packages", "sdk-ts"); @@ -16,26 +13,18 @@ const packageRoot = path.join(artifactRoot, "packages"); const runtimeRoot = path.join(artifactRoot, "runtime"); const publicRegistry = "https://registry.npmjs.org"; -class StagehandArtifactPackCommandError extends Error { - name = "StagehandArtifactPackCommandError"; - - constructor() { - super("Stagehand sandbox artifact preparation failed."); - } -} - await rm(artifactRoot, { force: true, recursive: true }); await Promise.all([ mkdir(packageRoot, { recursive: true }), mkdir(runtimeRoot, { recursive: true }), ]); -await run( +await runArtifactPackCommand( "pnpm", ["exec", "turbo", "run", "build", "--filter", "@browserbasehq/stagehand-codemode"], repositoryRoot, ); -await run("pnpm", ["pack", "--pack-destination", packageRoot], sdkRoot); -await run("pnpm", ["pack", "--pack-destination", packageRoot], codeModeRoot); +await runArtifactPackCommand("pnpm", ["pack", "--pack-destination", packageRoot], sdkRoot); +await runArtifactPackCommand("pnpm", ["pack", "--pack-destination", packageRoot], codeModeRoot); const packed = await readdir(packageRoot); const stagehandSource = requiredArtifact(packed, /^browserbasehq-stagehand-(?!codemode-).+\.tgz$/); @@ -59,7 +48,7 @@ await writeFile( path.join(runtimeRoot, "package.json"), `${JSON.stringify(runtimeManifest, null, 2)}\n`, ); -await run( +await runArtifactPackCommand( "npm", [ "install", @@ -93,14 +82,6 @@ function requiredArtifact(files, pattern) { return matches[0]; } -async function run(file, args, cwd) { - try { - return await execFileAsync(file, args, { cwd, maxBuffer: commandMaxBuffer }); - } catch { - throw new StagehandArtifactPackCommandError(); - } -} - async function artifactSummary(artifactPath) { const content = await readFile(artifactPath); return { diff --git a/packages/integrations/examples/vercel-sandbox/scripts/pack-command.mjs b/packages/integrations/examples/vercel-sandbox/scripts/pack-command.mjs new file mode 100644 index 0000000000..0bcb64b10c --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-command.mjs @@ -0,0 +1,21 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const commandMaxBuffer = 16 * 1024 * 1024; + +export class StagehandArtifactPackCommandError extends Error { + name = "StagehandArtifactPackCommandError"; + + constructor() { + super("Stagehand sandbox artifact preparation failed."); + } +} + +export async function runArtifactPackCommand(file, args, cwd) { + try { + return await execFileAsync(file, args, { cwd, maxBuffer: commandMaxBuffer }); + } catch { + throw new StagehandArtifactPackCommandError(); + } +} diff --git a/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs b/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs new file mode 100644 index 0000000000..3786a612aa --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runArtifactPackCommand } from "../scripts/pack-command.mjs"; + +void test("artifact pack command failures expose only a fixed typed error", async () => { + const secret = "stderr-token=do-not-reflect"; + + await assert.rejects( + runArtifactPackCommand( + process.execPath, + ["-e", `process.stderr.write(${JSON.stringify(secret)}); process.exit(1)`], + process.cwd(), + ), + (error) => { + assert.equal(error.name, "StagehandArtifactPackCommandError"); + assert.equal(error.message, "Stagehand sandbox artifact preparation failed."); + assert.equal(error.message.includes(secret), false); + assert.equal(Object.hasOwn(error, "stdout"), false); + assert.equal(Object.hasOwn(error, "stderr"), false); + return true; + }, + ); +}); diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts index e61f8e01f9..0e19789276 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -68,6 +68,34 @@ void test("runtime lock rejects non-string resolved values", async () => { }); void test("Sandbox.create receives only allowlisted Vercel credentials", async () => { + const createOptions = await captureSandboxCreateOptions({ + teamId: "expected-team", + projectId: "expected-project", + token: "expected-token", + networkPolicy: "deny-all", + } as NonNullable[0]["vercelCredentials"]>); + const forwardedOptions = createOptions as typeof createOptions & { + projectId: string; + teamId: string; + token: string; + }; + assert.equal(forwardedOptions.teamId, "expected-team"); + assert.equal(forwardedOptions.projectId, "expected-project"); + assert.equal(forwardedOptions.token, "expected-token"); + assert.equal(createOptions.networkPolicy, "allow-all"); + assert.deepEqual(createOptions.tags, { purpose: "stagehand-codemode-mcp" }); +}); + +void test("Sandbox.create preserves SDK credential discovery when credentials are omitted", async () => { + const createOptions = await captureSandboxCreateOptions(); + assert.equal(Object.hasOwn(createOptions, "teamId"), false); + assert.equal(Object.hasOwn(createOptions, "projectId"), false); + assert.equal(Object.hasOwn(createOptions, "token"), false); +}); + +async function captureSandboxCreateOptions( + vercelCredentials?: Parameters[0]["vercelCredentials"], +): Promise[0]> { const artifactRoot = await writeArtifacts("https://registry.npmjs.org/supergateway.tgz"); let createOptions: Parameters[0] | undefined; const fetchMock = mock.method(globalThis, "fetch", async (input) => { @@ -94,33 +122,18 @@ void test("Sandbox.create receives only allowlisted Vercel credentials", async ( packageArtifactsPath: artifactRoot, browserbaseApiKey: "unused-test-key", browserbaseProjectId: "unused-test-project", - vercelCredentials: { - teamId: "expected-team", - projectId: "expected-project", - token: "expected-token", - networkPolicy: "deny-all", - } as NonNullable[0]["vercelCredentials"]>, + vercelCredentials, }), { name: "StagehandSandboxSetupError" }, ); - assert.ok(createOptions); - const forwardedOptions = createOptions as typeof createOptions & { - projectId: string; - teamId: string; - token: string; - }; - assert.equal(forwardedOptions.teamId, "expected-team"); - assert.equal(forwardedOptions.projectId, "expected-project"); - assert.equal(forwardedOptions.token, "expected-token"); - assert.equal(createOptions.networkPolicy, "allow-all"); - assert.deepEqual(createOptions.tags, { purpose: "stagehand-codemode-mcp" }); + return createOptions; } finally { createMock.mock.restore(); fetchMock.mock.restore(); await rm(artifactRoot, { force: true, recursive: true }); } -}); +} async function writeArtifacts(resolved: unknown): Promise { const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-artifacts-")); From e3e70f2247c5bbdd1b723bf838cc2d5fe4f47616 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:47:52 +0000 Subject: [PATCH 24/24] test(vercel): make stderr sentinel deterministic --- .../examples/vercel-sandbox/src/pack-command.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs b/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs index 3786a612aa..32d1847528 100644 --- a/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs @@ -9,7 +9,7 @@ void test("artifact pack command failures expose only a fixed typed error", asyn await assert.rejects( runArtifactPackCommand( process.execPath, - ["-e", `process.stderr.write(${JSON.stringify(secret)}); process.exit(1)`], + ["-e", `require("node:fs").writeSync(2, ${JSON.stringify(secret)}); process.exit(1)`], process.cwd(), ), (error) => {