Skip to content
Open
Show file tree
Hide file tree
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 Aug 6, 2026
85e9ac3
test(integrations): smoke Vercel code mode in CI
shrey150 Aug 6, 2026
95902d6
Merge updated code-mode guidance parent
shrey150 Aug 6, 2026
eca28f1
Merge updated code-mode guidance parent
shrey150 Aug 7, 2026
fd2003a
Merge updated code-mode guidance parent
shrey150 Aug 7, 2026
175128f
Merge refreshed code-mode guidance parent
shrey150 Aug 7, 2026
f95a3bd
fix(vercel): use current agent instructions field
shrey150 Aug 7, 2026
d41e0f9
feat: add sandboxed code-mode image
shrey150 Aug 8, 2026
a434b4c
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-sandbox'…
shrey150 Aug 8, 2026
bd2c800
feat(vercel): run code mode in an E2B sandbox
shrey150 Aug 8, 2026
e4bbe74
fix: cover tsconfig changes in image CI
shrey150 Aug 8, 2026
c43ed42
Merge sandbox foundation updates
shrey150 Aug 8, 2026
df3959d
feat: add a source-installed E2B MCP boundary
shrey150 Aug 8, 2026
a804d84
fix: make E2B egress default deny
shrey150 Aug 8, 2026
8155c03
fix: keep MCP session setup offline
shrey150 Aug 8, 2026
8cf5b40
fix: poll E2B build readiness safely
shrey150 Aug 8, 2026
3a54a90
fix: terminate the offline setup branch
shrey150 Aug 8, 2026
bc92188
fix: validate the offline source mirror
shrey150 Aug 8, 2026
f8c6e80
docs: bound E2B custom servers to one tool call
shrey150 Aug 8, 2026
a6c7954
feat: run code-mode MCP in Vercel Sandbox
shrey150 Aug 8, 2026
ec7a7ee
fix: use the locked sandbox gateway
shrey150 Aug 8, 2026
e9c6682
fix(vercel): harden sandbox lifecycle boundary
shrey150 Aug 8, 2026
938efe1
test(vercel): make sandbox contracts deterministic
shrey150 Aug 8, 2026
48b87ba
Merge remote-tracking branch 'origin/shrey/stg-2765-codemode-publish'…
shrey150 Aug 8, 2026
4678772
refactor(vercel): install exact code-mode package artifacts
shrey150 Aug 8, 2026
245c2bb
test(vercel): cover invalid proxy port configuration
shrey150 Aug 8, 2026
75aa26c
fix(vercel): harden package-installed sandbox setup
shrey150 Aug 8, 2026
6a01338
fix(ci): register sandbox build task
shrey150 Aug 8, 2026
63ddf58
fix(vercel): close remaining setup review gaps
shrey150 Aug 8, 2026
35c1578
test(vercel): lock credential and pack failure branches
shrey150 Aug 8, 2026
e3e70f2
test(vercel): make stderr sentinel deterministic
shrey150 Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/codemode-framework-examples.yml
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
7 changes: 7 additions & 0 deletions packages/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- [E2B sandbox](./examples/e2b) source-installs the stdio server inside a Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection.

### Configuration

`stagehandCodeConfigFromEnv()` recognizes:
Expand All @@ -69,3 +73,6 @@ 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 source-installed microVM architecture in the
[E2B sandbox example](./examples/e2b). The sandbox provider supplies the security boundary.
109 changes: 109 additions & 0 deletions packages/integrations/examples/e2b/README.md
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.
27 changes: 27 additions & 0 deletions packages/integrations/examples/e2b/package.json
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"
}
}
96 changes: 96 additions & 0 deletions packages/integrations/examples/e2b/src/e2e.ts
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;
}
Loading
Loading