diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml new file mode 100644 index 0000000000..805bbdcd69 --- /dev/null +++ b/.github/workflows/codemode-framework-examples.yml @@ -0,0 +1,97 @@ +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: Vercel Sandbox package-installed MCP + package: "@browserbasehq/stagehand-integrations-example-vercel-sandbox" + 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-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 + - 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 }} + 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/README.md b/packages/integrations/README.md index 6f89cef5fb..5f1a72ae27 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -75,3 +75,8 @@ The published package includes the model-facing assets as exported package files The first public release intentionally supports the executable and these two assets only. Internal implementation modules and an in-process arbitrary-code executor are not public package APIs. + +## Framework examples + +- [Vercel Sandbox](./examples/vercel-sandbox) installs the exact packed artifact inside a + Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection. diff --git a/packages/integrations/examples/vercel-sandbox/README.md b/packages/integrations/examples/vercel-sandbox/README.md new file mode 100644 index 0000000000..ccf185d06c --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/README.md @@ -0,0 +1,161 @@ +# 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, 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 +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 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. 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 + +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({ + packageArtifactsPath: process.env.STAGEHAND_SANDBOX_ARTIFACTS!, + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!, + ...(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" }); + +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 +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox lease +``` + +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_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 + +[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. +- 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. + +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. + +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/package.json b/packages/integrations/examples/vercel-sandbox/package.json new file mode 100644 index 0000000000..13bfe39d59 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/package.json @@ -0,0 +1,33 @@ +{ + "name": "@browserbasehq/stagehand-integrations-example-vercel-sandbox", + "version": "4.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/sandbox.ts", + "./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", + "smoke": "tsx src/smoke.ts", + "test:contract": "tsx --test src/*.test.ts src/*.test.mjs src/guest/*.test.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/stagehand-codemode": "workspace:*", + "@modelcontextprotocol/sdk": "catalog:", + "@vercel/sandbox": "catalog:", + "supergateway": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} 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..4282be3490 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/scripts/pack-artifacts.mjs @@ -0,0 +1,106 @@ +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 { runArtifactPackCommand } from "./pack-command.mjs"; + +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 runArtifactPackCommand( + "pnpm", + ["exec", "turbo", "run", "build", "--filter", "@browserbasehq/stagehand-codemode"], + repositoryRoot, +); +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$/); +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 runArtifactPackCommand( + "npm", + [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + `--registry=${publicRegistry}`, + ], + 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/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/e2e.ts b/packages/integrations/examples/vercel-sandbox/src/e2e.ts new file mode 100644 index 0000000000..183f040322 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/e2e.ts @@ -0,0 +1,235 @@ +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 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({ + 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 = NO_ERROR; + +try { + 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"); + + 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"); +} 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 !== NO_ERROR && cleanupErrors.length > 0) { + throw new AggregateError( + [primaryError, ...cleanupErrors], + "Vercel Sandbox E2E failed and cleanup also failed", + ); +} +if (primaryError !== NO_ERROR) throw primaryError; +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; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + 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/auth-proxy.mjs b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs new file mode 100644 index 0000000000..105481c149 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.mjs @@ -0,0 +1,99 @@ +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 bridgePort = configuredPort("BRIDGE_PORT", 8000, false); +const proxyPort = configuredPort("PROXY_PORT", 3000, true); +const passthroughHeaders = [ + "accept", + "content-type", + "content-length", + "last-event-id", + "mcp-session-id", + "mcp-protocol-version", +]; + +function authorized(value) { + 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); +} + +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; + } + + 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: 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 new file mode 100644 index 0000000000..a12218b7ba --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/auth-proxy.test.mjs @@ -0,0 +1,231 @@ +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 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; + 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 })); + }); + const bridgePort = await listen(bridge, 0); + context.after(() => closeServer(bridge)); + + 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}`; + + assert.equal((await fetchWithTimeout(`${proxyOrigin}/healthz`)).status, 401); + assert.equal( + ( + await fetchWithTimeout(`${proxyOrigin}/healthz`, { + headers: { Authorization: `bEaReR ${TOKEN}` }, + }) + ).status, + 200, + ); + assert.equal( + ( + await fetchWithTimeout(`${proxyOrigin}/private`, { + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 404, + ); + assert.equal( + ( + await fetchWithTimeout(`${proxyOrigin}/mcp`, { + method: "PUT", + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 405, + ); + assert.equal( + ( + await fetchWithTimeout(`${proxyOrigin}/mcp`, { + headers: { Authorization: AUTHORIZATION }, + }) + ).status, + 405, + ); + 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(`${proxyOrigin}/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(`${proxyOrigin}/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"); +}); + +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) => { + 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) { + 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) }); +} + +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", () => { + 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); + }); + }); +} + +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 new file mode 100644 index 0000000000..8b4782dcb1 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/guest/stdio-wrapper.mjs @@ -0,0 +1,34 @@ +import { spawn } from "node:child_process"; + +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(); +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) => { + removeSignalHandlers(); + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); 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.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs new file mode 100644 index 0000000000..5780eb200b --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -0,0 +1,47 @@ +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_SANDBOX_ARTIFACTS; + 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)); + + let timeout; + const exit = await Promise.race([ + 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"); + }); + + 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 new file mode 100644 index 0000000000..96fb9d7956 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { createStagehandSandbox } from "./sandbox.js"; + +const SHUTDOWN_FALLBACK_MS = 35_000; + +type LeaseEnd = { signal?: NodeJS.Signals }; + +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, + }); + + 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.stdin.pause(); + 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 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/pack-command.test.mjs b/packages/integrations/examples/vercel-sandbox/src/pack-command.test.mjs new file mode 100644 index 0000000000..32d1847528 --- /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", `require("node:fs").writeSync(2, ${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 new file mode 100644 index 0000000000..0e19789276 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -0,0 +1,159 @@ +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, { 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({ + packageArtifactsPath: "relative-artifacts", + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + }), + { + name: "StagehandPackageArtifactError", + message: "Stagehand package artifact is invalid.", + }, + ); +}); + +void test("runtime lock rejects dependency sources outside file and the npm registry", async () => { + const artifactRoot = await writeArtifacts("https://packages.example.test/supergateway.tgz"); + 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("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 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) => { + 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, + }), + { name: "StagehandSandboxSetupError" }, + ); + assert.ok(createOptions); + 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-")); + 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; +} 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..4a2fb82f5a --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -0,0 +1,542 @@ +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"; + +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 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"; + + 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}).`); + } +} + +class StagehandPackageArtifactError extends Error { + override readonly name = "StagehandPackageArtifactError"; + + constructor() { + super("Stagehand package artifact is invalid."); + } +} + +export type StagehandSandboxOptions = { + packageArtifactsPath: string; + browserbaseApiKey: string; + browserbaseProjectId: string; + vercelCredentials?: { + teamId: string; + projectId: string; + token: string; + }; + readinessTimeoutMs?: number; + sandboxTimeoutMs?: number; + cleanupTimeoutMs?: number; +}; + +export type StagehandSandboxConnection = { + url: URL; + token: string; + close: () => Promise; +}; + +/** + * 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 { + assertNonEmpty(options.browserbaseApiKey, "browserbaseApiKey"); + assertNonEmpty(options.browserbaseProjectId, "browserbaseProjectId"); + const artifacts = await loadPackageArtifacts(options); + + const cdpHost = await discoverBrowserbaseCdpHost(options); + let sandbox: Sandbox; + try { + const vercelCredentials = options.vercelCredentials; + 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" }, + ...(vercelCredentials + ? { + teamId: vercelCredentials.teamId, + projectId: vercelCredentials.projectId, + token: vercelCredentials.token, + } + : {}), + }); + } catch { + throw new StagehandSandboxSetupError(); + } + const close = sandboxCloser(sandbox, options.cleanupTimeoutMs ?? 30_000); + + try { + await installStagehandPackages(sandbox, artifacts); + 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), { + signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), + }).catch(() => undefined); + if (unauthorized?.status !== 401) { + throw new StagehandSandboxHealthError(); + } + + return { + url: new URL("/mcp", origin), + token, + close, + }; + } catch (error) { + try { + await close(); + } catch { + throw new StagehandSandboxSetupError(); + } + if ( + error instanceof StagehandSandboxSetupError || + error instanceof StagehandSandboxHealthError || + error instanceof StagehandSandboxCommandError + ) { + throw error; + } + throw new StagehandSandboxSetupError(); + } +} + +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 installStagehandPackages( + sandbox: Sandbox, + artifacts: PackageArtifacts, +): Promise { + await run(sandbox, "create package install directories", "mkdir", [ + "-p", + PACKAGE_ROOT, + RUNTIME_ROOT, + ]); + 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 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 package artifacts", + "npm", + ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], + RUNTIME_ROOT, + ); +} + +async function protectRuntimeFiles(sandbox: Sandbox): Promise { + await run(sandbox.asUser("root"), "protect the trusted runtime", "bash", [ + "-lc", + [ + `test -x ${GATEWAY_BIN}`, + `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 ${RUNTIME_ROOT} ${PACKAGE_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; + 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; + await delay(Math.min(250, Math.max(0, deadline - Date.now()))); + } + throw new StagehandSandboxHealthError(); +} + +async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Promise { + let sessionId: string | undefined; + let discoveredHost: string | undefined; + let primaryError: unknown = NO_ERROR; + + 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") sessionId = session.id; + if (!sessionId || typeof session.connectUrl !== "string") { + throw new Error("Browserbase CDP host discovery returned an invalid session"); + } + discoveredHost = assertBrowserbaseCdpHost(new URL(session.connectUrl).hostname); + } catch (error) { + primaryError = error; + } + + let cleanupError: unknown = NO_ERROR; + 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 !== NO_ERROR || cleanupError !== NO_ERROR || !discoveredHost) { + throw new StagehandCdpDiscoveryError(); + } + 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 StagehandSandboxDisposeError(); +} + +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) { + void stderr; + throw new StagehandSandboxCommandError(label, result.exitCode); + } + 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 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)); +} + +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; 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 }))); +} + +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/packages/integrations/examples/vercel-sandbox/src/smoke.ts b/packages/integrations/examples/vercel-sandbox/src/smoke.ts new file mode 100644 index 0000000000..6b984c3211 --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/src/smoke.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +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-vercel-sandbox-smoke", version: "1.0.0" }); +const NO_ERROR = Symbol("no error"); +let primaryError: unknown = NO_ERROR; + +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 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(), + marker: await page.evaluate(() => document.documentElement.dataset.smoke), + }; + `, + }, + }); + + assert.ok(containsState(first.structuredContent, { title: "Example Domain" })); + assert.ok( + containsState(second.structuredContent, { + title: "Example Domain", + marker: "persisted", + }), + ); +} catch (error) { + primaryError = error; +} + +let cleanupError: unknown = NO_ERROR; +await client.close().catch((error: unknown) => { + cleanupError = error; +}); +if (primaryError !== NO_ERROR && cleanupError !== NO_ERROR) { + throw new AggregateError( + [primaryError, cleanupError], + "Stagehand sandbox smoke failed and MCP client cleanup also failed", + ); +} +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`, +); + +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 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)); +} diff --git a/packages/integrations/examples/vercel-sandbox/tsconfig.json b/packages/integrations/examples/vercel-sandbox/tsconfig.json new file mode 100644 index 0000000000..8f14c5759a --- /dev/null +++ b/packages/integrations/examples/vercel-sandbox/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 ae60d670e2..4d6adbff2f 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 @@ -316,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,6 +598,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-sandbox: + dependencies: + '@browserbasehq/stagehand-codemode': + specifier: workspace:* + version: link:../.. + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.29.0(zod@4.4.3) + '@vercel/sandbox': + specifier: 'catalog:' + version: 2.9.2 + supergateway: + specifier: 'catalog:' + version: 3.4.3(bufferutil@4.1.0) + 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: @@ -3085,6 +3116,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==} @@ -3117,6 +3151,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'} @@ -3324,6 +3361,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==} @@ -4969,6 +5009,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==} @@ -5027,6 +5070,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'} @@ -5824,6 +5870,10 @@ packages: 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==} @@ -6804,6 +6854,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'} @@ -6835,6 +6889,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==} @@ -7045,6 +7102,10 @@ 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'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7339,6 +7400,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'} @@ -8997,6 +9066,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) @@ -9982,6 +10073,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 @@ -10033,6 +10141,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 @@ -10238,6 +10348,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: {} @@ -12232,6 +12346,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.3: {} + jose@6.2.4: {} joycon@3.1.1: {} @@ -12282,6 +12398,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) @@ -13222,6 +13340,8 @@ snapshots: orderedmap@2.1.1: {} + os-paths@4.4.0: {} + outdent@0.5.0: {} own-keys@1.0.2: @@ -14584,6 +14704,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 @@ -14652,6 +14788,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 @@ -14878,6 +15023,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.29.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15212,6 +15359,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: @@ -15273,6 +15428,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 1ffc7f1bd4..870b832305 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,12 @@ packages: - packages/* + - packages/integrations/examples/* catalogMode: prefer catalog: "@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 @@ -48,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: 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/**"],