-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: run code-mode MCP in Vercel Sandbox #2626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shrey150
wants to merge
31
commits into
shrey/stg-2765-codemode-publish
Choose a base branch
from
shrey/stg-2765-codemode-vercel
base: shrey/stg-2765-codemode-publish
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 19 commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
5b207ec
feat(vercel): add Stagehand code-mode MCP example
shrey150 85e9ac3
test(integrations): smoke Vercel code mode in CI
shrey150 95902d6
Merge updated code-mode guidance parent
shrey150 eca28f1
Merge updated code-mode guidance parent
shrey150 fd2003a
Merge updated code-mode guidance parent
shrey150 175128f
Merge refreshed code-mode guidance parent
shrey150 f95a3bd
fix(vercel): use current agent instructions field
shrey150 d41e0f9
feat: add sandboxed code-mode image
shrey150 a434b4c
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-sandbox'…
shrey150 bd2c800
feat(vercel): run code mode in an E2B sandbox
shrey150 e4bbe74
fix: cover tsconfig changes in image CI
shrey150 c43ed42
Merge sandbox foundation updates
shrey150 df3959d
feat: add a source-installed E2B MCP boundary
shrey150 a804d84
fix: make E2B egress default deny
shrey150 8155c03
fix: keep MCP session setup offline
shrey150 8cf5b40
fix: poll E2B build readiness safely
shrey150 3a54a90
fix: terminate the offline setup branch
shrey150 bc92188
fix: validate the offline source mirror
shrey150 f8c6e80
docs: bound E2B custom servers to one tool call
shrey150 a6c7954
feat: run code-mode MCP in Vercel Sandbox
shrey150 ec7a7ee
fix: use the locked sandbox gateway
shrey150 e9c6682
fix(vercel): harden sandbox lifecycle boundary
shrey150 938efe1
test(vercel): make sandbox contracts deterministic
shrey150 48b87ba
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-publish'…
shrey150 4678772
refactor(vercel): install exact code-mode package artifacts
shrey150 245c2bb
test(vercel): cover invalid proxy port configuration
shrey150 75aa26c
fix(vercel): harden package-installed sandbox setup
shrey150 6a01338
fix(ci): register sandbox build task
shrey150 63ddf58
fix(vercel): close remaining setup review gaps
shrey150 35c1578
test(vercel): lock credential and pack failure branches
shrey150 e3e70f2
test(vercel): make stderr sentinel deterministic
shrey150 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| name: Code-mode sandbox examples | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, labeled] | ||
| paths: | ||
| - ".github/workflows/codemode-framework-examples.yml" | ||
| - "packages/integrations/**" | ||
| - "packages/extension/**" | ||
| - "packages/protocol/**" | ||
| - "packages/sdk-ts/**" | ||
| - "package.json" | ||
| - "pnpm-lock.yaml" | ||
| - "pnpm-workspace.yaml" | ||
| - "tsconfig.json" | ||
| - "turbo.json" | ||
| push: | ||
| branches: [main, v4-spike] | ||
| paths: | ||
| - ".github/workflows/codemode-framework-examples.yml" | ||
| - "packages/integrations/**" | ||
| - "packages/extension/**" | ||
| - "packages/protocol/**" | ||
| - "packages/sdk-ts/**" | ||
| - "package.json" | ||
| - "pnpm-lock.yaml" | ||
| - "pnpm-workspace.yaml" | ||
| - "tsconfig.json" | ||
| - "turbo.json" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| example: | ||
| 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: E2B source-installed MCP | ||
| package: "@browserbasehq/stagehand-integrations-example-e2b" | ||
| 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 ${{ matrix.package }} typecheck | ||
| - run: pnpm --filter ${{ matrix.package }} smoke | ||
| env: | ||
| CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} | ||
| STAGEHAND_BROWSER: local | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # 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<void>; | ||
| }; | ||
| ``` | ||
|
|
||
| `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=<e2b-api-key> | ||
| BROWSERBASE_API_KEY=<browserbase-api-key> | ||
| BROWSERBASE_PROJECT_ID=<optional-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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "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": { | ||
| "@browserbasehq/stagehand-integrations": "workspace:*", | ||
| "@modelcontextprotocol/sdk": "catalog:", | ||
| "e2b": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "catalog:", | ||
| "tsx": "catalog:", | ||
| "typescript": "catalog:" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22.18.0" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| 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<string, unknown>): 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<string, unknown>; | ||
| 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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.