Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
extension: ${{ steps.filter.outputs.extension }}
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 }}
Expand All @@ -39,6 +40,7 @@ jobs:
- 'packages/sdk-ts/**'
- 'packages/extension/**'
- 'packages/protocol/**'
- 'packages/integrations/**'
- 'pnpm-lock.yaml'
- 'turbo.json'
- '.github/**'
Expand Down Expand Up @@ -167,6 +169,7 @@ jobs:
packages/extension/dist/**
packages/extension/artifacts/**
packages/sdk-ts/dist/**
packages/integrations/dist/**
packages/evals/dist/**
retention-days: 1

Expand Down Expand Up @@ -211,6 +214,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/stagehandBrowserbaseSmoke.test.ts
Expand Down
5 changes: 5 additions & 0 deletions packages/integrations/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/integrations/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
Comment thread
shrey150 marked this conversation as resolved.
"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",
Comment thread
shrey150 marked this conversation as resolved.
"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"
}
}
13 changes: 13 additions & 0 deletions packages/integrations/src/codemode/mcp-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await server.connect(new StdioServerTransport());
}
25 changes: 25 additions & 0 deletions packages/integrations/src/codemode/stdio-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type AsyncCloser = {
close(): Promise<unknown>;
};

export const STDIO_SHUTDOWN_GRACE_MS = 5_000;

export async function closeCodeModeStdio(
resources: readonly AsyncCloser[],
timeoutMs = STDIO_SHUTDOWN_GRACE_MS,
): Promise<boolean> {
let timeout: NodeJS.Timeout | undefined;
const cleanup = Promise.allSettled(
resources.map((resource) => Promise.resolve().then(() => resource.close())),
).then((results) => results.every((result) => result.status === "fulfilled"));
const deadline = new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), timeoutMs);
timeout.unref();
});

try {
return await Promise.race([cleanup, deadline]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
23 changes: 23 additions & 0 deletions packages/integrations/src/codemode/stdio-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { connectCodeModeStdio, createCodeModeMcpHost } from "./mcp-runtime.js";
import { closeCodeModeStdio } from "./stdio-lifecycle.js";

const server = createCodeModeMcpHost();
let closing = false;

async function shutdown(code: number): Promise<void> {
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));

await connectCodeModeStdio(server);
process.stderr.write("Stagehand code-mode MCP host listening on stdio\n");
26 changes: 26 additions & 0 deletions packages/integrations/tests/mcp-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createCodeModeMcpHost>;

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");
});
});
42 changes: 42 additions & 0 deletions packages/integrations/tests/stdio-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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("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<void>(() => undefined)) };
const result = closeCodeModeStdio([stuck], 5_000);

await vi.advanceTimersByTimeAsync(5_000);

await expect(result).resolves.toBe(false);
});
});
160 changes: 160 additions & 0 deletions packages/integrations/tests/stdio-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
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";
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], {
env: baseEnv,
stdio: ["pipe", "pipe", "pipe"],
});
}

async function waitForReady(child: ChildProcessWithoutNullStreams): Promise<string> {
let stderr = "";
return await new Promise<string>((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(readyMessage)) 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 waitForOutput(stream: Stream, expected: string): Promise<string> {
let output = "";
return new Promise<string>((resolve, reject) => {
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);
});
}

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("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 {
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",
});
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 Promise.all([client.connect(transport), ready]);
expect(client.getServerCapabilities()).not.toHaveProperty("tools");
} finally {
await client.close();
}
});
});
13 changes: 13 additions & 0 deletions packages/integrations/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
15 changes: 15 additions & 0 deletions packages/integrations/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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",
});
Loading
Loading