From 2cfa7b02ea686bb5559b0bd72510bba7936387dd Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 01:34:18 -0700 Subject: [PATCH 1/8] feat: scaffold Stagehand code-mode MCP host --- .github/workflows/ci.yml | 2 + packages/integrations/README.md | 5 + packages/integrations/package.json | 33 ++++ .../integrations/src/codemode/mcp-runtime.ts | 13 ++ .../src/codemode/stdio-lifecycle.ts | 25 +++ .../integrations/src/codemode/stdio-server.ts | 22 +++ .../integrations/tests/mcp-runtime.test.ts | 26 +++ .../tests/stdio-lifecycle.test.ts | 32 +++ .../integrations/tests/stdio-server.test.ts | 112 +++++++++++ packages/integrations/tsconfig.json | 13 ++ packages/integrations/tsdown.config.ts | 15 ++ pnpm-lock.yaml | 182 ++++++++++-------- pnpm-workspace.yaml | 1 + turbo.json | 11 ++ vitest.config.ts | 1 + 15 files changed, 413 insertions(+), 80 deletions(-) create mode 100644 packages/integrations/README.md create mode 100644 packages/integrations/package.json create mode 100644 packages/integrations/src/codemode/mcp-runtime.ts create mode 100644 packages/integrations/src/codemode/stdio-lifecycle.ts create mode 100644 packages/integrations/src/codemode/stdio-server.ts create mode 100644 packages/integrations/tests/mcp-runtime.test.ts create mode 100644 packages/integrations/tests/stdio-lifecycle.test.ts create mode 100644 packages/integrations/tests/stdio-server.test.ts create mode 100644 packages/integrations/tsconfig.json create mode 100644 packages/integrations/tsdown.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0309eb0745..d69cb95adb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: - '.github/**' sdk-ts: - 'packages/sdk-ts/**' + - 'packages/integrations/**' - 'packages/extension/**' - 'packages/protocol/**' - 'pnpm-lock.yaml' @@ -167,6 +168,7 @@ jobs: packages/extension/dist/** packages/extension/artifacts/** packages/sdk-ts/dist/** + packages/integrations/dist/** packages/evals/dist/** retention-days: 1 diff --git a/packages/integrations/README.md b/packages/integrations/README.md new file mode 100644 index 0000000000..86cbf8043b --- /dev/null +++ b/packages/integrations/README.md @@ -0,0 +1,5 @@ +# Stagehand integrations + +Private workspace package for Stagehand integration adapters. + +The code-mode stdio entrypoint currently provides the MCP host and process lifecycle used by later code-mode capabilities. It intentionally advertises no tools yet. diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 0000000000..264cfa56b9 --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,33 @@ +{ + "name": "@browserbasehq/stagehand-integrations", + "version": "4.0.0", + "private": true, + "description": "Shared integration surfaces for Stagehand V4", + "files": [ + "dist" + ], + "type": "module", + "exports": { + "./codemode/stdio-server": { + "import": "./dist/codemode/stdio-server.mjs" + } + }, + "scripts": { + "build": "tsdown", + "test": "pnpm run build && vitest run --root ../.. packages/integrations/tests", + "test:unit": "vitest run --root ../.. packages/integrations/tests", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/integrations/src/codemode/mcp-runtime.ts b/packages/integrations/src/codemode/mcp-runtime.ts new file mode 100644 index 0000000000..89fa58ab89 --- /dev/null +++ b/packages/integrations/src/codemode/mcp-runtime.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +export function createCodeModeMcpHost(): McpServer { + return new McpServer({ + name: "stagehand-codemode", + version: "4.0.0", + }); +} + +export async function connectCodeModeStdio(server: McpServer): Promise { + await server.connect(new StdioServerTransport()); +} diff --git a/packages/integrations/src/codemode/stdio-lifecycle.ts b/packages/integrations/src/codemode/stdio-lifecycle.ts new file mode 100644 index 0000000000..c0fdbad2f9 --- /dev/null +++ b/packages/integrations/src/codemode/stdio-lifecycle.ts @@ -0,0 +1,25 @@ +export type AsyncCloser = { + close(): Promise; +}; + +export const STDIO_SHUTDOWN_GRACE_MS = 5_000; + +export async function closeCodeModeStdio( + resources: readonly AsyncCloser[], + timeoutMs = STDIO_SHUTDOWN_GRACE_MS, +): Promise { + let timeout: NodeJS.Timeout | undefined; + const cleanup = Promise.allSettled(resources.map((resource) => resource.close())).then( + (results) => results.every((result) => result.status === "fulfilled"), + ); + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + timeout.unref(); + }); + + try { + return await Promise.race([cleanup, deadline]); + } finally { + if (timeout) clearTimeout(timeout); + } +} diff --git a/packages/integrations/src/codemode/stdio-server.ts b/packages/integrations/src/codemode/stdio-server.ts new file mode 100644 index 0000000000..d1fe1ae78f --- /dev/null +++ b/packages/integrations/src/codemode/stdio-server.ts @@ -0,0 +1,22 @@ +import { connectCodeModeStdio, createCodeModeMcpHost } from "./mcp-runtime.js"; +import { closeCodeModeStdio } from "./stdio-lifecycle.js"; + +const server = createCodeModeMcpHost(); +await connectCodeModeStdio(server); +let closing = false; + +async function shutdown(code: number): Promise { + if (closing) return; + closing = true; + const clean = await closeCodeModeStdio([server]); + if (!clean) { + process.stderr.write("Failed to close Stagehand code mode cleanly.\n"); + } + process.exit(code === 0 && !clean ? 1 : code); +} + +process.once("SIGINT", () => void shutdown(130)); +process.once("SIGTERM", () => void shutdown(143)); +process.stdin.once("end", () => void shutdown(0)); +process.stdin.once("close", () => void shutdown(0)); +process.stderr.write("Stagehand code-mode MCP host listening on stdio\n"); diff --git a/packages/integrations/tests/mcp-runtime.test.ts b/packages/integrations/tests/mcp-runtime.test.ts new file mode 100644 index 0000000000..dcfa098c3f --- /dev/null +++ b/packages/integrations/tests/mcp-runtime.test.ts @@ -0,0 +1,26 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createCodeModeMcpHost } from "../src/codemode/mcp-runtime.js"; + +describe("code-mode MCP host", () => { + let client: Client; + let server: ReturnType; + + beforeEach(async () => { + server = createCodeModeMcpHost(); + client = new Client({ name: "stagehand-codemode-host-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + it("initializes without advertising the tools capability", () => { + expect(client.getServerCapabilities()).not.toHaveProperty("tools"); + }); +}); diff --git a/packages/integrations/tests/stdio-lifecycle.test.ts b/packages/integrations/tests/stdio-lifecycle.test.ts new file mode 100644 index 0000000000..af484b433b --- /dev/null +++ b/packages/integrations/tests/stdio-lifecycle.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { closeCodeModeStdio } from "../src/codemode/stdio-lifecycle.js"; + +describe("closeCodeModeStdio", () => { + afterEach(() => vi.useRealTimers()); + + it("closes every resource concurrently", async () => { + const first = { close: vi.fn(async () => undefined) }; + const second = { close: vi.fn(async () => undefined) }; + + await expect(closeCodeModeStdio([first, second], 50)).resolves.toBe(true); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + }); + + it("reports cleanup failures without exposing their messages", async () => { + const healthy = { close: vi.fn(async () => undefined) }; + const failing = { close: vi.fn(async () => Promise.reject(new Error("secret detail"))) }; + + await expect(closeCodeModeStdio([healthy, failing], 50)).resolves.toBe(false); + }); + + it("bounds cleanup when a resource never settles", async () => { + vi.useFakeTimers(); + const stuck = { close: vi.fn(() => new Promise(() => undefined)) }; + const result = closeCodeModeStdio([stuck], 5_000); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(result).resolves.toBe(false); + }); +}); diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts new file mode 100644 index 0000000000..a5cb7107bd --- /dev/null +++ b/packages/integrations/tests/stdio-server.test.ts @@ -0,0 +1,112 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { describe, expect, it } from "vitest"; + +const entrypoint = fileURLToPath(new URL("../dist/codemode/stdio-server.mjs", import.meta.url)); +const baseEnv = { PATH: process.env.PATH ?? "" }; + +function startServer(): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [entrypoint], { + env: baseEnv, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + let stderr = ""; + return await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`stdio host did not start: ${stderr}`)), + 10_000, + ); + const onData = (chunk: Buffer) => { + stderr += chunk.toString(); + if (!stderr.includes("Stagehand code-mode MCP host listening on stdio")) return; + clearTimeout(timeout); + child.stderr.off("data", onData); + resolve(stderr); + }; + child.stderr.on("data", onData); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + reject( + new Error(`stdio host exited before ready (code=${code}, signal=${signal}): ${stderr}`), + ); + }); + }); +} + +function waitForExit( + child: ChildProcessWithoutNullStreams, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("stdio host did not exit within 10 seconds")); + }, 10_000); + child.once("error", reject); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }); +} + +describe("built code-mode stdio host", () => { + it("starts and exits successfully on stdin EOF", async () => { + const child = startServer(); + try { + await waitForReady(child); + const exit = waitForExit(child); + child.stdin.end(); + await expect(exit).resolves.toStrictEqual({ code: 0, signal: null }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + }); + + it.skipIf(process.platform === "win32")( + "preserves SIGINT and SIGTERM exit semantics", + async () => { + for (const [signal, expectedCode] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const) { + const child = startServer(); + try { + await waitForReady(child); + const exit = waitForExit(child); + child.kill(signal); + await expect(exit).resolves.toStrictEqual({ code: expectedCode, signal: null }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + } + }, + 30_000, + ); + + it("initializes without advertising tools through the compiled child", async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint], + env: baseEnv, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const client = new Client({ name: "stagehand-codemode-stdio-test", version: "1.0.0" }); + + try { + await client.connect(transport); + expect(client.getServerCapabilities()).not.toHaveProperty("tools"); + expect(stderr).toContain("Stagehand code-mode MCP host listening on stdio"); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 0000000000..36876ff6db --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/integrations/tsdown.config.ts b/packages/integrations/tsdown.config.ts new file mode 100644 index 0000000000..587cfd7b80 --- /dev/null +++ b/packages/integrations/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + "codemode/stdio-server": "src/codemode/stdio-server.ts", + }, + format: ["esm"], + platform: "node", + target: "node22", + dts: { + sourcemap: true, + }, + sourcemap: true, + outDir: "dist", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a706f2f8ec..98c7625bd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ catalogs: '@mdx-js/mdx': specifier: 3.1.1 version: 3.1.1 + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0 '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -485,72 +488,6 @@ 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/protocol: - dependencies: - camelcase-keys: - specifier: 'catalog:' - version: 10.0.2 - snakecase-keys: - specifier: 'catalog:' - version: 9.0.2 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@types/chrome': - specifier: 'catalog:' - version: 0.2.2 - '@types/node': - specifier: 'catalog:' - version: 24.13.2 - chrome-launcher: - specifier: 'catalog:' - version: 1.2.1 - oxfmt: - specifier: 'catalog:' - version: 0.57.0 - typescript: - specifier: 'catalog:' - version: 5.9.3 - vite: - specifier: 8.1.3 - version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0) - vitest: - 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/sdk-go: {} - - packages/sdk-python: {} - - packages/sdk-ts: - dependencies: - '@browserbasehq/sdk': - specifier: 'catalog:' - version: 2.16.0 - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/core': - specifier: 'catalog:' - version: 2.9.0(@opentelemetry/api@1.9.1) - chrome-launcher: - specifier: 'catalog:' - version: 1.2.1 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - publint: - specifier: 'catalog:' - version: 0.3.21 - tsdown: - specifier: 'catalog:' - version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) - vitest: - specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - packages/extension: dependencies: '@ai-sdk/anthropic': @@ -621,6 +558,91 @@ 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: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.29.0(zod@4.4.3) + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + tsdown: + specifier: 'catalog:' + version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + 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/protocol: + dependencies: + camelcase-keys: + specifier: 'catalog:' + version: 10.0.2 + snakecase-keys: + specifier: 'catalog:' + version: 9.0.2 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/chrome': + specifier: 'catalog:' + version: 0.2.2 + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + chrome-launcher: + specifier: 'catalog:' + version: 1.2.1 + oxfmt: + specifier: 'catalog:' + version: 0.57.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 8.1.3 + version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0) + vitest: + 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/sdk-go: {} + + packages/sdk-python: {} + + packages/sdk-ts: + dependencies: + '@browserbasehq/sdk': + specifier: 'catalog:' + version: 2.16.0 + '@opentelemetry/api': + specifier: 'catalog:' + version: 1.9.1 + '@opentelemetry/core': + specifier: 'catalog:' + version: 2.9.0(@opentelemetry/api@1.9.1) + chrome-launcher: + specifier: 'catalog:' + version: 1.2.1 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + publint: + specifier: 'catalog:' + version: 0.3.21 + tsdown: + specifier: 'catalog:' + version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + packages: '@ai-sdk/amazon-bedrock@3.0.111': @@ -8970,8 +8992,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) - express-rate-limit: 8.6.0(express@5.2.1(supports-color@8.1.1)) + 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 @@ -10309,7 +10331,7 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0(supports-color@8.1.1): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -11175,10 +11197,10 @@ snapshots: expr-eval-fork@3.0.3: {} - express-rate-limit@8.6.0(express@5.2.1(supports-color@8.1.1)): + express-rate-limit@8.6.0(express@5.2.1): dependencies: debug: 4.4.3(supports-color@8.1.1) - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 ip-address: 10.2.0 transitivePeerDependencies: - supports-color @@ -11219,10 +11241,10 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@8.1.1): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@8.1.1) + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -11232,7 +11254,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@8.1.1) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -11243,8 +11265,8 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.2.1 - router: 2.2.0(supports-color@8.1.1) - send: 1.2.1(supports-color@8.1.1) + router: 2.2.0 + send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.1.0 @@ -11358,7 +11380,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@8.1.1): + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -14035,7 +14057,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 - router@2.2.0(supports-color@8.1.1): + router@2.2.0: dependencies: debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 @@ -14125,7 +14147,7 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1(supports-color@8.1.1): + send@1.2.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -14160,7 +14182,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@8.1.1) + send: 1.2.1 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 90e5e925a0..1ffc7f1bd4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalogMode: prefer catalog: + "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 "@ast-grep/napi": 0.44.1 diff --git a/turbo.json b/turbo.json index dd2ff5479e..4eb9656bb0 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,11 @@ "inputs": ["$TURBO_DEFAULT$", "!dist/**"], "outputs": ["dist/**"] }, + "@browserbasehq/stagehand-integrations#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "!dist/**"], + "outputs": ["dist/**"] + }, "@browserbasehq/stagehand-evals#build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**"], @@ -61,6 +66,12 @@ "@browserbasehq/stagehand-evals#typecheck": { "dependsOn": ["^build"] }, + "@browserbasehq/stagehand-integrations#typecheck": { + "dependsOn": ["^build"] + }, + "@browserbasehq/stagehand-integrations#test:unit": { + "dependsOn": ["^build", "@browserbasehq/stagehand-integrations#build"] + }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { "dependsOn": ["^build"], diff --git a/vitest.config.ts b/vitest.config.ts index 826ac255c9..0c3f2127e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "packages/protocol/json-rpc/tests/**/*.test.ts", "packages/docs/tests/**/*.test.ts", "packages/evals/tests/**/*.test.ts", + "packages/integrations/tests/**/*.test.ts", "packages/extension/tests/**/*.test.ts", "packages/sdk-ts/tests/**/*.test.ts", "packages/extension/understudy/**/*.test.ts", From 1803b14a8ad5eb4dd9d9d36f7b2de1ffce5891ba Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 01:44:19 -0700 Subject: [PATCH 2/8] feat: add Stagehand code execution tool --- packages/integrations/README.md | 66 +++- packages/integrations/package.json | 8 +- packages/integrations/src/codemode/config.ts | 99 +++++ .../integrations/src/codemode/executor.ts | 308 +++++++++++++++ packages/integrations/src/codemode/index.ts | 10 + .../integrations/src/codemode/mcp-server.ts | 50 +++ packages/integrations/src/codemode/snippet.ts | 48 +++ .../integrations/src/codemode/stdio-server.ts | 12 +- .../src/codemode/tool-contract.ts | 71 ++++ packages/integrations/src/codemode/types.ts | 72 ++++ packages/integrations/tests/config.test.ts | 138 +++++++ packages/integrations/tests/executor.test.ts | 364 ++++++++++++++++++ .../integrations/tests/mcp-server.test.ts | 101 +++++ packages/integrations/tests/snippet.test.ts | 106 +++++ .../integrations/tests/stdio-server.test.ts | 58 ++- .../integrations/tests/tool-contract.test.ts | 80 ++++ packages/integrations/tsdown.config.ts | 1 + pnpm-lock.yaml | 6 + 18 files changed, 1577 insertions(+), 21 deletions(-) create mode 100644 packages/integrations/src/codemode/config.ts create mode 100644 packages/integrations/src/codemode/executor.ts create mode 100644 packages/integrations/src/codemode/index.ts create mode 100644 packages/integrations/src/codemode/mcp-server.ts create mode 100644 packages/integrations/src/codemode/snippet.ts create mode 100644 packages/integrations/src/codemode/tool-contract.ts create mode 100644 packages/integrations/src/codemode/types.ts create mode 100644 packages/integrations/tests/config.test.ts create mode 100644 packages/integrations/tests/executor.test.ts create mode 100644 packages/integrations/tests/mcp-server.test.ts create mode 100644 packages/integrations/tests/snippet.test.ts create mode 100644 packages/integrations/tests/tool-contract.test.ts diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 86cbf8043b..6aeb8b8618 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -1,5 +1,67 @@ # Stagehand integrations -Private workspace package for Stagehand integration adapters. +This package contains shared integration surfaces for Stagehand V4. It is private while the public API and packaging contract are validated. -The code-mode stdio entrypoint currently provides the MCP host and process lifecycle used by later code-mode capabilities. It intentionally advertises no tools yet. +## Code mode + +The `./codemode` export gives an agent one `code_execute` tool backed by a persistent Stagehand browser. Frameworks can either launch the thin local MCP server or wrap `StagehandCodeExecutor` as a native tool. + +```ts +import { + StagehandCodeExecutor, + stagehandCodeConfigFromEnv, +} from "@browserbasehq/stagehand-integrations/codemode"; + +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); + +try { + const result = await executor.execute({ + code: ` + await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); + return { title: await page.title(), url: await page.url() }; + `, + }); + console.log(result); +} finally { + await executor.close(); +} +``` + +The executor initializes the browser on the first valid call, serializes calls, and preserves pages, cookies, and navigation state until its owner closes it. + +### Low-level eval integration + +Eval harnesses that already own Stagehand and browser initialization should call `executeStagehandSnippet` directly. This reuses the exact generated-code semantics without replacing the eval harness's startup, cleanup, task bindings, or metrics collection. + +### Local MCP integration + +The `./codemode/stdio-server` export is an internal process entrypoint. It is not a command-line interface and accepts no arguments. The owning framework launches one process per agent run and selects local or Browserbase startup through its environment: + +```text +STAGEHAND_BROWSER=local +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. + +### Configuration + +`stagehandCodeConfigFromEnv()` recognizes: + +| Variable | Purpose | +| ------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `STAGEHAND_BROWSER` | Optional `local` or `browserbase` override | +| `BROWSERBASE_API_KEY` | Selects and authenticates Browserbase when present | +| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project forwarded when creating a session | +| `STAGEHAND_MODEL_NAME` | Optional Stagehand model name | +| `STAGEHAND_MODEL_API_KEY` | Optional explicit model-provider key | +| Provider API keys | Supplies the key for a matching explicit model provider | +| `GEMINI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY` | Selects `google/gemini-2.5-flash-lite` when no model is explicit | + +Without a browser override, the helper selects Browserbase when `BROWSERBASE_API_KEY` exists and a headless local browser otherwise. + +Native callers run generated JavaScript in their own process. An `AbortSignal` can cancel queued work before the snippet begins, but it cannot safely preempt arbitrary JavaScript already running in the same process. Native integrations that require hard time limits should put the executor behind a child-process boundary, as the stdio MCP integration does. + +### 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. diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 264cfa56b9..a29ede83e4 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -8,6 +8,10 @@ ], "type": "module", "exports": { + "./codemode": { + "types": "./dist/codemode/index.d.mts", + "import": "./dist/codemode/index.mjs" + }, "./codemode/stdio-server": { "import": "./dist/codemode/stdio-server.mjs" } @@ -19,7 +23,9 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@modelcontextprotocol/sdk": "catalog:" + "@browserbasehq/stagehand": "workspace:*", + "@modelcontextprotocol/sdk": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/integrations/src/codemode/config.ts b/packages/integrations/src/codemode/config.ts new file mode 100644 index 0000000000..adc0c10d00 --- /dev/null +++ b/packages/integrations/src/codemode/config.ts @@ -0,0 +1,99 @@ +import { StagehandClientCreateConfigSchema } from "@browserbasehq/stagehand"; +import type { StagehandCodeConfig } from "./types.js"; + +const ANTHROPIC_DIRECT_BROWSER_ACCESS_HEADER = "anthropic-dangerous-direct-browser-access"; + +export function stagehandCodeConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): StagehandCodeConfig { + const requestedBrowser = nonEmpty(env.STAGEHAND_BROWSER)?.toLowerCase(); + if ( + requestedBrowser !== undefined && + requestedBrowser !== "local" && + requestedBrowser !== "browserbase" + ) { + throw new Error('STAGEHAND_BROWSER must be either "local" or "browserbase".'); + } + + const browserbaseApiKey = nonEmpty(env.BROWSERBASE_API_KEY); + const browserbaseProjectId = nonEmpty(env.BROWSERBASE_PROJECT_ID); + const browserType = requestedBrowser ?? (browserbaseApiKey ? "browserbase" : "local"); + if (browserType === "browserbase" && !browserbaseApiKey) { + throw new Error('BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".'); + } + + const explicitModelName = nonEmpty(env.STAGEHAND_MODEL_NAME); + const explicitModelApiKey = nonEmpty(env.STAGEHAND_MODEL_API_KEY); + if (!explicitModelName && explicitModelApiKey) { + throw new Error("STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set."); + } + + const inferredGoogleKey = providerApiKey("google", env); + const modelName = + explicitModelName ?? (inferredGoogleKey ? "google/gemini-2.5-flash-lite" : undefined); + const modelProvider = modelName ? providerName(modelName) : undefined; + const modelApiKey = explicitModelApiKey ?? providerApiKey(modelProvider, env); + + const stagehand = StagehandClientCreateConfigSchema.parse({ + logging: { level: "off" }, + ...(modelName + ? { + model: { + modelName, + ...(modelApiKey ? { apiKey: modelApiKey } : {}), + ...(modelProvider === "anthropic" + ? { headers: { [ANTHROPIC_DIRECT_BROWSER_ACCESS_HEADER]: "true" } } + : {}), + }, + } + : {}), + }); + + return { + browser: + browserType === "browserbase" + ? { + type: "browserbase", + launchOptions: { + apiKey: browserbaseApiKey, + ...(browserbaseProjectId ? { projectId: browserbaseProjectId } : {}), + }, + } + : { + type: "local", + launchOptions: { headless: true }, + }, + stagehand, + }; +} + +function providerName(modelName: string): string | undefined { + const separator = modelName.indexOf("/"); + return separator === -1 ? undefined : modelName.slice(0, separator).toLowerCase(); +} + +function providerApiKey(provider: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + switch (provider) { + case "openai": + return nonEmpty(env.OPENAI_API_KEY); + case "anthropic": + return nonEmpty(env.ANTHROPIC_API_KEY); + case "google": + return ( + nonEmpty(env.GEMINI_API_KEY) ?? + nonEmpty(env.GOOGLE_GENERATIVE_AI_API_KEY) ?? + nonEmpty(env.GOOGLE_API_KEY) + ); + case "groq": + return nonEmpty(env.GROQ_API_KEY); + case "cerebras": + return nonEmpty(env.CEREBRAS_API_KEY); + default: + return undefined; + } +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/integrations/src/codemode/executor.ts b/packages/integrations/src/codemode/executor.ts new file mode 100644 index 0000000000..687ed53e54 --- /dev/null +++ b/packages/integrations/src/codemode/executor.ts @@ -0,0 +1,308 @@ +import { + browserbase, + localBrowser, + Stagehand, + type Page, + type StagehandBrowser, + type StagehandMetrics, +} from "@browserbasehq/stagehand"; +import { executeStagehandSnippet } from "./snippet.js"; +import type { + CodeExecuteFailure, + CodeExecuteInput, + CodeExecuteResult, + CodeLogEntry, + CodePageState, + StagehandCodeConfig, +} from "./types.js"; + +export type StagehandCodeExecutorOptions = StagehandCodeConfig; + +const MAX_CODE_BYTES = 100_000; +const MAX_LOG_BYTES = 64 * 1024; +const MAX_RESULT_BYTES = 256 * 1024; +const MAX_ERROR_MESSAGE_LENGTH = 4_000; +const MIN_SENSITIVE_VALUE_LENGTH = 8; +const SECRET_FIELD = /(?:api.?key|authorization|cookie|password|secret|token)/i; +const URL = /\b(?:https?|wss?):\/\/[^\s"'<>]+/gi; +const CREDENTIAL = + /\b(authorization|api[_-]?key|password|secret|token)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/gi; +const BEARER_TOKEN = /\bbearer\s+[^\s,;]+/gi; + +export class StagehandCodeExecutor { + private stagehand?: Stagehand; + private browser?: StagehandBrowser; + private queue = Promise.resolve(); + private closed = false; + private closePromise?: Promise; + private readonly sensitiveValues: string[]; + + constructor(private readonly options: StagehandCodeExecutorOptions) { + this.sensitiveValues = collectSensitiveValues(options); + } + + execute(input: CodeExecuteInput, signal?: AbortSignal): Promise { + const validation = validate(input); + if (validation) return Promise.resolve(validation); + + const operation = this.queue.then(() => this.executeQueued(input, signal)); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + metrics(): Promise { + const operation = this.queue.then(() => this.stagehand?.metrics()); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + close(): Promise { + this.closed = true; + this.closePromise ??= this.queue.then(async () => { + const stagehand = this.stagehand; + const browser = this.browser; + this.stagehand = undefined; + this.browser = undefined; + + const errors: unknown[] = []; + if (stagehand) { + await stagehand.close().catch((error) => errors.push(error)); + } + if (browser) { + await browser.close().catch((error) => errors.push(error)); + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to close Stagehand code mode."); + } + }); + return this.closePromise; + } + + private async executeQueued( + input: CodeExecuteInput, + signal?: AbortSignal, + ): Promise { + if (this.closed) { + return failure("closed", "Code executor is closed."); + } + if (signal?.aborted) { + return failure("aborted", "Code execution was aborted before it began."); + } + + const logs: CodeLogEntry[] = []; + let page: Page | undefined; + try { + const stagehand = await this.ensureStagehand(); + const context = stagehand.browser.context; + page = + (await context.activePage()) ?? (await context.pages())[0] ?? (await context.newPage()); + + if (signal?.aborted) { + return failure("aborted", "Code execution was aborted before it began.", "CodeModeError", { + page: await readPageState(page), + }); + } + + const value = await executeStagehandSnippet({ + code: input.code, + page, + context, + stagehand, + console: createCodeConsole(logs), + }); + const currentPage = (await context.activePage()) ?? page; + + return { + ok: true, + page: await readPageState(currentPage), + ...(value === undefined ? {} : { value: jsonSafe(value) }), + ...(logs.length === 0 ? {} : { logs }), + }; + } catch (error) { + const normalized = normalizeError(error, this.sensitiveValues); + const currentPage = (await this.activePage().catch(() => undefined)) ?? page; + return failure("runtime", normalized.message, normalized.name, { + ...(currentPage ? { page: await readPageState(currentPage).catch(() => undefined) } : {}), + ...(logs.length === 0 ? {} : { logs }), + }); + } + } + + private async ensureStagehand(): Promise { + if (this.stagehand) return this.stagehand; + + const browserConfig = this.options.browser; + const browser = + browserConfig.type === "browserbase" + ? await browserbase.launch(browserConfig.launchOptions) + : await localBrowser.launch(browserConfig.launchOptions); + + try { + const stagehand = await Stagehand.create({ + browser, + logging: { level: "off" }, + ...this.options.stagehand, + }); + this.browser = browser; + this.stagehand = stagehand; + return stagehand; + } catch (error) { + try { + await browser.close(); + } catch (closeError) { + throw new AggregateError( + [error, closeError], + "Stagehand code mode initialization and browser cleanup both failed.", + ); + } + throw error; + } + } + + private async activePage(): Promise { + if (!this.stagehand) return undefined; + return ( + (await this.stagehand.browser.context.activePage()) ?? + (await this.stagehand.browser.context.pages())[0] + ); + } +} + +function validate(input: CodeExecuteInput): CodeExecuteFailure | undefined { + if (!input || typeof input.code !== "string" || input.code.trim().length === 0) { + return failure("validation", "code must be a non-empty JavaScript function body."); + } + if (Buffer.byteLength(input.code) > MAX_CODE_BYTES) { + return failure("validation", `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes.`); + } + return undefined; +} + +function createCodeConsole(logs: CodeLogEntry[]) { + let logBytes = 0; + const append = (level: CodeLogEntry["level"], values: unknown[]) => { + if (logBytes >= MAX_LOG_BYTES) return; + const text = formatLog(values); + const remaining = MAX_LOG_BYTES - logBytes; + const bounded = Buffer.from(text).subarray(0, remaining).toString(); + logBytes += Buffer.byteLength(bounded); + logs.push({ level, text: bounded }); + }; + return Object.freeze({ + log: (...values: unknown[]) => append("log", values), + warn: (...values: unknown[]) => append("warn", values), + error: (...values: unknown[]) => append("error", values), + }); +} + +async function readPageState(page: Page): Promise { + const [url, title] = await Promise.all([page.url(), page.title()]); + return { url, title }; +} + +function jsonSafe(value: unknown): unknown { + if (value === undefined) return undefined; + const serialized = JSON.stringify(value, (_key, nested) => { + if (typeof nested === "bigint") return nested.toString(); + if (nested instanceof Uint8Array) { + return { + type: "bytes", + encoding: "base64", + data: Buffer.from(nested).toString("base64"), + }; + } + return nested; + }); + if (serialized === undefined) return undefined; + const bytes = Buffer.byteLength(serialized); + if (bytes <= MAX_RESULT_BYTES) return JSON.parse(serialized); + return { + truncated: true, + original_bytes: bytes, + preview: Buffer.from(serialized).subarray(0, MAX_RESULT_BYTES).toString(), + }; +} + +function formatLog(values: unknown[]): string { + return values + .map((value) => { + if (typeof value === "string") return value; + try { + const safe = jsonSafe(value); + return safe === undefined ? String(value) : JSON.stringify(safe); + } catch { + return "[Unserializable value]"; + } + }) + .join(" "); +} + +function normalizeError( + error: unknown, + sensitiveValues: string[], +): { name: string; message: string } { + if (!(error instanceof Error)) { + return { name: "Error", message: "Code execution failed with a non-Error value." }; + } + + const safeName = /^[A-Za-z_$][A-Za-z0-9_$.-]{0,99}$/.test(error.name) ? error.name : "Error"; + return { + name: safeName, + message: sanitizeErrorMessage(error.message, sensitiveValues), + }; +} + +function sanitizeErrorMessage(message: string, sensitiveValues: string[]): string { + let sanitized = message; + // Remove complete configured secrets before pattern redaction and final truncation. + for (const sensitiveValue of sensitiveValues) { + sanitized = sanitized.replaceAll(sensitiveValue, "[REDACTED]"); + } + sanitized = sanitized + .replace(URL, "[REDACTED_URL]") + .replace(CREDENTIAL, "$1=[REDACTED]") + .replace(BEARER_TOKEN, "Bearer [REDACTED]"); + return sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) || "Code execution failed."; +} + +function collectSensitiveValues(value: unknown): string[] { + const values = new Set(); + const seen = new WeakMap(); + + const visit = (current: unknown, key = "", parentIsSensitive = false) => { + const isSensitive = parentIsSensitive || SECRET_FIELD.test(key); + if (typeof current === "string") { + if (isSensitive && current.length >= MIN_SENSITIVE_VALUE_LENGTH) values.add(current); + return; + } + if (!current || typeof current !== "object") return; + const previousSensitivity = seen.get(current); + if (previousSensitivity === true || (previousSensitivity === false && !isSensitive)) return; + seen.set(current, isSensitive); + for (const [nestedKey, nestedValue] of Object.entries(current)) { + visit(nestedValue, nestedKey, isSensitive); + } + }; + + visit(value); + return [...values].sort((left, right) => right.length - left.length); +} + +function failure( + kind: CodeExecuteFailure["error"]["kind"], + message: string, + name = "CodeModeError", + evidence: Pick = {}, +): CodeExecuteFailure { + return { + ok: false, + ...evidence, + error: { kind, name, message }, + }; +} diff --git a/packages/integrations/src/codemode/index.ts b/packages/integrations/src/codemode/index.ts new file mode 100644 index 0000000000..bcda1ddde2 --- /dev/null +++ b/packages/integrations/src/codemode/index.ts @@ -0,0 +1,10 @@ +export { stagehandCodeConfigFromEnv } from "./config.js"; +export { StagehandCodeExecutor, type StagehandCodeExecutorOptions } from "./executor.js"; +export { connectCodeModeStdio, createCodeModeMcp, createCodeModeMcpServer } from "./mcp-server.js"; +export { executeStagehandSnippet } from "./snippet.js"; +export { + CODE_EXECUTE_DESCRIPTION, + codeExecuteResultText, + codeExecuteSchema, +} from "./tool-contract.js"; +export * from "./types.js"; diff --git a/packages/integrations/src/codemode/mcp-server.ts b/packages/integrations/src/codemode/mcp-server.ts new file mode 100644 index 0000000000..6862e8cca1 --- /dev/null +++ b/packages/integrations/src/codemode/mcp-server.ts @@ -0,0 +1,50 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StagehandCodeExecutor, type StagehandCodeExecutorOptions } from "./executor.js"; +import * as codeModeMcpRuntime from "./mcp-runtime.js"; +import { + CODE_EXECUTE_DESCRIPTION, + codeExecuteOutputSchema, + codeExecuteResultText, + codeExecuteSchema, +} from "./tool-contract.js"; +import type { CodeExecuteInput, CodeExecuteResult } from "./types.js"; + +export function createCodeModeMcpServer(executor: StagehandCodeExecutor): McpServer { + const server = codeModeMcpRuntime.createCodeModeMcpHost(); + server.registerTool( + "code_execute", + { + title: "Execute Stagehand V4 code", + description: CODE_EXECUTE_DESCRIPTION, + inputSchema: codeExecuteSchema.shape, + outputSchema: codeExecuteOutputSchema, + }, + async (input, extra) => { + const result = await executor.execute(input as CodeExecuteInput, extra.signal); + return mcpResult(result); + }, + ); + return server; +} + +export async function connectCodeModeStdio(executor: StagehandCodeExecutor): Promise { + const server = createCodeModeMcpServer(executor); + await codeModeMcpRuntime.connectCodeModeStdio(server); + return server; +} + +export function createCodeModeMcp(options: StagehandCodeExecutorOptions): { + executor: StagehandCodeExecutor; + server: McpServer; +} { + const executor = new StagehandCodeExecutor(options); + return { executor, server: createCodeModeMcpServer(executor) }; +} + +function mcpResult(result: CodeExecuteResult) { + return { + content: [{ type: "text" as const, text: codeExecuteResultText(result) }], + structuredContent: result as unknown as Record, + isError: !result.ok, + }; +} diff --git a/packages/integrations/src/codemode/snippet.ts b/packages/integrations/src/codemode/snippet.ts new file mode 100644 index 0000000000..279c0e4a22 --- /dev/null +++ b/packages/integrations/src/codemode/snippet.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; +import type { ExecuteStagehandSnippetInput } from "./types.js"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new ( + ...args: string[] +) => (...values: unknown[]) => Promise; + +const RESERVED_BINDINGS = new Set(["page", "context", "stagehand", "z", "console"]); +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +export async function executeStagehandSnippet( + input: ExecuteStagehandSnippetInput, +): Promise { + const bindings = Object.entries(input.bindings ?? {}); + for (const [name] of bindings) { + if (!isAsyncFunctionParameter(name)) { + throw new TypeError(`Code-mode binding "${name}" is not a valid JavaScript identifier.`); + } + if (RESERVED_BINDINGS.has(name)) { + throw new TypeError(`Code-mode binding "${name}" is reserved.`); + } + } + + const parameters: Array<[string, unknown]> = [ + ["page", input.page], + ["context", input.context], + ...(input.stagehand + ? ([ + ["stagehand", input.stagehand], + ["z", z], + ] as Array<[string, unknown]>) + : []), + ...bindings, + ["console", input.console ?? console], + ]; + const fn = new AsyncFunction(...parameters.map(([name]) => name), input.code); + return await fn(...parameters.map(([, value]) => value)); +} + +function isAsyncFunctionParameter(name: string): boolean { + if (!IDENTIFIER.test(name)) return false; + try { + new AsyncFunction(name, ""); + return true; + } catch { + return false; + } +} diff --git a/packages/integrations/src/codemode/stdio-server.ts b/packages/integrations/src/codemode/stdio-server.ts index d1fe1ae78f..85edc756f8 100644 --- a/packages/integrations/src/codemode/stdio-server.ts +++ b/packages/integrations/src/codemode/stdio-server.ts @@ -1,14 +1,16 @@ -import { connectCodeModeStdio, createCodeModeMcpHost } from "./mcp-runtime.js"; +import { stagehandCodeConfigFromEnv } from "./config.js"; +import { StagehandCodeExecutor } from "./executor.js"; +import { connectCodeModeStdio } from "./mcp-server.js"; import { closeCodeModeStdio } from "./stdio-lifecycle.js"; -const server = createCodeModeMcpHost(); -await connectCodeModeStdio(server); +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); +const server = await connectCodeModeStdio(executor); let closing = false; async function shutdown(code: number): Promise { if (closing) return; closing = true; - const clean = await closeCodeModeStdio([server]); + const clean = await closeCodeModeStdio([server, executor]); if (!clean) { process.stderr.write("Failed to close Stagehand code mode cleanly.\n"); } @@ -19,4 +21,4 @@ process.once("SIGINT", () => void shutdown(130)); process.once("SIGTERM", () => void shutdown(143)); process.stdin.once("end", () => void shutdown(0)); process.stdin.once("close", () => void shutdown(0)); -process.stderr.write("Stagehand code-mode MCP host listening on stdio\n"); +process.stderr.write("Stagehand code-mode MCP listening on stdio\n"); diff --git a/packages/integrations/src/codemode/tool-contract.ts b/packages/integrations/src/codemode/tool-contract.ts new file mode 100644 index 0000000000..a53f8fef55 --- /dev/null +++ b/packages/integrations/src/codemode/tool-contract.ts @@ -0,0 +1,71 @@ +import { z } from "zod/v4"; +import type { CodeExecuteResult } from "./types.js"; + +export const CODE_EXECUTE_DESCRIPTION = [ + "Execute an async JavaScript function body against one long-lived Stagehand V4 browser.", + "The executor lazily creates a local or Browserbase browser on the first call and reuses it for later calls.", + "The executor itself is not a security sandbox. The owning framework may run it inside a sandbox or another isolation boundary.", + "If execution stops responding, the owning framework should terminate and restart the local tool process.", +].join("\n"); + +export const codeExecuteSchema = z.object({ + code: z + .string() + .refine((code) => code.trim().length > 0, "code must contain JavaScript source") + .refine( + (code) => new TextEncoder().encode(code).byteLength <= 100_000, + "code must be at most 100000 UTF-8 bytes", + ) + .describe( + "Async JavaScript function body. page, context, stagehand, z, and console are in scope.", + ), +}); + +export const codeExecuteOutputSchema = z + .object({ + ok: z.boolean(), + page: z + .object({ + url: z.string(), + title: z.string(), + }) + .optional(), + value: z.unknown().optional(), + logs: z + .array( + z.object({ + level: z.enum(["log", "warn", "error"]), + text: z.string(), + }), + ) + .optional(), + error: z + .object({ + kind: z.enum(["validation", "runtime", "aborted", "closed"]), + name: z.string(), + message: z.string(), + }) + .optional(), + }) + .superRefine((result, context) => { + if (result.ok) { + if (!result.page) { + context.addIssue({ code: "custom", message: "successful results require page state" }); + } + if (result.error) { + context.addIssue({ code: "custom", message: "successful results cannot include an error" }); + } + return; + } + + if (!result.error) { + context.addIssue({ code: "custom", message: "failed results require an error" }); + } + if (result.value !== undefined) { + context.addIssue({ code: "custom", message: "failed results cannot include a value" }); + } + }); + +export function codeExecuteResultText(result: CodeExecuteResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/packages/integrations/src/codemode/types.ts b/packages/integrations/src/codemode/types.ts new file mode 100644 index 0000000000..41f7e31dc8 --- /dev/null +++ b/packages/integrations/src/codemode/types.ts @@ -0,0 +1,72 @@ +import type { + BrowserbaseLaunchOptions, + BrowserContext, + LocalBrowserLaunchOptions, + Page, + Stagehand, + StagehandClientCreateConfig, +} from "@browserbasehq/stagehand"; + +export type CodeExecuteInput = { + code: string; +}; + +export type CodePageState = { + url: string; + title: string; +}; + +export type CodeLogEntry = { + level: "log" | "warn" | "error"; + text: string; +}; + +export type CodeExecuteErrorKind = "validation" | "runtime" | "aborted" | "closed"; + +export type CodeExecuteSuccess = { + ok: true; + page: CodePageState; + value?: unknown; + logs?: CodeLogEntry[]; +}; + +export type CodeExecuteFailure = { + ok: false; + page?: CodePageState; + logs?: CodeLogEntry[]; + error: { + kind: CodeExecuteErrorKind; + name: string; + message: string; + }; +}; + +export type CodeExecuteResult = CodeExecuteSuccess | CodeExecuteFailure; + +export type StagehandSnippetConsole = Pick; + +export type StagehandSnippetBindings = Record; + +export type ExecuteStagehandSnippetInput = { + code: string; + page: Page; + context: BrowserContext; + stagehand?: Stagehand; + bindings?: StagehandSnippetBindings; + console?: StagehandSnippetConsole; +}; + +export type StagehandCodeBrowserConfig = + | { + type: "local"; + launchOptions?: LocalBrowserLaunchOptions; + } + | { + type: "browserbase"; + launchOptions: BrowserbaseLaunchOptions; + }; + +export type StagehandCodeConfig = { + browser: StagehandCodeBrowserConfig; + stagehand?: StagehandClientCreateConfig; +}; diff --git a/packages/integrations/tests/config.test.ts b/packages/integrations/tests/config.test.ts new file mode 100644 index 0000000000..ae64607cbc --- /dev/null +++ b/packages/integrations/tests/config.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { stagehandCodeConfigFromEnv } from "../src/codemode/config.js"; + +describe("stagehandCodeConfigFromEnv", () => { + it("defaults to a headless local browser without Browserbase credentials", () => { + expect(stagehandCodeConfigFromEnv({})).toMatchObject({ + browser: { type: "local", launchOptions: { headless: true } }, + stagehand: { logging: { level: "off" } }, + }); + }); + + it("lets an explicit local selection win when Browserbase credentials are present", () => { + expect( + stagehandCodeConfigFromEnv({ + STAGEHAND_BROWSER: " local ", + BROWSERBASE_API_KEY: "bb_secret", + BROWSERBASE_PROJECT_ID: "project-id", + }).browser, + ).toStrictEqual({ type: "local", launchOptions: { headless: true } }); + }); + + it("forwards Browserbase API key and project ID", () => { + expect( + stagehandCodeConfigFromEnv({ + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: " bb_secret ", + BROWSERBASE_PROJECT_ID: " project-id ", + }).browser, + ).toStrictEqual({ + type: "browserbase", + launchOptions: { apiKey: "bb_secret", projectId: "project-id" }, + }); + }); + + it("omits a blank Browserbase project ID", () => { + expect( + stagehandCodeConfigFromEnv({ + BROWSERBASE_API_KEY: "bb_secret", + BROWSERBASE_PROJECT_ID: " ", + }).browser, + ).toStrictEqual({ + type: "browserbase", + launchOptions: { apiKey: "bb_secret" }, + }); + }); + + it("rejects invalid or unauthenticated Browserbase selections", () => { + expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_BROWSER: "remote" })).toThrow( + 'STAGEHAND_BROWSER must be either "local" or "browserbase".', + ); + expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_BROWSER: "browserbase" })).toThrow( + 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', + ); + }); + + it.each([ + ["openai/gpt-5.4-mini", "OPENAI_API_KEY", "openai-key"], + ["anthropic/claude-sonnet-4-6", "ANTHROPIC_API_KEY", "anthropic-key"], + ["google/gemini-3-flash-preview", "GEMINI_API_KEY", "google-key"], + ["groq/llama-3.3-70b-versatile", "GROQ_API_KEY", "groq-key"], + ["cerebras/llama3.1-8b", "CEREBRAS_API_KEY", "cerebras-key"], + ])("pairs an explicit %s model with its provider key", (modelName, envName, apiKey) => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: modelName, + [envName]: apiKey, + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName, + apiKey, + ...(modelName.startsWith("anthropic/") + ? { + headers: { + "anthropic-dangerous-direct-browser-access": "true", + }, + } + : {}), + }); + }); + + it("prefers the explicit model key over provider environment keys", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "openai/gpt-5.4-mini", + STAGEHAND_MODEL_API_KEY: "explicit-key", + OPENAI_API_KEY: "provider-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "openai/gpt-5.4-mini", + apiKey: "explicit-key", + }); + }); + + it("uses the eval-native Google key precedence", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "google/gemini-3-flash-preview", + GEMINI_API_KEY: "gemini-key", + GOOGLE_GENERATIVE_AI_API_KEY: "generative-key", + GOOGLE_API_KEY: "google-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "google/gemini-3-flash-preview", + apiKey: "gemini-key", + }); + }); + + it("infers the default Google model only when no model is explicit", () => { + const config = stagehandCodeConfigFromEnv({ + GOOGLE_GENERATIVE_AI_API_KEY: "generative-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "google/gemini-2.5-flash-lite", + apiKey: "generative-key", + }); + }); + + it("does not apply Google credentials to an explicit non-Google model", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "anthropic/claude-sonnet-4-6", + GEMINI_API_KEY: "google-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "anthropic/claude-sonnet-4-6", + headers: { + "anthropic-dangerous-direct-browser-access": "true", + }, + }); + }); + + it("rejects an explicit model key without a model name", () => { + expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_MODEL_API_KEY: "orphan-key" })).toThrow( + "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", + ); + }); +}); diff --git a/packages/integrations/tests/executor.test.ts b/packages/integrations/tests/executor.test.ts new file mode 100644 index 0000000000..35ddd0f7ec --- /dev/null +++ b/packages/integrations/tests/executor.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StagehandCodeConfig } from "../src/codemode/types.js"; + +const sdkMocks = vi.hoisted(() => ({ + browserbaseLaunch: vi.fn(), + localLaunch: vi.fn(), + stagehandCreate: vi.fn(), +})); + +vi.mock("@browserbasehq/stagehand", () => ({ + browserbase: { launch: sdkMocks.browserbaseLaunch }, + localBrowser: { launch: sdkMocks.localLaunch }, + Stagehand: { create: sdkMocks.stagehandCreate }, +})); + +const { StagehandCodeExecutor } = await import("../src/codemode/executor.js"); + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function localConfig(stagehand: Record = {}): StagehandCodeConfig { + return { + browser: { type: "local", launchOptions: { headless: true } }, + stagehand: stagehand as never, + }; +} + +function fakeRuntime() { + const page = { + url: vi.fn(async () => "https://example.com"), + title: vi.fn(async () => "Example"), + hold: vi.fn(async () => undefined), + sideEffect: vi.fn(() => "side-effect"), + }; + const context = { + activePage: vi.fn(async () => page), + pages: vi.fn(async () => [page]), + newPage: vi.fn(async () => page), + }; + const stagehand = { + browser: { context }, + close: vi.fn(async () => undefined), + metrics: vi.fn(async () => ({ act: { prompt_tokens: 1 } })), + }; + const browser = { close: vi.fn(async () => undefined) }; + return { page, context, stagehand, browser }; +} + +describe("StagehandCodeExecutor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("validates code before launching a browser", async () => { + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: " " })).resolves.toMatchObject({ + ok: false, + error: { kind: "validation" }, + }); + await expect(executor.execute({ code: "é".repeat(50_001) })).resolves.toMatchObject({ + ok: false, + error: { kind: "validation", message: expect.stringContaining("100000 UTF-8 bytes") }, + }); + expect(sdkMocks.localLaunch).not.toHaveBeenCalled(); + }); + + it("lazily launches once, reuses state, and closes both owners once", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: true, + value: 1, + page: { url: "https://example.com", title: "Example" }, + }); + await expect(executor.execute({ code: "return 2;" })).resolves.toMatchObject({ + ok: true, + value: 2, + }); + + expect(sdkMocks.localLaunch).toHaveBeenCalledOnce(); + expect(sdkMocks.localLaunch).toHaveBeenCalledWith({ headless: true }); + expect(sdkMocks.stagehandCreate).toHaveBeenCalledOnce(); + await executor.close(); + await executor.close(); + expect(runtime.stagehand.close).toHaveBeenCalledOnce(); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + }); + + it("forwards Browserbase launch options", async () => { + const runtime = fakeRuntime(); + sdkMocks.browserbaseLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor({ + browser: { + type: "browserbase", + launchOptions: { apiKey: "bb_secret", projectId: "project-id" }, + }, + }); + + await executor.execute({ code: "return 1;" }); + + expect(sdkMocks.browserbaseLaunch).toHaveBeenCalledWith({ + apiKey: "bb_secret", + projectId: "project-id", + }); + expect(sdkMocks.localLaunch).not.toHaveBeenCalled(); + }); + + it("serializes concurrent calls in FIFO order", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const first = executor.execute({ code: 'await page.hold(); return "first";' }); + const second = executor.execute({ code: 'return "second";' }); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + expect(await Promise.race([second.then(() => "settled"), Promise.resolve("queued")])).toBe( + "queued", + ); + + gate.resolve(); + + await expect(first).resolves.toMatchObject({ ok: true, value: "first" }); + await expect(second).resolves.toMatchObject({ ok: true, value: "second" }); + }); + + it("cancels queued work before its snippet begins", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + const controller = new AbortController(); + + const first = executor.execute({ code: "await page.hold();" }); + const second = executor.execute({ code: "return page.sideEffect();" }, controller.signal); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + controller.abort(); + gate.resolve(); + + await first; + await expect(second).resolves.toMatchObject({ ok: false, error: { kind: "aborted" } }); + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + }); + + it("rechecks cancellation after lazy browser initialization", async () => { + const runtime = fakeRuntime(); + const launch = deferred(); + sdkMocks.localLaunch.mockReturnValue(launch.promise); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + const controller = new AbortController(); + + const result = executor.execute({ code: "return page.sideEffect();" }, controller.signal); + await vi.waitFor(() => expect(sdkMocks.localLaunch).toHaveBeenCalledOnce()); + controller.abort(); + launch.resolve(runtime.browser); + + await expect(result).resolves.toMatchObject({ + ok: false, + page: { url: "https://example.com", title: "Example" }, + error: { kind: "aborted" }, + }); + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + }); + + it("marks queued work closed and drains before cleanup", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const active = executor.execute({ code: "await page.hold();" }); + const queued = executor.execute({ code: "return page.sideEffect();" }); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + const close = executor.close(); + gate.resolve(); + + await active; + await expect(queued).resolves.toMatchObject({ ok: false, error: { kind: "closed" } }); + await close; + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + expect(runtime.stagehand.close).toHaveBeenCalledOnce(); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { kind: "closed" }, + }); + }); + + it("queues metrics behind execution", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const active = executor.execute({ code: "await page.hold();" }); + const metrics = executor.metrics(); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + expect(runtime.stagehand.metrics).not.toHaveBeenCalled(); + gate.resolve(); + + await active; + await expect(metrics).resolves.toStrictEqual({ act: { prompt_tokens: 1 } }); + }); + + it("closes a launched browser when Stagehand initialization fails", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockRejectedValue(new Error("initialization failed")); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { kind: "runtime", message: "initialization failed" }, + }); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + }); + + it("reports a generic aggregate when initialization and cleanup both fail", async () => { + const runtime = fakeRuntime(); + runtime.browser.close.mockRejectedValue(new Error("browser close secret")); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockRejectedValue(new Error("init secret")); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { + kind: "runtime", + name: "AggregateError", + message: "Stagehand code mode initialization and browser cleanup both failed.", + }, + }); + }); + + it("normalizes JSON values and bounds returned output", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect( + executor.execute({ code: "return { big: 12n, bytes: new Uint8Array([1, 2, 3]) };" }), + ).resolves.toMatchObject({ + ok: true, + value: { + big: "12", + bytes: { type: "bytes", encoding: "base64", data: "AQID" }, + }, + }); + const large = await executor.execute({ code: 'return "x".repeat(300000);' }); + expect(large).toMatchObject({ + ok: true, + value: { truncated: true, original_bytes: 300_002 }, + }); + if (large.ok && typeof large.value === "object" && large.value) { + expect( + Buffer.byteLength(String((large.value as { preview: string }).preview)), + ).toBeLessThanOrEqual(256 * 1024); + } + }); + + it("captures circular console values without changing snippet success", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect( + executor.execute({ + code: 'const circular = {}; circular.self = circular; console.log(circular); return "ok";', + }), + ).resolves.toMatchObject({ + ok: true, + value: "ok", + logs: [{ level: "log", text: "[Unserializable value]" }], + }); + }); + + it("redacts nested and shared secrets while preserving short ordinary text", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const shared = { value: "shared-secret-value" }; + const circular: Record = { value: "circular-secret-value" }; + circular.self = circular; + const executor = new StagehandCodeExecutor( + localConfig({ + publicCopy: shared, + tokens: { shared, circular }, + cookies: { nested: { value: "nested-secret-value" } }, + apiKey: "short", + }), + ); + + const result = await executor.execute({ + code: ` + throw new Error( + "shared-secret-value nested-secret-value circular-secret-value short ordinary " + + "token=short Bearer bearer-value https://example.com/private" + ); + `, + }); + + expect(result).toMatchObject({ ok: false, error: { kind: "runtime" } }); + if (result.ok === false) { + expect(result.error.message).toContain("[REDACTED]"); + expect(result.error.message).toContain("short ordinary"); + expect(result.error.message).toContain("token=[REDACTED]"); + expect(result.error.message).toContain("Bearer [REDACTED]"); + expect(result.error.message).toContain("[REDACTED_URL]"); + expect(result.error.message).not.toContain("shared-secret-value"); + expect(result.error.message).not.toContain("nested-secret-value"); + expect(result.error.message).not.toContain("circular-secret-value"); + expect(result.error.message).not.toContain("bearer-value"); + } + }); + + it("normalizes non-Error throws and invalid error names", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: 'throw "raw secret";' })).resolves.toMatchObject({ + ok: false, + error: { name: "Error", message: "Code execution failed with a non-Error value." }, + }); + await expect( + executor.execute({ + code: 'const error = new Error("failed"); error.name = "bad name"; throw error;', + }), + ).resolves.toMatchObject({ + ok: false, + error: { name: "Error", message: "failed" }, + }); + }); +}); diff --git a/packages/integrations/tests/mcp-server.test.ts b/packages/integrations/tests/mcp-server.test.ts new file mode 100644 index 0000000000..3ec4fcb6ad --- /dev/null +++ b/packages/integrations/tests/mcp-server.test.ts @@ -0,0 +1,101 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StagehandCodeExecutor } from "../src/codemode/executor.js"; +import { createCodeModeMcpServer } from "../src/codemode/mcp-server.js"; +import type { CodeExecuteResult } from "../src/codemode/types.js"; + +describe("code-mode MCP server", () => { + let client: Client; + let server: ReturnType; + let execute: ReturnType; + + beforeEach(async () => { + execute = vi.fn(); + server = createCodeModeMcpServer({ execute } as unknown as StagehandCodeExecutor); + client = new Client({ name: "stagehand-codemode-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + it("advertises exactly one tool with complete input and output schemas", async () => { + const tools = await client.listTools(); + + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0]).toMatchObject({ + name: "code_execute", + inputSchema: { + type: "object", + required: ["code"], + properties: { code: { type: "string" } }, + }, + outputSchema: { + type: "object", + required: ["ok"], + properties: { + ok: {}, + page: {}, + value: {}, + logs: {}, + error: {}, + }, + }, + }); + }); + + it("rejects invalid input before invoking the executor", async () => { + const response = await client.callTool({ + name: "code_execute", + arguments: { code: " " }, + }); + + expect(response).toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: expect.stringContaining("code must contain JavaScript source"), + }, + ], + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + { + result: { + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: { answer: 42 }, + } satisfies CodeExecuteResult, + isError: false, + }, + { + result: { + ok: false, + error: { kind: "runtime", name: "Error", message: "failed" }, + } satisfies CodeExecuteResult, + isError: true, + }, + ])("returns structured and text results for ok=$result.ok", async ({ result, isError }) => { + execute.mockResolvedValueOnce(result); + + const response = await client.callTool({ + name: "code_execute", + arguments: { code: "return 42;" }, + }); + + expect(response.structuredContent).toStrictEqual(result); + expect(response.isError).toBe(isError); + expect(response.content).toStrictEqual([ + { type: "text", text: JSON.stringify(result, null, 2) }, + ]); + expect(execute).toHaveBeenCalledWith({ code: "return 42;" }, expect.any(AbortSignal)); + }); +}); diff --git a/packages/integrations/tests/snippet.test.ts b/packages/integrations/tests/snippet.test.ts new file mode 100644 index 0000000000..462f81ade9 --- /dev/null +++ b/packages/integrations/tests/snippet.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeStagehandSnippet } from "../src/codemode/snippet.js"; + +const page = { marker: "page" }; +const context = { marker: "context" }; +const stagehand = { marker: "stagehand" }; + +describe("executeStagehandSnippet", () => { + it("injects browser, Stagehand, Zod, custom bindings, and console", async () => { + const codeConsole = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await executeStagehandSnippet({ + code: ` + console.log(label); + return { + page: page.marker, + context: context.marker, + stagehand: stagehand.marker, + parsed: z.object({ value: z.number() }).parse({ value: count }).value, + }; + `, + page: page as never, + context: context as never, + stagehand: stagehand as never, + bindings: { label: "ready", count: 3 }, + console: codeConsole, + }); + + expect(result).toStrictEqual({ + page: "page", + context: "context", + stagehand: "stagehand", + parsed: 3, + }); + expect(codeConsole.log).toHaveBeenCalledWith("ready"); + }); + + it("omits Stagehand and Zod in deterministic mode", async () => { + await expect( + executeStagehandSnippet({ + code: "return { stagehand: typeof stagehand, z: typeof z };", + page: page as never, + context: context as never, + }), + ).resolves.toStrictEqual({ stagehand: "undefined", z: "undefined" }); + }); + + it("awaits asynchronous code and propagates runtime errors", async () => { + await expect( + executeStagehandSnippet({ + code: "return await Promise.resolve(42);", + page: page as never, + context: context as never, + }), + ).resolves.toBe(42); + + await expect( + executeStagehandSnippet({ + code: 'throw new Error("snippet failed");', + page: page as never, + context: context as never, + }), + ).rejects.toThrow("snippet failed"); + }); + + it.each(["bad-name", "await", "class"])("rejects invalid binding name %s", async (name) => { + await expect( + executeStagehandSnippet({ + code: "return 1;", + page: page as never, + context: context as never, + bindings: { [name]: true }, + }), + ).rejects.toThrow(`Code-mode binding "${name}" is not a valid JavaScript identifier.`); + }); + + it.each(["page", "context", "stagehand", "z", "console"])( + "rejects reserved binding name %s", + async (name) => { + await expect( + executeStagehandSnippet({ + code: "return 1;", + page: page as never, + context: context as never, + bindings: { [name]: true }, + }), + ).rejects.toThrow(`Code-mode binding "${name}" is reserved.`); + }, + ); + + it("does not persist local variables between calls", async () => { + await executeStagehandSnippet({ + code: "const localOnly = 1; return localOnly;", + page: page as never, + context: context as never, + }); + + await expect( + executeStagehandSnippet({ + code: "return localOnly;", + page: page as never, + context: context as never, + }), + ).rejects.toThrow("localOnly is not defined"); + }); +}); diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts index a5cb7107bd..0d87d63a08 100644 --- a/packages/integrations/tests/stdio-server.test.ts +++ b/packages/integrations/tests/stdio-server.test.ts @@ -5,11 +5,14 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { describe, expect, it } from "vitest"; const entrypoint = fileURLToPath(new URL("../dist/codemode/stdio-server.mjs", import.meta.url)); -const baseEnv = { PATH: process.env.PATH ?? "" }; +const baseEnv = { + PATH: process.env.PATH ?? "", + STAGEHAND_BROWSER: "local", +}; -function startServer(): ChildProcessWithoutNullStreams { +function startServer(env: NodeJS.ProcessEnv = baseEnv): ChildProcessWithoutNullStreams { return spawn(process.execPath, [entrypoint], { - env: baseEnv, + env, stdio: ["pipe", "pipe", "pipe"], }); } @@ -18,12 +21,12 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise((resolve, reject) => { const timeout = setTimeout( - () => reject(new Error(`stdio host did not start: ${stderr}`)), + () => reject(new Error(`stdio server did not start: ${stderr}`)), 10_000, ); const onData = (chunk: Buffer) => { stderr += chunk.toString(); - if (!stderr.includes("Stagehand code-mode MCP host listening on stdio")) return; + if (!stderr.includes("Stagehand code-mode MCP listening on stdio")) return; clearTimeout(timeout); child.stderr.off("data", onData); resolve(stderr); @@ -32,7 +35,7 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { clearTimeout(timeout); reject( - new Error(`stdio host exited before ready (code=${code}, signal=${signal}): ${stderr}`), + new Error(`stdio server exited before ready (code=${code}, signal=${signal}): ${stderr}`), ); }); }); @@ -44,7 +47,7 @@ function waitForExit( return new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); - reject(new Error("stdio host did not exit within 10 seconds")); + reject(new Error("stdio server did not exit within 10 seconds")); }, 10_000); child.once("error", reject); child.once("exit", (code, signal) => { @@ -54,9 +57,13 @@ function waitForExit( }); } -describe("built code-mode stdio host", () => { - it("starts and exits successfully on stdin EOF", async () => { - const child = startServer(); +describe("built code-mode stdio server", () => { + it("starts in explicit local mode and exits successfully on stdin EOF", async () => { + const child = startServer({ + ...baseEnv, + BROWSERBASE_API_KEY: "unused-browserbase-key", + BROWSERBASE_PROJECT_ID: "unused-project-id", + }); try { await waitForReady(child); const exit = waitForExit(child); @@ -88,7 +95,20 @@ describe("built code-mode stdio host", () => { 30_000, ); - it("initializes without advertising tools through the compiled child", async () => { + it("fails startup for an invalid browser mode", async () => { + const child = startServer({ ...baseEnv, STAGEHAND_BROWSER: "remote" }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const exit = await waitForExit(child); + + expect(exit.code).not.toBe(0); + expect(stderr).toContain('STAGEHAND_BROWSER must be either "local" or "browserbase".'); + }); + + it("supports MCP initialization, discovery, and validation through the compiled child", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint], @@ -103,8 +123,20 @@ describe("built code-mode stdio host", () => { try { await client.connect(transport); - expect(client.getServerCapabilities()).not.toHaveProperty("tools"); - expect(stderr).toContain("Stagehand code-mode MCP host listening on stdio"); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toStrictEqual(["code_execute"]); + await expect( + client.callTool({ name: "code_execute", arguments: { code: " " } }), + ).resolves.toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: expect.stringContaining("code must contain JavaScript source"), + }, + ], + }); + expect(stderr).toContain("Stagehand code-mode MCP listening on stdio"); } finally { await client.close(); } diff --git a/packages/integrations/tests/tool-contract.test.ts b/packages/integrations/tests/tool-contract.test.ts new file mode 100644 index 0000000000..b512d6ee9c --- /dev/null +++ b/packages/integrations/tests/tool-contract.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + codeExecuteOutputSchema, + codeExecuteResultText, + codeExecuteSchema, +} from "../src/codemode/tool-contract.js"; + +describe("code-mode tool contract", () => { + it("accepts nonblank code up to 100,000 UTF-8 bytes", () => { + expect(codeExecuteSchema.parse({ code: "return 1;" })).toStrictEqual({ code: "return 1;" }); + expect(codeExecuteSchema.safeParse({ code: " \n\t " }).success).toBe(false); + expect(codeExecuteSchema.safeParse({ code: "é".repeat(50_000) }).success).toBe(true); + expect(codeExecuteSchema.safeParse({ code: `${"é".repeat(50_000)}a` }).success).toBe(false); + }); + + it("validates complete success and failure results", () => { + expect( + codeExecuteOutputSchema.parse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: { answer: 42 }, + logs: [{ level: "log", text: "ready" }], + }), + ).toMatchObject({ ok: true, value: { answer: 42 } }); + + expect( + codeExecuteOutputSchema.parse({ + ok: false, + error: { kind: "runtime", name: "Error", message: "failed" }, + }), + ).toMatchObject({ ok: false, error: { kind: "runtime" } }); + }); + + it("rejects invalid success/failure combinations", () => { + expect(codeExecuteOutputSchema.safeParse({ ok: true }).success).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + error: { kind: "runtime", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + expect(codeExecuteOutputSchema.safeParse({ ok: false }).success).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: false, + value: 42, + error: { kind: "runtime", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + }); + + it("rejects unknown error kinds and log levels", () => { + expect( + codeExecuteOutputSchema.safeParse({ + ok: false, + error: { kind: "timeout", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + logs: [{ level: "debug", text: "nope" }], + }).success, + ).toBe(false); + }); + + it("renders the result as stable pretty JSON", () => { + expect( + codeExecuteResultText({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: 42, + }), + ).toBe( + '{\n "ok": true,\n "page": {\n "url": "https://example.com",\n "title": "Example"\n },\n "value": 42\n}', + ); + }); +}); diff --git a/packages/integrations/tsdown.config.ts b/packages/integrations/tsdown.config.ts index 587cfd7b80..c203574ed1 100644 --- a/packages/integrations/tsdown.config.ts +++ b/packages/integrations/tsdown.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { + "codemode/index": "src/codemode/index.ts", "codemode/stdio-server": "src/codemode/stdio-server.ts", }, format: ["esm"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c7625bd3..b54bc747f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,9 +560,15 @@ importers: packages/integrations: dependencies: + '@browserbasehq/stagehand': + specifier: workspace:* + version: link:../sdk-ts '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(zod@4.4.3) + zod: + specifier: 'catalog:' + version: 4.4.3 devDependencies: '@types/node': specifier: 'catalog:' From d3967694c47524af956201bfbf0fae042ed5841f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 01:49:55 -0700 Subject: [PATCH 3/8] feat: add Stagehand code-mode guidance --- packages/integrations/README.md | 4 + packages/integrations/codemode/REFERENCE.md | 211 ++++++++++++++++++ packages/integrations/codemode/SKILL.md | 168 ++++++++++++++ packages/integrations/package.json | 14 +- .../scripts/generate-codemode-content.mjs | 37 +++ .../src/codemode/generated-content.ts | 5 + packages/integrations/src/codemode/index.ts | 1 + .../src/codemode/tool-contract.ts | 3 + .../tests/generated-content.test.ts | 106 +++++++++ 9 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/codemode/REFERENCE.md create mode 100644 packages/integrations/codemode/SKILL.md create mode 100644 packages/integrations/scripts/generate-codemode-content.mjs create mode 100644 packages/integrations/src/codemode/generated-content.ts create mode 100644 packages/integrations/tests/generated-content.test.ts diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 6aeb8b8618..3a5df2478a 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -62,6 +62,10 @@ Without a browser override, the helper selects Browserbase when `BROWSERBASE_API Native callers run generated JavaScript in their own process. An `AbortSignal` can cancel queued work before the snippet begins, but it cannot safely preempt arbitrary JavaScript already running in the same process. Native integrations that require hard time limits should put the executor behind a child-process boundary, as the stdio MCP integration does. +### Skill and reference + +`codemode/SKILL.md` is the concise agent guide. `codemode/REFERENCE.md` is the longer API lookup. Both are exported as raw package assets and as bundle-safe JavaScript strings. + ### 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. diff --git a/packages/integrations/codemode/REFERENCE.md b/packages/integrations/codemode/REFERENCE.md new file mode 100644 index 0000000000..2d7a83d85e --- /dev/null +++ b/packages/integrations/codemode/REFERENCE.md @@ -0,0 +1,211 @@ +# Stagehand V4 code-mode reference + +This reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the +short operational guide intended for every agent context; this file is the longer lookup reference. + +## Function body contract + +The tool builds an async function from the submitted body. These values are injected: + +```ts +page: Page; +context: BrowserContext; +stagehand: Stagehand; +z: typeof import("zod/v4"); +console: Pick; +``` + +Eval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm +intentionally omits `stagehand` and `z`. + +## Page + +Navigation and page state: + +```ts +page.goto(url, { waitUntil?, timeout? }): Promise +page.reload(options?): Promise +page.goBack(options?): Promise +page.goForward(options?): Promise +page.url(): Promise +page.title(): Promise +page.pageId: string +page.close(): Promise +``` + +Input and pointer operations: + +```ts +page.click(x, y, options?): Promise +page.hover(x, y): Promise +page.scroll(x, y, deltaX, deltaY): Promise +page.dragAndDrop(fromX, fromY, toX, toY, options?): Promise +page.type(text, options?): Promise +page.keyPress(key, options?): Promise +``` + +DOM and setup operations: + +```ts +page.evaluate(expressionOrFunction, arg?): Promise +page.addInitScript(script, arg?): Promise +page.setExtraHTTPHeaders(headers): Promise +page.setViewportSize(width, height, options?): Promise +page.waitForLoadState('load' | 'domcontentloaded' | 'networkidle', timeout?): Promise +page.waitForTimeout(milliseconds): Promise +page.waitForSelector(selector, options?): Promise +page.screenshot(options?): Promise +page.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }> +page.locator(selector): Locator +``` + +`page.evaluate` accepts a function and one serializable argument. Prefer one object when several +values are needed: + +```js +return await page.evaluate( + ({ selector, limit }) => + Array.from(document.querySelectorAll(selector)) + .slice(0, limit) + .map((node) => node.textContent?.trim() ?? ""), + { selector: "article h2", limit: 20 }, +); +``` + +## Locator + +```ts +locator.click(options?): Promise +locator.hover(): Promise +locator.fill(value): Promise +locator.count(): Promise +locator.isChecked(): Promise +locator.inputValue(): Promise +locator.isVisible(): Promise +locator.innerText(): Promise +locator.innerHtml(): Promise +locator.textContent(): Promise +locator.scrollTo(percent): Promise +locator.centroid(): Promise<{ x: number; y: number }> +locator.highlight(options?): Promise +locator.sendClickEvent(options?): Promise +locator.type(text, options?): Promise +locator.selectOption(values): Promise +locator.setInputFiles(files): Promise +locator.first(): Locator +locator.nth(index): Locator +``` + +Stagehand locators do not implement Playwright's collection/filter/frame helpers. Use `count` with +`nth`, or use `page.evaluate` for bulk DOM reads. + +## BrowserContext + +```ts +context.pages(): Promise +context.newPage(options?): Promise +context.activePage(): Promise +context.setActivePage(page): Promise +context.setExtraHTTPHeaders(headers): Promise +context.getDomainPolicy(): Promise +context.setDomainPolicy(policy): Promise +context.cookies(urls?): Promise +context.addCookies(cookies): Promise +context.clearCookies(options?): Promise +``` + +The tool owner closes the context and browser. Generated code should not call `context.close()`, +`stagehand.close()`, or `stagehand.browser.close()`. + +## Stagehand AI methods + +```ts +stagehand.act(instruction, options?): Promise<{ + data: { success; message; actionDescription; actions }; + metadata: StagehandResultMetadata; +}> + +stagehand.observe(instruction?, options?): Promise<{ + data: Action[]; + metadata: StagehandResultMetadata; +}> + +stagehand.extract(instruction, schema?, options?): Promise<{ + data: unknown; + metadata: StagehandResultMetadata; +}> +``` + +The useful operation result is under `.data`. The `.metadata` object contains cache and model-usage +information for that operation. + +To target a non-active page: + +```js +const pages = await context.pages(); +const result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), { + page: pages[1], +}); +return result.data; +``` + +## Multiple pages + +Page arrays are snapshots. Await `context.pages()` again after an interaction that may open or +close a tab. Use `context.setActivePage(page)` when later operations should target that tab. + +```js +const before = await context.pages(); +const beforePageIds = new Set(before.map((page) => page.pageId)); +await before[0].locator('a[target="_blank"]').first().click(); +await before[0].waitForTimeout(500); +const after = await context.pages(); +const opened = after.find((candidate) => !beforePageIds.has(candidate.pageId)); +if (!opened) throw new Error("Expected a new page"); +await context.setActivePage(opened); +return await opened.url(); +``` + +## Pagination pattern + +```js +const records = new Map(); +const seenPages = new Set(); + +for (let pageIndex = 0; pageIndex < 50; pageIndex++) { + const rows = await page.evaluate(() => + Array.from(document.querySelectorAll("table tbody tr")).map((row) => + Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""), + ), + ); + const signature = JSON.stringify(rows); + if (seenPages.has(signature)) break; + seenPages.add(signature); + for (const row of rows) records.set(JSON.stringify(row), row); + + const next = page.locator(".paginate_button.next").first(); + if (!(await next.isVisible())) break; + await next.click(); + await page.waitForTimeout(300); +} + +return Array.from(records.values()); +``` + +## Common incompatibilities + +| Incorrect assumption | Stagehand V4 form | +| ------------------------------ | ----------------------------------------------------------- | +| `page.url` is a string | `await page.url()` | +| `page.content()` | `await page.locator('body').innerHtml()` or `page.evaluate` | +| `locator.innerHTML()` | `await locator.innerHtml()` | +| `locator.all()` | `count()` plus `nth(index)` | +| `locator.evaluate()` | `page.evaluate()` | +| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` | +| `context.waitForEvent('page')` | Compare `await context.pages()` before and after | +| `result.success` from `act` | `result.data.success` | +| Raw array from `observe` | `result.data` | +| Raw object from `extract` | `result.data` | + +When this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand` +TypeScript declaration files are authoritative. diff --git a/packages/integrations/codemode/SKILL.md b/packages/integrations/codemode/SKILL.md new file mode 100644 index 0000000000..9449a8373d --- /dev/null +++ b/packages/integrations/codemode/SKILL.md @@ -0,0 +1,168 @@ +# Stagehand V4 code-mode syntax + +You have one code execution tool. Its `code` argument is the body of an async JavaScript function, +not a complete program. Write direct `await` statements and finish with a JSON-serializable return +value. Use the tool name supplied by the host framework. + +The following objects are always in scope: + +- `page`: the active Stagehand `Page`. +- `context`: the Stagehand `BrowserContext` shared across calls. In host code, this is + `stagehand.browser.context`, not `stagehand.context`. +- `console`: captured `log`, `warn`, and `error` methods. + +AI-enabled surfaces also inject: + +- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`. +- `z`: Zod V4 for `stagehand.extract` schemas. + +If the host labels the surface deterministic, `stagehand` and `z` are intentionally unavailable. + +Do not import packages, read environment variables, construct Stagehand, or close Stagehand or the +browser. The tool owner manages initialization and cleanup. + +## Hard rules + +1. Use deterministic page and locator methods first when the page structure is known or inspectable. +2. Treat the method lists below as allow-lists. Stagehand V4 is not Playwright; do not guess methods. +3. Await `page.url()`, `page.title()`, and every `context` method. +4. Stop when the requested evidence is complete. Do not re-fetch exact evidence with an AI method. +5. During pagination, stop on the first repeated row signature and return deduplicated records. + +## Deterministic browser syntax + +```js +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +const heading = await page.locator("h1").innerText(); +return { heading, url: await page.url(), title: await page.title() }; +``` + +Supported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`, +`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`, +`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`, +`tools`, `url`, `title`, and `locator`. + +Supported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`, +`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`, +`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`. + +Supported context methods include `pages`, `newPage`, `activePage`, `setActivePage`, +`setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, `addCookies`, and +`clearCookies`. + +To submit a filled field with Enter, call `page.keyPress` with the key first. It does not take a +selector, and V4 does not expose `page.keyboard`: + +```js +await page.locator('input[name="q"]').fill("vegetarian lasagna"); +await page.keyPress("Enter"); +``` + +Do not use Playwright-only methods such as locator `all`, `allTextContents`, `evaluate`, +`evaluateAll`, `filter`, `getAttribute`, `contentFrame`, or `innerHTML`; page `content`, +`frameLocator`, `frames`, or `keyboard`; or context `waitForEvent`. Use `innerHtml` with a lowercase +`l`. + +For DOM collection reads, attributes, or custom traversal, use `page.evaluate`: + +```js +const links = await page.evaluate(() => + Array.from(document.querySelectorAll("a")).map((a) => ({ + text: a.textContent?.trim() ?? "", + href: a.getAttribute("href"), + })), +); +return links; +``` + +## Stagehand AI syntax + +Stagehand V4 AI methods return `{ data, metadata }`. Read the useful result from `.data`. + +```js +const result = await stagehand.act("Click the sign-in button"); +if (!result.data.success) throw new Error(result.data.message); +return result.data; +``` + +```js +const result = await stagehand.observe("Find the checkout button"); +return result.data; +``` + +```js +const result = await stagehand.extract( + "Extract the product name and price", + z.object({ name: z.string(), price: z.string() }), +); +return result.data; +``` + +Keep every `z.object` property required. Strict structured-output providers reject extraction +schemas containing `.optional()`. When the page may omit a value, keep the key required and make +its value nullable: + +```js +z.object({ title: z.string(), rating: z.string().nullable() }); +``` + +Pass `{ page: anotherPage }` as the final options object when an AI method should target a page +other than the active page. + +## Pages and state across calls + +```js +const before = await context.pages(); +const beforePageIds = new Set(before.map((page) => page.pageId)); +const current = before[before.length - 1]; +await current.locator("button").first().click(); +await current.waitForTimeout(500); +const after = await context.pages(); +const opened = after.find((candidate) => !beforePageIds.has(candidate.pageId)); +if (!opened) throw new Error("Expected a new page"); +await context.setActivePage(opened); +return { pageCount: after.length, activeUrl: await opened.url() }; +``` + +There is no `context.waitForEvent`. `context.pages()` returns fresh Page wrappers, so detect a newly +opened tab by comparing the stable `page.pageId` values before and after the click. A click does not +necessarily make the new tab active; call `context.setActivePage(opened)` explicitly. + +The same browser, pages, cookies, and navigation state persist across successful tool calls. Local +JavaScript variables do not persist, so rediscover pages and elements each call. If a call stops +responding, the owning framework should terminate and restart the tool process; the restarted +process begins with a new browser. + +## Cross-origin iframes + +Do not use `page.frameLocator()` or locator `contentFrame()`. Call `page.snapshot({ includeIframes: +true })`, locate the relevant accessibility reference in `formattedTree`, look up its frame-piercing +XPath in `xpathMap`, and use the XPath with the normal locator methods: + +```js +const snapshot = await page.snapshot({ includeIframes: true }); +const ref = "0-2"; // Discover the task-specific ref from formattedTree. +const xpath = snapshot.xpathMap[ref]; +if (!xpath) throw new Error(`No XPath for accessibility ref ${ref}`); +const field = page.locator(`xpath=${xpath}`); +await field.fill("value"); +return { value: await field.inputValue() }; +``` + +## Efficient recovery + +- Batch related deterministic operations when later steps depend on earlier state. +- If navigation or `setActivePage` times out, inspect current pages and URLs before repeating it. +- A failed `act`, `observe`, or `extract` does not destroy the browser. Continue with deterministic + methods when possible. +- Pagination controls may be visually enabled without changing the rendered rows. Deduplicate rows + and stop on the first repeated page signature. + +## Return discipline + +Return only compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, and +plain objects. Do not return Page, Locator, BrowserContext, Stagehand, or Zod objects. Await +asynchronous methods before returning. + +For the newest exact declarations, inspect the installed `@browserbasehq/stagehand` TypeScript +declarations and this package's `codemode/REFERENCE.md` when filesystem access is available. diff --git a/packages/integrations/package.json b/packages/integrations/package.json index a29ede83e4..0e19a3fd3c 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -4,7 +4,9 @@ "private": true, "description": "Shared integration surfaces for Stagehand V4", "files": [ - "dist" + "dist", + "codemode/SKILL.md", + "codemode/REFERENCE.md" ], "type": "module", "exports": { @@ -14,13 +16,17 @@ }, "./codemode/stdio-server": { "import": "./dist/codemode/stdio-server.mjs" - } + }, + "./codemode/SKILL.md": "./codemode/SKILL.md", + "./codemode/REFERENCE.md": "./codemode/REFERENCE.md" }, "scripts": { - "build": "tsdown", + "build": "pnpm run check:generated && tsdown", + "check:generated": "node scripts/generate-codemode-content.mjs --check", + "generate": "node scripts/generate-codemode-content.mjs", "test": "pnpm run build && vitest run --root ../.. packages/integrations/tests", "test:unit": "vitest run --root ../.. packages/integrations/tests", - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "pnpm run check:generated && tsc --noEmit -p tsconfig.json" }, "dependencies": { "@browserbasehq/stagehand": "workspace:*", diff --git a/packages/integrations/scripts/generate-codemode-content.mjs b/packages/integrations/scripts/generate-codemode-content.mjs new file mode 100644 index 0000000000..ed0048969d --- /dev/null +++ b/packages/integrations/scripts/generate-codemode-content.mjs @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const outputUrl = new URL("../src/codemode/generated-content.ts", import.meta.url); +const skill = readFileSync(new URL("../codemode/SKILL.md", import.meta.url), "utf8").trim(); +const reference = readFileSync(new URL("../codemode/REFERENCE.md", import.meta.url), "utf8").trim(); +const output = [ + "// Generated by scripts/generate-codemode-content.mjs. Do not edit directly.", + "export const STAGEHAND_CODEMODE_SKILL =", + ` ${singleQuotedString(skill)};`, + "export const STAGEHAND_CODEMODE_REFERENCE =", + ` ${singleQuotedString(reference)};`, + "", +].join("\n"); + +if (process.argv.includes("--check")) { + const generated = readFileSync(outputUrl, "utf8"); + if (generated !== output) { + throw new Error(`Generated code-mode content is stale. Run "pnpm generate" in ${packageRoot}.`); + } + process.stdout.write("Generated code-mode content is current\n"); +} else { + writeFileSync(outputUrl, output); + process.stdout.write(`Generated code-mode content in ${packageRoot}\n`); +} + +function singleQuotedString(value) { + return `'${value + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n") + .replaceAll("\t", "\\t") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029")}'`; +} diff --git a/packages/integrations/src/codemode/generated-content.ts b/packages/integrations/src/codemode/generated-content.ts new file mode 100644 index 0000000000..a92422ff6f --- /dev/null +++ b/packages/integrations/src/codemode/generated-content.ts @@ -0,0 +1,5 @@ +// Generated by scripts/generate-codemode-content.mjs. Do not edit directly. +export const STAGEHAND_CODEMODE_SKILL = + '# Stagehand V4 code-mode syntax\n\nYou have one code execution tool. Its `code` argument is the body of an async JavaScript function,\nnot a complete program. Write direct `await` statements and finish with a JSON-serializable return\nvalue. Use the tool name supplied by the host framework.\n\nThe following objects are always in scope:\n\n- `page`: the active Stagehand `Page`.\n- `context`: the Stagehand `BrowserContext` shared across calls. In host code, this is\n `stagehand.browser.context`, not `stagehand.context`.\n- `console`: captured `log`, `warn`, and `error` methods.\n\nAI-enabled surfaces also inject:\n\n- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`.\n- `z`: Zod V4 for `stagehand.extract` schemas.\n\nIf the host labels the surface deterministic, `stagehand` and `z` are intentionally unavailable.\n\nDo not import packages, read environment variables, construct Stagehand, or close Stagehand or the\nbrowser. The tool owner manages initialization and cleanup.\n\n## Hard rules\n\n1. Use deterministic page and locator methods first when the page structure is known or inspectable.\n2. Treat the method lists below as allow-lists. Stagehand V4 is not Playwright; do not guess methods.\n3. Await `page.url()`, `page.title()`, and every `context` method.\n4. Stop when the requested evidence is complete. Do not re-fetch exact evidence with an AI method.\n5. During pagination, stop on the first repeated row signature and return deduplicated records.\n\n## Deterministic browser syntax\n\n```js\nawait page.goto("https://example.com", { waitUntil: "domcontentloaded" });\nconst heading = await page.locator("h1").innerText();\nreturn { heading, url: await page.url(), title: await page.title() };\n```\n\nSupported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`,\n`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`,\n`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`,\n`tools`, `url`, `title`, and `locator`.\n\nSupported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`,\n`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`,\n`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`.\n\nSupported context methods include `pages`, `newPage`, `activePage`, `setActivePage`,\n`setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, `addCookies`, and\n`clearCookies`.\n\nTo submit a filled field with Enter, call `page.keyPress` with the key first. It does not take a\nselector, and V4 does not expose `page.keyboard`:\n\n```js\nawait page.locator(\'input[name="q"]\').fill("vegetarian lasagna");\nawait page.keyPress("Enter");\n```\n\nDo not use Playwright-only methods such as locator `all`, `allTextContents`, `evaluate`,\n`evaluateAll`, `filter`, `getAttribute`, `contentFrame`, or `innerHTML`; page `content`,\n`frameLocator`, `frames`, or `keyboard`; or context `waitForEvent`. Use `innerHtml` with a lowercase\n`l`.\n\nFor DOM collection reads, attributes, or custom traversal, use `page.evaluate`:\n\n```js\nconst links = await page.evaluate(() =>\n Array.from(document.querySelectorAll("a")).map((a) => ({\n text: a.textContent?.trim() ?? "",\n href: a.getAttribute("href"),\n })),\n);\nreturn links;\n```\n\n## Stagehand AI syntax\n\nStagehand V4 AI methods return `{ data, metadata }`. Read the useful result from `.data`.\n\n```js\nconst result = await stagehand.act("Click the sign-in button");\nif (!result.data.success) throw new Error(result.data.message);\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.observe("Find the checkout button");\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.extract(\n "Extract the product name and price",\n z.object({ name: z.string(), price: z.string() }),\n);\nreturn result.data;\n```\n\nKeep every `z.object` property required. Strict structured-output providers reject extraction\nschemas containing `.optional()`. When the page may omit a value, keep the key required and make\nits value nullable:\n\n```js\nz.object({ title: z.string(), rating: z.string().nullable() });\n```\n\nPass `{ page: anotherPage }` as the final options object when an AI method should target a page\nother than the active page.\n\n## Pages and state across calls\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nconst current = before[before.length - 1];\nawait current.locator("button").first().click();\nawait current.waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn { pageCount: after.length, activeUrl: await opened.url() };\n```\n\nThere is no `context.waitForEvent`. `context.pages()` returns fresh Page wrappers, so detect a newly\nopened tab by comparing the stable `page.pageId` values before and after the click. A click does not\nnecessarily make the new tab active; call `context.setActivePage(opened)` explicitly.\n\nThe same browser, pages, cookies, and navigation state persist across successful tool calls. Local\nJavaScript variables do not persist, so rediscover pages and elements each call. If a call stops\nresponding, the owning framework should terminate and restart the tool process; the restarted\nprocess begins with a new browser.\n\n## Cross-origin iframes\n\nDo not use `page.frameLocator()` or locator `contentFrame()`. Call `page.snapshot({ includeIframes:\ntrue })`, locate the relevant accessibility reference in `formattedTree`, look up its frame-piercing\nXPath in `xpathMap`, and use the XPath with the normal locator methods:\n\n```js\nconst snapshot = await page.snapshot({ includeIframes: true });\nconst ref = "0-2"; // Discover the task-specific ref from formattedTree.\nconst xpath = snapshot.xpathMap[ref];\nif (!xpath) throw new Error(`No XPath for accessibility ref ${ref}`);\nconst field = page.locator(`xpath=${xpath}`);\nawait field.fill("value");\nreturn { value: await field.inputValue() };\n```\n\n## Efficient recovery\n\n- Batch related deterministic operations when later steps depend on earlier state.\n- If navigation or `setActivePage` times out, inspect current pages and URLs before repeating it.\n- A failed `act`, `observe`, or `extract` does not destroy the browser. Continue with deterministic\n methods when possible.\n- Pagination controls may be visually enabled without changing the rendered rows. Deduplicate rows\n and stop on the first repeated page signature.\n\n## Return discipline\n\nReturn only compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, and\nplain objects. Do not return Page, Locator, BrowserContext, Stagehand, or Zod objects. Await\nasynchronous methods before returning.\n\nFor the newest exact declarations, inspect the installed `@browserbasehq/stagehand` TypeScript\ndeclarations and this package\'s `codemode/REFERENCE.md` when filesystem access is available.'; +export const STAGEHAND_CODEMODE_REFERENCE = + '# Stagehand V4 code-mode reference\n\nThis reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the\nshort operational guide intended for every agent context; this file is the longer lookup reference.\n\n## Function body contract\n\nThe tool builds an async function from the submitted body. These values are injected:\n\n```ts\npage: Page;\ncontext: BrowserContext;\nstagehand: Stagehand;\nz: typeof import("zod/v4");\nconsole: Pick;\n```\n\nEval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm\nintentionally omits `stagehand` and `z`.\n\n## Page\n\nNavigation and page state:\n\n```ts\npage.goto(url, { waitUntil?, timeout? }): Promise\npage.reload(options?): Promise\npage.goBack(options?): Promise\npage.goForward(options?): Promise\npage.url(): Promise\npage.title(): Promise\npage.pageId: string\npage.close(): Promise\n```\n\nInput and pointer operations:\n\n```ts\npage.click(x, y, options?): Promise\npage.hover(x, y): Promise\npage.scroll(x, y, deltaX, deltaY): Promise\npage.dragAndDrop(fromX, fromY, toX, toY, options?): Promise\npage.type(text, options?): Promise\npage.keyPress(key, options?): Promise\n```\n\nDOM and setup operations:\n\n```ts\npage.evaluate(expressionOrFunction, arg?): Promise\npage.addInitScript(script, arg?): Promise\npage.setExtraHTTPHeaders(headers): Promise\npage.setViewportSize(width, height, options?): Promise\npage.waitForLoadState(\'load\' | \'domcontentloaded\' | \'networkidle\', timeout?): Promise\npage.waitForTimeout(milliseconds): Promise\npage.waitForSelector(selector, options?): Promise\npage.screenshot(options?): Promise\npage.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }>\npage.locator(selector): Locator\n```\n\n`page.evaluate` accepts a function and one serializable argument. Prefer one object when several\nvalues are needed:\n\n```js\nreturn await page.evaluate(\n ({ selector, limit }) =>\n Array.from(document.querySelectorAll(selector))\n .slice(0, limit)\n .map((node) => node.textContent?.trim() ?? ""),\n { selector: "article h2", limit: 20 },\n);\n```\n\n## Locator\n\n```ts\nlocator.click(options?): Promise\nlocator.hover(): Promise\nlocator.fill(value): Promise\nlocator.count(): Promise\nlocator.isChecked(): Promise\nlocator.inputValue(): Promise\nlocator.isVisible(): Promise\nlocator.innerText(): Promise\nlocator.innerHtml(): Promise\nlocator.textContent(): Promise\nlocator.scrollTo(percent): Promise\nlocator.centroid(): Promise<{ x: number; y: number }>\nlocator.highlight(options?): Promise\nlocator.sendClickEvent(options?): Promise\nlocator.type(text, options?): Promise\nlocator.selectOption(values): Promise\nlocator.setInputFiles(files): Promise\nlocator.first(): Locator\nlocator.nth(index): Locator\n```\n\nStagehand locators do not implement Playwright\'s collection/filter/frame helpers. Use `count` with\n`nth`, or use `page.evaluate` for bulk DOM reads.\n\n## BrowserContext\n\n```ts\ncontext.pages(): Promise\ncontext.newPage(options?): Promise\ncontext.activePage(): Promise\ncontext.setActivePage(page): Promise\ncontext.setExtraHTTPHeaders(headers): Promise\ncontext.getDomainPolicy(): Promise\ncontext.setDomainPolicy(policy): Promise\ncontext.cookies(urls?): Promise\ncontext.addCookies(cookies): Promise\ncontext.clearCookies(options?): Promise\n```\n\nThe tool owner closes the context and browser. Generated code should not call `context.close()`,\n`stagehand.close()`, or `stagehand.browser.close()`.\n\n## Stagehand AI methods\n\n```ts\nstagehand.act(instruction, options?): Promise<{\n data: { success; message; actionDescription; actions };\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.observe(instruction?, options?): Promise<{\n data: Action[];\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.extract(instruction, schema?, options?): Promise<{\n data: unknown;\n metadata: StagehandResultMetadata;\n}>\n```\n\nThe useful operation result is under `.data`. The `.metadata` object contains cache and model-usage\ninformation for that operation.\n\nTo target a non-active page:\n\n```js\nconst pages = await context.pages();\nconst result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), {\n page: pages[1],\n});\nreturn result.data;\n```\n\n## Multiple pages\n\nPage arrays are snapshots. Await `context.pages()` again after an interaction that may open or\nclose a tab. Use `context.setActivePage(page)` when later operations should target that tab.\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nawait before[0].locator(\'a[target="_blank"]\').first().click();\nawait before[0].waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn await opened.url();\n```\n\n## Pagination pattern\n\n```js\nconst records = new Map();\nconst seenPages = new Set();\n\nfor (let pageIndex = 0; pageIndex < 50; pageIndex++) {\n const rows = await page.evaluate(() =>\n Array.from(document.querySelectorAll("table tbody tr")).map((row) =>\n Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""),\n ),\n );\n const signature = JSON.stringify(rows);\n if (seenPages.has(signature)) break;\n seenPages.add(signature);\n for (const row of rows) records.set(JSON.stringify(row), row);\n\n const next = page.locator(".paginate_button.next").first();\n if (!(await next.isVisible())) break;\n await next.click();\n await page.waitForTimeout(300);\n}\n\nreturn Array.from(records.values());\n```\n\n## Common incompatibilities\n\n| Incorrect assumption | Stagehand V4 form |\n| ------------------------------ | ----------------------------------------------------------- |\n| `page.url` is a string | `await page.url()` |\n| `page.content()` | `await page.locator(\'body\').innerHtml()` or `page.evaluate` |\n| `locator.innerHTML()` | `await locator.innerHtml()` |\n| `locator.all()` | `count()` plus `nth(index)` |\n| `locator.evaluate()` | `page.evaluate()` |\n| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` |\n| `context.waitForEvent(\'page\')` | Compare `await context.pages()` before and after |\n| `result.success` from `act` | `result.data.success` |\n| Raw array from `observe` | `result.data` |\n| Raw object from `extract` | `result.data` |\n\nWhen this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand`\nTypeScript declaration files are authoritative.'; diff --git a/packages/integrations/src/codemode/index.ts b/packages/integrations/src/codemode/index.ts index bcda1ddde2..7a4b236b18 100644 --- a/packages/integrations/src/codemode/index.ts +++ b/packages/integrations/src/codemode/index.ts @@ -2,6 +2,7 @@ export { stagehandCodeConfigFromEnv } from "./config.js"; export { StagehandCodeExecutor, type StagehandCodeExecutorOptions } from "./executor.js"; export { connectCodeModeStdio, createCodeModeMcp, createCodeModeMcpServer } from "./mcp-server.js"; export { executeStagehandSnippet } from "./snippet.js"; +export { STAGEHAND_CODEMODE_REFERENCE, STAGEHAND_CODEMODE_SKILL } from "./generated-content.js"; export { CODE_EXECUTE_DESCRIPTION, codeExecuteResultText, diff --git a/packages/integrations/src/codemode/tool-contract.ts b/packages/integrations/src/codemode/tool-contract.ts index a53f8fef55..02d069ca64 100644 --- a/packages/integrations/src/codemode/tool-contract.ts +++ b/packages/integrations/src/codemode/tool-contract.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { STAGEHAND_CODEMODE_SKILL } from "./generated-content.js"; import type { CodeExecuteResult } from "./types.js"; export const CODE_EXECUTE_DESCRIPTION = [ @@ -6,6 +7,8 @@ export const CODE_EXECUTE_DESCRIPTION = [ "The executor lazily creates a local or Browserbase browser on the first call and reuses it for later calls.", "The executor itself is not a security sandbox. The owning framework may run it inside a sandbox or another isolation boundary.", "If execution stops responding, the owning framework should terminate and restart the local tool process.", + "", + STAGEHAND_CODEMODE_SKILL, ].join("\n"); export const codeExecuteSchema = z.object({ diff --git a/packages/integrations/tests/generated-content.test.ts b/packages/integrations/tests/generated-content.test.ts new file mode 100644 index 0000000000..8850a9b55e --- /dev/null +++ b/packages/integrations/tests/generated-content.test.ts @@ -0,0 +1,106 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + STAGEHAND_CODEMODE_REFERENCE, + STAGEHAND_CODEMODE_SKILL, +} from "../src/codemode/generated-content.js"; +import { CODE_EXECUTE_DESCRIPTION } from "../src/codemode/tool-contract.js"; + +const execFileAsync = promisify(execFile); +const packageRoot = new URL("..", import.meta.url); +const temporaryRoots: string[] = []; + +describe("generated code-mode content", () => { + afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ); + }); + + it("matches the committed Markdown assets", async () => { + const [skill, reference] = await Promise.all([ + readFile(new URL("codemode/SKILL.md", packageRoot), "utf8"), + readFile(new URL("codemode/REFERENCE.md", packageRoot), "utf8"), + ]); + + expect(STAGEHAND_CODEMODE_SKILL).toBe(skill.trim()); + expect(STAGEHAND_CODEMODE_REFERENCE).toBe(reference.trim()); + }); + + it("resolves the raw Markdown assets through package exports", async () => { + const [skill, reference] = await Promise.all([ + readFile( + new URL(import.meta.resolve("@browserbasehq/stagehand-integrations/codemode/SKILL.md")), + "utf8", + ), + readFile( + new URL(import.meta.resolve("@browserbasehq/stagehand-integrations/codemode/REFERENCE.md")), + "utf8", + ), + ]); + + expect(skill.trim()).toBe(STAGEHAND_CODEMODE_SKILL); + expect(reference.trim()).toBe(STAGEHAND_CODEMODE_REFERENCE); + }); + + it("embeds the complete generated skill in the tool description", () => { + expect(CODE_EXECUTE_DESCRIPTION).toContain("Execute an async JavaScript function body"); + expect(CODE_EXECUTE_DESCRIPTION.endsWith(STAGEHAND_CODEMODE_SKILL)).toBe(true); + }); + + it("passes the committed stale-content check", async () => { + await expect( + execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: packageRoot, + }), + ).resolves.toMatchObject({ stdout: "Generated code-mode content is current\n" }); + }); + + it("escapes special characters and rejects stale output", async () => { + const fixtureRoot = await mkdtemp(path.join(tmpdir(), "stagehand-codemode-generator-")); + temporaryRoots.push(fixtureRoot); + await Promise.all([ + mkdir(path.join(fixtureRoot, "scripts"), { recursive: true }), + mkdir(path.join(fixtureRoot, "codemode"), { recursive: true }), + mkdir(path.join(fixtureRoot, "src", "codemode"), { recursive: true }), + ]); + await copyFile( + new URL("scripts/generate-codemode-content.mjs", packageRoot), + path.join(fixtureRoot, "scripts", "generate-codemode-content.mjs"), + ); + await writeFile( + path.join(fixtureRoot, "codemode", "SKILL.md"), + "slash\\ quote' carriage\rreturn\nline\ttab\u2028separator\u2029paragraph", + ); + await writeFile(path.join(fixtureRoot, "codemode", "REFERENCE.md"), "reference"); + + await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs"], { + cwd: fixtureRoot, + }); + const generated = await readFile( + path.join(fixtureRoot, "src", "codemode", "generated-content.ts"), + "utf8", + ); + expect(generated).toContain("slash\\\\"); + expect(generated).toContain("quote\\'"); + expect(generated).toContain("carriage\\rreturn\\nline\\ttab"); + expect(generated).toContain("\\u2028separator\\u2029paragraph"); + + await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: fixtureRoot, + }); + await writeFile(path.join(fixtureRoot, "codemode", "SKILL.md"), "changed"); + + await expect( + execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: fixtureRoot, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("Generated code-mode content is stale"), + }); + }); +}); From 1fc90402e74bf1739ed8ff13696785a42565dce3 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 13:39:52 -0700 Subject: [PATCH 4/8] fix: harden code-mode host lifecycle and CI --- .github/workflows/ci.yml | 25 ++++++++++++++++ .../src/codemode/stdio-lifecycle.ts | 6 ++-- .../integrations/src/codemode/stdio-server.ts | 3 +- .../tests/stdio-lifecycle.test.ts | 10 +++++++ .../integrations/tests/stdio-server.test.ts | 30 +++++++++++++++---- turbo.json | 12 +++++++- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69cb95adb..002f15aac7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: runs-on: ubuntu-latest outputs: extension: ${{ steps.filter.outputs.extension }} + integrations: ${{ steps.filter.outputs.integrations }} + is_internal_head: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} sdk-ts: ${{ steps.filter.outputs.sdk-ts }} sdk-python: ${{ steps.filter.outputs.sdk-python }} sdk-go: ${{ steps.filter.outputs.sdk-go }} @@ -37,7 +39,14 @@ jobs: - '.github/**' sdk-ts: - 'packages/sdk-ts/**' + - 'packages/extension/**' + - 'packages/protocol/**' + - 'pnpm-lock.yaml' + - 'turbo.json' + - '.github/**' + integrations: - 'packages/integrations/**' + - 'packages/sdk-ts/**' - 'packages/extension/**' - 'packages/protocol/**' - 'pnpm-lock.yaml' @@ -193,6 +202,21 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + integrations: + name: TypeScript integrations + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [build, determine-changes] + if: needs.determine-changes.outputs.integrations == 'true' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: ./.github/actions/setup-node-pnpm + with: + use-prebuilt-artifacts: "true" + + - run: pnpm exec turbo run test:unit --filter @browserbasehq/stagehand-integrations + browser-ts: name: TypeScript browser runs-on: ubuntu-latest @@ -213,6 +237,7 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - name: Browserbase smoke test + if: needs.determine-changes.outputs.is_internal_head == 'true' run: >- pnpm exec vitest run --root . packages/sdk-ts/tests/browser-runtime/stagehand-browserbase-smoke.test.ts diff --git a/packages/integrations/src/codemode/stdio-lifecycle.ts b/packages/integrations/src/codemode/stdio-lifecycle.ts index c0fdbad2f9..b12736a647 100644 --- a/packages/integrations/src/codemode/stdio-lifecycle.ts +++ b/packages/integrations/src/codemode/stdio-lifecycle.ts @@ -9,9 +9,9 @@ export async function closeCodeModeStdio( timeoutMs = STDIO_SHUTDOWN_GRACE_MS, ): Promise { let timeout: NodeJS.Timeout | undefined; - const cleanup = Promise.allSettled(resources.map((resource) => resource.close())).then( - (results) => results.every((result) => result.status === "fulfilled"), - ); + const cleanup = Promise.allSettled( + resources.map((resource) => Promise.resolve().then(() => resource.close())), + ).then((results) => results.every((result) => result.status === "fulfilled")); const deadline = new Promise((resolve) => { timeout = setTimeout(() => resolve(false), timeoutMs); timeout.unref(); diff --git a/packages/integrations/src/codemode/stdio-server.ts b/packages/integrations/src/codemode/stdio-server.ts index d1fe1ae78f..4d93cb4050 100644 --- a/packages/integrations/src/codemode/stdio-server.ts +++ b/packages/integrations/src/codemode/stdio-server.ts @@ -2,7 +2,6 @@ import { connectCodeModeStdio, createCodeModeMcpHost } from "./mcp-runtime.js"; import { closeCodeModeStdio } from "./stdio-lifecycle.js"; const server = createCodeModeMcpHost(); -await connectCodeModeStdio(server); let closing = false; async function shutdown(code: number): Promise { @@ -19,4 +18,6 @@ process.once("SIGINT", () => void shutdown(130)); process.once("SIGTERM", () => void shutdown(143)); process.stdin.once("end", () => void shutdown(0)); process.stdin.once("close", () => void shutdown(0)); + +await connectCodeModeStdio(server); process.stderr.write("Stagehand code-mode MCP host listening on stdio\n"); diff --git a/packages/integrations/tests/stdio-lifecycle.test.ts b/packages/integrations/tests/stdio-lifecycle.test.ts index af484b433b..5eac6dfe5e 100644 --- a/packages/integrations/tests/stdio-lifecycle.test.ts +++ b/packages/integrations/tests/stdio-lifecycle.test.ts @@ -20,6 +20,16 @@ describe("closeCodeModeStdio", () => { await expect(closeCodeModeStdio([healthy, failing], 50)).resolves.toBe(false); }); + it("contains synchronous cleanup failures", async () => { + const failing = { + close: vi.fn(() => { + throw new Error("secret detail"); + }), + }; + + await expect(closeCodeModeStdio([failing], 50)).resolves.toBe(false); + }); + it("bounds cleanup when a resource never settles", async () => { vi.useFakeTimers(); const stuck = { close: vi.fn(() => new Promise(() => undefined)) }; diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts index a5cb7107bd..19ccfbafe2 100644 --- a/packages/integrations/tests/stdio-server.test.ts +++ b/packages/integrations/tests/stdio-server.test.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { Stream } from "node:stream"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -6,6 +7,7 @@ import { describe, expect, it } from "vitest"; const entrypoint = fileURLToPath(new URL("../dist/codemode/stdio-server.mjs", import.meta.url)); const baseEnv = { PATH: process.env.PATH ?? "" }; +const readyMessage = "Stagehand code-mode MCP host listening on stdio"; function startServer(): ChildProcessWithoutNullStreams { return spawn(process.execPath, [entrypoint], { @@ -23,7 +25,7 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { stderr += chunk.toString(); - if (!stderr.includes("Stagehand code-mode MCP host listening on stdio")) return; + if (!stderr.includes(readyMessage)) return; clearTimeout(timeout); child.stderr.off("data", onData); resolve(stderr); @@ -38,6 +40,24 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + let output = ""; + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`stdio host did not emit ${JSON.stringify(expected)}: ${output}`)), + 10_000, + ); + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (!output.includes(expected)) return; + clearTimeout(timeout); + stream.off("data", onData); + resolve(output); + }; + stream.on("data", onData); + }); +} + function waitForExit( child: ChildProcessWithoutNullStreams, ): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { @@ -95,16 +115,14 @@ describe("built code-mode stdio host", () => { env: baseEnv, stderr: "pipe", }); - let stderr = ""; - transport.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); + if (!transport.stderr) throw new Error("stdio transport did not expose stderr"); + const ready = waitForOutput(transport.stderr, readyMessage); const client = new Client({ name: "stagehand-codemode-stdio-test", version: "1.0.0" }); try { await client.connect(transport); + await ready; expect(client.getServerCapabilities()).not.toHaveProperty("tools"); - expect(stderr).toContain("Stagehand code-mode MCP host listening on stdio"); } finally { await client.close(); } diff --git a/turbo.json b/turbo.json index 4eb9656bb0..2c5e0b5510 100644 --- a/turbo.json +++ b/turbo.json @@ -70,7 +70,17 @@ "dependsOn": ["^build"] }, "@browserbasehq/stagehand-integrations#test:unit": { - "dependsOn": ["^build", "@browserbasehq/stagehand-integrations#build"] + "dependsOn": ["^build", "@browserbasehq/stagehand-integrations#build"], + "inputs": [ + "$TURBO_DEFAULT$", + "tests/**", + "src/**", + "**/*.test.ts", + "$TURBO_ROOT$/vitest.config.ts", + "!$TURBO_ROOT$/packages/*/dist/**", + "!$TURBO_ROOT$/packages/*/.turbo/**", + "!$TURBO_ROOT$/packages/*/node_modules/**" + ] }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { From 0aff7ff5c0d191080b560f2b93680c43d3c97c28 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 13:44:42 -0700 Subject: [PATCH 5/8] fix: harden code execution boundaries --- packages/integrations/src/codemode/config.ts | 18 ++++-- .../integrations/src/codemode/executor.ts | 58 ++++++++++++++----- packages/integrations/src/codemode/index.ts | 15 ++++- packages/integrations/src/codemode/limits.ts | 1 + .../src/codemode/tool-contract.ts | 5 +- packages/integrations/tests/config.test.ts | 37 ++++++++---- packages/integrations/tests/executor.test.ts | 50 +++++++++++++++- .../integrations/tests/stdio-server.test.ts | 4 +- 8 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 packages/integrations/src/codemode/limits.ts diff --git a/packages/integrations/src/codemode/config.ts b/packages/integrations/src/codemode/config.ts index adc0c10d00..6301845a71 100644 --- a/packages/integrations/src/codemode/config.ts +++ b/packages/integrations/src/codemode/config.ts @@ -3,6 +3,10 @@ import type { StagehandCodeConfig } from "./types.js"; const ANTHROPIC_DIRECT_BROWSER_ACCESS_HEADER = "anthropic-dangerous-direct-browser-access"; +class StagehandCodeConfigError extends Error { + override readonly name = "StagehandCodeConfigError"; +} + export function stagehandCodeConfigFromEnv( env: NodeJS.ProcessEnv = process.env, ): StagehandCodeConfig { @@ -12,20 +16,26 @@ export function stagehandCodeConfigFromEnv( requestedBrowser !== "local" && requestedBrowser !== "browserbase" ) { - throw new Error('STAGEHAND_BROWSER must be either "local" or "browserbase".'); + throw new StagehandCodeConfigError( + 'STAGEHAND_BROWSER must be either "local" or "browserbase".', + ); } const browserbaseApiKey = nonEmpty(env.BROWSERBASE_API_KEY); const browserbaseProjectId = nonEmpty(env.BROWSERBASE_PROJECT_ID); const browserType = requestedBrowser ?? (browserbaseApiKey ? "browserbase" : "local"); if (browserType === "browserbase" && !browserbaseApiKey) { - throw new Error('BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".'); + throw new StagehandCodeConfigError( + 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', + ); } const explicitModelName = nonEmpty(env.STAGEHAND_MODEL_NAME); const explicitModelApiKey = nonEmpty(env.STAGEHAND_MODEL_API_KEY); if (!explicitModelName && explicitModelApiKey) { - throw new Error("STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set."); + throw new StagehandCodeConfigError( + "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", + ); } const inferredGoogleKey = providerApiKey("google", env); @@ -80,8 +90,8 @@ function providerApiKey(provider: string | undefined, env: NodeJS.ProcessEnv): s return nonEmpty(env.ANTHROPIC_API_KEY); case "google": return ( - nonEmpty(env.GEMINI_API_KEY) ?? nonEmpty(env.GOOGLE_GENERATIVE_AI_API_KEY) ?? + nonEmpty(env.GEMINI_API_KEY) ?? nonEmpty(env.GOOGLE_API_KEY) ); case "groq": diff --git a/packages/integrations/src/codemode/executor.ts b/packages/integrations/src/codemode/executor.ts index 687ed53e54..719d38222b 100644 --- a/packages/integrations/src/codemode/executor.ts +++ b/packages/integrations/src/codemode/executor.ts @@ -6,6 +6,7 @@ import { type StagehandBrowser, type StagehandMetrics, } from "@browserbasehq/stagehand"; +import { MAX_CODE_BYTES } from "./limits.js"; import { executeStagehandSnippet } from "./snippet.js"; import type { CodeExecuteFailure, @@ -18,7 +19,6 @@ import type { export type StagehandCodeExecutorOptions = StagehandCodeConfig; -const MAX_CODE_BYTES = 100_000; const MAX_LOG_BYTES = 64 * 1024; const MAX_RESULT_BYTES = 256 * 1024; const MAX_ERROR_MESSAGE_LENGTH = 4_000; @@ -29,6 +29,22 @@ const CREDENTIAL = /\b(authorization|api[_-]?key|password|secret|token)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/gi; const BEARER_TOKEN = /\bbearer\s+[^\s,;]+/gi; +class StagehandCodeCloseError extends Error { + override readonly name = "StagehandCodeCloseError"; + + constructor() { + super("Failed to close Stagehand code mode."); + } +} + +class StagehandCodeInitializationError extends Error { + override readonly name = "StagehandCodeInitializationError"; + + constructor() { + super("Stagehand code mode initialization and browser cleanup both failed."); + } +} + export class StagehandCodeExecutor { private stagehand?: Stagehand; private browser?: StagehandBrowser; @@ -70,16 +86,18 @@ export class StagehandCodeExecutor { this.stagehand = undefined; this.browser = undefined; - const errors: unknown[] = []; + let failed = false; if (stagehand) { - await stagehand.close().catch((error) => errors.push(error)); + await stagehand.close().catch(() => { + failed = true; + }); } if (browser) { - await browser.close().catch((error) => errors.push(error)); - } - if (errors.length > 0) { - throw new AggregateError(errors, "Failed to close Stagehand code mode."); + await browser.close().catch(() => { + failed = true; + }); } + if (failed) throw new StagehandCodeCloseError(); }); return this.closePromise; } @@ -155,11 +173,8 @@ export class StagehandCodeExecutor { } catch (error) { try { await browser.close(); - } catch (closeError) { - throw new AggregateError( - [error, closeError], - "Stagehand code mode initialization and browser cleanup both failed.", - ); + } catch { + throw new StagehandCodeInitializationError(); } throw error; } @@ -190,7 +205,7 @@ function createCodeConsole(logs: CodeLogEntry[]) { if (logBytes >= MAX_LOG_BYTES) return; const text = formatLog(values); const remaining = MAX_LOG_BYTES - logBytes; - const bounded = Buffer.from(text).subarray(0, remaining).toString(); + const bounded = truncateUtf8(text, remaining); logBytes += Buffer.byteLength(bounded); logs.push({ level, text: bounded }); }; @@ -225,10 +240,25 @@ function jsonSafe(value: unknown): unknown { return { truncated: true, original_bytes: bytes, - preview: Buffer.from(serialized).subarray(0, MAX_RESULT_BYTES).toString(), + preview: truncateUtf8(serialized, MAX_RESULT_BYTES), }; } +function truncateUtf8(value: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + if (Buffer.byteLength(value) <= maxBytes) return value; + + const characters: string[] = []; + let bytes = 0; + for (const character of value) { + const characterBytes = Buffer.byteLength(character); + if (bytes + characterBytes > maxBytes) break; + characters.push(character); + bytes += characterBytes; + } + return characters.join(""); +} + function formatLog(values: unknown[]): string { return values .map((value) => { diff --git a/packages/integrations/src/codemode/index.ts b/packages/integrations/src/codemode/index.ts index bcda1ddde2..9f0611f390 100644 --- a/packages/integrations/src/codemode/index.ts +++ b/packages/integrations/src/codemode/index.ts @@ -7,4 +7,17 @@ export { codeExecuteResultText, codeExecuteSchema, } from "./tool-contract.js"; -export * from "./types.js"; +export type { + CodeExecuteErrorKind, + CodeExecuteFailure, + CodeExecuteInput, + CodeExecuteResult, + CodeExecuteSuccess, + CodeLogEntry, + CodePageState, + ExecuteStagehandSnippetInput, + StagehandCodeBrowserConfig, + StagehandCodeConfig, + StagehandSnippetBindings, + StagehandSnippetConsole, +} from "./types.js"; diff --git a/packages/integrations/src/codemode/limits.ts b/packages/integrations/src/codemode/limits.ts new file mode 100644 index 0000000000..624fd5e789 --- /dev/null +++ b/packages/integrations/src/codemode/limits.ts @@ -0,0 +1 @@ +export const MAX_CODE_BYTES = 100_000; diff --git a/packages/integrations/src/codemode/tool-contract.ts b/packages/integrations/src/codemode/tool-contract.ts index a53f8fef55..d6024e22bd 100644 --- a/packages/integrations/src/codemode/tool-contract.ts +++ b/packages/integrations/src/codemode/tool-contract.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { MAX_CODE_BYTES } from "./limits.js"; import type { CodeExecuteResult } from "./types.js"; export const CODE_EXECUTE_DESCRIPTION = [ @@ -13,8 +14,8 @@ export const codeExecuteSchema = z.object({ .string() .refine((code) => code.trim().length > 0, "code must contain JavaScript source") .refine( - (code) => new TextEncoder().encode(code).byteLength <= 100_000, - "code must be at most 100000 UTF-8 bytes", + (code) => new TextEncoder().encode(code).byteLength <= MAX_CODE_BYTES, + `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes`, ) .describe( "Async JavaScript function body. page, context, stagehand, z, and console are in scope.", diff --git a/packages/integrations/tests/config.test.ts b/packages/integrations/tests/config.test.ts index ae64607cbc..dcc982bfc6 100644 --- a/packages/integrations/tests/config.test.ts +++ b/packages/integrations/tests/config.test.ts @@ -45,12 +45,23 @@ describe("stagehandCodeConfigFromEnv", () => { }); it("rejects invalid or unauthenticated Browserbase selections", () => { - expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_BROWSER: "remote" })).toThrow( - 'STAGEHAND_BROWSER must be either "local" or "browserbase".', - ); - expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_BROWSER: "browserbase" })).toThrow( - 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', - ); + for (const [env, message] of [ + [ + { STAGEHAND_BROWSER: "remote" }, + 'STAGEHAND_BROWSER must be either "local" or "browserbase".', + ], + [ + { STAGEHAND_BROWSER: "browserbase" }, + 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', + ], + ] as const) { + try { + stagehandCodeConfigFromEnv(env); + throw new Error("expected configuration to be rejected"); + } catch (error) { + expect(error).toMatchObject({ name: "StagehandCodeConfigError", message }); + } + } }); it.each([ @@ -101,7 +112,7 @@ describe("stagehandCodeConfigFromEnv", () => { expect(config.stagehand?.model).toStrictEqual({ modelName: "google/gemini-3-flash-preview", - apiKey: "gemini-key", + apiKey: "generative-key", }); }); @@ -131,8 +142,14 @@ describe("stagehandCodeConfigFromEnv", () => { }); it("rejects an explicit model key without a model name", () => { - expect(() => stagehandCodeConfigFromEnv({ STAGEHAND_MODEL_API_KEY: "orphan-key" })).toThrow( - "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", - ); + try { + stagehandCodeConfigFromEnv({ STAGEHAND_MODEL_API_KEY: "orphan-key" }); + throw new Error("expected configuration to be rejected"); + } catch (error) { + expect(error).toMatchObject({ + name: "StagehandCodeConfigError", + message: "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", + }); + } }); }); diff --git a/packages/integrations/tests/executor.test.ts b/packages/integrations/tests/executor.test.ts index 35ddd0f7ec..ad33e086f0 100644 --- a/packages/integrations/tests/executor.test.ts +++ b/packages/integrations/tests/executor.test.ts @@ -103,6 +103,29 @@ describe("StagehandCodeExecutor", () => { expect(runtime.browser.close).toHaveBeenCalledOnce(); }); + it("reports lifecycle cleanup failures without retaining underlying errors", async () => { + const runtime = fakeRuntime(); + runtime.stagehand.close.mockRejectedValue(new Error("stagehand close secret")); + runtime.browser.close.mockRejectedValue(new Error("browser close secret")); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await executor.execute({ code: "return 1;" }); + const error = await executor.close().then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toMatchObject({ + name: "StagehandCodeCloseError", + message: "Failed to close Stagehand code mode.", + }); + expect(error).not.toBeInstanceOf(AggregateError); + expect(error).not.toHaveProperty("errors"); + expect(String(error)).not.toContain("secret"); + }); + it("forwards Browserbase launch options", async () => { const runtime = fakeRuntime(); sdkMocks.browserbaseLaunch.mockResolvedValue(runtime.browser); @@ -253,7 +276,7 @@ describe("StagehandCodeExecutor", () => { ok: false, error: { kind: "runtime", - name: "AggregateError", + name: "StagehandCodeInitializationError", message: "Stagehand code mode initialization and browser cleanup both failed.", }, }); @@ -284,6 +307,31 @@ describe("StagehandCodeExecutor", () => { Buffer.byteLength(String((large.value as { preview: string }).preview)), ).toBeLessThanOrEqual(256 * 1024); } + + const multibyte = await executor.execute({ code: 'return "é".repeat(200000);' }); + expect(multibyte).toMatchObject({ ok: true, value: { truncated: true } }); + if (multibyte.ok && typeof multibyte.value === "object" && multibyte.value) { + const preview = String((multibyte.value as { preview: string }).preview); + expect(Buffer.byteLength(preview)).toBeLessThanOrEqual(256 * 1024); + expect(preview).not.toContain("�"); + } + }); + + it("truncates captured logs on UTF-8 character boundaries", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const result = await executor.execute({ + code: 'console.log("a" + "é".repeat(40000)); return "ok";', + }); + + expect(result).toMatchObject({ ok: true, value: "ok" }); + if (result.ok && result.logs) { + expect(Buffer.byteLength(result.logs[0].text)).toBeLessThanOrEqual(64 * 1024); + expect(result.logs[0].text).not.toContain("�"); + } }); it("captures circular console values without changing snippet success", async () => { diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts index 6947cbfcb3..bb7584d031 100644 --- a/packages/integrations/tests/stdio-server.test.ts +++ b/packages/integrations/tests/stdio-server.test.ts @@ -34,7 +34,7 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + child.once("close", (code, signal) => { clearTimeout(timeout); reject( new Error(`stdio server exited before ready (code=${code}, signal=${signal}): ${stderr}`), @@ -70,7 +70,7 @@ function waitForExit( reject(new Error("stdio server did not exit within 10 seconds")); }, 10_000); child.once("error", reject); - child.once("exit", (code, signal) => { + child.once("close", (code, signal) => { clearTimeout(timeout); resolve({ code, signal }); }); From 90151a56c0fdfadeed2c2345b858b108898d306c Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 13:47:35 -0700 Subject: [PATCH 6/8] fix: verify generated code-mode guidance --- packages/integrations/codemode/REFERENCE.md | 2 +- .../src/codemode/generated-content.ts | 2 +- .../tests/generated-content.test.ts | 25 ++++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/integrations/codemode/REFERENCE.md b/packages/integrations/codemode/REFERENCE.md index 2d7a83d85e..ed16d398d0 100644 --- a/packages/integrations/codemode/REFERENCE.md +++ b/packages/integrations/codemode/REFERENCE.md @@ -108,7 +108,7 @@ context.activePage(): Promise context.setActivePage(page): Promise context.setExtraHTTPHeaders(headers): Promise context.getDomainPolicy(): Promise -context.setDomainPolicy(policy): Promise +context.setDomainPolicy(policy: DomainPolicy | null): Promise context.cookies(urls?): Promise context.addCookies(cookies): Promise context.clearCookies(options?): Promise diff --git a/packages/integrations/src/codemode/generated-content.ts b/packages/integrations/src/codemode/generated-content.ts index a92422ff6f..697ceecca0 100644 --- a/packages/integrations/src/codemode/generated-content.ts +++ b/packages/integrations/src/codemode/generated-content.ts @@ -2,4 +2,4 @@ export const STAGEHAND_CODEMODE_SKILL = '# Stagehand V4 code-mode syntax\n\nYou have one code execution tool. Its `code` argument is the body of an async JavaScript function,\nnot a complete program. Write direct `await` statements and finish with a JSON-serializable return\nvalue. Use the tool name supplied by the host framework.\n\nThe following objects are always in scope:\n\n- `page`: the active Stagehand `Page`.\n- `context`: the Stagehand `BrowserContext` shared across calls. In host code, this is\n `stagehand.browser.context`, not `stagehand.context`.\n- `console`: captured `log`, `warn`, and `error` methods.\n\nAI-enabled surfaces also inject:\n\n- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`.\n- `z`: Zod V4 for `stagehand.extract` schemas.\n\nIf the host labels the surface deterministic, `stagehand` and `z` are intentionally unavailable.\n\nDo not import packages, read environment variables, construct Stagehand, or close Stagehand or the\nbrowser. The tool owner manages initialization and cleanup.\n\n## Hard rules\n\n1. Use deterministic page and locator methods first when the page structure is known or inspectable.\n2. Treat the method lists below as allow-lists. Stagehand V4 is not Playwright; do not guess methods.\n3. Await `page.url()`, `page.title()`, and every `context` method.\n4. Stop when the requested evidence is complete. Do not re-fetch exact evidence with an AI method.\n5. During pagination, stop on the first repeated row signature and return deduplicated records.\n\n## Deterministic browser syntax\n\n```js\nawait page.goto("https://example.com", { waitUntil: "domcontentloaded" });\nconst heading = await page.locator("h1").innerText();\nreturn { heading, url: await page.url(), title: await page.title() };\n```\n\nSupported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`,\n`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`,\n`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`,\n`tools`, `url`, `title`, and `locator`.\n\nSupported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`,\n`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`,\n`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`.\n\nSupported context methods include `pages`, `newPage`, `activePage`, `setActivePage`,\n`setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, `addCookies`, and\n`clearCookies`.\n\nTo submit a filled field with Enter, call `page.keyPress` with the key first. It does not take a\nselector, and V4 does not expose `page.keyboard`:\n\n```js\nawait page.locator(\'input[name="q"]\').fill("vegetarian lasagna");\nawait page.keyPress("Enter");\n```\n\nDo not use Playwright-only methods such as locator `all`, `allTextContents`, `evaluate`,\n`evaluateAll`, `filter`, `getAttribute`, `contentFrame`, or `innerHTML`; page `content`,\n`frameLocator`, `frames`, or `keyboard`; or context `waitForEvent`. Use `innerHtml` with a lowercase\n`l`.\n\nFor DOM collection reads, attributes, or custom traversal, use `page.evaluate`:\n\n```js\nconst links = await page.evaluate(() =>\n Array.from(document.querySelectorAll("a")).map((a) => ({\n text: a.textContent?.trim() ?? "",\n href: a.getAttribute("href"),\n })),\n);\nreturn links;\n```\n\n## Stagehand AI syntax\n\nStagehand V4 AI methods return `{ data, metadata }`. Read the useful result from `.data`.\n\n```js\nconst result = await stagehand.act("Click the sign-in button");\nif (!result.data.success) throw new Error(result.data.message);\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.observe("Find the checkout button");\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.extract(\n "Extract the product name and price",\n z.object({ name: z.string(), price: z.string() }),\n);\nreturn result.data;\n```\n\nKeep every `z.object` property required. Strict structured-output providers reject extraction\nschemas containing `.optional()`. When the page may omit a value, keep the key required and make\nits value nullable:\n\n```js\nz.object({ title: z.string(), rating: z.string().nullable() });\n```\n\nPass `{ page: anotherPage }` as the final options object when an AI method should target a page\nother than the active page.\n\n## Pages and state across calls\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nconst current = before[before.length - 1];\nawait current.locator("button").first().click();\nawait current.waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn { pageCount: after.length, activeUrl: await opened.url() };\n```\n\nThere is no `context.waitForEvent`. `context.pages()` returns fresh Page wrappers, so detect a newly\nopened tab by comparing the stable `page.pageId` values before and after the click. A click does not\nnecessarily make the new tab active; call `context.setActivePage(opened)` explicitly.\n\nThe same browser, pages, cookies, and navigation state persist across successful tool calls. Local\nJavaScript variables do not persist, so rediscover pages and elements each call. If a call stops\nresponding, the owning framework should terminate and restart the tool process; the restarted\nprocess begins with a new browser.\n\n## Cross-origin iframes\n\nDo not use `page.frameLocator()` or locator `contentFrame()`. Call `page.snapshot({ includeIframes:\ntrue })`, locate the relevant accessibility reference in `formattedTree`, look up its frame-piercing\nXPath in `xpathMap`, and use the XPath with the normal locator methods:\n\n```js\nconst snapshot = await page.snapshot({ includeIframes: true });\nconst ref = "0-2"; // Discover the task-specific ref from formattedTree.\nconst xpath = snapshot.xpathMap[ref];\nif (!xpath) throw new Error(`No XPath for accessibility ref ${ref}`);\nconst field = page.locator(`xpath=${xpath}`);\nawait field.fill("value");\nreturn { value: await field.inputValue() };\n```\n\n## Efficient recovery\n\n- Batch related deterministic operations when later steps depend on earlier state.\n- If navigation or `setActivePage` times out, inspect current pages and URLs before repeating it.\n- A failed `act`, `observe`, or `extract` does not destroy the browser. Continue with deterministic\n methods when possible.\n- Pagination controls may be visually enabled without changing the rendered rows. Deduplicate rows\n and stop on the first repeated page signature.\n\n## Return discipline\n\nReturn only compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, and\nplain objects. Do not return Page, Locator, BrowserContext, Stagehand, or Zod objects. Await\nasynchronous methods before returning.\n\nFor the newest exact declarations, inspect the installed `@browserbasehq/stagehand` TypeScript\ndeclarations and this package\'s `codemode/REFERENCE.md` when filesystem access is available.'; export const STAGEHAND_CODEMODE_REFERENCE = - '# Stagehand V4 code-mode reference\n\nThis reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the\nshort operational guide intended for every agent context; this file is the longer lookup reference.\n\n## Function body contract\n\nThe tool builds an async function from the submitted body. These values are injected:\n\n```ts\npage: Page;\ncontext: BrowserContext;\nstagehand: Stagehand;\nz: typeof import("zod/v4");\nconsole: Pick;\n```\n\nEval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm\nintentionally omits `stagehand` and `z`.\n\n## Page\n\nNavigation and page state:\n\n```ts\npage.goto(url, { waitUntil?, timeout? }): Promise\npage.reload(options?): Promise\npage.goBack(options?): Promise\npage.goForward(options?): Promise\npage.url(): Promise\npage.title(): Promise\npage.pageId: string\npage.close(): Promise\n```\n\nInput and pointer operations:\n\n```ts\npage.click(x, y, options?): Promise\npage.hover(x, y): Promise\npage.scroll(x, y, deltaX, deltaY): Promise\npage.dragAndDrop(fromX, fromY, toX, toY, options?): Promise\npage.type(text, options?): Promise\npage.keyPress(key, options?): Promise\n```\n\nDOM and setup operations:\n\n```ts\npage.evaluate(expressionOrFunction, arg?): Promise\npage.addInitScript(script, arg?): Promise\npage.setExtraHTTPHeaders(headers): Promise\npage.setViewportSize(width, height, options?): Promise\npage.waitForLoadState(\'load\' | \'domcontentloaded\' | \'networkidle\', timeout?): Promise\npage.waitForTimeout(milliseconds): Promise\npage.waitForSelector(selector, options?): Promise\npage.screenshot(options?): Promise\npage.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }>\npage.locator(selector): Locator\n```\n\n`page.evaluate` accepts a function and one serializable argument. Prefer one object when several\nvalues are needed:\n\n```js\nreturn await page.evaluate(\n ({ selector, limit }) =>\n Array.from(document.querySelectorAll(selector))\n .slice(0, limit)\n .map((node) => node.textContent?.trim() ?? ""),\n { selector: "article h2", limit: 20 },\n);\n```\n\n## Locator\n\n```ts\nlocator.click(options?): Promise\nlocator.hover(): Promise\nlocator.fill(value): Promise\nlocator.count(): Promise\nlocator.isChecked(): Promise\nlocator.inputValue(): Promise\nlocator.isVisible(): Promise\nlocator.innerText(): Promise\nlocator.innerHtml(): Promise\nlocator.textContent(): Promise\nlocator.scrollTo(percent): Promise\nlocator.centroid(): Promise<{ x: number; y: number }>\nlocator.highlight(options?): Promise\nlocator.sendClickEvent(options?): Promise\nlocator.type(text, options?): Promise\nlocator.selectOption(values): Promise\nlocator.setInputFiles(files): Promise\nlocator.first(): Locator\nlocator.nth(index): Locator\n```\n\nStagehand locators do not implement Playwright\'s collection/filter/frame helpers. Use `count` with\n`nth`, or use `page.evaluate` for bulk DOM reads.\n\n## BrowserContext\n\n```ts\ncontext.pages(): Promise\ncontext.newPage(options?): Promise\ncontext.activePage(): Promise\ncontext.setActivePage(page): Promise\ncontext.setExtraHTTPHeaders(headers): Promise\ncontext.getDomainPolicy(): Promise\ncontext.setDomainPolicy(policy): Promise\ncontext.cookies(urls?): Promise\ncontext.addCookies(cookies): Promise\ncontext.clearCookies(options?): Promise\n```\n\nThe tool owner closes the context and browser. Generated code should not call `context.close()`,\n`stagehand.close()`, or `stagehand.browser.close()`.\n\n## Stagehand AI methods\n\n```ts\nstagehand.act(instruction, options?): Promise<{\n data: { success; message; actionDescription; actions };\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.observe(instruction?, options?): Promise<{\n data: Action[];\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.extract(instruction, schema?, options?): Promise<{\n data: unknown;\n metadata: StagehandResultMetadata;\n}>\n```\n\nThe useful operation result is under `.data`. The `.metadata` object contains cache and model-usage\ninformation for that operation.\n\nTo target a non-active page:\n\n```js\nconst pages = await context.pages();\nconst result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), {\n page: pages[1],\n});\nreturn result.data;\n```\n\n## Multiple pages\n\nPage arrays are snapshots. Await `context.pages()` again after an interaction that may open or\nclose a tab. Use `context.setActivePage(page)` when later operations should target that tab.\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nawait before[0].locator(\'a[target="_blank"]\').first().click();\nawait before[0].waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn await opened.url();\n```\n\n## Pagination pattern\n\n```js\nconst records = new Map();\nconst seenPages = new Set();\n\nfor (let pageIndex = 0; pageIndex < 50; pageIndex++) {\n const rows = await page.evaluate(() =>\n Array.from(document.querySelectorAll("table tbody tr")).map((row) =>\n Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""),\n ),\n );\n const signature = JSON.stringify(rows);\n if (seenPages.has(signature)) break;\n seenPages.add(signature);\n for (const row of rows) records.set(JSON.stringify(row), row);\n\n const next = page.locator(".paginate_button.next").first();\n if (!(await next.isVisible())) break;\n await next.click();\n await page.waitForTimeout(300);\n}\n\nreturn Array.from(records.values());\n```\n\n## Common incompatibilities\n\n| Incorrect assumption | Stagehand V4 form |\n| ------------------------------ | ----------------------------------------------------------- |\n| `page.url` is a string | `await page.url()` |\n| `page.content()` | `await page.locator(\'body\').innerHtml()` or `page.evaluate` |\n| `locator.innerHTML()` | `await locator.innerHtml()` |\n| `locator.all()` | `count()` plus `nth(index)` |\n| `locator.evaluate()` | `page.evaluate()` |\n| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` |\n| `context.waitForEvent(\'page\')` | Compare `await context.pages()` before and after |\n| `result.success` from `act` | `result.data.success` |\n| Raw array from `observe` | `result.data` |\n| Raw object from `extract` | `result.data` |\n\nWhen this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand`\nTypeScript declaration files are authoritative.'; + '# Stagehand V4 code-mode reference\n\nThis reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the\nshort operational guide intended for every agent context; this file is the longer lookup reference.\n\n## Function body contract\n\nThe tool builds an async function from the submitted body. These values are injected:\n\n```ts\npage: Page;\ncontext: BrowserContext;\nstagehand: Stagehand;\nz: typeof import("zod/v4");\nconsole: Pick;\n```\n\nEval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm\nintentionally omits `stagehand` and `z`.\n\n## Page\n\nNavigation and page state:\n\n```ts\npage.goto(url, { waitUntil?, timeout? }): Promise\npage.reload(options?): Promise\npage.goBack(options?): Promise\npage.goForward(options?): Promise\npage.url(): Promise\npage.title(): Promise\npage.pageId: string\npage.close(): Promise\n```\n\nInput and pointer operations:\n\n```ts\npage.click(x, y, options?): Promise\npage.hover(x, y): Promise\npage.scroll(x, y, deltaX, deltaY): Promise\npage.dragAndDrop(fromX, fromY, toX, toY, options?): Promise\npage.type(text, options?): Promise\npage.keyPress(key, options?): Promise\n```\n\nDOM and setup operations:\n\n```ts\npage.evaluate(expressionOrFunction, arg?): Promise\npage.addInitScript(script, arg?): Promise\npage.setExtraHTTPHeaders(headers): Promise\npage.setViewportSize(width, height, options?): Promise\npage.waitForLoadState(\'load\' | \'domcontentloaded\' | \'networkidle\', timeout?): Promise\npage.waitForTimeout(milliseconds): Promise\npage.waitForSelector(selector, options?): Promise\npage.screenshot(options?): Promise\npage.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }>\npage.locator(selector): Locator\n```\n\n`page.evaluate` accepts a function and one serializable argument. Prefer one object when several\nvalues are needed:\n\n```js\nreturn await page.evaluate(\n ({ selector, limit }) =>\n Array.from(document.querySelectorAll(selector))\n .slice(0, limit)\n .map((node) => node.textContent?.trim() ?? ""),\n { selector: "article h2", limit: 20 },\n);\n```\n\n## Locator\n\n```ts\nlocator.click(options?): Promise\nlocator.hover(): Promise\nlocator.fill(value): Promise\nlocator.count(): Promise\nlocator.isChecked(): Promise\nlocator.inputValue(): Promise\nlocator.isVisible(): Promise\nlocator.innerText(): Promise\nlocator.innerHtml(): Promise\nlocator.textContent(): Promise\nlocator.scrollTo(percent): Promise\nlocator.centroid(): Promise<{ x: number; y: number }>\nlocator.highlight(options?): Promise\nlocator.sendClickEvent(options?): Promise\nlocator.type(text, options?): Promise\nlocator.selectOption(values): Promise\nlocator.setInputFiles(files): Promise\nlocator.first(): Locator\nlocator.nth(index): Locator\n```\n\nStagehand locators do not implement Playwright\'s collection/filter/frame helpers. Use `count` with\n`nth`, or use `page.evaluate` for bulk DOM reads.\n\n## BrowserContext\n\n```ts\ncontext.pages(): Promise\ncontext.newPage(options?): Promise\ncontext.activePage(): Promise\ncontext.setActivePage(page): Promise\ncontext.setExtraHTTPHeaders(headers): Promise\ncontext.getDomainPolicy(): Promise\ncontext.setDomainPolicy(policy: DomainPolicy | null): Promise\ncontext.cookies(urls?): Promise\ncontext.addCookies(cookies): Promise\ncontext.clearCookies(options?): Promise\n```\n\nThe tool owner closes the context and browser. Generated code should not call `context.close()`,\n`stagehand.close()`, or `stagehand.browser.close()`.\n\n## Stagehand AI methods\n\n```ts\nstagehand.act(instruction, options?): Promise<{\n data: { success; message; actionDescription; actions };\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.observe(instruction?, options?): Promise<{\n data: Action[];\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.extract(instruction, schema?, options?): Promise<{\n data: unknown;\n metadata: StagehandResultMetadata;\n}>\n```\n\nThe useful operation result is under `.data`. The `.metadata` object contains cache and model-usage\ninformation for that operation.\n\nTo target a non-active page:\n\n```js\nconst pages = await context.pages();\nconst result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), {\n page: pages[1],\n});\nreturn result.data;\n```\n\n## Multiple pages\n\nPage arrays are snapshots. Await `context.pages()` again after an interaction that may open or\nclose a tab. Use `context.setActivePage(page)` when later operations should target that tab.\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nawait before[0].locator(\'a[target="_blank"]\').first().click();\nawait before[0].waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn await opened.url();\n```\n\n## Pagination pattern\n\n```js\nconst records = new Map();\nconst seenPages = new Set();\n\nfor (let pageIndex = 0; pageIndex < 50; pageIndex++) {\n const rows = await page.evaluate(() =>\n Array.from(document.querySelectorAll("table tbody tr")).map((row) =>\n Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""),\n ),\n );\n const signature = JSON.stringify(rows);\n if (seenPages.has(signature)) break;\n seenPages.add(signature);\n for (const row of rows) records.set(JSON.stringify(row), row);\n\n const next = page.locator(".paginate_button.next").first();\n if (!(await next.isVisible())) break;\n await next.click();\n await page.waitForTimeout(300);\n}\n\nreturn Array.from(records.values());\n```\n\n## Common incompatibilities\n\n| Incorrect assumption | Stagehand V4 form |\n| ------------------------------ | ----------------------------------------------------------- |\n| `page.url` is a string | `await page.url()` |\n| `page.content()` | `await page.locator(\'body\').innerHtml()` or `page.evaluate` |\n| `locator.innerHTML()` | `await locator.innerHtml()` |\n| `locator.all()` | `count()` plus `nth(index)` |\n| `locator.evaluate()` | `page.evaluate()` |\n| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` |\n| `context.waitForEvent(\'page\')` | Compare `await context.pages()` before and after |\n| `result.success` from `act` | `result.data.success` |\n| Raw array from `observe` | `result.data` |\n| Raw object from `extract` | `result.data` |\n\nWhen this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand`\nTypeScript declaration files are authoritative.'; diff --git a/packages/integrations/tests/generated-content.test.ts b/packages/integrations/tests/generated-content.test.ts index 8850a9b55e..e6ac6d2239 100644 --- a/packages/integrations/tests/generated-content.test.ts +++ b/packages/integrations/tests/generated-content.test.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -61,6 +62,8 @@ describe("generated code-mode content", () => { }); it("escapes special characters and rejects stale output", async () => { + const fixtureSkill = "slash\\ quote' carriage\rreturn\nline\ttab\u2028separator\u2029paragraph"; + const fixtureReference = "reference"; const fixtureRoot = await mkdtemp(path.join(tmpdir(), "stagehand-codemode-generator-")); temporaryRoots.push(fixtureRoot); await Promise.all([ @@ -72,24 +75,28 @@ describe("generated code-mode content", () => { new URL("scripts/generate-codemode-content.mjs", packageRoot), path.join(fixtureRoot, "scripts", "generate-codemode-content.mjs"), ); - await writeFile( - path.join(fixtureRoot, "codemode", "SKILL.md"), - "slash\\ quote' carriage\rreturn\nline\ttab\u2028separator\u2029paragraph", - ); - await writeFile(path.join(fixtureRoot, "codemode", "REFERENCE.md"), "reference"); + await writeFile(path.join(fixtureRoot, "codemode", "SKILL.md"), fixtureSkill); + await writeFile(path.join(fixtureRoot, "codemode", "REFERENCE.md"), fixtureReference); await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs"], { cwd: fixtureRoot, }); - const generated = await readFile( - path.join(fixtureRoot, "src", "codemode", "generated-content.ts"), - "utf8", - ); + const generatedPath = path.join(fixtureRoot, "src", "codemode", "generated-content.ts"); + const generated = await readFile(generatedPath, "utf8"); expect(generated).toContain("slash\\\\"); expect(generated).toContain("quote\\'"); expect(generated).toContain("carriage\\rreturn\\nline\\ttab"); expect(generated).toContain("\\u2028separator\\u2029paragraph"); + const generatedUrl = pathToFileURL(generatedPath); + generatedUrl.searchParams.set("test", String(Date.now())); + const roundTrip = (await import(generatedUrl.href)) as { + STAGEHAND_CODEMODE_REFERENCE: string; + STAGEHAND_CODEMODE_SKILL: string; + }; + expect(roundTrip.STAGEHAND_CODEMODE_SKILL).toBe(fixtureSkill); + expect(roundTrip.STAGEHAND_CODEMODE_REFERENCE).toBe(fixtureReference); + await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { cwd: fixtureRoot, }); From dcf2a2e98530546e0389f994f27c257c9417535f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 15:06:35 -0700 Subject: [PATCH 7/8] fix: close code-mode host review gaps --- .github/workflows/ci.yml | 6 --- .../integrations/tests/stdio-server.test.ts | 50 +++++++++++++++---- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002f15aac7..fa374ae894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,6 @@ jobs: - '.github/**' integrations: - 'packages/integrations/**' - - 'packages/sdk-ts/**' - - 'packages/extension/**' - - 'packages/protocol/**' - - 'pnpm-lock.yaml' - - 'turbo.json' - - '.github/**' sdk-python: - 'packages/sdk-python/**' - 'packages/extension/**' diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts index 19ccfbafe2..25d8bb3018 100644 --- a/packages/integrations/tests/stdio-server.test.ts +++ b/packages/integrations/tests/stdio-server.test.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import type { Stream } from "node:stream"; +import { PassThrough, type Stream } from "node:stream"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -43,18 +43,36 @@ async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { let output = ""; return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`stdio host did not emit ${JSON.stringify(expected)}: ${output}`)), - 10_000, - ); - const onData = (chunk: Buffer) => { - output += chunk.toString(); - if (!output.includes(expected)) return; + const cleanup = () => { clearTimeout(timeout); stream.off("data", onData); + stream.off("error", onError); + stream.off("end", onEnd); + stream.off("close", onClose); + }; + const succeed = () => { + cleanup(); resolve(output); }; + const fail = (message: string) => { + cleanup(); + reject(new Error(message)); + }; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes(expected)) succeed(); + }; + const onError = () => fail(`stdio output stream failed before ${JSON.stringify(expected)}`); + const onEnd = () => fail(`stdio output stream ended before ${JSON.stringify(expected)}`); + const onClose = () => fail(`stdio output stream closed before ${JSON.stringify(expected)}`); + const timeout = setTimeout( + () => fail(`stdio host did not emit ${JSON.stringify(expected)}: ${output}`), + 10_000, + ); stream.on("data", onData); + stream.once("error", onError); + stream.once("end", onEnd); + stream.once("close", onClose); }); } @@ -75,6 +93,19 @@ function waitForExit( } describe("built code-mode stdio host", () => { + it("cleans up output waiters when the stream closes before the expected output", async () => { + const stream = new PassThrough(); + const output = waitForOutput(stream, readyMessage); + + stream.destroy(); + + await expect(output).rejects.toThrow(`closed before ${JSON.stringify(readyMessage)}`); + expect(stream.listenerCount("data")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + expect(stream.listenerCount("end")).toBe(0); + expect(stream.listenerCount("close")).toBe(0); + }); + it("starts and exits successfully on stdin EOF", async () => { const child = startServer(); try { @@ -120,8 +151,7 @@ describe("built code-mode stdio host", () => { const client = new Client({ name: "stagehand-codemode-stdio-test", version: "1.0.0" }); try { - await client.connect(transport); - await ready; + await Promise.all([client.connect(transport), ready]); expect(client.getServerCapabilities()).not.toHaveProperty("tools"); } finally { await client.close(); From 795623c5b91d5618eda3a9472856ba96cd29db3f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 15:09:25 -0700 Subject: [PATCH 8/8] fix: bound multibyte log truncation --- .../integrations/src/codemode/executor.ts | 4 ++++ packages/integrations/tests/executor.test.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/integrations/src/codemode/executor.ts b/packages/integrations/src/codemode/executor.ts index 719d38222b..413b1230c7 100644 --- a/packages/integrations/src/codemode/executor.ts +++ b/packages/integrations/src/codemode/executor.ts @@ -206,6 +206,10 @@ function createCodeConsole(logs: CodeLogEntry[]) { const text = formatLog(values); const remaining = MAX_LOG_BYTES - logBytes; const bounded = truncateUtf8(text, remaining); + if (bounded.length === 0) { + if (text.length > 0) logBytes = MAX_LOG_BYTES; + return; + } logBytes += Buffer.byteLength(bounded); logs.push({ level, text: bounded }); }; diff --git a/packages/integrations/tests/executor.test.ts b/packages/integrations/tests/executor.test.ts index ad33e086f0..eff7956826 100644 --- a/packages/integrations/tests/executor.test.ts +++ b/packages/integrations/tests/executor.test.ts @@ -334,6 +334,28 @@ describe("StagehandCodeExecutor", () => { } }); + it("stops capturing logs when the remaining byte cannot hold a UTF-8 character", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const result = await executor.execute({ + code: ` + console.log("a".repeat(65535)); + for (let index = 0; index < 100; index += 1) console.log("é"); + return "ok"; + `, + }); + + expect(result).toMatchObject({ ok: true, value: "ok" }); + if (result.ok && result.logs) { + expect(result.logs).toHaveLength(1); + expect(Buffer.byteLength(result.logs[0].text)).toBe(65_535); + expect(result.logs.every((entry) => entry.text.length > 0)).toBe(true); + } + }); + it("captures circular console values without changing snippet success", async () => { const runtime = fakeRuntime(); sdkMocks.localLaunch.mockResolvedValue(runtime.browser);