From 2cfa7b02ea686bb5559b0bd72510bba7936387dd Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 01:34:18 -0700 Subject: [PATCH 1/4] 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 1fc90402e74bf1739ed8ff13696785a42565dce3 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 13:39:52 -0700 Subject: [PATCH 2/4] 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 dcf2a2e98530546e0389f994f27c257c9417535f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 15:06:35 -0700 Subject: [PATCH 3/4] 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 a62a8ba143c25882160af892884bf006cd76165a Mon Sep 17 00:00:00 2001 From: miguel Date: Sun, 9 Aug 2026 08:38:31 -0700 Subject: [PATCH 4/4] ci: fold integrations tests into sdk-ts gating, drop dedicated job The dedicated integrations job triple-ran the suite whenever the sdk-ts filter also tripped (unit-ts's unfiltered turbo run + bare vitest run + the dedicated job), and its paths-filter missed pnpm-lock.yaml, turbo.json, and .github/** unlike every sibling filter. Gate integrations under the sdk-ts filter like protocol and extension, so the suite runs exactly once per CI run in unit-ts with full trigger coverage. Also drop the three negation globs from the integrations test:unit turbo inputs: they only trim root-anchored package positives (like protocol/** in the extension task), and this task has none. --- .github/workflows/ci.yml | 19 +------------------ turbo.json | 5 +---- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6099d99ec7..2f8bb58a56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,6 @@ 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 }} @@ -41,11 +40,10 @@ jobs: - 'packages/sdk-ts/**' - 'packages/extension/**' - 'packages/protocol/**' + - 'packages/integrations/**' - 'pnpm-lock.yaml' - 'turbo.json' - '.github/**' - integrations: - - 'packages/integrations/**' sdk-python: - 'packages/sdk-python/**' - 'packages/extension/**' @@ -196,21 +194,6 @@ 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 diff --git a/turbo.json b/turbo.json index 1532c38c26..fcecb93d4b 100644 --- a/turbo.json +++ b/turbo.json @@ -81,10 +81,7 @@ "tests/**", "src/**", "**/*.test.ts", - "$TURBO_ROOT$/vitest.config.ts", - "!$TURBO_ROOT$/packages/*/dist/**", - "!$TURBO_ROOT$/packages/*/.turbo/**", - "!$TURBO_ROOT$/packages/*/node_modules/**" + "$TURBO_ROOT$/vitest.config.ts" ] }, "@browserbasehq/stagehand-docs#typecheck": {},