From 41f35574cab95056c06995e82f1ef11cef5c9f9f Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Tue, 18 Aug 2026 22:04:51 +0530 Subject: [PATCH 1/5] feat(integrations): add Grok Build integration and related docs --- packages/docs/docs.json | 1 + .../docs/images/integrations/grok-build.svg | 1 + packages/docs/v4/integrations/grok-build.mdx | 96 ++++++ packages/docs/v4/integrations/overview.mdx | 9 +- packages/integrations/core/package.json | 4 + .../core/src/acp/agent-process.ts | 107 +++++++ packages/integrations/core/src/acp/env.ts | 36 +++ .../core/src/acp/facade-launcher.ts | 31 ++ .../integrations/core/src/acp/facade-mcp.ts | 17 ++ packages/integrations/core/src/acp/index.ts | 183 +++++++++++ .../core/src/facade/stdio-server.ts | 4 +- .../core/tests/acp-facade-mcp.test.ts | 79 +++++ packages/integrations/core/tests/acp.test.ts | 285 ++++++++++++++++++ .../core/tests/fixtures/fake-acp-agent.mjs | 99 ++++++ .../core/tests/fixtures/record-env.mjs | 14 + packages/integrations/core/tsdown.config.ts | 2 + packages/integrations/grok-build/README.md | 25 ++ packages/integrations/grok-build/package.json | 24 ++ packages/integrations/grok-build/src/agent.ts | 241 +++++++++++++++ .../grok-build/tests/agent.test.ts | 212 +++++++++++++ .../integrations/grok-build/tsconfig.json | 14 + .../integrations/grok-build/vitest.config.ts | 9 + pnpm-lock.yaml | 108 +++++++ pnpm-workspace.yaml | 3 + turbo.json | 3 + 25 files changed, 1602 insertions(+), 5 deletions(-) create mode 100644 packages/docs/images/integrations/grok-build.svg create mode 100644 packages/docs/v4/integrations/grok-build.mdx create mode 100644 packages/integrations/core/src/acp/agent-process.ts create mode 100644 packages/integrations/core/src/acp/env.ts create mode 100644 packages/integrations/core/src/acp/facade-launcher.ts create mode 100644 packages/integrations/core/src/acp/facade-mcp.ts create mode 100644 packages/integrations/core/src/acp/index.ts create mode 100644 packages/integrations/core/tests/acp-facade-mcp.test.ts create mode 100644 packages/integrations/core/tests/acp.test.ts create mode 100644 packages/integrations/core/tests/fixtures/fake-acp-agent.mjs create mode 100644 packages/integrations/core/tests/fixtures/record-env.mjs create mode 100644 packages/integrations/grok-build/README.md create mode 100644 packages/integrations/grok-build/package.json create mode 100644 packages/integrations/grok-build/src/agent.ts create mode 100644 packages/integrations/grok-build/tests/agent.test.ts create mode 100644 packages/integrations/grok-build/tsconfig.json create mode 100644 packages/integrations/grok-build/vitest.config.ts diff --git a/packages/docs/docs.json b/packages/docs/docs.json index f6e2d3a120..e4f3328bc4 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -51,6 +51,7 @@ "v4/integrations/overview", "v4/integrations/claude-code", "v4/integrations/codex", + "v4/integrations/grok-build", "v4/integrations/eve", "v4/integrations/deep-agents", "v4/integrations/crewai", diff --git a/packages/docs/images/integrations/grok-build.svg b/packages/docs/images/integrations/grok-build.svg new file mode 100644 index 0000000000..7342b543cd --- /dev/null +++ b/packages/docs/images/integrations/grok-build.svg @@ -0,0 +1 @@ +Grok Build diff --git a/packages/docs/v4/integrations/grok-build.mdx b/packages/docs/v4/integrations/grok-build.mdx new file mode 100644 index 0000000000..150cb740f1 --- /dev/null +++ b/packages/docs/v4/integrations/grok-build.mdx @@ -0,0 +1,96 @@ +--- +title: "Grok Build" +description: "Give a Grok Build agent persistent Stagehand browser tools over ACP." +--- + +The Grok Build integration starts Grok as an ACP agent and gives its session exactly one MCP server: the Stagehand facade. The facade owns one persistent browser and exposes `run`, `snapshot`, and `screenshot` for the full prompt turn. + + +Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter. + + +## Prerequisites + +- Node.js 24 or newer +- pnpm 11.10.0 +- `XAI_API_KEY` or an existing `grok login` +- A current Google Chrome installation for local browser mode + +## Quickstart + + + +```bash +git clone https://github.com/browserbase/stagehand.git +cd stagehand +pnpm install --frozen-lockfile +pnpm exec turbo run build \ + --filter @browserbasehq/stagehand-integrations +``` + + +Use an API key for headless or CI runs: + +```bash +export XAI_API_KEY="your-xai-api-key" +``` + +Alternatively, run `grok login` once. The example copies only Grok's cached `auth.json` into its temporary isolated home. + + +The example defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome: + +```bash +export STAGEHAND_BROWSER="browserbase" +export BROWSERBASE_API_KEY="your-browserbase-api-key" +``` + + +```bash +pnpm --dir packages/integrations/grok-build start -- \ + "Open https://example.com, snapshot it, request a screenshot, and report the title." +``` + + + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `XAI_API_KEY` | Grok agent credential. It is not made available to the Stagehand facade runtime. | +| `GROK_HOME` | Optional path to an existing Grok home. When `XAI_API_KEY` is unset, the example copies cached `auth.json` from this directory (or `~/.grok`) into the isolated runtime. | +| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. Defaults to `browserbase` when `BROWSERBASE_API_KEY` is set, otherwise `local`. | +| `BROWSERBASE_API_KEY` | Required for Browserbase. | +| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. | +| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. | +| `STAGEHAND_MODEL_API_KEY` | Credential for `STAGEHAND_MODEL_NAME`. | + +## Keep the browser session alive + +The example opens one ACP session with Grok and mounts exactly one MCP server: the Stagehand facade. That server owns the browser for the full prompt turn, so `run`, `snapshot`, and `screenshot` share page state. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser. + +Grok is restricted to a browser-only tool surface, matching the Claude and Codex examples. An agent profile allowlists only `search_tool` and `use_tool` (required to discover and invoke lazy MCP tools). Shell, file, plan, subagent, memory, and web-search capabilities are denied. The client approves one-time permission only for `stagehand__run`, `stagehand__snapshot`, and `stagehand__screenshot`; everything else is rejected or cancelled. + +Each run uses a disposable workspace, home directory, and `GROK_HOME`. Compatibility MCP imports from Claude/Cursor are disabled, and user or project MCP servers, plugins, hooks, skills, and rules are not loaded. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables plus basic OS values needed to launch Node and local Chrome; `XAI_API_KEY` and unrelated host secrets are unavailable to the facade. The ACP bridge terminates the full agent process tree on cancellation or failure. + +## Troubleshooting + + + +Set `XAI_API_KEY` or run `grok login`. A login stored under a custom home is discovered when `GROK_HOME` points to that directory before starting the example. + + +Build `@browserbasehq/stagehand-integrations` from the repository root. The ACP profile resolves the built `facade/stdio-server.mjs` entrypoint. + + +Confirm Chrome is available for local mode, or set valid Browserbase credentials. The browser starts lazily on the first facade tool call. + + + + +`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/overview#security-boundary). + + + + Read the ACP profile, isolated runtime setup, and tests. + diff --git a/packages/docs/v4/integrations/overview.mdx b/packages/docs/v4/integrations/overview.mdx index 1073a73842..bea10295d3 100644 --- a/packages/docs/v4/integrations/overview.mdx +++ b/packages/docs/v4/integrations/overview.mdx @@ -1,7 +1,7 @@ --- title: "Integrations" sidebarTitle: "Overview" -description: "Connect Claude Code, Codex, CrewAI, Deep Agents, Eve, Mastra, Pi, or the Vercel AI SDK to a persistent Stagehand browser." +description: "Connect Claude Code, Codex, Grok Build, CrewAI, Deep Agents, Eve, Mastra, Pi, or the Vercel AI SDK to a persistent Stagehand browser." --- Each integration gives your agent one persistent browser and three tools: `run`, `snapshot`, and `screenshot`. Your agent decides how to navigate and interact while Stagehand manages the browser session. @@ -19,6 +19,9 @@ Stagehand ships these experimental integrations from the monorepo and does not p Give a Codex agent persistent Stagehand browser tools over MCP/stdio. + + Give a Grok Build agent persistent Stagehand browser tools. + Give Vercel's framework for building durable agents native Stagehand tools. @@ -75,7 +78,7 @@ Every integration exposes the same browser capabilities. The tools share a browser for the lifetime of the integration's client session. A navigation performed by `run` is visible to the next `snapshot`, and authentication and page state remain available across calls. -The Claude Code, Codex, CrewAI, Mastra, Vercel AI SDK, and local Deep Agents examples keep one MCP client session open so the stdio server and browser stay alive. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. +The Claude Code, Codex, CrewAI, Mastra, Vercel AI SDK, and local Deep Agents examples keep one MCP client session in the host so the stdio server and browser stay alive. Grok Build uses ACP: Stagehand is the ACP client and mounts the same facade as the session's only MCP server on `session/new`, so the browser still lasts the full prompt turn. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. The private [`core/` workspace package](https://github.com/browserbase/stagehand/tree/main/packages/integrations/core) owns the shared TypeScript tool contract, browser runtime, native bindings, and stdio MCP server. The TypeScript integrations and CrewAI use this package. @@ -85,7 +88,7 @@ Do not create a new MCP process for every tool call. Doing so starts a new brows ## Run the integrations from source -Claude Code, Codex, CrewAI, Mastra, and the Vercel AI SDK use the shared TypeScript Stagehand facade MCP server. Eve and Pi use native in-process bindings from the same package. These integrations require Node.js 24 or newer and [pnpm](https://pnpm.io/installation) 11.10.0. CrewAI also requires Python 3.11–3.13 and [uv](https://docs.astral.sh/uv/). +Claude Code, Codex, Grok Build, CrewAI, Mastra, and the Vercel AI SDK use the shared TypeScript Stagehand facade MCP server. Eve and Pi use native in-process bindings from the same package. These integrations require Node.js 24 or newer and [pnpm](https://pnpm.io/installation) 11.10.0. CrewAI also requires Python 3.11–3.13 and [uv](https://docs.astral.sh/uv/). diff --git a/packages/integrations/core/package.json b/packages/integrations/core/package.json index a9a3f232f7..93ea695747 100644 --- a/packages/integrations/core/package.json +++ b/packages/integrations/core/package.json @@ -11,6 +11,9 @@ ], "type": "module", "exports": { + "./acp": { + "import": "./dist/acp/index.mjs" + }, "./codemode/stdio-server": { "import": "./dist/codemode/stdio-server.mjs" }, @@ -28,6 +31,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@agentclientprotocol/sdk": "catalog:", "@browserbasehq/stagehand": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "zod": "catalog:" diff --git a/packages/integrations/core/src/acp/agent-process.ts b/packages/integrations/core/src/acp/agent-process.ts new file mode 100644 index 0000000000..d483d8d2b4 --- /dev/null +++ b/packages/integrations/core/src/acp/agent-process.ts @@ -0,0 +1,107 @@ +import { ndJsonStream, type Stream } from "@agentclientprotocol/sdk"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; + +export type AcpAgentProcess = { + readonly transport: Stream; + signal(signal: "SIGTERM" | "SIGKILL"): Promise; + terminate(graceMs: number): Promise; +}; + +export function spawnAcpAgentProcess(options: { + command: string; + args: readonly string[]; + cwd: string; + env: NodeJS.ProcessEnv; + stderr: NodeJS.WritableStream; +}): AcpAgentProcess { + const child = spawn(options.command, [...options.args], { + cwd: options.cwd, + detached: process.platform !== "win32", + env: definedEnvironment(options.env), + stdio: ["pipe", "pipe", "pipe"], + }); + child.stderr.pipe(options.stderr, { end: false }); + + let closed = false; + const closePromise = new Promise((resolve) => { + child.once("close", () => { + closed = true; + resolve(); + }); + }); + + return { + transport: ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ), + signal: (signal) => signalProcessTree(child, signal), + terminate: async (graceMs) => { + try { + if (closed) return; + await signalProcessTree(child, "SIGTERM"); + if (await closesWithin(closePromise, graceMs)) return; + await signalProcessTree(child, "SIGKILL"); + if (await closesWithin(closePromise, graceMs)) return; + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } finally { + child.stderr.unpipe(options.stderr); + } + }, + }; +} + +function definedEnvironment(source: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(source).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +} + +async function closesWithin(closed: Promise, graceMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + closed.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), graceMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function signalProcessTree( + child: ChildProcessWithoutNullStreams, + signal: "SIGTERM" | "SIGKILL", +): Promise { + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, signal); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + child.kill(signal); + return; + } + } + + if (process.platform === "win32" && child.pid !== undefined) { + await new Promise((resolve) => { + const args = ["/PID", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])]; + const terminator = spawn("taskkill", args, { stdio: "ignore" }); + terminator.once("error", () => { + child.kill(signal); + resolve(); + }); + terminator.once("close", () => resolve()); + }); + return; + } + + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(signal); +} diff --git a/packages/integrations/core/src/acp/env.ts b/packages/integrations/core/src/acp/env.ts new file mode 100644 index 0000000000..11f44390c5 --- /dev/null +++ b/packages/integrations/core/src/acp/env.ts @@ -0,0 +1,36 @@ +export function buildAcpFacadeEnv(source: NodeJS.ProcessEnv = process.env): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (/^(STAGEHAND_|BROWSERBASE_)/u.test(key) && value) env[key] = value; + } + return env; +} + +const RUNTIME_ENV_KEYS = [ + "PATH", + "HOME", + "TMPDIR", + "TMP", + "TEMP", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "ProgramW6432", + "SYSTEMROOT", + "SystemRoot", + "WINDIR", +] as const; + +export function buildAcpFacadeRuntimeEnv( + source: NodeJS.ProcessEnv = process.env, +): Record { + const env = buildAcpFacadeEnv(source); + for (const key of RUNTIME_ENV_KEYS) { + const value = source[key]; + if (value) env[key] = value; + } + return env; +} diff --git a/packages/integrations/core/src/acp/facade-launcher.ts b/packages/integrations/core/src/acp/facade-launcher.ts new file mode 100644 index 0000000000..bd496daf71 --- /dev/null +++ b/packages/integrations/core/src/acp/facade-launcher.ts @@ -0,0 +1,31 @@ +import { spawn } from "node:child_process"; + +import { buildAcpFacadeRuntimeEnv } from "./env.js"; + +const [facadeServerPath, ...facadeArgs] = process.argv.slice(2); +if (!facadeServerPath) throw new Error("Stagehand ACP facade launcher requires a server path."); + +// ACP agents may merge an MCP server's declared env with their own environment. +// Start the actual facade as a second process so its environment is enforced here. +const facade = spawn(process.execPath, [facadeServerPath, ...facadeArgs], { + env: buildAcpFacadeRuntimeEnv(), + stdio: "inherit", +}); + +let exiting = false; +const forwardSignal = (signal: NodeJS.Signals) => { + if (!facade.kill(signal)) process.exit(signal === "SIGINT" ? 130 : 143); +}; + +process.once("SIGINT", () => forwardSignal("SIGINT")); +process.once("SIGTERM", () => forwardSignal("SIGTERM")); +facade.once("error", (error) => { + process.stderr.write(`Unable to start Stagehand facade: ${error.message}\n`); + process.exit(1); +}); +facade.once("exit", (code, signal) => { + if (exiting) return; + exiting = true; + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); +}); diff --git a/packages/integrations/core/src/acp/facade-mcp.ts b/packages/integrations/core/src/acp/facade-mcp.ts new file mode 100644 index 0000000000..7e05e0ed1b --- /dev/null +++ b/packages/integrations/core/src/acp/facade-mcp.ts @@ -0,0 +1,17 @@ +import type { McpServerStdio } from "@agentclientprotocol/sdk"; +import { fileURLToPath } from "node:url"; + +import { buildAcpFacadeEnv } from "./env.js"; + +export function buildAcpFacadeMcpServer( + facadeServerPath: string, + source: NodeJS.ProcessEnv = process.env, +): McpServerStdio { + const facadeLauncherPath = fileURLToPath(new URL("./facade-launcher.mjs", import.meta.url)); + return { + name: "stagehand", + command: process.execPath, + args: [facadeLauncherPath, facadeServerPath], + env: Object.entries(buildAcpFacadeEnv(source)).map(([name, value]) => ({ name, value })), + }; +} diff --git a/packages/integrations/core/src/acp/index.ts b/packages/integrations/core/src/acp/index.ts new file mode 100644 index 0000000000..b726ceaa7d --- /dev/null +++ b/packages/integrations/core/src/acp/index.ts @@ -0,0 +1,183 @@ +import { + PROTOCOL_VERSION, + client, + methods, + type AuthenticateRequest, + type ClientContext, + type InitializeRequest, + type InitializeResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type ToolCallUpdate, +} from "@agentclientprotocol/sdk"; +import { fileURLToPath } from "node:url"; + +import { FACADE_AGENT_INSTRUCTIONS } from "../facade/contract.js"; +import { spawnAcpAgentProcess } from "./agent-process.js"; +import { buildAcpFacadeMcpServer } from "./facade-mcp.js"; + +const CLIENT_INFO = { + name: "stagehand-acp-facade", + title: "Stagehand ACP facade", + version: "1.0.0", +} as const; + +export type AcpFacadeAgentProfile = { + readonly id: string; + readonly command: string; + readonly args: readonly string[]; + readonly resolveAuthentication?: (input: { + initialization: InitializeResponse; + env: NodeJS.ProcessEnv; + }) => AuthenticateRequest | undefined; + readonly buildSessionMeta?: ( + instructions: string, + ) => Readonly> | undefined; + readonly buildPrompt?: (instruction: string, instructions: string) => string; + readonly isFacadeToolCall: (toolCall: ToolCallUpdate) => boolean; +}; + +export type RunAcpFacadeAgentOptions = { + readonly profile: AcpFacadeAgentProfile; + readonly instruction: string; + readonly cwd: string; + readonly env?: NodeJS.ProcessEnv; + readonly facadeServerPath?: string; + readonly signal?: AbortSignal; + readonly terminationGraceMs?: number; + readonly stderr?: NodeJS.WritableStream; +}; + +export function resolveAcpFacadePermission( + request: RequestPermissionRequest, + activeSessionId: string | undefined, + isFacadeToolCall: (toolCall: ToolCallUpdate) => boolean, +): RequestPermissionResponse { + if (request.sessionId !== activeSessionId) return cancelledPermission(); + + const kind = isFacadeToolCall(request.toolCall) ? "allow_once" : "reject_once"; + const option = request.options.find((candidate) => candidate.kind === kind); + return option + ? { outcome: { outcome: "selected", optionId: option.optionId } } + : cancelledPermission(); +} + +export async function runAcpFacadeAgent(options: RunAcpFacadeAgentOptions): Promise { + const instruction = options.instruction.trim(); + if (!instruction) throw new Error("ACP facade instruction must not be empty."); + const env = options.env ?? process.env; + const facadeServerPath = + options.facadeServerPath ?? + fileURLToPath(new URL("../facade/stdio-server.mjs", import.meta.url)); + const signal = options.signal; + const agentProcess = spawnAcpAgentProcess({ + command: options.profile.command, + args: options.profile.args, + cwd: options.cwd, + env, + stderr: options.stderr ?? process.stderr, + }); + let activeSessionId: string | undefined; + let agentContext: ClientContext | undefined; + let abortFallback: ReturnType | undefined; + const terminationGraceMs = options.terminationGraceMs ?? 2_000; + const requestOptions = signal ? { cancellationSignal: signal } : undefined; + + const onAbort = () => { + if (agentContext && activeSessionId) { + void agentContext + .notify(methods.agent.session.cancel, { sessionId: activeSessionId }) + .catch(() => undefined) + .finally(() => void agentProcess.signal("SIGTERM")); + abortFallback = setTimeout(() => void agentProcess.signal("SIGKILL"), terminationGraceMs); + } else { + void agentProcess.signal("SIGTERM"); + abortFallback = setTimeout(() => void agentProcess.signal("SIGKILL"), terminationGraceMs); + } + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + const app = client({ name: CLIENT_INFO.name }).onRequest( + methods.client.session.requestPermission, + ({ params }) => + signal?.aborted + ? cancelledPermission() + : resolveAcpFacadePermission(params, activeSessionId, options.profile.isFacadeToolCall), + ); + + try { + return await app.connectWith(agentProcess.transport, async (context) => { + agentContext = context; + const initialization = await context.request( + methods.agent.initialize, + { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: CLIENT_INFO, + }, + requestOptions, + ); + if (initialization.protocolVersion !== PROTOCOL_VERSION) { + throw new Error( + `ACP agent ${options.profile.id} negotiated unsupported protocol version ${initialization.protocolVersion}.`, + ); + } + + const authMethods = initialization.authMethods ?? []; + if (authMethods.length > 0) { + const authentication = options.profile.resolveAuthentication?.({ initialization, env }); + if (!authentication) { + throw new Error( + `ACP agent ${options.profile.id} requires authentication, but its profile selected no advertised method.`, + ); + } + if (!authMethods.some((method) => method.id === authentication.methodId)) { + throw new Error( + `ACP agent ${options.profile.id} did not advertise authentication method ${JSON.stringify(authentication.methodId)}.`, + ); + } + await context.request(methods.agent.authenticate, authentication, requestOptions); + } + + const sessionMeta = options.profile.buildSessionMeta?.(FACADE_AGENT_INSTRUCTIONS); + return context + .buildSession({ + cwd: options.cwd, + mcpServers: [buildAcpFacadeMcpServer(facadeServerPath, env)], + ...(sessionMeta ? { _meta: { ...sessionMeta } } : {}), + }) + .withSession(async (session) => { + activeSessionId = session.sessionId; + if (signal?.aborted) throw interruptedError(options.profile.id); + const prompt = + options.profile.buildPrompt?.(instruction, FACADE_AGENT_INSTRUCTIONS) ?? + `${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\n${instruction}`; + const responsePromise = session.prompt(prompt, requestOptions); + const textPromise = session.readText(); + const [response, text] = await Promise.all([responsePromise, textPromise]); + if (signal?.aborted || response.stopReason === "cancelled") { + throw interruptedError(options.profile.id); + } + if (response.stopReason !== "end_turn") { + throw new Error(`ACP agent ${options.profile.id} stopped with ${response.stopReason}.`); + } + const result = text.trim(); + if (!result) + throw new Error(`ACP agent ${options.profile.id} returned no assistant text.`); + return result; + }); + }); + } finally { + signal?.removeEventListener("abort", onAbort); + if (abortFallback) clearTimeout(abortFallback); + await agentProcess.terminate(terminationGraceMs); + } +} + +function cancelledPermission(): RequestPermissionResponse { + return { outcome: { outcome: "cancelled" } }; +} + +function interruptedError(profileId: string): Error { + return new Error(`ACP agent ${profileId} run interrupted.`); +} diff --git a/packages/integrations/core/src/facade/stdio-server.ts b/packages/integrations/core/src/facade/stdio-server.ts index c52f2a3789..41c622deae 100644 --- a/packages/integrations/core/src/facade/stdio-server.ts +++ b/packages/integrations/core/src/facade/stdio-server.ts @@ -161,8 +161,8 @@ async function shutdown(code: number): Promise { process.exit(code === 0 && !clean ? 1 : code); } -process.once("SIGINT", () => void shutdown(130)); -process.once("SIGTERM", () => void shutdown(143)); +process.on("SIGINT", () => void shutdown(130)); +process.on("SIGTERM", () => void shutdown(143)); process.stdin.once("end", () => void shutdown(0)); process.stdin.once("close", () => void shutdown(0)); diff --git a/packages/integrations/core/tests/acp-facade-mcp.test.ts b/packages/integrations/core/tests/acp-facade-mcp.test.ts new file mode 100644 index 0000000000..2f70440d73 --- /dev/null +++ b/packages/integrations/core/tests/acp-facade-mcp.test.ts @@ -0,0 +1,79 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { buildAcpFacadeEnv } from "../src/acp/env.js"; +import { buildAcpFacadeMcpServer } from "../src/acp/facade-mcp.js"; + +const envFixture = fileURLToPath(new URL("./fixtures/record-env.mjs", import.meta.url)); +const builtFacadeLauncher = fileURLToPath( + new URL("../dist/acp/facade-launcher.mjs", import.meta.url), +); +const resolvedFacadeLauncher = fileURLToPath( + new URL("../src/acp/facade-launcher.mjs", import.meta.url), +); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("ACP facade MCP adapter", () => { + it("allowlists only non-empty Stagehand and Browserbase values", () => { + expect( + buildAcpFacadeEnv({ + STAGEHAND_BROWSER: "local", + BROWSERBASE_API_KEY: "bb-secret", + STAGEHAND_EMPTY: "", + XAI_API_KEY: "xai-secret", + OTHER_SECRET: "hidden", + }), + ).toStrictEqual({ + STAGEHAND_BROWSER: "local", + BROWSERBASE_API_KEY: "bb-secret", + }); + }); + + it("builds the exact Stagehand stdio MCP definition", () => { + expect( + buildAcpFacadeMcpServer("/absolute/facade.mjs", { + STAGEHAND_BROWSER: "local", + XAI_API_KEY: "not-forwarded", + }), + ).toStrictEqual({ + name: "stagehand", + command: process.execPath, + args: [resolvedFacadeLauncher, "/absolute/facade.mjs"], + env: [{ name: "STAGEHAND_BROWSER", value: "local" }], + }); + }); + + it("enforces the allowlist in the actual facade runtime", async () => { + const cwd = await mkdtemp(join(tmpdir(), "stagehand-acp-env-test-")); + temporaryDirectories.push(cwd); + const recordPath = join(cwd, "env.json"); + const child = spawn(process.execPath, [builtFacadeLauncher, envFixture, recordPath], { + env: { + ...process.env, + STAGEHAND_BROWSER: "local", + BROWSERBASE_API_KEY: "bb-secret", + XAI_API_KEY: "xai-secret", + OTHER_SECRET: "hidden", + }, + stdio: "ignore", + }); + + const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null]; + expect({ code, signal }).toStrictEqual({ code: 0, signal: null }); + expect(JSON.parse(await readFile(recordPath, "utf8"))).toStrictEqual({ + STAGEHAND_BROWSER: "local", + BROWSERBASE_API_KEY: "bb-secret", + }); + }); +}); diff --git a/packages/integrations/core/tests/acp.test.ts b/packages/integrations/core/tests/acp.test.ts new file mode 100644 index 0000000000..441fafcd9b --- /dev/null +++ b/packages/integrations/core/tests/acp.test.ts @@ -0,0 +1,285 @@ +import type { RequestPermissionRequest, ToolCallUpdate } from "@agentclientprotocol/sdk"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + resolveAcpFacadePermission, + runAcpFacadeAgent, + type AcpFacadeAgentProfile, +} from "../src/acp/index.js"; +import { FACADE_AGENT_INSTRUCTIONS } from "../src/facade/contract.js"; + +const fixture = fileURLToPath(new URL("./fixtures/fake-acp-agent.mjs", import.meta.url)); +const resolvedFacadeLauncher = fileURLToPath( + new URL("../src/acp/facade-launcher.mjs", import.meta.url), +); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("ACP facade helpers", () => { + it("allows one-time only for active-session facade calls", () => { + const request = permissionRequest({ allowed: true }); + expect(resolveAcpFacadePermission(request, "session-1", isAllowed)).toStrictEqual({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + expect( + resolveAcpFacadePermission(permissionRequest({ allowed: false }), "session-1", isAllowed), + ).toStrictEqual({ outcome: { outcome: "selected", optionId: "reject" } }); + expect(resolveAcpFacadePermission(request, "other-session", isAllowed)).toStrictEqual({ + outcome: { outcome: "cancelled" }, + }); + expect( + resolveAcpFacadePermission({ ...request, options: [] }, "session-1", isAllowed), + ).toStrictEqual({ outcome: { outcome: "cancelled" } }); + }); +}); + +describe("ACP facade runner", () => { + it("initializes, authenticates, mounts one facade, delivers instructions, and streams text", async () => { + const runtime = await makeRuntime("permission"); + const text = await runAcpFacadeAgent({ + profile: profile(), + instruction: "Open example.com", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/absolute/facade.mjs", + }); + + expect(text).toBe("Hello browser"); + const events = await readEvents(runtime.recordPath); + expect(events.find((event) => event.type === "initialize")?.params).toMatchObject({ + protocolVersion: 1, + clientCapabilities: {}, + }); + expect(events.find((event) => event.type === "authenticate")?.params).toStrictEqual({ + methodId: "test-auth", + }); + expect(events.find((event) => event.type === "session-new")?.params).toStrictEqual({ + cwd: runtime.cwd, + mcpServers: [ + { + name: "stagehand", + command: process.execPath, + args: [resolvedFacadeLauncher, "/absolute/facade.mjs"], + env: [{ name: "STAGEHAND_BROWSER", value: "local" }], + }, + ], + _meta: { rules: FACADE_AGENT_INSTRUCTIONS }, + }); + expect(events.find((event) => event.type === "prompt")?.params.prompt).toStrictEqual([ + { type: "text", text: "Open example.com" }, + ]); + expect(events.find((event) => event.type === "permission-result")?.permission).toStrictEqual({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + }); + + it("rejects non-facade permission requests", async () => { + const runtime = await makeRuntime("deny-permission"); + await expect( + runAcpFacadeAgent({ + profile: profile(), + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + }), + ).resolves.toBe("Hello browser"); + const events = await readEvents(runtime.recordPath); + expect(events.find((event) => event.type === "permission-result")?.permission).toStrictEqual({ + outcome: { outcome: "selected", optionId: "reject" }, + }); + }); + + it("skips optional auth and prepends instructions when a profile has no native mapping", async () => { + const runtime = await makeRuntime("no-auth"); + const fallbackProfile: AcpFacadeAgentProfile = { + id: "fallback", + command: process.execPath, + args: [fixture], + isFacadeToolCall: isAllowed, + }; + await expect( + runAcpFacadeAgent({ + profile: fallbackProfile, + instruction: "Task only", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + }), + ).resolves.toBe("Hello browser"); + const events = await readEvents(runtime.recordPath); + expect(events.some((event) => event.type === "authenticate")).toBe(false); + expect(events.find((event) => event.type === "session-new")?.params).not.toHaveProperty( + "_meta", + ); + expect(events.find((event) => event.type === "prompt")?.params.prompt).toStrictEqual([ + { + type: "text", + text: `${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\nTask only`, + }, + ]); + }); + + it("fails when required authentication cannot be selected", async () => { + const runtime = await makeRuntime("success"); + const missingAuth = { ...profile(), resolveAuthentication: undefined }; + await expect( + runAcpFacadeAgent({ + profile: missingAuth, + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + }), + ).rejects.toThrow("requires authentication"); + }); + + it("reports an agent process that exits during startup", async () => { + const runtime = await makeRuntime("exit-after-initialize"); + await expect( + runAcpFacadeAgent({ + profile: profile(), + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + }), + ).rejects.toThrow(); + }); + + it.each([ + { behavior: "empty", message: "returned no assistant text" }, + { behavior: "refusal", message: "stopped with refusal" }, + { behavior: "protocol-mismatch", message: "unsupported protocol version 99" }, + ])("reports $behavior failures", async ({ behavior, message }) => { + const runtime = await makeRuntime(behavior); + await expect( + runAcpFacadeAgent({ + profile: profile(), + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + }), + ).rejects.toThrow(message); + }); + + it("cancels and force-terminates an unresponsive agent", async () => { + const runtime = await makeRuntime("hang"); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + await expect( + runAcpFacadeAgent({ + profile: profile(), + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + signal: controller.signal, + terminationGraceMs: 50, + }), + ).rejects.toThrow(); + }); + + it("force-terminates descendants of an unresponsive agent wrapper", async () => { + const runtime = await makeRuntime("hang-with-descendant"); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 100); + await expect( + runAcpFacadeAgent({ + profile: profile(), + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + signal: controller.signal, + terminationGraceMs: 50, + }), + ).rejects.toThrow(); + + const events = await readEvents(runtime.recordPath); + const descendant = events.find((event) => event.type === "descendant"); + expect(descendant?.pid).toEqual(expect.any(Number)); + await expectProcessToStop(descendant?.pid as number); + }); +}); + +function profile(): AcpFacadeAgentProfile { + return { + id: "fake", + command: process.execPath, + args: [fixture], + resolveAuthentication: () => ({ methodId: "test-auth" }), + buildSessionMeta: (instructions) => ({ rules: instructions }), + buildPrompt: (instruction) => instruction, + isFacadeToolCall: isAllowed, + }; +} + +function isAllowed(toolCall: ToolCallUpdate): boolean { + return toolCall._meta?.allowed === true; +} + +function permissionRequest({ allowed }: { allowed: boolean }): RequestPermissionRequest { + return { + sessionId: "session-1", + toolCall: { toolCallId: "call-1", _meta: { allowed } }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }; +} + +async function makeRuntime(behavior: string): Promise<{ + cwd: string; + recordPath: string; + env: NodeJS.ProcessEnv; +}> { + const cwd = await mkdtemp(join(tmpdir(), "stagehand-acp-test-")); + temporaryDirectories.push(cwd); + const recordPath = join(cwd, "events.jsonl"); + await writeFile(recordPath, ""); + return { + cwd, + recordPath, + env: { + ...process.env, + ACP_FAKE_BEHAVIOR: behavior, + ACP_RECORD_PATH: recordPath, + STAGEHAND_BROWSER: "local", + XAI_API_KEY: "agent-only-secret", + OTHER_SECRET: "agent-only-secret", + }, + }; +} + +async function readEvents(path: string): Promise>> { + return (await readFile(path, "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +async function expectProcessToStop(pid: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`ACP descendant process ${pid} is still running.`); +} diff --git a/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs b/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs new file mode 100644 index 0000000000..fc84833918 --- /dev/null +++ b/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs @@ -0,0 +1,99 @@ +import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "@agentclientprotocol/sdk"; +import { spawn } from "node:child_process"; +import { appendFile } from "node:fs/promises"; +import { Readable, Writable } from "node:stream"; + +const behavior = process.env.ACP_FAKE_BEHAVIOR ?? "success"; +const recordPath = process.env.ACP_RECORD_PATH; +let sessionId = "fake-session"; + +async function record(event) { + if (recordPath) await appendFile(recordPath, `${JSON.stringify(event)}\n`); +} + +const app = agent({ name: "fake-stagehand-test-agent" }) + .onRequest(methods.agent.initialize, async ({ params }) => { + await record({ type: "initialize", params }); + if (behavior === "hang-with-descendant") { + const descendant = spawn( + process.execPath, + ["-e", "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)"], + { stdio: "ignore" }, + ); + await record({ type: "descendant", pid: descendant.pid }); + } + if (behavior === "exit-after-initialize") { + setTimeout(() => process.exit(17), 0); + } + return { + protocolVersion: behavior === "protocol-mismatch" ? 99 : PROTOCOL_VERSION, + agentCapabilities: {}, + agentInfo: { name: "fake-agent", version: "1.0.0" }, + ...(behavior === "no-auth" ? {} : { authMethods: [{ id: "test-auth", name: "Test auth" }] }), + }; + }) + .onRequest(methods.agent.authenticate, async ({ params }) => { + await record({ type: "authenticate", params }); + return {}; + }) + .onRequest(methods.agent.session.new, async ({ params }) => { + await record({ type: "session-new", params }); + return { sessionId }; + }) + .onNotification(methods.agent.session.cancel, async ({ params }) => { + await record({ type: "cancel", params }); + }) + .onRequest(methods.agent.session.prompt, async ({ params, client }) => { + await record({ type: "prompt", params }); + if (behavior === "hang" || behavior === "hang-with-descendant") { + process.on("SIGTERM", () => undefined); + return await new Promise(() => undefined); + } + + if (behavior === "permission" || behavior === "deny-permission") { + const allowed = behavior === "permission"; + const permission = await client.request(methods.client.session.requestPermission, { + sessionId, + toolCall: { + toolCallId: "tool-1", + title: allowed ? "stagehand__snapshot" : "bash", + _meta: allowed ? { allowed: true } : { allowed: false }, + }, + options: [ + { optionId: "allow", name: "Allow once", kind: "allow_once" }, + { optionId: "reject", name: "Reject once", kind: "reject_once" }, + ], + }); + await record({ type: "permission-result", permission }); + } + + if (behavior !== "empty") { + await client.notify(methods.client.session.update, { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello " }, + }, + }); + await client.notify(methods.client.session.update, { + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "ignored" }, + }, + }); + await client.notify(methods.client.session.update, { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "browser" }, + }, + }); + } + return { stopReason: behavior === "refusal" ? "refusal" : "end_turn" }; + }); + +const connection = app.connect( + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +await connection.closed; diff --git a/packages/integrations/core/tests/fixtures/record-env.mjs b/packages/integrations/core/tests/fixtures/record-env.mjs new file mode 100644 index 0000000000..5bcb12e771 --- /dev/null +++ b/packages/integrations/core/tests/fixtures/record-env.mjs @@ -0,0 +1,14 @@ +import { writeFile } from "node:fs/promises"; + +const recordPath = process.argv[2]; +if (!recordPath) throw new Error("record-env fixture requires an output path"); + +await writeFile( + recordPath, + JSON.stringify({ + STAGEHAND_BROWSER: process.env.STAGEHAND_BROWSER, + BROWSERBASE_API_KEY: process.env.BROWSERBASE_API_KEY, + XAI_API_KEY: process.env.XAI_API_KEY, + OTHER_SECRET: process.env.OTHER_SECRET, + }), +); diff --git a/packages/integrations/core/tsdown.config.ts b/packages/integrations/core/tsdown.config.ts index d18c6ef4b6..0e4303b60e 100644 --- a/packages/integrations/core/tsdown.config.ts +++ b/packages/integrations/core/tsdown.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { + "acp/facade-launcher": "src/acp/facade-launcher.ts", + "acp/index": "src/acp/index.ts", "codemode/stdio-server": "src/codemode/stdio-server.ts", "facade/index": "src/facade/index.ts", "facade/stdio-server": "src/facade/stdio-server.ts", diff --git a/packages/integrations/grok-build/README.md b/packages/integrations/grok-build/README.md new file mode 100644 index 0000000000..4da6e5769b --- /dev/null +++ b/packages/integrations/grok-build/README.md @@ -0,0 +1,25 @@ +# Grok Build + Stagehand over ACP + +This example starts the packaged Grok Build CLI as an ACP v1 agent and mounts the persistent Stagehand facade as its only session MCP server. + +## Run + +From the repository root: + +```bash +pnpm install +pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations +export XAI_API_KEY=xai-... +pnpm --dir packages/integrations/grok-build start -- \ + "Open https://example.com, snapshot it, request a screenshot, and report the title." +``` + +An existing `grok login` can be used instead of `XAI_API_KEY`. Grok uses its configured default model. Browser selection continues to use `STAGEHAND_BROWSER`, `BROWSERBASE_API_KEY`, and `BROWSERBASE_PROJECT_ID`. + +## Isolation + +Each run creates a disposable workspace, home directory, and `GROK_HOME`, copies only cached `auth.json` when API-key auth is unavailable, and disables user compatibility MCP imports. The ACP session supplies exactly one `stagehand` stdio server. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` values plus basic OS values needed to launch Node and local Chrome, so `XAI_API_KEY` and unrelated host secrets are unavailable to the facade. + +The shared `@browserbasehq/stagehand-integrations/acp` transport handles protocol initialization, auth, session updates, permission decisions, cancellation, and process-tree cleanup. This package supplies only Grok's command and protocol-specific profile behavior. + +The Grok process is restricted to `search_tool` and `use_tool` through an ACP `--agent-profile` allowlist so it can discover and invoke the lazy Stagehand MCP tools. Shell, file, subagent, memory, plan, and web-search capabilities are disabled for this browser-only example, matching the Claude and Codex integrations. diff --git a/packages/integrations/grok-build/package.json b/packages/integrations/grok-build/package.json new file mode 100644 index 0000000000..6e14a4c1b4 --- /dev/null +++ b/packages/integrations/grok-build/package.json @@ -0,0 +1,24 @@ +{ + "name": "@browserbasehq/stagehand-integrations-example-grok-build-facade", + "version": "4.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "node src/agent.ts", + "test": "pnpm -w exec turbo run build --filter @browserbasehq/stagehand-integrations && vitest run", + "test:unit": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/stagehand-integrations": "workspace:*", + "@xai-official/grok": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/integrations/grok-build/src/agent.ts b/packages/integrations/grok-build/src/agent.ts new file mode 100644 index 0000000000..0feade0dd7 --- /dev/null +++ b/packages/integrations/grok-build/src/agent.ts @@ -0,0 +1,241 @@ +import type { + AcpFacadeAgentProfile, + RunAcpFacadeAgentOptions, +} from "@browserbasehq/stagehand-integrations/acp"; +import { runAcpFacadeAgent } from "@browserbasehq/stagehand-integrations/acp"; +import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; +import { createRequire } from "node:module"; +import { access, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +const require = createRequire(import.meta.url); + +export const STAGEHAND_GROK_TOOL_NAMES = new Set( + FACADE_TOOLS.map((tool) => `stagehand__${tool.name}`), +); + +/** Grok ACP agent-profile filename. `--tools` is documented as headless-only; this allowlist applies in `agent stdio`. */ +export const STAGEHAND_GROK_AGENT_PROFILE = "stagehand-browser.md"; + +const GROK_AGENT_PROFILE = [ + "---", + "name: stagehand-browser", + "description: Browser-only Stagehand MCP agent. Discover and invoke Stagehand tools; do not use shell, files, or subagents.", + "tools:", + " - search_tool", + " - use_tool", + "disallowedTools:", + " - Agent", + "---", + "", + "Use only Stagehand browser tools discovered through MCP.", + "", +].join("\n"); + +export type GrokRuntime = { + root: string; + userHome: string; + cwd: string; + grokHome: string; + agentProfilePath: string; + cachedAuthAvailable: boolean; +}; + +export type RunGrokBuildOptions = { + env?: NodeJS.ProcessEnv; + grokExecutable?: string; + facadeServerPath?: string; + signal?: AbortSignal; + makeRuntime?: (env: NodeJS.ProcessEnv) => Promise; + runAcp?: (options: RunAcpFacadeAgentOptions) => Promise; +}; + +export function resolveGrokExecutable(): string { + return require.resolve("@xai-official/grok/bin/grok"); +} + +export function grokAcpArgs(agentProfilePath: string): string[] { + return [ + "--tools", + "search_tool,use_tool", + "--deny", + "Bash", + "--deny", + "Edit", + "--deny", + "Write", + "--deny", + "Read", + "--deny", + "Grep", + "--deny", + "WebFetch", + "--no-plan", + "--no-subagents", + "--no-memory", + "--disable-web-search", + "agent", + "--no-leader", + "--agent-profile", + agentProfilePath, + "stdio", + ]; +} + +export function createGrokProfile(options: { + executable: string; + cachedAuthAvailable: boolean; + agentProfilePath: string; +}): AcpFacadeAgentProfile { + return { + id: "grok-build", + command: options.executable, + args: grokAcpArgs(options.agentProfilePath), + resolveAuthentication: ({ initialization, env }) => { + const advertised = new Set((initialization.authMethods ?? []).map((method) => method.id)); + if (env.XAI_API_KEY?.trim() && advertised.has("xai.api_key")) { + return { methodId: "xai.api_key", _meta: { headless: true } }; + } + if (options.cachedAuthAvailable && advertised.has("cached_token")) { + return { methodId: "cached_token", _meta: { headless: true } }; + } + return undefined; + }, + buildSessionMeta: (instructions) => ({ rules: instructions }), + buildPrompt: (instruction) => instruction, + isFacadeToolCall: (toolCall) => { + const metadata = readRecord(toolCall._meta); + const xaiTool = readRecord(metadata?.["x.ai/tool"]); + const rawInput = readRecord(toolCall.rawInput); + const toolName = + readString(rawInput?.tool_name) ?? readString(toolCall.name) ?? readString(toolCall.title); + if (!STAGEHAND_GROK_TOOL_NAMES.has(toolName ?? "")) return false; + return ( + xaiTool?.namespace === "mcp" || + (xaiTool?.namespace === "grok_build" && xaiTool?.name === "use_tool") + ); + }, + }; +} + +export async function createGrokRuntime( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const root = await mkdtemp(join(tmpdir(), "stagehand-grok-build-")); + const userHome = join(root, "home"); + const cwd = join(root, "workspace"); + const grokHome = join(userHome, ".grok"); + const agentProfilePath = join(grokHome, STAGEHAND_GROK_AGENT_PROFILE); + await Promise.all([mkdir(cwd, { recursive: true }), mkdir(grokHome, { recursive: true })]); + await Promise.all([ + writeFile(agentProfilePath, GROK_AGENT_PROFILE, { mode: 0o600 }), + writeFile( + join(grokHome, "config.toml"), + [ + "[cli]", + "auto_update = false", + "", + "[compat.claude]", + "mcps = false", + "", + "[compat.cursor]", + "mcps = false", + "", + "[subagents]", + "enabled = false", + "", + "[ui]", + 'permission_mode = "ask"', + "", + ].join("\n"), + { mode: 0o600 }, + ), + ]); + + let cachedAuthAvailable = false; + if (!env.XAI_API_KEY?.trim()) { + const sourceHome = env.GROK_HOME?.trim() || join(homedir(), ".grok"); + const sourceAuth = join(sourceHome, "auth.json"); + try { + await access(sourceAuth); + await copyFile(sourceAuth, join(grokHome, "auth.json")); + cachedAuthAvailable = true; + } catch { + // Missing cached auth is reported after ACP initialization, when Grok's + // advertised methods are known. + } + } + return { root, userHome, cwd, grokHome, agentProfilePath, cachedAuthAvailable }; +} + +export function resolveInstruction(args: string[]): string { + return (args[0] === "--" ? args.slice(1) : args).join(" ").trim(); +} + +export async function runGrokBuild( + instruction: string, + options: RunGrokBuildOptions = {}, +): Promise { + const env = options.env ?? process.env; + const runtime = await (options.makeRuntime ?? createGrokRuntime)(env); + try { + const agentEnv = { + ...env, + HOME: runtime.userHome, + USERPROFILE: runtime.userHome, + GROK_HOME: runtime.grokHome, + }; + const profile = createGrokProfile({ + executable: options.grokExecutable ?? resolveGrokExecutable(), + cachedAuthAvailable: runtime.cachedAuthAvailable, + agentProfilePath: runtime.agentProfilePath, + }); + return await (options.runAcp ?? runAcpFacadeAgent)({ + profile, + instruction, + cwd: runtime.cwd, + env: agentEnv, + ...(options.facadeServerPath ? { facadeServerPath: options.facadeServerPath } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }); + } finally { + await rm(runtime.root, { recursive: true, force: true }); + } +} + +function readRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +async function main(): Promise { + const instruction = resolveInstruction(process.argv.slice(2)); + if (!instruction) throw new Error('Usage: pnpm start "your instruction"'); + const controller = new AbortController(); + const onSignal = () => controller.abort(new Error("Grok Build run interrupted.")); + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + try { + // oxlint-disable-next-line no-console -- CLI example prints the agent result. + console.log(await runGrokBuild(instruction, { signal: controller.signal })); + } finally { + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + } +} + +if (import.meta.main) { + main().catch(handleFailure); +} + +function handleFailure(error: unknown): void { + // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/packages/integrations/grok-build/tests/agent.test.ts b/packages/integrations/grok-build/tests/agent.test.ts new file mode 100644 index 0000000000..bad99e0169 --- /dev/null +++ b/packages/integrations/grok-build/tests/agent.test.ts @@ -0,0 +1,212 @@ +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RunAcpFacadeAgentOptions } from "@browserbasehq/stagehand-integrations/acp"; +import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; + +import { + createGrokProfile, + createGrokRuntime, + grokAcpArgs, + resolveGrokExecutable, + resolveInstruction, + runGrokBuild, + STAGEHAND_GROK_AGENT_PROFILE, + STAGEHAND_GROK_TOOL_NAMES, + type GrokRuntime, +} from "../src/agent.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("Grok ACP profile", () => { + it("derives its Grok MCP identities from the shared facade contract", () => { + const expected = FACADE_TOOLS.map((tool) => `stagehand__${tool.name}`).sort(); + expect([...STAGEHAND_GROK_TOOL_NAMES].sort()).toStrictEqual(expected); + }); + + it("uses API-key auth before cached auth and keeps Grok's configured model", () => { + const profile = createGrokProfile({ + executable: "/grok", + cachedAuthAvailable: true, + agentProfilePath: "/tmp/stagehand-browser.md", + }); + const initialization = { + protocolVersion: 1, + authMethods: [ + { id: "xai.api_key", name: "API key" }, + { id: "cached_token", name: "Cached token" }, + ], + }; + expect(profile.args).toStrictEqual(grokAcpArgs("/tmp/stagehand-browser.md")); + expect( + profile.resolveAuthentication?.({ initialization, env: { XAI_API_KEY: "xai-secret" } }), + ).toStrictEqual({ methodId: "xai.api_key", _meta: { headless: true } }); + expect(profile.resolveAuthentication?.({ initialization, env: {} })).toStrictEqual({ + methodId: "cached_token", + _meta: { headless: true }, + }); + }); + + it("returns no auth method when credentials or advertised methods are missing", () => { + const profile = createGrokProfile({ + executable: "/grok", + cachedAuthAvailable: false, + agentProfilePath: "/tmp/stagehand-browser.md", + }); + expect( + profile.resolveAuthentication?.({ + initialization: { + protocolVersion: 1, + authMethods: [{ id: "cached_token", name: "Cached token" }], + }, + env: {}, + }), + ).toBeUndefined(); + }); + + it("sets Grok rules metadata and classifies exact legacy and current Grok MCP identities", () => { + const profile = createGrokProfile({ + executable: "/grok", + cachedAuthAvailable: false, + agentProfilePath: "/tmp/stagehand-browser.md", + }); + expect(profile.buildSessionMeta?.("rules")).toStrictEqual({ rules: "rules" }); + expect( + profile.isFacadeToolCall({ + toolCallId: "one", + title: "stagehand__run", + _meta: { "x.ai/tool": { namespace: "mcp" } }, + }), + ).toBe(true); + expect( + profile.isFacadeToolCall({ + toolCallId: "two", + title: "stagehand__snapshot", + rawInput: { tool_name: "stagehand__snapshot" }, + _meta: { "x.ai/tool": { namespace: "grok_build", name: "use_tool" } }, + }), + ).toBe(true); + expect( + profile.isFacadeToolCall({ + toolCallId: "three", + title: "bash", + _meta: { "x.ai/tool": { namespace: "grok_build", name: "use_tool" } }, + }), + ).toBe(false); + expect( + profile.isFacadeToolCall({ + toolCallId: "four", + title: "stagehand__run", + _meta: { "x.ai/tool": { namespace: "grok_build", name: "bash" } }, + }), + ).toBe(false); + }); + + it("resolves the packaged Grok executable", async () => { + const executable = resolveGrokExecutable(); + await expect(access(executable)).resolves.toBeUndefined(); + expect(executable).toContain("@xai-official/grok/bin/grok"); + }); +}); + +describe("Grok isolated runtime", () => { + it("copies only cached auth and writes an MCP-compatible isolated config", async () => { + const sourceHome = await makeTemp("grok-source-"); + await mkdir(sourceHome, { recursive: true }); + await writeFile(join(sourceHome, "auth.json"), '{"token":"cached"}\n'); + await writeFile(join(sourceHome, "config.toml"), "[mcp_servers.other]\ncommand='other'\n"); + + const runtime = await createGrokRuntime({ GROK_HOME: sourceHome }); + temporaryDirectories.push(runtime.root); + expect(runtime.cachedAuthAvailable).toBe(true); + await expect(readFile(join(runtime.grokHome, "auth.json"), "utf8")).resolves.toContain( + "cached", + ); + expect(runtime.agentProfilePath).toBe(join(runtime.grokHome, STAGEHAND_GROK_AGENT_PROFILE)); + await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain( + "description: Browser-only Stagehand MCP agent", + ); + await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain("search_tool"); + await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain("use_tool"); + await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.not.toContain("bash"); + const config = await readFile(join(runtime.grokHome, "config.toml"), "utf8"); + expect(config).toContain("auto_update = false"); + expect(config).toContain("[compat.claude]"); + expect(config).toContain("[compat.cursor]"); + expect(config).toContain("[subagents]"); + expect(config).toContain("enabled = false"); + expect(config).not.toContain("mcp_servers.other"); + }); + + it("does not copy cached auth when an API key is supplied", async () => { + const sourceHome = await makeTemp("grok-source-"); + await writeFile(join(sourceHome, "auth.json"), '{"token":"cached"}\n'); + const runtime = await createGrokRuntime({ + GROK_HOME: sourceHome, + XAI_API_KEY: "xai-secret", + }); + temporaryDirectories.push(runtime.root); + expect(runtime.cachedAuthAvailable).toBe(false); + await expect(access(join(runtime.grokHome, "auth.json"))).rejects.toThrow(); + }); + + it("passes the isolated home to ACP and removes all runtime state", async () => { + const root = await makeTemp("grok-run-"); + const runtime: GrokRuntime = { + root, + userHome: join(root, "home"), + cwd: join(root, "workspace"), + grokHome: join(root, "grok-home"), + agentProfilePath: join(root, "grok-home", STAGEHAND_GROK_AGENT_PROFILE), + cachedAuthAvailable: true, + }; + await Promise.all([ + mkdir(runtime.cwd, { recursive: true }), + mkdir(runtime.userHome, { recursive: true }), + mkdir(runtime.grokHome, { recursive: true }), + ]); + const runAcp = vi.fn(async (_options: RunAcpFacadeAgentOptions) => "done"); + + await expect( + runGrokBuild("Open example.com", { + env: { XAI_API_KEY: "xai-secret" }, + grokExecutable: "/grok", + makeRuntime: async () => runtime, + runAcp, + }), + ).resolves.toBe("done"); + expect(runAcp).toHaveBeenCalledOnce(); + expect(runAcp.mock.calls[0]?.[0]).toMatchObject({ + instruction: "Open example.com", + cwd: runtime.cwd, + env: { + HOME: runtime.userHome, + USERPROFILE: runtime.userHome, + GROK_HOME: runtime.grokHome, + XAI_API_KEY: "xai-secret", + }, + profile: { + command: "/grok", + args: grokAcpArgs(runtime.agentProfilePath), + }, + }); + await expect(access(root)).rejects.toThrow(); + }); + + it("normalizes CLI instructions", () => { + expect(resolveInstruction(["--", "open", "example.com"])).toBe("open example.com"); + }); +}); + +async function makeTemp(prefix: string): Promise { + const path = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(path); + return path; +} diff --git a/packages/integrations/grok-build/tsconfig.json b/packages/integrations/grok-build/tsconfig.json new file mode 100644 index 0000000000..e9859394d1 --- /dev/null +++ b/packages/integrations/grok-build/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "rootDir": ".", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/integrations/grok-build/vitest.config.ts b/packages/integrations/grok-build/vitest.config.ts new file mode 100644 index 0000000000..d82214c96a --- /dev/null +++ b/packages/integrations/grok-build/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + hookTimeout: 20_000, + testTimeout: 20_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ef422543..6e181f0cae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ settings: catalogs: default: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0 '@ai-sdk/anthropic': specifier: ^4.0.8 version: 4.0.8 @@ -289,6 +292,9 @@ catalogs: '@types/node': specifier: ^24 version: 24.13.2 + '@xai-official/grok': + specifier: 1.0.5 + version: 1.0.5 ai: specifier: ^7.0.16 version: 7.0.16 @@ -628,6 +634,9 @@ importers: packages/integrations/core: dependencies: + '@agentclientprotocol/sdk': + specifier: 'catalog:' + version: 1.3.0(zod@4.4.3) '@browserbasehq/stagehand': specifier: workspace:* version: link:../../sdk-ts @@ -679,6 +688,25 @@ 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@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations/grok-build: + dependencies: + '@browserbasehq/stagehand-integrations': + specifier: workspace:* + version: link:../core + '@xai-official/grok': + specifier: 'catalog:' + version: 1.0.5 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + 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@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations/mastra: dependencies: '@ai-sdk/openai': @@ -837,6 +865,11 @@ packages: express: optional: true + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/amazon-bedrock@3.0.111': resolution: {integrity: sha512-vkwKdIn8qLsAXEsr8IPp+6U0INmL0Z6aXz93SPCKU9pfWHVnZXEpBE6N4tVIpa6Y9EBqdRo9XGiLmaoYx5XhrQ==} engines: {node: '>=18'} @@ -1886,6 +1919,9 @@ packages: peerDependencies: hono: ^4 + '@iarna/toml@3.0.0': + resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3546,6 +3582,43 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + '@xai-official/grok-darwin-arm64@1.0.5': + resolution: {integrity: sha512-akEtE93V7nOHEMfj16kkl3Nxl/AjcNaRkZCnY5HoIayy3VR6qQ6VTsQdUka4ZgT1bGJCZff4UIcTVpFGDaMAQQ==} + cpu: [arm64] + os: [darwin] + + '@xai-official/grok-darwin-x64@1.0.5': + resolution: {integrity: sha512-kIr1YUidX+Zl8Ti7qFBCmQyVzR/D779Q0/pNvCBo7enLObFaGiM7JUAuQ7Y23aaIC/m1rwPv7xzoQ3DGHOO1yA==} + cpu: [x64] + os: [darwin] + + '@xai-official/grok-linux-arm64@1.0.5': + resolution: {integrity: sha512-yPdBddzfqBNfmyKGEuy0Vy+8zr/0I9xTqX9aYzaaz5QUS3+3Xe3bIJ2EYbAZV6o/16WUC2ZbkLNUx3X3IkBSRA==} + cpu: [arm64] + os: [linux] + + '@xai-official/grok-linux-x64@1.0.5': + resolution: {integrity: sha512-JJ6ajCCdBeLc14jvcGKQmeOvoJdEvWMe2Bj82McNikup1d6QaX/E7MI7nmMmoHfQthqRfUSnFk/ExFRu4fodTw==} + cpu: [x64] + os: [linux] + + '@xai-official/grok-win32-arm64@1.0.5': + resolution: {integrity: sha512-B4cIPElA8KWpOX2fvIxFeb8U5Ifwal56ZNKtvqPgatflLMf9Quo+3KnBUXH7X8/Ohwe81ff8G+mcU6Ja6Z5RKQ==} + cpu: [arm64] + os: [win32] + + '@xai-official/grok-win32-x64@1.0.5': + resolution: {integrity: sha512-EqPxlKg5FMFymmnF+w58VQ8V+ZrghIp7Qsuc2VCurIY82H+6qvnAMgJXGCpo2qH6JSWfh0HPiHkmTk32kHstjw==} + cpu: [x64] + os: [win32] + + '@xai-official/grok@1.0.5': + resolution: {integrity: sha512-kk5hez+Oz5CvWonDGkMNmL483CWRIGRF2ki8jQzpIXH56P0fhCgaX9lrr0IUoFCKh/rYAm5vfCPgQsdIIYLu8Q==} + engines: {node: '>=20'} + cpu: [arm64, x64] + os: [darwin, linux, win32] + hasBin: true + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -8226,6 +8299,10 @@ snapshots: optionalDependencies: express: 5.2.1 + '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': + dependencies: + zod: 4.4.3 + '@ai-sdk/amazon-bedrock@3.0.111(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 2.0.91(zod@4.4.3) @@ -9465,6 +9542,8 @@ snapshots: dependencies: hono: 4.12.32 + '@iarna/toml@3.0.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.33.5': @@ -11264,6 +11343,35 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} + '@xai-official/grok-darwin-arm64@1.0.5': + optional: true + + '@xai-official/grok-darwin-x64@1.0.5': + optional: true + + '@xai-official/grok-linux-arm64@1.0.5': + optional: true + + '@xai-official/grok-linux-x64@1.0.5': + optional: true + + '@xai-official/grok-win32-arm64@1.0.5': + optional: true + + '@xai-official/grok-win32-x64@1.0.5': + optional: true + + '@xai-official/grok@1.0.5': + dependencies: + '@iarna/toml': 3.0.0 + optionalDependencies: + '@xai-official/grok-darwin-arm64': 1.0.5 + '@xai-official/grok-darwin-x64': 1.0.5 + '@xai-official/grok-linux-arm64': 1.0.5 + '@xai-official/grok-linux-x64': 1.0.5 + '@xai-official/grok-win32-arm64': 1.0.5 + '@xai-official/grok-win32-x64': 1.0.5 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36a3d2ff29..b3bf24e334 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: catalogMode: prefer catalog: + "@agentclientprotocol/sdk": 1.3.0 + "@xai-official/grok": 1.0.5 "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 @@ -60,6 +62,7 @@ catalog: overrides: vite: "catalog:" allowBuilds: + "@xai-official/grok": true "@ast-grep/lang-go": true "@ast-grep/lang-python": true "@google/genai": false diff --git a/turbo.json b/turbo.json index 32b50787d1..ee278dce7c 100644 --- a/turbo.json +++ b/turbo.json @@ -99,6 +99,9 @@ "@browserbasehq/stagehand-integrations-example-codex-facade#typecheck": { "dependsOn": ["^build"] }, + "@browserbasehq/stagehand-integrations-example-grok-build-facade#typecheck": { + "dependsOn": ["^build"] + }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { "dependsOn": ["^build"], From 304c4030f17a894f0cd9bc31d6f0e11d7326a695 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 19 Aug 2026 11:00:56 +0530 Subject: [PATCH 2/5] fix(integrations): harden Grok ACP lifecycle --- packages/docs/v4/integrations/grok-build.mdx | 4 +- .../core/src/acp/agent-process.ts | 6 +++ .../core/src/acp/facade-launcher.ts | 13 +++++-- packages/integrations/core/src/acp/index.ts | 9 +++++ .../core/tests/acp-facade-mcp.test.ts | 14 +++++++ packages/integrations/core/tests/acp.test.ts | 37 ++++++++++++++++++- packages/integrations/grok-build/src/agent.ts | 29 ++++++++++----- .../grok-build/tests/agent.test.ts | 10 +++++ 8 files changed, 105 insertions(+), 17 deletions(-) diff --git a/packages/docs/v4/integrations/grok-build.mdx b/packages/docs/v4/integrations/grok-build.mdx index 150cb740f1..261693f481 100644 --- a/packages/docs/v4/integrations/grok-build.mdx +++ b/packages/docs/v4/integrations/grok-build.mdx @@ -3,7 +3,7 @@ title: "Grok Build" description: "Give a Grok Build agent persistent Stagehand browser tools over ACP." --- -The Grok Build integration starts Grok as an ACP agent and gives its session exactly one MCP server: the Stagehand facade. The facade owns one persistent browser and exposes `run`, `snapshot`, and `screenshot` for the full prompt turn. +The Grok Build integration starts Grok as an ACP agent and gives its session exactly one MCP server: the Stagehand facade MCP server. The facade owns one persistent browser and exposes `run`, `snapshot`, and `screenshot` for the full prompt turn. Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter. @@ -63,7 +63,7 @@ pnpm --dir packages/integrations/grok-build start -- \ | `BROWSERBASE_API_KEY` | Required for Browserbase. | | `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. | | `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. | -| `STAGEHAND_MODEL_API_KEY` | Credential for `STAGEHAND_MODEL_NAME`. | +| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive Grok's credential. | ## Keep the browser session alive diff --git a/packages/integrations/core/src/acp/agent-process.ts b/packages/integrations/core/src/acp/agent-process.ts index d483d8d2b4..01f8724625 100644 --- a/packages/integrations/core/src/acp/agent-process.ts +++ b/packages/integrations/core/src/acp/agent-process.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from "node:stream"; export type AcpAgentProcess = { readonly transport: Stream; + readonly started: Promise; signal(signal: "SIGTERM" | "SIGKILL"): Promise; terminate(graceMs: number): Promise; }; @@ -21,6 +22,10 @@ export function spawnAcpAgentProcess(options: { env: definedEnvironment(options.env), stdio: ["pipe", "pipe", "pipe"], }); + const started = new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.on("error", reject); + }); child.stderr.pipe(options.stderr, { end: false }); let closed = false; @@ -32,6 +37,7 @@ export function spawnAcpAgentProcess(options: { }); return { + started, transport: ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(child.stdout) as ReadableStream, diff --git a/packages/integrations/core/src/acp/facade-launcher.ts b/packages/integrations/core/src/acp/facade-launcher.ts index bd496daf71..ae1725308a 100644 --- a/packages/integrations/core/src/acp/facade-launcher.ts +++ b/packages/integrations/core/src/acp/facade-launcher.ts @@ -17,8 +17,10 @@ const forwardSignal = (signal: NodeJS.Signals) => { if (!facade.kill(signal)) process.exit(signal === "SIGINT" ? 130 : 143); }; -process.once("SIGINT", () => forwardSignal("SIGINT")); -process.once("SIGTERM", () => forwardSignal("SIGTERM")); +const onSigint = () => forwardSignal("SIGINT"); +const onSigterm = () => forwardSignal("SIGTERM"); +process.once("SIGINT", onSigint); +process.once("SIGTERM", onSigterm); facade.once("error", (error) => { process.stderr.write(`Unable to start Stagehand facade: ${error.message}\n`); process.exit(1); @@ -26,6 +28,9 @@ facade.once("error", (error) => { facade.once("exit", (code, signal) => { if (exiting) return; exiting = true; - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); + if (signal) { + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + process.kill(process.pid, signal); + } else process.exit(code ?? 1); }); diff --git a/packages/integrations/core/src/acp/index.ts b/packages/integrations/core/src/acp/index.ts index b726ceaa7d..83be0d7012 100644 --- a/packages/integrations/core/src/acp/index.ts +++ b/packages/integrations/core/src/acp/index.ts @@ -70,6 +70,7 @@ export async function runAcpFacadeAgent(options: RunAcpFacadeAgentOptions): Prom options.facadeServerPath ?? fileURLToPath(new URL("../facade/stdio-server.mjs", import.meta.url)); const signal = options.signal; + if (signal?.aborted) throw interruptedError(options.profile.id); const agentProcess = spawnAcpAgentProcess({ command: options.profile.command, args: options.profile.args, @@ -106,6 +107,14 @@ export async function runAcpFacadeAgent(options: RunAcpFacadeAgentOptions): Prom ); try { + try { + await agentProcess.started; + } catch (error) { + throw new Error( + `Unable to start ACP agent ${options.profile.id}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } return await app.connectWith(agentProcess.transport, async (context) => { agentContext = context; const initialization = await context.request( diff --git a/packages/integrations/core/tests/acp-facade-mcp.test.ts b/packages/integrations/core/tests/acp-facade-mcp.test.ts index 2f70440d73..c81c300c7f 100644 --- a/packages/integrations/core/tests/acp-facade-mcp.test.ts +++ b/packages/integrations/core/tests/acp-facade-mcp.test.ts @@ -76,4 +76,18 @@ describe("ACP facade MCP adapter", () => { BROWSERBASE_API_KEY: "bb-secret", }); }); + + it.skipIf(process.platform === "win32")( + "preserves signal termination from the facade process", + async () => { + const child = spawn( + process.execPath, + [builtFacadeLauncher, "-e", 'process.kill(process.pid, "SIGTERM")'], + { stdio: "ignore" }, + ); + + const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null]; + expect({ code, signal }).toStrictEqual({ code: null, signal: "SIGTERM" }); + }, + ); }); diff --git a/packages/integrations/core/tests/acp.test.ts b/packages/integrations/core/tests/acp.test.ts index 441fafcd9b..fd72ef40d1 100644 --- a/packages/integrations/core/tests/acp.test.ts +++ b/packages/integrations/core/tests/acp.test.ts @@ -156,6 +156,36 @@ describe("ACP facade runner", () => { ).rejects.toThrow(); }); + it("reports a controlled startup failure when the ACP executable is missing", async () => { + const runtime = await makeRuntime("success"); + await expect( + runAcpFacadeAgent({ + profile: { ...profile(), command: join(runtime.cwd, "missing-acp-agent") }, + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + terminationGraceMs: 10, + }), + ).rejects.toThrow("Unable to start ACP agent fake"); + }); + + it("does not spawn the ACP process when already cancelled", async () => { + const runtime = await makeRuntime("success"); + const controller = new AbortController(); + controller.abort(); + await expect( + runAcpFacadeAgent({ + profile: { ...profile(), command: join(runtime.cwd, "missing-acp-agent") }, + instruction: "Task", + cwd: runtime.cwd, + env: runtime.env, + facadeServerPath: "/facade.mjs", + signal: controller.signal, + }), + ).rejects.toThrow("run interrupted"); + }); + it.each([ { behavior: "empty", message: "returned no assistant text" }, { behavior: "refusal", message: "stopped with refusal" }, @@ -249,11 +279,16 @@ async function makeRuntime(behavior: string): Promise<{ temporaryDirectories.push(cwd); const recordPath = join(cwd, "events.jsonl"); await writeFile(recordPath, ""); + const hostEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.startsWith("STAGEHAND_") && !name.startsWith("BROWSERBASE_"), + ), + ); return { cwd, recordPath, env: { - ...process.env, + ...hostEnv, ACP_FAKE_BEHAVIOR: behavior, ACP_RECORD_PATH: recordPath, STAGEHAND_BROWSER: "local", diff --git a/packages/integrations/grok-build/src/agent.ts b/packages/integrations/grok-build/src/agent.ts index 0feade0dd7..532f068cd8 100644 --- a/packages/integrations/grok-build/src/agent.ts +++ b/packages/integrations/grok-build/src/agent.ts @@ -6,7 +6,7 @@ import { runAcpFacadeAgent } from "@browserbasehq/stagehand-integrations/acp"; import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; import { createRequire } from "node:module"; import { access, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; const require = createRequire(import.meta.url); @@ -55,6 +55,13 @@ export function resolveGrokExecutable(): string { return require.resolve("@xai-official/grok/bin/grok"); } +export function resolveGrokAuthHome(env: NodeJS.ProcessEnv): string | undefined { + const configured = env.GROK_HOME?.trim(); + if (configured) return configured; + const userHome = env.HOME?.trim() || env.USERPROFILE?.trim(); + return userHome ? join(userHome, ".grok") : undefined; +} + export function grokAcpArgs(agentProfilePath: string): string[] { return [ "--tools", @@ -155,15 +162,17 @@ export async function createGrokRuntime( let cachedAuthAvailable = false; if (!env.XAI_API_KEY?.trim()) { - const sourceHome = env.GROK_HOME?.trim() || join(homedir(), ".grok"); - const sourceAuth = join(sourceHome, "auth.json"); - try { - await access(sourceAuth); - await copyFile(sourceAuth, join(grokHome, "auth.json")); - cachedAuthAvailable = true; - } catch { - // Missing cached auth is reported after ACP initialization, when Grok's - // advertised methods are known. + const sourceHome = resolveGrokAuthHome(env); + if (sourceHome) { + const sourceAuth = join(sourceHome, "auth.json"); + try { + await access(sourceAuth); + await copyFile(sourceAuth, join(grokHome, "auth.json")); + cachedAuthAvailable = true; + } catch { + // Missing cached auth is reported after ACP initialization, when Grok's + // advertised methods are known. + } } } return { root, userHome, cwd, grokHome, agentProfilePath, cachedAuthAvailable }; diff --git a/packages/integrations/grok-build/tests/agent.test.ts b/packages/integrations/grok-build/tests/agent.test.ts index bad99e0169..f1ce47cd74 100644 --- a/packages/integrations/grok-build/tests/agent.test.ts +++ b/packages/integrations/grok-build/tests/agent.test.ts @@ -9,6 +9,7 @@ import { createGrokProfile, createGrokRuntime, grokAcpArgs, + resolveGrokAuthHome, resolveGrokExecutable, resolveInstruction, runGrokBuild, @@ -114,6 +115,15 @@ describe("Grok ACP profile", () => { await expect(access(executable)).resolves.toBeUndefined(); expect(executable).toContain("@xai-official/grok/bin/grok"); }); + + it("resolves cached auth only from the supplied environment", () => { + expect(resolveGrokAuthHome({ GROK_HOME: "/configured", HOME: "/home" })).toBe("/configured"); + expect(resolveGrokAuthHome({ HOME: "/home" })).toBe(join("/home", ".grok")); + expect(resolveGrokAuthHome({ USERPROFILE: "C:\\Users\\test" })).toBe( + join("C:\\Users\\test", ".grok"), + ); + expect(resolveGrokAuthHome({})).toBeUndefined(); + }); }); describe("Grok isolated runtime", () => { From 88adff8f28cf538db09f9c5b85bddebafc435615 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Fri, 21 Aug 2026 16:03:25 +0530 Subject: [PATCH 3/5] docs(integrations): list grok-build in the package overview --- packages/integrations/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 00665ba3d0..840302e7f3 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -17,6 +17,7 @@ else, never restated. | `deepagents/` | Python LangChain Deep Agents integrations: a local stdio MCP server and a Managed Deep Agents project with native tools. | | `eve/` | Eve example with the tools bound natively via `defineTool` (Eve has no external-process tool mounting). | | `fx/` | fx configuration templates and skill — fx consumes the facade via its user-global MCP config. | +| `grok-build/` | Grok Build example over ACP — the shared ACP transport in `core/` mounts the facade as the session's only MCP server. | | `mastra/` | Mastra example over MCP/stdio via Mastra's `MCPClient`. | | `pi/` | Pi extension registering the tools natively (Pi ships without built-in MCP). | | `vercel-ai/` | Vercel AI SDK example over MCP/stdio via `createMCPClient`. | From 62197564027f69f08ea1fdfdad48516e0e27b1d5 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Sun, 30 Aug 2026 23:20:55 +0530 Subject: [PATCH 4/5] fix(integrations): address ACP review feedback --- packages/docs/v4/integrations/grok-build.mdx | 4 ++-- .../integrations/core/src/acp/agent-process.ts | 6 +++++- packages/integrations/core/src/acp/env.ts | 10 +++------- .../core/src/acp/facade-launcher.ts | 5 ++++- packages/integrations/core/src/acp/index.ts | 8 ++++---- packages/integrations/core/src/harness/env.ts | 8 +++++--- .../integrations/core/tests/harness.test.ts | 18 +++++++----------- packages/integrations/grok-build/README.md | 2 +- packages/integrations/grok-build/src/agent.ts | 13 ++++++++++--- .../grok-build/tests/agent.test.ts | 5 ++++- 10 files changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/docs/v4/integrations/grok-build.mdx b/packages/docs/v4/integrations/grok-build.mdx index 261693f481..c82ed4f2ae 100644 --- a/packages/docs/v4/integrations/grok-build.mdx +++ b/packages/docs/v4/integrations/grok-build.mdx @@ -69,9 +69,9 @@ pnpm --dir packages/integrations/grok-build start -- \ The example opens one ACP session with Grok and mounts exactly one MCP server: the Stagehand facade. That server owns the browser for the full prompt turn, so `run`, `snapshot`, and `screenshot` share page state. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser. -Grok is restricted to a browser-only tool surface, matching the Claude and Codex examples. An agent profile allowlists only `search_tool` and `use_tool` (required to discover and invoke lazy MCP tools). Shell, file, plan, subagent, memory, and web-search capabilities are denied. The client approves one-time permission only for `stagehand__run`, `stagehand__snapshot`, and `stagehand__screenshot`; everything else is rejected or cancelled. +The agent profile restricts Grok to a browser-only tool surface, matching the Claude and Codex examples. It allows only `search_tool` and `use_tool`, which discover and invoke lazy MCP tools, and denies shell, file, plan, subagent, memory, and web-search capabilities. The client approves one-time permission only for `stagehand__run`, `stagehand__snapshot`, and `stagehand__screenshot`; it rejects or cancels everything else. -Each run uses a disposable workspace, home directory, and `GROK_HOME`. Compatibility MCP imports from Claude/Cursor are disabled, and user or project MCP servers, plugins, hooks, skills, and rules are not loaded. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables plus basic OS values needed to launch Node and local Chrome; `XAI_API_KEY` and unrelated host secrets are unavailable to the facade. The ACP bridge terminates the full agent process tree on cancellation or failure. +Each run uses a disposable workspace, home directory, and `GROK_HOME`. The launcher disables compatibility MCP imports from Claude and Cursor, and it does not load user or project MCP servers, plugins, hooks, skills, or rules. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables plus basic OS values needed to launch Node and local Chrome; it does not expose `XAI_API_KEY` or unrelated host secrets to the facade. The ACP bridge terminates the full agent process tree on cancellation or failure. ## Troubleshooting diff --git a/packages/integrations/core/src/acp/agent-process.ts b/packages/integrations/core/src/acp/agent-process.ts index 01f8724625..1c11030feb 100644 --- a/packages/integrations/core/src/acp/agent-process.ts +++ b/packages/integrations/core/src/acp/agent-process.ts @@ -2,6 +2,8 @@ import { ndJsonStream, type Stream } from "@agentclientprotocol/sdk"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { Readable, Writable } from "node:stream"; +import { HarnessAdapterError } from "../harness/contract.js"; + export type AcpAgentProcess = { readonly transport: Stream; readonly started: Promise; @@ -24,7 +26,9 @@ export function spawnAcpAgentProcess(options: { }); const started = new Promise((resolve, reject) => { child.once("spawn", resolve); - child.on("error", reject); + child.on("error", (error) => { + reject(new HarnessAdapterError("Unable to start the ACP agent process.", { cause: error })); + }); }); child.stderr.pipe(options.stderr, { end: false }); diff --git a/packages/integrations/core/src/acp/env.ts b/packages/integrations/core/src/acp/env.ts index 11f44390c5..03c233ac0f 100644 --- a/packages/integrations/core/src/acp/env.ts +++ b/packages/integrations/core/src/acp/env.ts @@ -1,10 +1,6 @@ -export function buildAcpFacadeEnv(source: NodeJS.ProcessEnv = process.env): Record { - const env: Record = {}; - for (const [key, value] of Object.entries(source)) { - if (/^(STAGEHAND_|BROWSERBASE_)/u.test(key) && value) env[key] = value; - } - return env; -} +import { buildAllowlistedEnv } from "../harness/env.js"; + +export const buildAcpFacadeEnv = buildAllowlistedEnv; const RUNTIME_ENV_KEYS = [ "PATH", diff --git a/packages/integrations/core/src/acp/facade-launcher.ts b/packages/integrations/core/src/acp/facade-launcher.ts index ae1725308a..aee4750828 100644 --- a/packages/integrations/core/src/acp/facade-launcher.ts +++ b/packages/integrations/core/src/acp/facade-launcher.ts @@ -1,9 +1,12 @@ import { spawn } from "node:child_process"; +import { HarnessAdapterError } from "../harness/contract.js"; import { buildAcpFacadeRuntimeEnv } from "./env.js"; const [facadeServerPath, ...facadeArgs] = process.argv.slice(2); -if (!facadeServerPath) throw new Error("Stagehand ACP facade launcher requires a server path."); +if (!facadeServerPath) { + throw new HarnessAdapterError("Stagehand ACP facade launcher requires a server path."); +} // ACP agents may merge an MCP server's declared env with their own environment. // Start the actual facade as a second process so its environment is enforced here. diff --git a/packages/integrations/core/src/acp/index.ts b/packages/integrations/core/src/acp/index.ts index 83be0d7012..119e6e0d51 100644 --- a/packages/integrations/core/src/acp/index.ts +++ b/packages/integrations/core/src/acp/index.ts @@ -13,6 +13,7 @@ import { import { fileURLToPath } from "node:url"; import { FACADE_AGENT_INSTRUCTIONS } from "../facade/contract.js"; +import { HarnessAdapterError } from "../harness/contract.js"; import { spawnAcpAgentProcess } from "./agent-process.js"; import { buildAcpFacadeMcpServer } from "./facade-mcp.js"; @@ -110,10 +111,9 @@ export async function runAcpFacadeAgent(options: RunAcpFacadeAgentOptions): Prom try { await agentProcess.started; } catch (error) { - throw new Error( - `Unable to start ACP agent ${options.profile.id}: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); + throw new HarnessAdapterError(`Unable to start ACP agent ${options.profile.id}.`, { + cause: error, + }); } return await app.connectWith(agentProcess.transport, async (context) => { agentContext = context; diff --git a/packages/integrations/core/src/harness/env.ts b/packages/integrations/core/src/harness/env.ts index 43069f433a..4e5f413622 100644 --- a/packages/integrations/core/src/harness/env.ts +++ b/packages/integrations/core/src/harness/env.ts @@ -1,8 +1,10 @@ /** Only STAGEHAND_* and BROWSERBASE_* host env vars cross into harness processes. */ -export function buildAllowlistedEnv(): Record { +export function buildAllowlistedEnv( + source: NodeJS.ProcessEnv = process.env, +): Record { const env: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (/^(STAGEHAND_|BROWSERBASE_)/.test(key) && value) { + for (const [key, value] of Object.entries(source)) { + if (/^(STAGEHAND_|BROWSERBASE_)/u.test(key) && value) { env[key] = value; } } diff --git a/packages/integrations/core/tests/harness.test.ts b/packages/integrations/core/tests/harness.test.ts index 8ec2288c96..7747f979dc 100644 --- a/packages/integrations/core/tests/harness.test.ts +++ b/packages/integrations/core/tests/harness.test.ts @@ -1,11 +1,7 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildAllowlistedEnv, sanitizeErrorMessage } from "../src/harness/index.js"; describe("harness contract", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - it("redacts credential-bearing URL query parameters", () => { expect(sanitizeErrorMessage("wss://example.test?signingKey=top-secret&foo=bar")).toBe( "wss://example.test?signingKey=[redacted]&foo=bar", @@ -31,12 +27,12 @@ describe("harness contract", () => { }); it("allows Stagehand and Browserbase env vars while excluding other and empty values", () => { - vi.stubEnv("STAGEHAND_MODEL", "model"); - vi.stubEnv("BROWSERBASE_API_KEY", "browserbase-key"); - vi.stubEnv("STAGEHAND_EMPTY", ""); - vi.stubEnv("NOT_ALLOWLISTED_SECRET", "secret"); - - const env = buildAllowlistedEnv(); + const env = buildAllowlistedEnv({ + STAGEHAND_MODEL: "model", + BROWSERBASE_API_KEY: "browserbase-key", + STAGEHAND_EMPTY: "", + NOT_ALLOWLISTED_SECRET: "secret", + }); expect(env.STAGEHAND_MODEL).toBe("model"); expect(env.BROWSERBASE_API_KEY).toBe("browserbase-key"); diff --git a/packages/integrations/grok-build/README.md b/packages/integrations/grok-build/README.md index 9ac03d6b42..7a70bb435b 100644 --- a/packages/integrations/grok-build/README.md +++ b/packages/integrations/grok-build/README.md @@ -7,7 +7,7 @@ This example starts the packaged Grok Build CLI as an ACP v1 agent and mounts th From the repository root: ```bash -pnpm install +pnpm install --frozen-lockfile pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations export XAI_API_KEY=xai-... pnpm --dir packages/integrations/grok-build start -- \ diff --git a/packages/integrations/grok-build/src/agent.ts b/packages/integrations/grok-build/src/agent.ts index 532f068cd8..8fb9a76a28 100644 --- a/packages/integrations/grok-build/src/agent.ts +++ b/packages/integrations/grok-build/src/agent.ts @@ -4,6 +4,7 @@ import type { } from "@browserbasehq/stagehand-integrations/acp"; import { runAcpFacadeAgent } from "@browserbasehq/stagehand-integrations/acp"; import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; +import { sanitizeErrorMessage } from "@browserbasehq/stagehand-integrations/harness"; import { createRequire } from "node:module"; import { access, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -55,10 +56,16 @@ export function resolveGrokExecutable(): string { return require.resolve("@xai-official/grok/bin/grok"); } -export function resolveGrokAuthHome(env: NodeJS.ProcessEnv): string | undefined { +export function resolveGrokAuthHome( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): string | undefined { const configured = env.GROK_HOME?.trim(); if (configured) return configured; - const userHome = env.HOME?.trim() || env.USERPROFILE?.trim(); + const userHome = + platform === "win32" + ? env.USERPROFILE?.trim() || env.HOME?.trim() + : env.HOME?.trim() || env.USERPROFILE?.trim(); return userHome ? join(userHome, ".grok") : undefined; } @@ -245,6 +252,6 @@ if (import.meta.main) { function handleFailure(error: unknown): void { // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. - console.error(error instanceof Error ? error.message : error); + console.error(sanitizeErrorMessage(error instanceof Error ? error.message : String(error))); process.exitCode = 1; } diff --git a/packages/integrations/grok-build/tests/agent.test.ts b/packages/integrations/grok-build/tests/agent.test.ts index f1ce47cd74..be9e3c9cec 100644 --- a/packages/integrations/grok-build/tests/agent.test.ts +++ b/packages/integrations/grok-build/tests/agent.test.ts @@ -113,7 +113,7 @@ describe("Grok ACP profile", () => { it("resolves the packaged Grok executable", async () => { const executable = resolveGrokExecutable(); await expect(access(executable)).resolves.toBeUndefined(); - expect(executable).toContain("@xai-official/grok/bin/grok"); + expect(executable.replaceAll("\\", "/")).toContain("@xai-official/grok/bin/grok"); }); it("resolves cached auth only from the supplied environment", () => { @@ -122,6 +122,9 @@ describe("Grok ACP profile", () => { expect(resolveGrokAuthHome({ USERPROFILE: "C:\\Users\\test" })).toBe( join("C:\\Users\\test", ".grok"), ); + expect( + resolveGrokAuthHome({ HOME: "/git-bash/home", USERPROFILE: "C:\\Users\\test" }, "win32"), + ).toBe(join("C:\\Users\\test", ".grok")); expect(resolveGrokAuthHome({})).toBeUndefined(); }); }); From 46a63e6e946194691342ca9f6869ef1334cc079c Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 2 Sep 2026 16:31:44 +0530 Subject: [PATCH 5/5] feat(integrations): add Grok Build harness --- .github/workflows/ci.yml | 1 + packages/docs/v4/integrations/grok-build.mdx | 91 ++-- packages/docs/v4/integrations/overview.mdx | 2 +- packages/evals/framework/benchHarness.ts | 11 + packages/evals/framework/grokBuildRunner.ts | 187 +++++++ .../evals/framework/grokBuildToolAdapter.ts | 315 ++++++++++++ .../framework/harnesses/grokBuildAdapter.ts | 119 +++++ packages/evals/package.json | 2 + .../tests/framework/benchHarness.test.ts | 19 +- .../tests/framework/grokBuildAdapter.test.ts | 61 +++ .../tests/framework/grokBuildRunner.test.ts | 88 ++++ .../framework/grokBuildToolAdapter.test.ts | 88 ++++ packages/integrations/README.md | 29 +- packages/integrations/core/package.json | 4 - .../core/src/acp/agent-process.ts | 117 ----- packages/integrations/core/src/acp/env.ts | 32 -- .../core/src/acp/facade-launcher.ts | 39 -- .../integrations/core/src/acp/facade-mcp.ts | 17 - packages/integrations/core/src/acp/index.ts | 192 ------- .../core/src/facade/stdio-server.ts | 4 +- packages/integrations/core/src/harness/env.ts | 8 +- .../core/tests/acp-facade-mcp.test.ts | 93 ---- packages/integrations/core/tests/acp.test.ts | 320 ------------ .../core/tests/fixtures/fake-acp-agent.mjs | 99 ---- .../core/tests/fixtures/record-env.mjs | 14 - .../integrations/core/tests/harness.test.ts | 18 +- packages/integrations/core/tsdown.config.ts | 2 - .../integrations/grok-build-sdk/package.json | 34 ++ .../integrations/grok-build-sdk/src/index.ts | 1 + .../grok-build-sdk/src/session.ts | 460 +++++++++++++++++ .../grok-build-sdk/tests/session.test.ts | 190 +++++++ .../tsconfig.json | 2 +- .../grok-build-sdk/tsdown.config.ts | 11 + .../integrations/grok-build/.grok/config.toml | 15 + packages/integrations/grok-build/AGENTS.md | 9 + packages/integrations/grok-build/README.md | 47 +- packages/integrations/grok-build/package.json | 24 - packages/integrations/grok-build/src/agent.ts | 257 ---------- .../grok-build/tests/agent.test.ts | 225 -------- .../integrations/grok-build/vitest.config.ts | 9 - pnpm-lock.yaml | 481 ++++-------------- pnpm-workspace.yaml | 3 - turbo.json | 21 +- vitest.config.ts | 1 + 44 files changed, 1840 insertions(+), 1922 deletions(-) create mode 100644 packages/evals/framework/grokBuildRunner.ts create mode 100644 packages/evals/framework/grokBuildToolAdapter.ts create mode 100644 packages/evals/framework/harnesses/grokBuildAdapter.ts create mode 100644 packages/evals/tests/framework/grokBuildAdapter.test.ts create mode 100644 packages/evals/tests/framework/grokBuildRunner.test.ts create mode 100644 packages/evals/tests/framework/grokBuildToolAdapter.test.ts delete mode 100644 packages/integrations/core/src/acp/agent-process.ts delete mode 100644 packages/integrations/core/src/acp/env.ts delete mode 100644 packages/integrations/core/src/acp/facade-launcher.ts delete mode 100644 packages/integrations/core/src/acp/facade-mcp.ts delete mode 100644 packages/integrations/core/src/acp/index.ts delete mode 100644 packages/integrations/core/tests/acp-facade-mcp.test.ts delete mode 100644 packages/integrations/core/tests/acp.test.ts delete mode 100644 packages/integrations/core/tests/fixtures/fake-acp-agent.mjs delete mode 100644 packages/integrations/core/tests/fixtures/record-env.mjs create mode 100644 packages/integrations/grok-build-sdk/package.json create mode 100644 packages/integrations/grok-build-sdk/src/index.ts create mode 100644 packages/integrations/grok-build-sdk/src/session.ts create mode 100644 packages/integrations/grok-build-sdk/tests/session.test.ts rename packages/integrations/{grok-build => grok-build-sdk}/tsconfig.json (80%) create mode 100644 packages/integrations/grok-build-sdk/tsdown.config.ts create mode 100644 packages/integrations/grok-build/.grok/config.toml create mode 100644 packages/integrations/grok-build/AGENTS.md delete mode 100644 packages/integrations/grok-build/package.json delete mode 100644 packages/integrations/grok-build/src/agent.ts delete mode 100644 packages/integrations/grok-build/tests/agent.test.ts delete mode 100644 packages/integrations/grok-build/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ce240ccc3..53819bc098 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,7 @@ jobs: packages/integrations/deepagents-sdk/dist/** packages/integrations/fx-sdk/dist/** packages/integrations/cursor-sdk/dist/** + packages/integrations/grok-build-sdk/dist/** packages/evals/dist/** retention-days: 1 diff --git a/packages/docs/v4/integrations/grok-build.mdx b/packages/docs/v4/integrations/grok-build.mdx index c82ed4f2ae..913583a39d 100644 --- a/packages/docs/v4/integrations/grok-build.mdx +++ b/packages/docs/v4/integrations/grok-build.mdx @@ -1,9 +1,9 @@ --- title: "Grok Build" -description: "Give a Grok Build agent persistent Stagehand browser tools over ACP." +description: "Give Grok Build persistent Stagehand browser tools through its CLI and project MCP configuration." --- -The Grok Build integration starts Grok as an ACP agent and gives its session exactly one MCP server: the Stagehand facade MCP server. The facade owns one persistent browser and exposes `run`, `snapshot`, and `screenshot` for the full prompt turn. +Grok Build can load the Stagehand facade as a project MCP server. The facade owns one persistent browser and exposes `run`, `snapshot`, and `screenshot` for the full CLI run. The integration uses Grok's headless streaming output directly. Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter. @@ -13,84 +13,73 @@ Stagehand ships this experimental integration from the repository rather than pu - Node.js 24 or newer - pnpm 11.10.0 -- `XAI_API_KEY` or an existing `grok login` -- A current Google Chrome installation for local browser mode +- The Grok Build CLI and either `XAI_API_KEY` or an existing `grok login` +- Google Chrome for local browser mode ## Quickstart - + ```bash git clone https://github.com/browserbase/stagehand.git cd stagehand pnpm install --frozen-lockfile -pnpm exec turbo run build \ - --filter @browserbasehq/stagehand-integrations +pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations ``` - -Use an API key for headless or CI runs: - + ```bash -export XAI_API_KEY="your-xai-api-key" +npm install --global @xai-official/grok +grok login +# or: export XAI_API_KEY="your-xai-api-key" ``` - -Alternatively, run `grok login` once. The example copies only Grok's cached `auth.json` into its temporary isolated home. - -The example defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome: - -```bash -export STAGEHAND_BROWSER="browserbase" -export BROWSERBASE_API_KEY="your-browserbase-api-key" -``` + +Copy `packages/integrations/grok-build/.grok/config.toml` into the project where Grok will run. Replace the absolute facade path and browser credentials. ```bash -pnpm --dir packages/integrations/grok-build start -- \ - "Open https://example.com, snapshot it, request a screenshot, and report the title." +cd packages/integrations/grok-build +grok -p \ + --output-format streaming-json \ + --always-approve \ + --tools search_tool,use_tool \ + --disallowed-tools Agent \ + --no-plan \ + --no-subagents \ + --disable-web-search \ + "Open https://example.com, snapshot it, and report the heading." ``` -## Configuration +## Eval harness + +The registered `grok_build` harness follows the same CLI pattern as the Cursor harness. It creates an isolated temporary home and workspace, copies cached Grok authentication only when no API key is present, writes the selected Stagehand MCP mount to project configuration, runs `grok -p --output-format streaming-json`, and maps native tool and usage events into the shared verifier trajectory. + +```bash +evals run b:webvoyager \ + --harness grok_build \ + --tool stagehand_facade \ + -l 1 -t 1 -e browserbase +``` | Variable | Purpose | | --- | --- | -| `XAI_API_KEY` | Grok agent credential. It is not made available to the Stagehand facade runtime. | -| `GROK_HOME` | Optional path to an existing Grok home. When `XAI_API_KEY` is unset, the example copies cached `auth.json` from this directory (or `~/.grok`) into the isolated runtime. | -| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. Defaults to `browserbase` when `BROWSERBASE_API_KEY` is set, otherwise `local`. | +| `XAI_API_KEY` | Grok credential. The Stagehand MCP server receives only its own configured environment. | +| `GROK_HOME` | Source for cached `auth.json` when `XAI_API_KEY` is unset. | +| `EVAL_GROK_BUILD_PATH` | Optional path to the `grok` binary for eval runs. | +| `EVAL_GROK_BUILD_MAX_TURNS` | Grok turn limit. Defaults to 50. | +| `EVAL_GROK_BUILD_SANDBOX` | Optional Grok sandbox profile. | +| `STAGEHAND_BROWSER` | `local` or `browserbase`. | | `BROWSERBASE_API_KEY` | Required for Browserbase. | -| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. | -| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. | -| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive Grok's credential. | - -## Keep the browser session alive - -The example opens one ACP session with Grok and mounts exactly one MCP server: the Stagehand facade. That server owns the browser for the full prompt turn, so `run`, `snapshot`, and `screenshot` share page state. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser. - -The agent profile restricts Grok to a browser-only tool surface, matching the Claude and Codex examples. It allows only `search_tool` and `use_tool`, which discover and invoke lazy MCP tools, and denies shell, file, plan, subagent, memory, and web-search capabilities. The client approves one-time permission only for `stagehand__run`, `stagehand__snapshot`, and `stagehand__screenshot`; it rejects or cancels everything else. - -Each run uses a disposable workspace, home directory, and `GROK_HOME`. The launcher disables compatibility MCP imports from Claude and Cursor, and it does not load user or project MCP servers, plugins, hooks, skills, or rules. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables plus basic OS values needed to launch Node and local Chrome; it does not expose `XAI_API_KEY` or unrelated host secrets to the facade. The ACP bridge terminates the full agent process tree on cancellation or failure. - -## Troubleshooting - - -Set `XAI_API_KEY` or run `grok login`. A login stored under a custom home is discovered when `GROK_HOME` points to that directory before starting the example. - - -Build `@browserbasehq/stagehand-integrations` from the repository root. The ACP profile resolves the built `facade/stdio-server.mjs` entrypoint. - - -Confirm Chrome is available for local mode, or set valid Browserbase credentials. The browser starts lazily on the first facade tool call. - - +The harness supports `stagehand_facade`, `playwright_mcp`, and `chrome_devtools_mcp`. It restricts Grok to its MCP discovery and invocation tools, disables subagents, plan mode, and web search, and asks Grok not to edit repository files. The temporary Grok home disables compatibility MCP imports, memory, and background leader reuse so user configuration cannot add unrelated tools. `run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/overview#security-boundary). - Read the ACP profile, isolated runtime setup, and tests. + Open the CLI configuration and eval harness instructions. diff --git a/packages/docs/v4/integrations/overview.mdx b/packages/docs/v4/integrations/overview.mdx index 47c9415621..a708f31cf0 100644 --- a/packages/docs/v4/integrations/overview.mdx +++ b/packages/docs/v4/integrations/overview.mdx @@ -81,7 +81,7 @@ Every integration exposes the same browser capabilities. The tools share a browser for the lifetime of the integration's client session. A navigation performed by `run` is visible to the next `snapshot`, and authentication and page state remain available across calls. -The Claude Code, Codex, CrewAI, Mastra, fx, Vercel AI SDK, and local Deep Agents examples keep one MCP client session in the host so the stdio server and browser stay alive. Grok Build uses ACP: Stagehand is the ACP client and mounts the same facade as the session's only MCP server on `session/new`, so the browser still lasts the full prompt turn. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. +The Claude Code, Codex, Grok Build, CrewAI, Mastra, fx, Vercel AI SDK, and local Deep Agents examples keep one MCP client session open so the stdio server and browser stay alive. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. The private [`core/` workspace package](https://github.com/browserbase/stagehand/tree/main/packages/integrations/core) owns the shared TypeScript tool contract, browser runtime, native bindings, and stdio MCP server. The TypeScript integrations and CrewAI use this package. diff --git a/packages/evals/framework/benchHarness.ts b/packages/evals/framework/benchHarness.ts index 99c8f3d610..40e83a3eb4 100644 --- a/packages/evals/framework/benchHarness.ts +++ b/packages/evals/framework/benchHarness.ts @@ -22,6 +22,8 @@ import { runFxAgent } from "./fxRunner.js"; import { FX_TOOL_SURFACES, prepareFxToolAdapter } from "./fxToolAdapter.js"; import { runCursorAgent } from "./cursorRunner.js"; import { CURSOR_TOOL_SURFACES, prepareCursorToolAdapter } from "./cursorToolAdapter.js"; +import { runGrokBuildAgent } from "./grokBuildRunner.js"; +import { GROK_BUILD_TOOL_SURFACES, prepareGrokBuildToolAdapter } from "./grokBuildToolAdapter.js"; import { buildExternalHarnessTaskPlan, type ExternalHarnessTaskPlan, @@ -346,6 +348,14 @@ export const cursorHarness = defineExternalHarness({ runAgent: runCursorAgent, }); +export const grokBuildHarness = defineExternalHarness({ + harness: "grok_build", + supportedToolSurfaces: GROK_BUILD_TOOL_SURFACES, + defaultModels: ["grok-build/auto" as AvailableModel], + prepareToolAdapter: prepareGrokBuildToolAdapter, + runAgent: runGrokBuildAgent, +}); + const harnessRegistry = new Map([ ["stagehand", stagehandHarness], ["claude_code", claudeCodeHarness], @@ -356,6 +366,7 @@ const harnessRegistry = new Map([ ["deepagents", deepagentsHarness], ["fx", fxHarness], ["cursor", cursorHarness], + ["grok_build", grokBuildHarness], ]); export function registerBenchHarness(harness: BenchHarness): () => void { diff --git a/packages/evals/framework/grokBuildRunner.ts b/packages/evals/framework/grokBuildRunner.ts new file mode 100644 index 0000000000..4debc2ab2d --- /dev/null +++ b/packages/evals/framework/grokBuildRunner.ts @@ -0,0 +1,187 @@ +import { + buildGrokBuildTranscript, + extractGrokBuildToolCall, + runGrokBuildSession, + stringifyError, + type GrokBuildProcessRunner, + type GrokBuildTokenUsage, +} from "@browserbasehq/stagehand-integrations-grok-build-sdk"; +import type { AvailableModel } from "stagehand-v3"; +import type { EvalLogger } from "../logger.js"; +import type { ExternalHarnessTaskPlan } from "./externalHarnessPlan.js"; +import type { PreparedGrokBuildToolAdapter } from "./grokBuildToolAdapter.js"; +import { grokBuildAdapter } from "./harnesses/grokBuildAdapter.js"; +import { + buildExternalHarnessPrompt, + metricValue, + parseEvalResult, + runExternalHarnessTask, + type ExternalHarnessToolAdapterLike, + type MetricValue, + type ParsedEvalResult, +} from "./harnesses/externalRunner.js"; +import type { TaskResult } from "./types.js"; +import type { ExternalHarnessVerifierConfig } from "./verifierAdapter.js"; + +export type { GrokBuildProcessRunner } from "@browserbasehq/stagehand-integrations-grok-build-sdk"; + +export interface GrokBuildRunnerInput { + plan: ExternalHarnessTaskPlan; + model: AvailableModel; + logger: EvalLogger; + toolAdapter?: PreparedGrokBuildToolAdapter; + signal?: AbortSignal; + runProcess?: GrokBuildProcessRunner; + verifier?: ExternalHarnessVerifierConfig; +} + +export interface ParsedGrokBuildResult extends ParsedEvalResult {} + +const MCP_ONLY_LINE = + "Your only browser access is the MCP server configured in this workspace. Never launch a browser yourself or run shell commands to browse."; + +function composeGrokBuildToolInstructions(toolInstructions?: string): string { + return [ + toolInstructions ?? "Use the available browser tools to complete the task.", + MCP_ONLY_LINE, + "Do not edit repository files.", + ].join("\n"); +} + +export function buildGrokBuildPrompt( + plan: ExternalHarnessTaskPlan, + toolInstructions?: string, +): string { + return buildExternalHarnessPrompt({ + plan, + toolInstructions: composeGrokBuildToolInstructions(toolInstructions), + resultContract: "marker", + }); +} + +export function parseGrokBuildResult(raw: string): ParsedGrokBuildResult { + return parseEvalResult(raw); +} + +export async function runGrokBuildAgent({ + plan, + model, + logger, + toolAdapter, + signal, + runProcess, + verifier, +}: GrokBuildRunnerInput): Promise { + const adapterLike: ExternalHarnessToolAdapterLike = { + promptInstructions: composeGrokBuildToolInstructions(toolAdapter?.promptInstructions), + captureEvidence: toolAdapter?.captureEvidence, + drainStepObservations: toolAdapter?.drainStepObservations, + observedToolMatcher: toolAdapter?.observedToolMatcher, + }; + return runExternalHarnessTask({ + harness: "grok_build", + plan, + logger, + toolAdapter: adapterLike, + verifier, + resultContract: "marker", + fallbackErrorMessage: "Grok Build did not report success", + parseResult: parseGrokBuildResult, + runSession: async (prompt) => { + const sessionResult = await runGrokBuildSession({ + prompt, + model, + logger, + signal, + runProcess, + session: { + ...(toolAdapter?.cwd && { cwd: toolAdapter.cwd }), + ...(toolAdapter?.env && { env: toolAdapter.env }), + ...(process.env.EVAL_GROK_BUILD_PATH && { + binaryPath: process.env.EVAL_GROK_BUILD_PATH, + }), + maxTurns: readGrokBuildMaxTurns(), + ...(process.env.EVAL_GROK_BUILD_SANDBOX && { + sandbox: process.env.EVAL_GROK_BUILD_SANDBOX, + }), + }, + onToolResult: toolAdapter?.onToolResult + ? (name) => toolAdapter.onToolResult!(name) + : undefined, + }); + const usage = sessionResult.tokenUsage; + return { + raw: sessionResult, + resultText: sessionResult.resultText, + transcriptText: buildGrokBuildTranscript(sessionResult.events), + iterationError: sessionResult.iterationError, + status: sessionResult.status, + stopReason: + sessionResult.stopReason || + (sessionResult.status === "sdk_error" + ? stringifyError(sessionResult.iterationError) || undefined + : undefined), + usage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + ...(usage.reported && { + cachedInputTokens: usage.cachedInputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + reasoningOutputTokens: usage.reasoningOutputTokens, + }), + }, + costUsd: sessionResult.costUsd, + metrics: buildGrokBuildMetrics(usage, sessionResult.endEvent, sessionResult.events), + }; + }, + toTrajectory: ( + { raw, parsed, finalObservation, stepObservations, observedToolName, status }, + taskSpec, + ) => + grokBuildAdapter.fromHarnessResult( + { + events: raw.events, + ...(finalObservation && { finalObservation }), + ...(stepObservations?.length && { stepObservations }), + ...(observedToolName && { observedToolName }), + finalAnswer: parsed.finalAnswer ?? raw.resultText, + status, + usage: { + input_tokens: raw.tokenUsage.inputTokens, + output_tokens: raw.tokenUsage.outputTokens, + cached_input_tokens: raw.tokenUsage.cachedInputTokens, + reasoning_tokens: raw.tokenUsage.reasoningOutputTokens, + }, + }, + taskSpec, + ), + }); +} + +export function readGrokBuildMaxTurns(): number { + for (const key of ["EVAL_GROK_BUILD_MAX_TURNS", "AGENT_EVAL_MAX_STEPS"]) { + const parsed = Number.parseInt(process.env[key] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 50; +} + +function buildGrokBuildMetrics( + usage: GrokBuildTokenUsage, + endEvent: Record | undefined, + events: Array>, +): Record { + const toolSteps = events.filter( + (event) => extractGrokBuildToolCall(event)?.subtype === "completed", + ).length; + return { + grok_build_input_tokens: metricValue(usage.inputTokens), + grok_build_output_tokens: metricValue(usage.outputTokens), + grok_build_total_tokens: metricValue(usage.totalTokens), + grok_build_cached_input_tokens: metricValue(usage.cachedInputTokens), + grok_build_reasoning_tokens: metricValue(usage.reasoningOutputTokens), + grok_build_num_turns: metricValue(endEvent?.num_turns), + grok_build_tool_steps: metricValue(toolSteps), + }; +} diff --git a/packages/evals/framework/grokBuildToolAdapter.ts b/packages/evals/framework/grokBuildToolAdapter.ts new file mode 100644 index 0000000000..855674055e --- /dev/null +++ b/packages/evals/framework/grokBuildToolAdapter.ts @@ -0,0 +1,315 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { stringify } from "smol-toml"; +import type { ProbeEvidence } from "stagehand-v3"; +import type { StartupProfile, ToolSurface } from "../core/contracts/tool.js"; +import { EvalsError } from "../errors.js"; +import type { EvalLogger } from "../logger.js"; +import { startAgentToolRuntime } from "./agentToolRuntime.js"; +import type { ExternalHarnessTaskPlan } from "./externalHarnessPlan.js"; +import { resolveStartupProfile, resolveToolSurface } from "./harnesses/toolSurfaceResolution.js"; +import { ObservationRecorder, type StepObservation } from "./observationRecorder.js"; + +export interface GrokBuildToolAdapterInput { + toolSurface?: ToolSurface; + startupProfile?: StartupProfile; + environment: "LOCAL" | "BROWSERBASE"; + plan: ExternalHarnessTaskPlan; + logger: EvalLogger; +} + +export interface PreparedGrokBuildToolAdapter { + toolSurface: ToolSurface; + startupProfile: StartupProfile; + cwd: string; + home: string; + grokHome: string; + env: Record; + mcpConfigPath: string; + mcpServerNames: string[]; + promptInstructions: string; + captureEvidence?: () => Promise; + drainStepObservations?: () => Promise; + onToolResult?: (toolName: string) => void; + observedToolMatcher?: (name: string) => boolean; + cleanup: () => Promise; +} + +type GrokBuildMcpServerSpec = { + command: string; + args?: string[]; + env?: Record; +}; + +export const GROK_BUILD_TOOL_SURFACES: ToolSurface[] = [ + "stagehand_facade", + "playwright_mcp", + "chrome_devtools_mcp", +]; + +export function buildGrokBuildMcpConfig( + mcpServers: Record, +): Record { + const normalized: Record = {}; + for (const [serverName, rawSpec] of Object.entries(mcpServers)) { + if (!isRecord(rawSpec) || typeof rawSpec.command !== "string") { + throw new EvalsError(`Invalid Grok Build MCP launch spec for server "${serverName}".`); + } + const spec = rawSpec as GrokBuildMcpServerSpec; + normalized[serverName] = { + command: spec.command, + args: stringArray(spec.args), + ...(isStringRecord(spec.env) && { env: spec.env }), + startup_timeout_sec: 60, + tool_timeout_sec: 300, + }; + } + return { mcp_servers: normalized }; +} + +export async function writeGrokBuildWorkspace( + cwd: string, + grokHome: string, + mcpServers: Record, +): Promise<{ mcpConfigPath: string }> { + const projectConfigDir = path.join(cwd, ".grok"); + const mcpConfigPath = path.join(projectConfigDir, "config.toml"); + await Promise.all([ + fsp.mkdir(projectConfigDir, { recursive: true }), + fsp.mkdir(grokHome, { recursive: true }), + ]); + await Promise.all([ + fsp.writeFile( + path.join(grokHome, "config.toml"), + stringify({ + cli: { auto_update: false, use_leader: false }, + compat: { claude: { mcps: false }, cursor: { mcps: false } }, + subagents: { enabled: false }, + memory: { enabled: false }, + }), + { mode: 0o600 }, + ), + fsp.writeFile(mcpConfigPath, stringify(buildGrokBuildMcpConfig(mcpServers)), { + mode: 0o600, + }), + ]); + return { mcpConfigPath }; +} + +export function resolveGrokBuildAuthHome( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): string | undefined { + const configured = env.GROK_HOME?.trim(); + if (configured) return configured; + const userHome = + platform === "win32" + ? env.USERPROFILE?.trim() || env.HOME?.trim() + : env.HOME?.trim() || env.USERPROFILE?.trim(); + return userHome ? path.join(userHome, ".grok") : undefined; +} + +export async function copyGrokBuildAuth( + env: NodeJS.ProcessEnv, + targetGrokHome: string, +): Promise { + if (env.XAI_API_KEY?.trim()) return false; + const sourceHome = resolveGrokBuildAuthHome(env); + if (!sourceHome) return false; + try { + await fsp.copyFile(path.join(sourceHome, "auth.json"), path.join(targetGrokHome, "auth.json")); + return true; + } catch { + return false; + } +} + +export function isGrokBuildMountToolName(serverNames: string[], toolName: string): boolean { + return serverNames.some( + (server) => + toolName === server || + toolName.startsWith(`${server}.`) || + toolName.startsWith(`${server}__`) || + toolName === `mcp__${server}` || + toolName.startsWith(`mcp__${server}__`), + ); +} + +export async function prepareGrokBuildToolAdapter( + input: GrokBuildToolAdapterInput, +): Promise { + const toolSurface = resolveToolSurface( + { harness: "grok_build", supportedToolSurfaces: GROK_BUILD_TOOL_SURFACES }, + input.toolSurface, + ); + if (toolSurface === undefined) { + throw new EvalsError("grok_build harness requires a tool surface."); + } + const startupProfile = resolveStartupProfile( + toolSurface, + input.environment, + input.startupProfile, + ); + const runtime = await startAgentToolRuntime({ + toolSurface, + startupProfile, + environment: input.environment, + logger: input.logger, + }); + + let root: string | undefined; + try { + const mount = runtime.running.agentMount; + if (!mount) { + throw new EvalsError(`Tool surface "${toolSurface}" does not provide an agent mount.`); + } + if (mount.via !== "mcp") { + throw new EvalsError( + `Grok Build does not support agent mounts delivered via "${mount.via}" yet.`, + ); + } + + root = await fsp.mkdtemp( + path.join(os.tmpdir(), `stagehand-evals-grok-build-${toolSurface.replace(/_/gu, "-")}-`), + ); + const capturedRoot = root; + const home = path.join(root, "home"); + const cwd = path.join(root, "workspace"); + const grokHome = path.join(home, ".grok"); + await Promise.all([ + fsp.mkdir(cwd, { recursive: true }), + fsp.mkdir(grokHome, { recursive: true }), + ]); + await copyGrokBuildAuth(process.env, grokHome); + const { mcpConfigPath } = await writeGrokBuildWorkspace(cwd, grokHome, mount.mcpServers); + const mcpServerNames = Object.keys(mount.mcpServers); + const recorder = runtime.running.captureEvidence + ? new ObservationRecorder(runtime.running.captureEvidence) + : undefined; + const observedToolMatcher = (name: string): boolean => + isGrokBuildMountToolName(mcpServerNames, name); + let cleanupPromise: Promise | undefined; + + input.logger.log({ + category: "grok_build", + message: `Initialized ${toolSurface} MCP mount for Grok Build (servers: ${mcpServerNames.join(", ")}).`, + level: 1, + auxiliary: { + startupProfile: { value: startupProfile, type: "string" }, + environment: { value: input.environment, type: "string" }, + }, + }); + + return { + toolSurface, + startupProfile, + cwd, + home, + grokHome, + env: stringEnv({ + ...process.env, + HOME: home, + USERPROFILE: home, + GROK_HOME: grokHome, + }), + mcpConfigPath, + mcpServerNames, + promptInstructions: mount.promptInstructions, + ...(runtime.running.captureEvidence && { + captureEvidence: boundedCaptureEvidence(runtime.running.captureEvidence), + }), + ...(recorder && { + drainStepObservations: async () => { + await recorder.settle(); + return recorder.drain(); + }, + onToolResult: (toolName: string) => { + if (observedToolMatcher(toolName)) void recorder.record(); + }, + }), + observedToolMatcher, + cleanup: async () => { + cleanupPromise ??= (async () => { + try { + await withTimeout( + runtime.cleanup(), + readPositiveIntEnv("EVAL_AGENT_MOUNT_CLEANUP_TIMEOUT_MS", 30_000), + ); + } catch { + // Best effort only. + } finally { + await fsp.rm(capturedRoot, { recursive: true, force: true }); + } + })(); + await cleanupPromise; + }, + }; + } catch (error) { + await withTimeout( + runtime.cleanup(), + readPositiveIntEnv("EVAL_AGENT_MOUNT_CLEANUP_TIMEOUT_MS", 30_000), + ).catch((): undefined => undefined); + if (root) await fsp.rm(root, { recursive: true, force: true }); + throw error; + } +} + +function boundedCaptureEvidence( + capture: () => Promise, +): () => Promise { + return async () => { + try { + return await withTimeout( + capture(), + readPositiveIntEnv("EVAL_CAPTURE_EVIDENCE_TIMEOUT_MS", 15_000), + ); + } catch { + return {}; + } + }; +} + +function readPositiveIntEnv(key: string, fallback: number): number { + const parsed = Number.parseInt(process.env[key] ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`grok_build adapter operation timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function stringEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/evals/framework/harnesses/grokBuildAdapter.ts b/packages/evals/framework/harnesses/grokBuildAdapter.ts new file mode 100644 index 0000000000..0148f12b2c --- /dev/null +++ b/packages/evals/framework/harnesses/grokBuildAdapter.ts @@ -0,0 +1,119 @@ +import { extractGrokBuildToolCall } from "@browserbasehq/stagehand-integrations-grok-build-sdk"; +import type { ProbeEvidence, TaskSpec, Trajectory } from "stagehand-v3"; +import type { StepObservation } from "../observationRecorder.js"; +import { + buildTrajectory, + type NormalizedToolCall, + type TrajectoryAdapter, +} from "./trajectoryAdapter.js"; + +export interface GrokBuildRunResult { + events: Array>; + finalAnswer?: string; + status?: Trajectory["status"]; + usage?: Partial; + finalObservation?: ProbeEvidence; + stepObservations?: StepObservation[]; + observedToolName?: (name: string) => boolean; +} + +export class GrokBuildTrajectoryAdapter implements TrajectoryAdapter { + fromHarnessResult(result: GrokBuildRunResult, taskSpec: TaskSpec): Trajectory { + const toolCalls: NormalizedToolCall[] = []; + const openCalls = new Map(); + let pendingReasoning = ""; + + for (const event of result.events) { + if ((event.type === "thought" || event.type === "text") && typeof event.data === "string") { + pendingReasoning = appendText(pendingReasoning, event.data); + continue; + } + const view = extractGrokBuildToolCall(event); + if (!view) continue; + if (view.subtype === "started") { + const call: NormalizedToolCall = { + name: view.name ?? "tool", + args: view.args, + result: undefined, + ok: true, + reasoning: pendingReasoning.trim() || undefined, + }; + toolCalls.push(call); + if (view.callId) openCalls.set(view.callId, call); + pendingReasoning = ""; + continue; + } + + const call = view.callId ? openCalls.get(view.callId) : undefined; + if (call) { + call.result = normalizeToolResult(view.result); + call.ok = view.ok; + if (view.error) call.error = view.error; + openCalls.delete(view.callId); + } else { + toolCalls.push({ + name: view.name ?? "tool", + args: view.args, + result: normalizeToolResult(view.result), + ok: view.ok, + ...(view.error && { error: view.error }), + reasoning: pendingReasoning.trim() || undefined, + }); + pendingReasoning = ""; + } + } + + for (const open of openCalls.values()) { + open.ok = false; + open.result = "no tool result"; + open.error = "no tool result"; + } + + attachStepObservations(toolCalls, result); + return buildTrajectory({ + taskSpec, + toolCalls, + finalAnswer: result.finalAnswer, + status: result.status ?? "complete", + usage: result.usage, + ...(result.finalObservation && { finalObservation: result.finalObservation }), + }); + } +} + +export const grokBuildAdapter = new GrokBuildTrajectoryAdapter(); + +function normalizeToolResult(result: unknown): unknown { + if (!isRecord(result) || !Array.isArray(result.content)) return result; + const text = result.content + .filter(isRecord) + .map((block) => (typeof block.text === "string" ? block.text : undefined)) + .filter((part): part is string => part !== undefined); + return text.length > 0 ? text.join("\n") : result; +} + +function attachStepObservations(toolCalls: NormalizedToolCall[], result: GrokBuildRunResult): void { + const observations = result.stepObservations ?? []; + if (observations.length === 0) return; + const isObservedTool = + result.observedToolName ?? ((name: string) => name.startsWith("mcp") || name.includes(".")); + const observedCalls = toolCalls.filter((call) => isObservedTool(call.name)); + const totalObservedRuns = + Math.max(...observations.map((observation) => observation.runIndex)) + 1; + if (observedCalls.length !== totalObservedRuns) return; + const byRunIndex = new Map( + observations.map((observation) => [observation.runIndex, observation.evidence]), + ); + observedCalls.forEach((call, ordinal) => { + const observation = byRunIndex.get(ordinal); + if (observation) call.probeEvidence = observation; + }); +} + +function appendText(current: string, next: string): string { + return current ? `${current}\n${next}` : next; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/evals/package.json b/packages/evals/package.json index 3b6c143ead..a897c8efe9 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -31,6 +31,7 @@ "@browserbasehq/stagehand-integrations-deepagents-sdk": "workspace:*", "@browserbasehq/stagehand-integrations-eve-sdk": "workspace:*", "@browserbasehq/stagehand-integrations-fx-sdk": "workspace:*", + "@browserbasehq/stagehand-integrations-grok-build-sdk": "workspace:*", "@browserbasehq/stagehand-integrations-mastra-sdk": "workspace:*", "@browserbasehq/stagehand-integrations-pi-sdk": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", @@ -46,6 +47,7 @@ "openai": "^4.104.0", "playwright": ">=1.55.1 <1.57.0", "sharp": "^0.34.5", + "smol-toml": "catalog:", "stagehand-v3": "npm:@browserbasehq/stagehand@3.7.1", "tsx": "catalog:", "ws": "^8.21.0", diff --git a/packages/evals/tests/framework/benchHarness.test.ts b/packages/evals/tests/framework/benchHarness.test.ts index 7c0b2e968c..4a4de8c251 100644 --- a/packages/evals/tests/framework/benchHarness.test.ts +++ b/packages/evals/tests/framework/benchHarness.test.ts @@ -13,6 +13,7 @@ import { mastraHarness, piHarness, cursorHarness, + grokBuildHarness, fxHarness, deepagentsHarness, eveHarness, @@ -21,6 +22,7 @@ import { import { MASTRA_TOOL_SURFACES } from "../../framework/mastraToolAdapter.js"; import { PI_TOOL_SURFACES } from "../../framework/piToolAdapter.js"; import { CURSOR_TOOL_SURFACES } from "../../framework/cursorToolAdapter.js"; +import { GROK_BUILD_TOOL_SURFACES } from "../../framework/grokBuildToolAdapter.js"; import { defaultModelsEnvKey } from "../../framework/benchPlanner.js"; import type { BenchMatrixRow } from "../../framework/benchTypes.js"; import type { DiscoveredTask } from "../../framework/types.js"; @@ -39,6 +41,7 @@ describe("bench harness registry", () => { "deepagents", "fx", "cursor", + "grok_build", ]); }); @@ -46,7 +49,7 @@ describe("bench harness registry", () => { expect(parseBenchHarness(undefined)).toBe("stagehand"); expect(parseBenchHarness("codex")).toBe("codex"); expect(() => parseBenchHarness("nope")).toThrow( - /Unknown harness "nope"\. Supported: stagehand, claude_code, codex, mastra, pi, eve, deepagents, fx, cursor\./, + /Unknown harness "nope"\. Supported: stagehand, claude_code, codex, mastra, pi, eve, deepagents, fx, cursor, grok_build\./, ); }); @@ -185,6 +188,20 @@ describe("bench harness registry", () => { expect(harness.defaultModels).toEqual(["cursor/auto"]); }); + it("registers grok_build as a concrete executable harness", () => { + const harness = getBenchHarness("grok_build"); + + expect(harness).toBe(grokBuildHarness); + expect(parseBenchHarness("grok_build")).toBe("grok_build"); + expect(isExecutableBenchHarness("grok_build")).toBe(true); + expect(harness.supportedTaskKinds).toEqual(["agent", "suite"]); + expect(harness.supportsApi).toBe(false); + expect(harness.execute).toBeDefined(); + expect(harness.supportedToolSurfaces).toEqual(GROK_BUILD_TOOL_SURFACES); + expect(harness.supportedToolSurfaces[0]).toBe("stagehand_facade"); + expect(harness.defaultModels).toEqual(["grok-build/auto"]); + }); + it("registers a new harness and rejects duplicate ids", () => { const fakeHarness = { harness: "fake_harness", diff --git a/packages/evals/tests/framework/grokBuildAdapter.test.ts b/packages/evals/tests/framework/grokBuildAdapter.test.ts new file mode 100644 index 0000000000..036df5ee1d --- /dev/null +++ b/packages/evals/tests/framework/grokBuildAdapter.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { TaskSpec } from "stagehand-v3"; +import { grokBuildAdapter } from "../../framework/harnesses/grokBuildAdapter.js"; + +const taskSpec: TaskSpec = { id: "grok-build-test", instruction: "do the task" }; + +describe("Grok Build trajectory adapter", () => { + it("pairs native tool calls and carries thought text into reasoning", () => { + const trajectory = grokBuildAdapter.fromHarnessResult( + { + events: [ + { type: "thought", data: "I will inspect the page." }, + { + type: "tool_call", + toolCallId: "call-1", + toolName: "stagehand__snapshot", + rawInput: {}, + }, + { + type: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: { title: "Example" }, + }, + ], + finalAnswer: "Example", + }, + taskSpec, + ); + expect(trajectory.steps).toHaveLength(1); + expect(trajectory.steps[0]).toMatchObject({ + actionName: "stagehand__snapshot", + reasoning: "I will inspect the page.", + toolOutput: { ok: true, result: { title: "Example" } }, + }); + expect(trajectory.finalAnswer).toBe("Example"); + }); + + it("fails open tool calls closed and attaches matching observations", () => { + const trajectory = grokBuildAdapter.fromHarnessResult( + { + events: [ + { + type: "tool_call", + toolCallId: "call-1", + toolName: "stagehand__run", + rawInput: {}, + }, + ], + observedToolName: (name) => name.startsWith("stagehand__"), + stepObservations: [{ runIndex: 0, evidence: { url: "https://example.com" } }], + }, + taskSpec, + ); + expect(trajectory.steps[0].toolOutput).toMatchObject({ + ok: false, + error: "no tool result", + }); + expect(trajectory.steps[0].probeEvidence.url).toBe("https://example.com"); + }); +}); diff --git a/packages/evals/tests/framework/grokBuildRunner.test.ts b/packages/evals/tests/framework/grokBuildRunner.test.ts new file mode 100644 index 0000000000..83ab8216a0 --- /dev/null +++ b/packages/evals/tests/framework/grokBuildRunner.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { AvailableModel } from "stagehand-v3"; +import type { GrokBuildProcessRunner } from "@browserbasehq/stagehand-integrations-grok-build-sdk"; +import { buildGrokBuildPrompt, runGrokBuildAgent } from "../../framework/grokBuildRunner.js"; +import type { ExternalHarnessTaskPlan } from "../../framework/externalHarnessPlan.js"; +import { EvalLogger } from "../../logger.js"; + +const plan: ExternalHarnessTaskPlan = { + dataset: "webvoyager", + taskId: "wv-1", + startUrl: "https://example.com", + instruction: "Report the heading", +}; + +describe("Grok Build runner", () => { + it("builds an MCP-only browser prompt", () => { + const prompt = buildGrokBuildPrompt(plan, "Use stagehand__run."); + expect(prompt).toContain("Dataset: webvoyager"); + expect(prompt).toContain("Start URL: https://example.com"); + expect(prompt).toContain("Use stagehand__run."); + expect(prompt).toContain("Your only browser access is the MCP server"); + expect(prompt).toContain("EVAL_RESULT:"); + }); + + it("runs the native stream and reports Grok Build metrics", async () => { + const result = await runGrokBuildAgent({ + plan, + model: "grok-build/auto" as AvailableModel, + logger: new EvalLogger(false), + runProcess: scriptedRunner([ + { + type: "tool_call", + toolCallId: "1", + toolName: "stagehand__run", + rawInput: { code: "return 1" }, + }, + { + type: "tool_call_update", + toolCallId: "1", + status: "completed", + rawOutput: "done", + }, + { + type: "text", + data: 'EVAL_RESULT: {"success":true,"summary":"done","finalAnswer":"ok"}', + }, + { + type: "end", + stopReason: "end_turn", + num_turns: 2, + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + total_cost_usd: 0.02, + }, + ]), + }); + const metrics = result.metrics as Record; + expect(result._success).toBe(true); + expect(result.harnessStatus).toBe("completed"); + expect(result.grokBuildStatus).toBe("completed"); + expect(result.finalAnswer).toBe("ok"); + expect(metrics.grok_build_tool_steps.value).toBe(1); + expect(metrics.grok_build_num_turns.value).toBe(2); + expect(metrics.harness_total_tokens.value).toBe(15); + expect(metrics.harness_cost_usd.value).toBe(0.02); + }); + + it("returns a failed result for a non-zero exit without an end event", async () => { + const result = await runGrokBuildAgent({ + plan, + model: "grok-build/auto" as AvailableModel, + logger: new EvalLogger(false), + runProcess: scriptedRunner([], 1), + }); + expect(result._success).toBe(false); + expect(result.harnessStatus).toBe("sdk_error"); + expect(result.error).toContain("exited with code 1"); + }); +}); + +function scriptedRunner( + events: Array>, + exitCode = 0, +): GrokBuildProcessRunner { + return async (input) => { + for (const event of events) await input.onStdoutLine(JSON.stringify(event)); + return { exitCode, signal: null }; + }; +} diff --git a/packages/evals/tests/framework/grokBuildToolAdapter.test.ts b/packages/evals/tests/framework/grokBuildToolAdapter.test.ts new file mode 100644 index 0000000000..ad09f3806b --- /dev/null +++ b/packages/evals/tests/framework/grokBuildToolAdapter.test.ts @@ -0,0 +1,88 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parse } from "smol-toml"; +import { + buildGrokBuildMcpConfig, + copyGrokBuildAuth, + GROK_BUILD_TOOL_SURFACES, + isGrokBuildMountToolName, + resolveGrokBuildAuthHome, + writeGrokBuildWorkspace, +} from "../../framework/grokBuildToolAdapter.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { recursive: true, force: true }))); +}); + +describe("Grok Build tool adapter helpers", () => { + it("supports MCP mounts and converts their config to Grok TOML shape", () => { + expect(GROK_BUILD_TOOL_SURFACES).toEqual([ + "stagehand_facade", + "playwright_mcp", + "chrome_devtools_mcp", + ]); + expect( + buildGrokBuildMcpConfig({ + stagehand: { command: "node", args: ["server.mjs"], env: { TOKEN: "value" } }, + }), + ).toEqual({ + mcp_servers: { + stagehand: { + command: "node", + args: ["server.mjs"], + env: { TOKEN: "value" }, + startup_timeout_sec: 60, + tool_timeout_sec: 300, + }, + }, + }); + }); + + it("writes isolated user and project config", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "grok-build-workspace-test-")); + tempDirs.push(root); + const cwd = path.join(root, "workspace"); + const grokHome = path.join(root, "home", ".grok"); + const result = await writeGrokBuildWorkspace(cwd, grokHome, { + stagehand: { command: "node", args: ["server.mjs"] }, + }); + const projectConfig = parse(await fsp.readFile(result.mcpConfigPath, "utf8")); + const userConfig = parse(await fsp.readFile(path.join(grokHome, "config.toml"), "utf8")); + expect(projectConfig).toMatchObject({ + mcp_servers: { stagehand: { command: "node", args: ["server.mjs"] } }, + }); + expect(userConfig).toMatchObject({ + cli: { auto_update: false, use_leader: false }, + subagents: { enabled: false }, + memory: { enabled: false }, + }); + }); + + it("copies cached auth only when no API key is supplied", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "grok-build-auth-test-")); + tempDirs.push(root); + const source = path.join(root, "source"); + const target = path.join(root, "target"); + await Promise.all([fsp.mkdir(source), fsp.mkdir(target)]); + await fsp.writeFile(path.join(source, "auth.json"), '{"token":"cached"}\n'); + expect(resolveGrokBuildAuthHome({ GROK_HOME: source })).toBe(source); + await expect(copyGrokBuildAuth({ GROK_HOME: source }, target)).resolves.toBe(true); + await expect(fsp.readFile(path.join(target, "auth.json"), "utf8")).resolves.toContain("cached"); + await fsp.rm(path.join(target, "auth.json")); + await expect( + copyGrokBuildAuth({ GROK_HOME: source, XAI_API_KEY: "secret" }, target), + ).resolves.toBe(false); + }); + + it("matches Grok MCP tool identities", () => { + const matches = (name: string) => isGrokBuildMountToolName(["stagehand"], name); + for (const name of ["stagehand.run", "stagehand__run", "mcp__stagehand__run"]) { + expect(matches(name)).toBe(true); + } + expect(matches("shell")).toBe(false); + }); +}); diff --git a/packages/integrations/README.md b/packages/integrations/README.md index b3cbf00cc1..f70c4a1efd 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -8,20 +8,21 @@ else, never restated. ## Structure -| Directory | What it is | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `core/` | The `@browserbasehq/stagehand-integrations` package: the facade contract, `StagehandFacadeTools`, the `stagehand-facade` stdio MCP bin, and the code-mode MCP host scaffold. | -| `claude-code/` | Claude Agent SDK example (programmatic MCP mount) plus a `.mcp.json` for connecting a running Claude Code CLI. | -| `codex/` | Codex SDK example (config-override MCP mount) plus a `config.toml` template for the codex CLI. | -| `cursor/` | Cursor agent CLI configuration template (`.cursor/mcp.json`) and AGENTS.md guidance — the CLI consumes the facade as a project MCP server. | -| `crewai/` | Python CrewAI example over MCP/stdio (uv project). | -| `deepagents/` | Python LangChain Deep Agents integrations: a local stdio MCP server and a Managed Deep Agents project with native tools. | -| `eve/` | Eve example with the tools bound natively via `defineTool` (Eve has no external-process tool mounting). | -| `fx/` | fx configuration templates and skill — fx consumes the facade via its user-global MCP config. | -| `grok-build/` | Grok Build example over ACP — the shared ACP transport in `core/` mounts the facade as the session's only MCP server. | -| `mastra/` | Mastra example over MCP/stdio via Mastra's `MCPClient`. | -| `pi/` | Pi extension registering the tools natively (Pi ships without built-in MCP). | -| `vercel-ai/` | Vercel AI SDK example over MCP/stdio via `createMCPClient`. | +| Directory | What it is | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core/` | The `@browserbasehq/stagehand-integrations` package: the facade contract, `StagehandFacadeTools`, the `stagehand-facade` stdio MCP bin, and the code-mode MCP host scaffold. | +| `claude-code/` | Claude Agent SDK example (programmatic MCP mount) plus a `.mcp.json` for connecting a running Claude Code CLI. | +| `codex/` | Codex SDK example (config-override MCP mount) plus a `config.toml` template for the codex CLI. | +| `cursor/` | Cursor agent CLI configuration template (`.cursor/mcp.json`) and AGENTS.md guidance — the CLI consumes the facade as a project MCP server. | +| `crewai/` | Python CrewAI example over MCP/stdio (uv project). | +| `deepagents/` | Python LangChain Deep Agents integrations: a local stdio MCP server and a Managed Deep Agents project with native tools. | +| `eve/` | Eve example with the tools bound natively via `defineTool` (Eve has no external-process tool mounting). | +| `fx/` | fx configuration templates and skill — fx consumes the facade via its user-global MCP config. | +| `grok-build/` | Grok Build CLI configuration template and AGENTS.md guidance. The CLI consumes the facade as a project MCP server. | +| `grok-build-sdk/` | Grok Build CLI session adapter used by evals. | +| `mastra/` | Mastra example over MCP/stdio via Mastra's `MCPClient`. | +| `pi/` | Pi extension registering the tools natively (Pi ships without built-in MCP). | +| `vercel-ai/` | Vercel AI SDK example over MCP/stdio via `createMCPClient`. | Each example is a self-contained project: install, export `BROWSERBASE_API_KEY`, and run — see the directory's README. TypeScript examples consume `core/` as a workspace dependency; the Python projects resolve the published diff --git a/packages/integrations/core/package.json b/packages/integrations/core/package.json index 7ae67da788..88d7715590 100644 --- a/packages/integrations/core/package.json +++ b/packages/integrations/core/package.json @@ -11,9 +11,6 @@ ], "type": "module", "exports": { - "./acp": { - "import": "./dist/acp/index.mjs" - }, "./codemode/stdio-server": { "import": "./dist/codemode/stdio-server.mjs" }, @@ -35,7 +32,6 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@agentclientprotocol/sdk": "catalog:", "@browserbasehq/stagehand": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "zod": "catalog:" diff --git a/packages/integrations/core/src/acp/agent-process.ts b/packages/integrations/core/src/acp/agent-process.ts deleted file mode 100644 index 1c11030feb..0000000000 --- a/packages/integrations/core/src/acp/agent-process.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ndJsonStream, type Stream } from "@agentclientprotocol/sdk"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { Readable, Writable } from "node:stream"; - -import { HarnessAdapterError } from "../harness/contract.js"; - -export type AcpAgentProcess = { - readonly transport: Stream; - readonly started: Promise; - signal(signal: "SIGTERM" | "SIGKILL"): Promise; - terminate(graceMs: number): Promise; -}; - -export function spawnAcpAgentProcess(options: { - command: string; - args: readonly string[]; - cwd: string; - env: NodeJS.ProcessEnv; - stderr: NodeJS.WritableStream; -}): AcpAgentProcess { - const child = spawn(options.command, [...options.args], { - cwd: options.cwd, - detached: process.platform !== "win32", - env: definedEnvironment(options.env), - stdio: ["pipe", "pipe", "pipe"], - }); - const started = new Promise((resolve, reject) => { - child.once("spawn", resolve); - child.on("error", (error) => { - reject(new HarnessAdapterError("Unable to start the ACP agent process.", { cause: error })); - }); - }); - child.stderr.pipe(options.stderr, { end: false }); - - let closed = false; - const closePromise = new Promise((resolve) => { - child.once("close", () => { - closed = true; - resolve(); - }); - }); - - return { - started, - transport: ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ), - signal: (signal) => signalProcessTree(child, signal), - terminate: async (graceMs) => { - try { - if (closed) return; - await signalProcessTree(child, "SIGTERM"); - if (await closesWithin(closePromise, graceMs)) return; - await signalProcessTree(child, "SIGKILL"); - if (await closesWithin(closePromise, graceMs)) return; - child.stdin.destroy(); - child.stdout.destroy(); - child.stderr.destroy(); - } finally { - child.stderr.unpipe(options.stderr); - } - }, - }; -} - -function definedEnvironment(source: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - Object.entries(source).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); -} - -async function closesWithin(closed: Promise, graceMs: number): Promise { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - closed.then(() => true), - new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), graceMs); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - -async function signalProcessTree( - child: ChildProcessWithoutNullStreams, - signal: "SIGTERM" | "SIGKILL", -): Promise { - if (process.platform !== "win32" && child.pid !== undefined) { - try { - process.kill(-child.pid, signal); - return; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return; - child.kill(signal); - return; - } - } - - if (process.platform === "win32" && child.pid !== undefined) { - await new Promise((resolve) => { - const args = ["/PID", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])]; - const terminator = spawn("taskkill", args, { stdio: "ignore" }); - terminator.once("error", () => { - child.kill(signal); - resolve(); - }); - terminator.once("close", () => resolve()); - }); - return; - } - - if (child.exitCode !== null || child.signalCode !== null) return; - child.kill(signal); -} diff --git a/packages/integrations/core/src/acp/env.ts b/packages/integrations/core/src/acp/env.ts deleted file mode 100644 index 03c233ac0f..0000000000 --- a/packages/integrations/core/src/acp/env.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { buildAllowlistedEnv } from "../harness/env.js"; - -export const buildAcpFacadeEnv = buildAllowlistedEnv; - -const RUNTIME_ENV_KEYS = [ - "PATH", - "HOME", - "TMPDIR", - "TMP", - "TEMP", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "LOCALAPPDATA", - "PROGRAMFILES", - "PROGRAMFILES(X86)", - "ProgramW6432", - "SYSTEMROOT", - "SystemRoot", - "WINDIR", -] as const; - -export function buildAcpFacadeRuntimeEnv( - source: NodeJS.ProcessEnv = process.env, -): Record { - const env = buildAcpFacadeEnv(source); - for (const key of RUNTIME_ENV_KEYS) { - const value = source[key]; - if (value) env[key] = value; - } - return env; -} diff --git a/packages/integrations/core/src/acp/facade-launcher.ts b/packages/integrations/core/src/acp/facade-launcher.ts deleted file mode 100644 index aee4750828..0000000000 --- a/packages/integrations/core/src/acp/facade-launcher.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { spawn } from "node:child_process"; - -import { HarnessAdapterError } from "../harness/contract.js"; -import { buildAcpFacadeRuntimeEnv } from "./env.js"; - -const [facadeServerPath, ...facadeArgs] = process.argv.slice(2); -if (!facadeServerPath) { - throw new HarnessAdapterError("Stagehand ACP facade launcher requires a server path."); -} - -// ACP agents may merge an MCP server's declared env with their own environment. -// Start the actual facade as a second process so its environment is enforced here. -const facade = spawn(process.execPath, [facadeServerPath, ...facadeArgs], { - env: buildAcpFacadeRuntimeEnv(), - stdio: "inherit", -}); - -let exiting = false; -const forwardSignal = (signal: NodeJS.Signals) => { - if (!facade.kill(signal)) process.exit(signal === "SIGINT" ? 130 : 143); -}; - -const onSigint = () => forwardSignal("SIGINT"); -const onSigterm = () => forwardSignal("SIGTERM"); -process.once("SIGINT", onSigint); -process.once("SIGTERM", onSigterm); -facade.once("error", (error) => { - process.stderr.write(`Unable to start Stagehand facade: ${error.message}\n`); - process.exit(1); -}); -facade.once("exit", (code, signal) => { - if (exiting) return; - exiting = true; - if (signal) { - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - process.kill(process.pid, signal); - } else process.exit(code ?? 1); -}); diff --git a/packages/integrations/core/src/acp/facade-mcp.ts b/packages/integrations/core/src/acp/facade-mcp.ts deleted file mode 100644 index d34f36cb9b..0000000000 --- a/packages/integrations/core/src/acp/facade-mcp.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { McpServerStdio } from "@agentclientprotocol/sdk"; -import { fileURLToPath } from "node:url"; - -import { buildAcpFacadeEnv } from "./env.js"; - -export function buildAcpFacadeMcpServer( - facadeServerPath: string, - source: NodeJS.ProcessEnv = process.env, -): McpServerStdio { - const facadeLauncherPath = fileURLToPath(new URL("./facade-launcher.mjs", import.meta.url)); - return { - name: "stagehand", - command: process.execPath, - args: [facadeLauncherPath, facadeServerPath, "--max-screenshot-base64-bytes=60000"], - env: Object.entries(buildAcpFacadeEnv(source)).map(([name, value]) => ({ name, value })), - }; -} diff --git a/packages/integrations/core/src/acp/index.ts b/packages/integrations/core/src/acp/index.ts deleted file mode 100644 index 119e6e0d51..0000000000 --- a/packages/integrations/core/src/acp/index.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - PROTOCOL_VERSION, - client, - methods, - type AuthenticateRequest, - type ClientContext, - type InitializeRequest, - type InitializeResponse, - type RequestPermissionRequest, - type RequestPermissionResponse, - type ToolCallUpdate, -} from "@agentclientprotocol/sdk"; -import { fileURLToPath } from "node:url"; - -import { FACADE_AGENT_INSTRUCTIONS } from "../facade/contract.js"; -import { HarnessAdapterError } from "../harness/contract.js"; -import { spawnAcpAgentProcess } from "./agent-process.js"; -import { buildAcpFacadeMcpServer } from "./facade-mcp.js"; - -const CLIENT_INFO = { - name: "stagehand-acp-facade", - title: "Stagehand ACP facade", - version: "1.0.0", -} as const; - -export type AcpFacadeAgentProfile = { - readonly id: string; - readonly command: string; - readonly args: readonly string[]; - readonly resolveAuthentication?: (input: { - initialization: InitializeResponse; - env: NodeJS.ProcessEnv; - }) => AuthenticateRequest | undefined; - readonly buildSessionMeta?: ( - instructions: string, - ) => Readonly> | undefined; - readonly buildPrompt?: (instruction: string, instructions: string) => string; - readonly isFacadeToolCall: (toolCall: ToolCallUpdate) => boolean; -}; - -export type RunAcpFacadeAgentOptions = { - readonly profile: AcpFacadeAgentProfile; - readonly instruction: string; - readonly cwd: string; - readonly env?: NodeJS.ProcessEnv; - readonly facadeServerPath?: string; - readonly signal?: AbortSignal; - readonly terminationGraceMs?: number; - readonly stderr?: NodeJS.WritableStream; -}; - -export function resolveAcpFacadePermission( - request: RequestPermissionRequest, - activeSessionId: string | undefined, - isFacadeToolCall: (toolCall: ToolCallUpdate) => boolean, -): RequestPermissionResponse { - if (request.sessionId !== activeSessionId) return cancelledPermission(); - - const kind = isFacadeToolCall(request.toolCall) ? "allow_once" : "reject_once"; - const option = request.options.find((candidate) => candidate.kind === kind); - return option - ? { outcome: { outcome: "selected", optionId: option.optionId } } - : cancelledPermission(); -} - -export async function runAcpFacadeAgent(options: RunAcpFacadeAgentOptions): Promise { - const instruction = options.instruction.trim(); - if (!instruction) throw new Error("ACP facade instruction must not be empty."); - const env = options.env ?? process.env; - const facadeServerPath = - options.facadeServerPath ?? - fileURLToPath(new URL("../facade/stdio-server.mjs", import.meta.url)); - const signal = options.signal; - if (signal?.aborted) throw interruptedError(options.profile.id); - const agentProcess = spawnAcpAgentProcess({ - command: options.profile.command, - args: options.profile.args, - cwd: options.cwd, - env, - stderr: options.stderr ?? process.stderr, - }); - let activeSessionId: string | undefined; - let agentContext: ClientContext | undefined; - let abortFallback: ReturnType | undefined; - const terminationGraceMs = options.terminationGraceMs ?? 2_000; - const requestOptions = signal ? { cancellationSignal: signal } : undefined; - - const onAbort = () => { - if (agentContext && activeSessionId) { - void agentContext - .notify(methods.agent.session.cancel, { sessionId: activeSessionId }) - .catch(() => undefined) - .finally(() => void agentProcess.signal("SIGTERM")); - abortFallback = setTimeout(() => void agentProcess.signal("SIGKILL"), terminationGraceMs); - } else { - void agentProcess.signal("SIGTERM"); - abortFallback = setTimeout(() => void agentProcess.signal("SIGKILL"), terminationGraceMs); - } - }; - signal?.addEventListener("abort", onAbort, { once: true }); - - const app = client({ name: CLIENT_INFO.name }).onRequest( - methods.client.session.requestPermission, - ({ params }) => - signal?.aborted - ? cancelledPermission() - : resolveAcpFacadePermission(params, activeSessionId, options.profile.isFacadeToolCall), - ); - - try { - try { - await agentProcess.started; - } catch (error) { - throw new HarnessAdapterError(`Unable to start ACP agent ${options.profile.id}.`, { - cause: error, - }); - } - return await app.connectWith(agentProcess.transport, async (context) => { - agentContext = context; - const initialization = await context.request( - methods.agent.initialize, - { - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: {}, - clientInfo: CLIENT_INFO, - }, - requestOptions, - ); - if (initialization.protocolVersion !== PROTOCOL_VERSION) { - throw new Error( - `ACP agent ${options.profile.id} negotiated unsupported protocol version ${initialization.protocolVersion}.`, - ); - } - - const authMethods = initialization.authMethods ?? []; - if (authMethods.length > 0) { - const authentication = options.profile.resolveAuthentication?.({ initialization, env }); - if (!authentication) { - throw new Error( - `ACP agent ${options.profile.id} requires authentication, but its profile selected no advertised method.`, - ); - } - if (!authMethods.some((method) => method.id === authentication.methodId)) { - throw new Error( - `ACP agent ${options.profile.id} did not advertise authentication method ${JSON.stringify(authentication.methodId)}.`, - ); - } - await context.request(methods.agent.authenticate, authentication, requestOptions); - } - - const sessionMeta = options.profile.buildSessionMeta?.(FACADE_AGENT_INSTRUCTIONS); - return context - .buildSession({ - cwd: options.cwd, - mcpServers: [buildAcpFacadeMcpServer(facadeServerPath, env)], - ...(sessionMeta ? { _meta: { ...sessionMeta } } : {}), - }) - .withSession(async (session) => { - activeSessionId = session.sessionId; - if (signal?.aborted) throw interruptedError(options.profile.id); - const prompt = - options.profile.buildPrompt?.(instruction, FACADE_AGENT_INSTRUCTIONS) ?? - `${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\n${instruction}`; - const responsePromise = session.prompt(prompt, requestOptions); - const textPromise = session.readText(); - const [response, text] = await Promise.all([responsePromise, textPromise]); - if (signal?.aborted || response.stopReason === "cancelled") { - throw interruptedError(options.profile.id); - } - if (response.stopReason !== "end_turn") { - throw new Error(`ACP agent ${options.profile.id} stopped with ${response.stopReason}.`); - } - const result = text.trim(); - if (!result) - throw new Error(`ACP agent ${options.profile.id} returned no assistant text.`); - return result; - }); - }); - } finally { - signal?.removeEventListener("abort", onAbort); - if (abortFallback) clearTimeout(abortFallback); - await agentProcess.terminate(terminationGraceMs); - } -} - -function cancelledPermission(): RequestPermissionResponse { - return { outcome: { outcome: "cancelled" } }; -} - -function interruptedError(profileId: string): Error { - return new Error(`ACP agent ${profileId} run interrupted.`); -} diff --git a/packages/integrations/core/src/facade/stdio-server.ts b/packages/integrations/core/src/facade/stdio-server.ts index 39502adfa1..53b1bc5bac 100644 --- a/packages/integrations/core/src/facade/stdio-server.ts +++ b/packages/integrations/core/src/facade/stdio-server.ts @@ -175,8 +175,8 @@ async function shutdown(code: number): Promise { process.exit(code === 0 && !clean ? 1 : code); } -process.on("SIGINT", () => void shutdown(130)); -process.on("SIGTERM", () => void shutdown(143)); +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)); diff --git a/packages/integrations/core/src/harness/env.ts b/packages/integrations/core/src/harness/env.ts index 4e5f413622..43069f433a 100644 --- a/packages/integrations/core/src/harness/env.ts +++ b/packages/integrations/core/src/harness/env.ts @@ -1,10 +1,8 @@ /** Only STAGEHAND_* and BROWSERBASE_* host env vars cross into harness processes. */ -export function buildAllowlistedEnv( - source: NodeJS.ProcessEnv = process.env, -): Record { +export function buildAllowlistedEnv(): Record { const env: Record = {}; - for (const [key, value] of Object.entries(source)) { - if (/^(STAGEHAND_|BROWSERBASE_)/u.test(key) && value) { + for (const [key, value] of Object.entries(process.env)) { + if (/^(STAGEHAND_|BROWSERBASE_)/.test(key) && value) { env[key] = value; } } diff --git a/packages/integrations/core/tests/acp-facade-mcp.test.ts b/packages/integrations/core/tests/acp-facade-mcp.test.ts deleted file mode 100644 index b721d41b34..0000000000 --- a/packages/integrations/core/tests/acp-facade-mcp.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; - -import { buildAcpFacadeEnv } from "../src/acp/env.js"; -import { buildAcpFacadeMcpServer } from "../src/acp/facade-mcp.js"; - -const envFixture = fileURLToPath(new URL("./fixtures/record-env.mjs", import.meta.url)); -const builtFacadeLauncher = fileURLToPath( - new URL("../dist/acp/facade-launcher.mjs", import.meta.url), -); -const resolvedFacadeLauncher = fileURLToPath( - new URL("../src/acp/facade-launcher.mjs", import.meta.url), -); -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); -}); - -describe("ACP facade MCP adapter", () => { - it("allowlists only non-empty Stagehand and Browserbase values", () => { - expect( - buildAcpFacadeEnv({ - STAGEHAND_BROWSER: "local", - BROWSERBASE_API_KEY: "bb-secret", - STAGEHAND_EMPTY: "", - XAI_API_KEY: "xai-secret", - OTHER_SECRET: "hidden", - }), - ).toStrictEqual({ - STAGEHAND_BROWSER: "local", - BROWSERBASE_API_KEY: "bb-secret", - }); - }); - - it("builds the exact Stagehand stdio MCP definition", () => { - expect( - buildAcpFacadeMcpServer("/absolute/facade.mjs", { - STAGEHAND_BROWSER: "local", - XAI_API_KEY: "not-forwarded", - }), - ).toStrictEqual({ - name: "stagehand", - command: process.execPath, - args: [resolvedFacadeLauncher, "/absolute/facade.mjs", "--max-screenshot-base64-bytes=60000"], - env: [{ name: "STAGEHAND_BROWSER", value: "local" }], - }); - }); - - it("enforces the allowlist in the actual facade runtime", async () => { - const cwd = await mkdtemp(join(tmpdir(), "stagehand-acp-env-test-")); - temporaryDirectories.push(cwd); - const recordPath = join(cwd, "env.json"); - const child = spawn(process.execPath, [builtFacadeLauncher, envFixture, recordPath], { - env: { - ...process.env, - STAGEHAND_BROWSER: "local", - BROWSERBASE_API_KEY: "bb-secret", - XAI_API_KEY: "xai-secret", - OTHER_SECRET: "hidden", - }, - stdio: "ignore", - }); - - const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null]; - expect({ code, signal }).toStrictEqual({ code: 0, signal: null }); - expect(JSON.parse(await readFile(recordPath, "utf8"))).toStrictEqual({ - STAGEHAND_BROWSER: "local", - BROWSERBASE_API_KEY: "bb-secret", - }); - }); - - it.skipIf(process.platform === "win32")( - "preserves signal termination from the facade process", - async () => { - const child = spawn( - process.execPath, - [builtFacadeLauncher, "-e", 'process.kill(process.pid, "SIGTERM")'], - { stdio: "ignore" }, - ); - - const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null]; - expect({ code, signal }).toStrictEqual({ code: null, signal: "SIGTERM" }); - }, - ); -}); diff --git a/packages/integrations/core/tests/acp.test.ts b/packages/integrations/core/tests/acp.test.ts deleted file mode 100644 index 519ca7ab7b..0000000000 --- a/packages/integrations/core/tests/acp.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import type { RequestPermissionRequest, ToolCallUpdate } from "@agentclientprotocol/sdk"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - resolveAcpFacadePermission, - runAcpFacadeAgent, - type AcpFacadeAgentProfile, -} from "../src/acp/index.js"; -import { FACADE_AGENT_INSTRUCTIONS } from "../src/facade/contract.js"; - -const fixture = fileURLToPath(new URL("./fixtures/fake-acp-agent.mjs", import.meta.url)); -const resolvedFacadeLauncher = fileURLToPath( - new URL("../src/acp/facade-launcher.mjs", import.meta.url), -); -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); -}); - -describe("ACP facade helpers", () => { - it("allows one-time only for active-session facade calls", () => { - const request = permissionRequest({ allowed: true }); - expect(resolveAcpFacadePermission(request, "session-1", isAllowed)).toStrictEqual({ - outcome: { outcome: "selected", optionId: "allow" }, - }); - expect( - resolveAcpFacadePermission(permissionRequest({ allowed: false }), "session-1", isAllowed), - ).toStrictEqual({ outcome: { outcome: "selected", optionId: "reject" } }); - expect(resolveAcpFacadePermission(request, "other-session", isAllowed)).toStrictEqual({ - outcome: { outcome: "cancelled" }, - }); - expect( - resolveAcpFacadePermission({ ...request, options: [] }, "session-1", isAllowed), - ).toStrictEqual({ outcome: { outcome: "cancelled" } }); - }); -}); - -describe("ACP facade runner", () => { - it("initializes, authenticates, mounts one facade, delivers instructions, and streams text", async () => { - const runtime = await makeRuntime("permission"); - const text = await runAcpFacadeAgent({ - profile: profile(), - instruction: "Open example.com", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/absolute/facade.mjs", - }); - - expect(text).toBe("Hello browser"); - const events = await readEvents(runtime.recordPath); - expect(events.find((event) => event.type === "initialize")?.params).toMatchObject({ - protocolVersion: 1, - clientCapabilities: {}, - }); - expect(events.find((event) => event.type === "authenticate")?.params).toStrictEqual({ - methodId: "test-auth", - }); - expect(events.find((event) => event.type === "session-new")?.params).toStrictEqual({ - cwd: runtime.cwd, - mcpServers: [ - { - name: "stagehand", - command: process.execPath, - args: [resolvedFacadeLauncher, "/absolute/facade.mjs", "--max-screenshot-base64-bytes=60000"], - env: [{ name: "STAGEHAND_BROWSER", value: "local" }], - }, - ], - _meta: { rules: FACADE_AGENT_INSTRUCTIONS }, - }); - expect(events.find((event) => event.type === "prompt")?.params.prompt).toStrictEqual([ - { type: "text", text: "Open example.com" }, - ]); - expect(events.find((event) => event.type === "permission-result")?.permission).toStrictEqual({ - outcome: { outcome: "selected", optionId: "allow" }, - }); - }); - - it("rejects non-facade permission requests", async () => { - const runtime = await makeRuntime("deny-permission"); - await expect( - runAcpFacadeAgent({ - profile: profile(), - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - }), - ).resolves.toBe("Hello browser"); - const events = await readEvents(runtime.recordPath); - expect(events.find((event) => event.type === "permission-result")?.permission).toStrictEqual({ - outcome: { outcome: "selected", optionId: "reject" }, - }); - }); - - it("skips optional auth and prepends instructions when a profile has no native mapping", async () => { - const runtime = await makeRuntime("no-auth"); - const fallbackProfile: AcpFacadeAgentProfile = { - id: "fallback", - command: process.execPath, - args: [fixture], - isFacadeToolCall: isAllowed, - }; - await expect( - runAcpFacadeAgent({ - profile: fallbackProfile, - instruction: "Task only", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - }), - ).resolves.toBe("Hello browser"); - const events = await readEvents(runtime.recordPath); - expect(events.some((event) => event.type === "authenticate")).toBe(false); - expect(events.find((event) => event.type === "session-new")?.params).not.toHaveProperty( - "_meta", - ); - expect(events.find((event) => event.type === "prompt")?.params.prompt).toStrictEqual([ - { - type: "text", - text: `${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\nTask only`, - }, - ]); - }); - - it("fails when required authentication cannot be selected", async () => { - const runtime = await makeRuntime("success"); - const missingAuth = { ...profile(), resolveAuthentication: undefined }; - await expect( - runAcpFacadeAgent({ - profile: missingAuth, - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - }), - ).rejects.toThrow("requires authentication"); - }); - - it("reports an agent process that exits during startup", async () => { - const runtime = await makeRuntime("exit-after-initialize"); - await expect( - runAcpFacadeAgent({ - profile: profile(), - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - }), - ).rejects.toThrow(); - }); - - it("reports a controlled startup failure when the ACP executable is missing", async () => { - const runtime = await makeRuntime("success"); - await expect( - runAcpFacadeAgent({ - profile: { ...profile(), command: join(runtime.cwd, "missing-acp-agent") }, - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - terminationGraceMs: 10, - }), - ).rejects.toThrow("Unable to start ACP agent fake"); - }); - - it("does not spawn the ACP process when already cancelled", async () => { - const runtime = await makeRuntime("success"); - const controller = new AbortController(); - controller.abort(); - await expect( - runAcpFacadeAgent({ - profile: { ...profile(), command: join(runtime.cwd, "missing-acp-agent") }, - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - signal: controller.signal, - }), - ).rejects.toThrow("run interrupted"); - }); - - it.each([ - { behavior: "empty", message: "returned no assistant text" }, - { behavior: "refusal", message: "stopped with refusal" }, - { behavior: "protocol-mismatch", message: "unsupported protocol version 99" }, - ])("reports $behavior failures", async ({ behavior, message }) => { - const runtime = await makeRuntime(behavior); - await expect( - runAcpFacadeAgent({ - profile: profile(), - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - }), - ).rejects.toThrow(message); - }); - - it("cancels and force-terminates an unresponsive agent", async () => { - const runtime = await makeRuntime("hang"); - const controller = new AbortController(); - setTimeout(() => controller.abort(), 100); - await expect( - runAcpFacadeAgent({ - profile: profile(), - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - signal: controller.signal, - terminationGraceMs: 50, - }), - ).rejects.toThrow(); - }); - - it("force-terminates descendants of an unresponsive agent wrapper", async () => { - const runtime = await makeRuntime("hang-with-descendant"); - const controller = new AbortController(); - setTimeout(() => controller.abort(), 100); - await expect( - runAcpFacadeAgent({ - profile: profile(), - instruction: "Task", - cwd: runtime.cwd, - env: runtime.env, - facadeServerPath: "/facade.mjs", - signal: controller.signal, - terminationGraceMs: 50, - }), - ).rejects.toThrow(); - - const events = await readEvents(runtime.recordPath); - const descendant = events.find((event) => event.type === "descendant"); - expect(descendant?.pid).toEqual(expect.any(Number)); - await expectProcessToStop(descendant?.pid as number); - }); -}); - -function profile(): AcpFacadeAgentProfile { - return { - id: "fake", - command: process.execPath, - args: [fixture], - resolveAuthentication: () => ({ methodId: "test-auth" }), - buildSessionMeta: (instructions) => ({ rules: instructions }), - buildPrompt: (instruction) => instruction, - isFacadeToolCall: isAllowed, - }; -} - -function isAllowed(toolCall: ToolCallUpdate): boolean { - return toolCall._meta?.allowed === true; -} - -function permissionRequest({ allowed }: { allowed: boolean }): RequestPermissionRequest { - return { - sessionId: "session-1", - toolCall: { toolCallId: "call-1", _meta: { allowed } }, - options: [ - { optionId: "allow", name: "Allow", kind: "allow_once" }, - { optionId: "reject", name: "Reject", kind: "reject_once" }, - ], - }; -} - -async function makeRuntime(behavior: string): Promise<{ - cwd: string; - recordPath: string; - env: NodeJS.ProcessEnv; -}> { - const cwd = await mkdtemp(join(tmpdir(), "stagehand-acp-test-")); - temporaryDirectories.push(cwd); - const recordPath = join(cwd, "events.jsonl"); - await writeFile(recordPath, ""); - const hostEnv = Object.fromEntries( - Object.entries(process.env).filter( - ([name]) => !name.startsWith("STAGEHAND_") && !name.startsWith("BROWSERBASE_"), - ), - ); - return { - cwd, - recordPath, - env: { - ...hostEnv, - ACP_FAKE_BEHAVIOR: behavior, - ACP_RECORD_PATH: recordPath, - STAGEHAND_BROWSER: "local", - XAI_API_KEY: "agent-only-secret", - OTHER_SECRET: "agent-only-secret", - }, - }; -} - -async function readEvents(path: string): Promise>> { - return (await readFile(path, "utf8")) - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as Record); -} - -async function expectProcessToStop(pid: number): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - try { - process.kill(pid, 0); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return; - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - throw new Error(`ACP descendant process ${pid} is still running.`); -} diff --git a/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs b/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs deleted file mode 100644 index fc84833918..0000000000 --- a/packages/integrations/core/tests/fixtures/fake-acp-agent.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "@agentclientprotocol/sdk"; -import { spawn } from "node:child_process"; -import { appendFile } from "node:fs/promises"; -import { Readable, Writable } from "node:stream"; - -const behavior = process.env.ACP_FAKE_BEHAVIOR ?? "success"; -const recordPath = process.env.ACP_RECORD_PATH; -let sessionId = "fake-session"; - -async function record(event) { - if (recordPath) await appendFile(recordPath, `${JSON.stringify(event)}\n`); -} - -const app = agent({ name: "fake-stagehand-test-agent" }) - .onRequest(methods.agent.initialize, async ({ params }) => { - await record({ type: "initialize", params }); - if (behavior === "hang-with-descendant") { - const descendant = spawn( - process.execPath, - ["-e", "process.on('SIGTERM', () => undefined); setInterval(() => undefined, 1000)"], - { stdio: "ignore" }, - ); - await record({ type: "descendant", pid: descendant.pid }); - } - if (behavior === "exit-after-initialize") { - setTimeout(() => process.exit(17), 0); - } - return { - protocolVersion: behavior === "protocol-mismatch" ? 99 : PROTOCOL_VERSION, - agentCapabilities: {}, - agentInfo: { name: "fake-agent", version: "1.0.0" }, - ...(behavior === "no-auth" ? {} : { authMethods: [{ id: "test-auth", name: "Test auth" }] }), - }; - }) - .onRequest(methods.agent.authenticate, async ({ params }) => { - await record({ type: "authenticate", params }); - return {}; - }) - .onRequest(methods.agent.session.new, async ({ params }) => { - await record({ type: "session-new", params }); - return { sessionId }; - }) - .onNotification(methods.agent.session.cancel, async ({ params }) => { - await record({ type: "cancel", params }); - }) - .onRequest(methods.agent.session.prompt, async ({ params, client }) => { - await record({ type: "prompt", params }); - if (behavior === "hang" || behavior === "hang-with-descendant") { - process.on("SIGTERM", () => undefined); - return await new Promise(() => undefined); - } - - if (behavior === "permission" || behavior === "deny-permission") { - const allowed = behavior === "permission"; - const permission = await client.request(methods.client.session.requestPermission, { - sessionId, - toolCall: { - toolCallId: "tool-1", - title: allowed ? "stagehand__snapshot" : "bash", - _meta: allowed ? { allowed: true } : { allowed: false }, - }, - options: [ - { optionId: "allow", name: "Allow once", kind: "allow_once" }, - { optionId: "reject", name: "Reject once", kind: "reject_once" }, - ], - }); - await record({ type: "permission-result", permission }); - } - - if (behavior !== "empty") { - await client.notify(methods.client.session.update, { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Hello " }, - }, - }); - await client.notify(methods.client.session.update, { - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "ignored" }, - }, - }); - await client.notify(methods.client.session.update, { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "browser" }, - }, - }); - } - return { stopReason: behavior === "refusal" ? "refusal" : "end_turn" }; - }); - -const connection = app.connect( - ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), -); -await connection.closed; diff --git a/packages/integrations/core/tests/fixtures/record-env.mjs b/packages/integrations/core/tests/fixtures/record-env.mjs deleted file mode 100644 index 5bcb12e771..0000000000 --- a/packages/integrations/core/tests/fixtures/record-env.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { writeFile } from "node:fs/promises"; - -const recordPath = process.argv[2]; -if (!recordPath) throw new Error("record-env fixture requires an output path"); - -await writeFile( - recordPath, - JSON.stringify({ - STAGEHAND_BROWSER: process.env.STAGEHAND_BROWSER, - BROWSERBASE_API_KEY: process.env.BROWSERBASE_API_KEY, - XAI_API_KEY: process.env.XAI_API_KEY, - OTHER_SECRET: process.env.OTHER_SECRET, - }), -); diff --git a/packages/integrations/core/tests/harness.test.ts b/packages/integrations/core/tests/harness.test.ts index 7747f979dc..8ec2288c96 100644 --- a/packages/integrations/core/tests/harness.test.ts +++ b/packages/integrations/core/tests/harness.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildAllowlistedEnv, sanitizeErrorMessage } from "../src/harness/index.js"; describe("harness contract", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("redacts credential-bearing URL query parameters", () => { expect(sanitizeErrorMessage("wss://example.test?signingKey=top-secret&foo=bar")).toBe( "wss://example.test?signingKey=[redacted]&foo=bar", @@ -27,12 +31,12 @@ describe("harness contract", () => { }); it("allows Stagehand and Browserbase env vars while excluding other and empty values", () => { - const env = buildAllowlistedEnv({ - STAGEHAND_MODEL: "model", - BROWSERBASE_API_KEY: "browserbase-key", - STAGEHAND_EMPTY: "", - NOT_ALLOWLISTED_SECRET: "secret", - }); + vi.stubEnv("STAGEHAND_MODEL", "model"); + vi.stubEnv("BROWSERBASE_API_KEY", "browserbase-key"); + vi.stubEnv("STAGEHAND_EMPTY", ""); + vi.stubEnv("NOT_ALLOWLISTED_SECRET", "secret"); + + const env = buildAllowlistedEnv(); expect(env.STAGEHAND_MODEL).toBe("model"); expect(env.BROWSERBASE_API_KEY).toBe("browserbase-key"); diff --git a/packages/integrations/core/tsdown.config.ts b/packages/integrations/core/tsdown.config.ts index 92990345a4..2766e2babd 100644 --- a/packages/integrations/core/tsdown.config.ts +++ b/packages/integrations/core/tsdown.config.ts @@ -2,8 +2,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: { - "acp/facade-launcher": "src/acp/facade-launcher.ts", - "acp/index": "src/acp/index.ts", "codemode/stdio-server": "src/codemode/stdio-server.ts", "facade/index": "src/facade/index.ts", "facade/stdio-server": "src/facade/stdio-server.ts", diff --git a/packages/integrations/grok-build-sdk/package.json b/packages/integrations/grok-build-sdk/package.json new file mode 100644 index 0000000000..b618cf48f0 --- /dev/null +++ b/packages/integrations/grok-build-sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@browserbasehq/stagehand-integrations-grok-build-sdk", + "version": "4.0.1", + "private": true, + "description": "Grok Build CLI harness adapter for Stagehand integrations", + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "test": "pnpm run build && vitest run --root ../../.. packages/integrations/grok-build-sdk/tests", + "test:unit": "vitest run --root ../../.. packages/integrations/grok-build-sdk/tests", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@browserbasehq/stagehand-integrations": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/integrations/grok-build-sdk/src/index.ts b/packages/integrations/grok-build-sdk/src/index.ts new file mode 100644 index 0000000000..9df91886ba --- /dev/null +++ b/packages/integrations/grok-build-sdk/src/index.ts @@ -0,0 +1 @@ +export * from "./session.js"; diff --git a/packages/integrations/grok-build-sdk/src/session.ts b/packages/integrations/grok-build-sdk/src/session.ts new file mode 100644 index 0000000000..c4c63367df --- /dev/null +++ b/packages/integrations/grok-build-sdk/src/session.ts @@ -0,0 +1,460 @@ +import { spawn } from "node:child_process"; +import { + HarnessAdapterError, + sanitizeErrorMessage, + type HarnessLogger, +} from "@browserbasehq/stagehand-integrations/harness"; + +export type GrokBuildEvent = Record; + +export type GrokBuildProcessExit = { + exitCode: number | null; + signal: NodeJS.Signals | null; +}; + +export type GrokBuildProcessRunner = (input: { + command: string; + args: string[]; + cwd?: string; + env?: Record; + signal: AbortSignal; + onStdoutLine: (line: string) => void | Promise; + onStderr: (chunk: string) => void; +}) => Promise; + +export type GrokBuildSessionConfig = { + cwd?: string; + env?: Record; + binaryPath?: string; + maxTurns?: number; + sandbox?: string; + extraArgs?: string[]; +}; + +export type GrokBuildTokenUsage = { + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + cacheCreationInputTokens: number; + reasoningOutputTokens: number; + totalTokens: number; + reported: boolean; +}; + +export type GrokBuildSessionResult = { + events: GrokBuildEvent[]; + endEvent?: GrokBuildEvent; + resultText: string; + status: "completed" | "max_turns" | "sdk_error"; + stopReason?: string; + tokenUsage: GrokBuildTokenUsage; + costUsd?: number; + exit?: GrokBuildProcessExit; + stderr: string; + iterationError?: unknown; +}; + +export type GrokBuildToolCallView = { + callId: string; + subtype: "started" | "completed"; + name?: string; + args: Record; + result?: unknown; + ok: boolean; + error?: string; +}; + +export const GROK_BUILD_BINARY = "grok"; +const STDERR_LIMIT = 64 * 1024; + +export function resolveGrokBuildBinary(override?: string): string { + return override ?? process.env.GROK_BUILD_PATH ?? GROK_BUILD_BINARY; +} + +export function normalizeGrokBuildModel(model: string): string | undefined { + if (model === "grok-build/auto" || model === "auto") return undefined; + return model.includes("/") ? model.slice(model.indexOf("/") + 1) : model; +} + +export function buildGrokBuildArgs(input: { + prompt: string; + model?: string; + session: GrokBuildSessionConfig; +}): string[] { + const { session } = input; + return [ + "-p", + input.prompt, + "--output-format", + "streaming-json", + "--always-approve", + "--tools", + "search_tool,use_tool", + "--disallowed-tools", + "Agent", + "--no-plan", + "--no-subagents", + "--disable-web-search", + ...(session.cwd ? ["--cwd", session.cwd] : []), + ...(input.model ? ["--model", input.model] : []), + ...(session.maxTurns ? ["--max-turns", String(session.maxTurns)] : []), + ...(session.sandbox ? ["--sandbox", session.sandbox] : []), + ...(session.extraArgs ?? []), + ]; +} + +export function parseGrokBuildStreamLine(line: string): GrokBuildEvent | undefined { + const trimmed = line.trim(); + if (!trimmed) return undefined; + try { + const parsed: unknown = JSON.parse(trimmed); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function extractGrokBuildToolCall(event: GrokBuildEvent): GrokBuildToolCallView | undefined { + if (event.type === "tool_call") { + return { + callId: readString(event.toolCallId) ?? "", + subtype: "started", + name: readString(event.toolName), + args: isRecord(event.rawInput) ? event.rawInput : {}, + ok: true, + }; + } + if (event.type !== "tool_call_update") return undefined; + const status = readString(event.status) ?? "completed"; + if (!["completed", "failed", "cancelled", "rejected"].includes(status)) return undefined; + const ok = status === "completed"; + const result = event.rawOutput ?? event.content; + return { + callId: readString(event.toolCallId) ?? "", + subtype: "completed", + args: {}, + ...(result !== undefined && { result }), + ok, + ...(!ok && { error: stringifyError(result) || `tool call ${status}` }), + }; +} + +export const defaultGrokBuildProcessRunner: GrokBuildProcessRunner = async (input) => { + return new Promise((resolve, reject) => { + const child = spawn(input.command, input.args, { + ...(input.cwd && { cwd: input.cwd }), + ...(input.env && { env: input.env }), + stdio: ["ignore", "pipe", "pipe"], + }); + let stdoutBuffer = ""; + let lineQueue = Promise.resolve(); + let killTimer: NodeJS.Timeout | undefined; + let settled = false; + + const queueLine = (line: string): void => { + lineQueue = lineQueue.then(() => input.onStdoutLine(line)); + }; + const removeAbort = (): void => input.signal.removeEventListener("abort", abort); + const abort = (): void => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + killTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, 5_000); + killTimer.unref(); + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdoutBuffer += chunk; + const lines = stdoutBuffer.split(/\r?\n/u); + stdoutBuffer = lines.pop() ?? ""; + for (const line of lines) queueLine(line); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => input.onStderr(chunk)); + child.on("error", (error: NodeJS.ErrnoException) => { + if (settled) return; + settled = true; + removeAbort(); + if (killTimer) clearTimeout(killTimer); + if (error.code === "ENOENT") { + reject( + new HarnessAdapterError( + "Grok Build harness requires the `grok` CLI (install `@xai-official/grok` globally, or set GROK_BUILD_PATH).", + { cause: error }, + ), + ); + return; + } + reject(error); + }); + child.on("close", (exitCode, signal) => { + if (settled) return; + settled = true; + removeAbort(); + if (killTimer) clearTimeout(killTimer); + if (stdoutBuffer) queueLine(stdoutBuffer); + lineQueue.then( + () => resolve({ exitCode, signal }), + (error) => reject(error), + ); + }); + + if (input.signal.aborted) abort(); + else input.signal.addEventListener("abort", abort, { once: true }); + }); +}; + +export async function runGrokBuildSession(input: { + prompt: string; + model: string; + signal?: AbortSignal; + logger: HarnessLogger; + session: GrokBuildSessionConfig; + runProcess?: GrokBuildProcessRunner; + onToolResult?: (toolName: string, view: GrokBuildToolCallView) => void | Promise; +}): Promise { + const events: GrokBuildEvent[] = []; + const controller = new AbortController(); + const forwardAbort = (): void => controller.abort(input.signal?.reason); + if (input.signal) { + if (input.signal.aborted) controller.abort(input.signal.reason); + else input.signal.addEventListener("abort", forwardAbort, { once: true }); + } + + const textParts: string[] = []; + const toolNames = new Map(); + let endEvent: GrokBuildEvent | undefined; + let errorEvent: GrokBuildEvent | undefined; + let stderr = ""; + let exit: GrokBuildProcessExit | undefined; + let iterationError: unknown; + + try { + const model = normalizeGrokBuildModel(input.model); + exit = await (input.runProcess ?? defaultGrokBuildProcessRunner)({ + command: resolveGrokBuildBinary(input.session.binaryPath), + args: buildGrokBuildArgs({ prompt: input.prompt, model, session: input.session }), + ...(input.session.cwd && { cwd: input.session.cwd }), + env: stringEnv({ ...process.env, ...input.session.env }), + signal: controller.signal, + onStdoutLine: async (line) => { + const parsed = parseGrokBuildStreamLine(line); + if (!parsed) return; + const event = deepSanitize(parsed) as GrokBuildEvent; + events.push(event); + logGrokBuildEvent(input.logger, event); + if (event.type === "text" && typeof event.data === "string") textParts.push(event.data); + if (event.type === "end") endEvent = event; + if (event.type === "error") errorEvent = event; + const view = extractGrokBuildToolCall(event); + if (view?.subtype === "started" && view.callId && view.name) { + toolNames.set(view.callId, view.name); + } + if (view?.subtype === "completed") { + const toolName = toolNames.get(view.callId) ?? view.name ?? "tool"; + await input.onToolResult?.(toolName, view); + } + }, + onStderr: (chunk) => { + stderr = `${stderr}${chunk}`.slice(-STDERR_LIMIT); + }, + }); + } catch (error) { + iterationError = new HarnessAdapterError( + sanitizeErrorMessage(stringifyError(error)) || "Grok Build session failed.", + ); + input.logger.warn({ + category: "grok_build", + message: `Grok Build stopped before a normal result: ${sanitizeErrorMessage(stringifyError(error))}`, + level: 0, + }); + } finally { + input.signal?.removeEventListener("abort", forwardAbort); + } + + stderr = sanitizeErrorMessage(stderr); + if (stderr) input.logger.log({ category: "grok_build", message: stderr, level: 1 }); + const externalAbortReason = input.signal?.aborted + ? stringifyError(input.signal.reason) || "Grok Build session aborted" + : undefined; + const stopReason = buildGrokBuildStopReason({ + endEvent, + errorEvent, + iterationError, + exit, + stderr, + externalAbortReason, + }); + const tokenUsage = readGrokBuildUsage(endEvent); + const costUsd = finiteNumber(endEvent?.total_cost_usd); + return { + events, + ...(endEvent && { endEvent }), + resultText: textParts.join(""), + status: resolveGrokBuildStatus(endEvent, errorEvent, iterationError, stopReason), + ...(stopReason && { stopReason: sanitizeErrorMessage(stopReason) }), + tokenUsage, + ...(costUsd !== undefined && { costUsd }), + ...(exit && { exit }), + stderr, + ...(iterationError !== undefined && { iterationError }), + }; +} + +export function readGrokBuildUsage(event: GrokBuildEvent | undefined): GrokBuildTokenUsage { + const usage = isRecord(event?.usage) ? event.usage : undefined; + const inputTokens = finiteNumber(usage?.input_tokens) ?? 0; + const cachedInputTokens = finiteNumber(usage?.cache_read_input_tokens) ?? 0; + const cacheCreationInputTokens = finiteNumber(usage?.cache_creation_input_tokens) ?? 0; + const outputTokens = finiteNumber(usage?.output_tokens) ?? 0; + const reasoningOutputTokens = finiteNumber(usage?.reasoning_tokens) ?? 0; + const totalTokens = + finiteNumber(usage?.total_tokens) ?? + inputTokens + cachedInputTokens + cacheCreationInputTokens + outputTokens; + return { + inputTokens, + outputTokens, + cachedInputTokens, + cacheCreationInputTokens, + reasoningOutputTokens, + totalTokens, + reported: usage !== undefined, + }; +} + +export function resolveGrokBuildStatus( + endEvent: GrokBuildEvent | undefined, + errorEvent: GrokBuildEvent | undefined, + iterationError: unknown, + stopReason?: string, +): "completed" | "max_turns" | "sdk_error" { + const reason = readString(endEvent?.stopReason) ?? ""; + if (/max_turn/iu.test(reason) || stopReason === "max turns reached") return "max_turns"; + if (iterationError || errorEvent || stopReason || !endEvent) return "sdk_error"; + return "completed"; +} + +export function buildGrokBuildStopReason(input: { + endEvent?: GrokBuildEvent; + errorEvent?: GrokBuildEvent; + iterationError?: unknown; + exit?: GrokBuildProcessExit; + stderr: string; + externalAbortReason?: string; +}): string | undefined { + if (input.externalAbortReason) return input.externalAbortReason; + if (input.iterationError) return stringifyError(input.iterationError); + if (input.errorEvent) { + return readString(input.errorEvent.message) ?? "Grok Build returned an error event"; + } + const reason = readString(input.endEvent?.stopReason); + if (reason && /max_turn/iu.test(reason)) return "max turns reached"; + if (!input.endEvent) { + const lastLine = input.stderr + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + return input.exit?.exitCode !== 0 + ? `grok exited with code ${String(input.exit?.exitCode ?? "unknown")}${lastLine ? `: ${lastLine}` : ""}` + : "Grok Build exited without a terminal end event"; + } + return undefined; +} + +export function buildGrokBuildTranscript(events: GrokBuildEvent[]): string { + return events + .map((event) => summarizeGrokBuildEvent(event).detail) + .filter((detail): detail is string => Boolean(detail)) + .join("\n"); +} + +export function logGrokBuildEvent(logger: HarnessLogger, event: GrokBuildEvent): void { + const summary = summarizeGrokBuildEvent(event); + logger.log({ + category: "grok_build", + message: summary.message, + level: 1, + auxiliary: { + type: { value: readString(event.type) ?? "unknown", type: "string" }, + ...(summary.detail && { detail: { value: summary.detail, type: "string" } }), + }, + }); +} + +export function summarizeGrokBuildEvent(event: GrokBuildEvent): { + message: string; + detail?: string; +} { + const type = readString(event.type) ?? "unknown"; + if ((type === "text" || type === "thought") && typeof event.data === "string") { + const detail = sanitizeErrorMessage(event.data); + return { message: `${type}: ${clip(detail, 500)}`, detail }; + } + const tool = extractGrokBuildToolCall(event); + if (tool) { + return { + message: `tool: ${tool.name ?? tool.callId ?? "unknown"} ${tool.subtype}${tool.ok ? "" : " failed"}`, + detail: sanitizeOptional(safeJson(event)), + }; + } + if (type === "end") { + return { message: `end: ${readString(event.stopReason) ?? "done"}`, detail: safeJson(event) }; + } + return { message: `${type} event`, detail: sanitizeOptional(safeJson(event)) }; +} + +function deepSanitize(value: unknown): unknown { + if (typeof value === "string") return sanitizeErrorMessage(value); + if (Array.isArray(value)) return value.map(deepSanitize); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, deepSanitize(child)]), + ); +} + +function sanitizeOptional(value: string | undefined): string | undefined { + return value === undefined ? undefined : sanitizeErrorMessage(value); +} + +function stringEnv( + env: NodeJS.ProcessEnv | Record, +): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + +function finiteNumber(value: unknown): number | undefined { + const number = typeof value === "number" ? value : Number.NaN; + return Number.isFinite(number) ? number : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function safeJson(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function stringifyError(value: unknown): string { + if (!value) return ""; + if (value instanceof Error) return value.message; + if (typeof value === "string") return value; + return safeJson(value) ?? "Unknown error"; +} + +export function clip(value: string, maxLength: number): string { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; +} diff --git a/packages/integrations/grok-build-sdk/tests/session.test.ts b/packages/integrations/grok-build-sdk/tests/session.test.ts new file mode 100644 index 0000000000..b8cea9b569 --- /dev/null +++ b/packages/integrations/grok-build-sdk/tests/session.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildGrokBuildArgs, + buildGrokBuildStopReason, + extractGrokBuildToolCall, + normalizeGrokBuildModel, + parseGrokBuildStreamLine, + readGrokBuildUsage, + resolveGrokBuildBinary, + runGrokBuildSession, + type GrokBuildProcessRunner, +} from "../src/session.ts"; + +const logger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +describe("Grok Build CLI session", () => { + it("builds a restricted one-shot CLI invocation", () => { + expect( + buildGrokBuildArgs({ + prompt: "do it", + model: "grok-build", + session: { cwd: "/workspace", maxTurns: 12, sandbox: "off" }, + }), + ).toEqual([ + "-p", + "do it", + "--output-format", + "streaming-json", + "--always-approve", + "--tools", + "search_tool,use_tool", + "--disallowed-tools", + "Agent", + "--no-plan", + "--no-subagents", + "--disable-web-search", + "--cwd", + "/workspace", + "--model", + "grok-build", + "--max-turns", + "12", + "--sandbox", + "off", + ]); + }); + + it("resolves the binary and normalizes harness model ids", () => { + expect(resolveGrokBuildBinary("/custom/grok")).toBe("/custom/grok"); + expect(normalizeGrokBuildModel("grok-build/auto")).toBeUndefined(); + expect(normalizeGrokBuildModel("xai/grok-build")).toBe("grok-build"); + expect(normalizeGrokBuildModel("grok-build")).toBe("grok-build"); + }); + + it("parses native streaming events and tool updates", () => { + expect(parseGrokBuildStreamLine("not-json")).toBeUndefined(); + expect(parseGrokBuildStreamLine('{"type":"text","data":"hi"}')).toEqual({ + type: "text", + data: "hi", + }); + expect( + extractGrokBuildToolCall({ + type: "tool_call", + toolCallId: "call-1", + toolName: "stagehand__run", + rawInput: { code: "return 1" }, + }), + ).toEqual({ + callId: "call-1", + subtype: "started", + name: "stagehand__run", + args: { code: "return 1" }, + ok: true, + }); + expect( + extractGrokBuildToolCall({ + type: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: { value: 1 }, + }), + ).toMatchObject({ + callId: "call-1", + subtype: "completed", + result: { value: 1 }, + ok: true, + }); + }); + + it("normalizes usage from the terminal end event", () => { + expect( + readGrokBuildUsage({ + type: "end", + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 2, + output_tokens: 5, + reasoning_tokens: 3, + }, + }), + ).toEqual({ + inputTokens: 10, + outputTokens: 5, + cachedInputTokens: 20, + cacheCreationInputTokens: 2, + reasoningOutputTokens: 3, + totalTokens: 37, + reported: true, + }); + }); + + it("runs the stream, joins text, and reports usage and cost", async () => { + const onToolResult = vi.fn(); + const result = await runGrokBuildSession({ + prompt: "do it", + model: "grok-build/auto", + logger, + session: { cwd: "/workspace" }, + runProcess: scriptedRunner([ + { type: "text", data: "EVAL_RESULT: " }, + { + type: "tool_call", + toolCallId: "call-1", + toolName: "stagehand__snapshot", + rawInput: {}, + }, + { + type: "tool_call_update", + toolCallId: "call-1", + status: "completed", + rawOutput: "snapshot", + }, + { type: "text", data: '{"success":true,"summary":"done","finalAnswer":"ok"}' }, + { + type: "end", + stopReason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + total_cost_usd: 0.02, + num_turns: 2, + }, + ]), + onToolResult, + }); + + expect(result.status).toBe("completed"); + expect(result.resultText).toContain('EVAL_RESULT: {"success":true'); + expect(result.tokenUsage.totalTokens).toBe(15); + expect(result.costUsd).toBe(0.02); + expect(onToolResult).toHaveBeenCalledWith( + "stagehand__snapshot", + expect.objectContaining({ subtype: "completed" }), + ); + }); + + it("fails a non-zero exit without an end event", async () => { + const result = await runGrokBuildSession({ + prompt: "do it", + model: "grok-build/auto", + logger, + session: {}, + runProcess: scriptedRunner([], 1), + }); + expect(result.status).toBe("sdk_error"); + expect(result.stopReason).toContain("exited with code 1"); + }); + + it("reports max-turn stops without treating them as SDK errors", () => { + expect( + buildGrokBuildStopReason({ + endEvent: { type: "end", stopReason: "max_turn_requests" }, + stderr: "", + }), + ).toBe("max turns reached"); + }); +}); + +function scriptedRunner( + events: Array>, + exitCode = 0, +): GrokBuildProcessRunner { + return async (input) => { + for (const event of events) await input.onStdoutLine(JSON.stringify(event)); + return { exitCode, signal: null }; + }; +} diff --git a/packages/integrations/grok-build/tsconfig.json b/packages/integrations/grok-build-sdk/tsconfig.json similarity index 80% rename from packages/integrations/grok-build/tsconfig.json rename to packages/integrations/grok-build-sdk/tsconfig.json index e9859394d1..3571933603 100644 --- a/packages/integrations/grok-build/tsconfig.json +++ b/packages/integrations/grok-build-sdk/tsconfig.json @@ -9,6 +9,6 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/integrations/grok-build-sdk/tsdown.config.ts b/packages/integrations/grok-build-sdk/tsdown.config.ts new file mode 100644 index 0000000000..49fbead000 --- /dev/null +++ b/packages/integrations/grok-build-sdk/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + deps: { neverBundle: ["@browserbasehq/stagehand-integrations"] }, + outputOptions: { minify: false }, +}); diff --git a/packages/integrations/grok-build/.grok/config.toml b/packages/integrations/grok-build/.grok/config.toml new file mode 100644 index 0000000000..3981c0ac37 --- /dev/null +++ b/packages/integrations/grok-build/.grok/config.toml @@ -0,0 +1,15 @@ +# Grok Build project MCP entry for the Stagehand facade. Adjust the absolute +# path and credentials before copying this file into another project. + +[mcp_servers.stagehand] +command = "node" +args = [ + "/absolute/path/to/stagehand/packages/integrations/core/dist/facade/stdio-server.mjs", + "--max-screenshot-base64-bytes=60000", +] +startup_timeout_sec = 60 +tool_timeout_sec = 300 + +[mcp_servers.stagehand.env] +STAGEHAND_BROWSER = "browserbase" +BROWSERBASE_API_KEY = "bb_live_..." diff --git a/packages/integrations/grok-build/AGENTS.md b/packages/integrations/grok-build/AGENTS.md new file mode 100644 index 0000000000..85938290b0 --- /dev/null +++ b/packages/integrations/grok-build/AGENTS.md @@ -0,0 +1,9 @@ +# Stagehand browser tools in Grok Build + +The `stagehand` MCP server exposes `stagehand__run`, `stagehand__snapshot`, and +`stagehand__screenshot`. Use only those tools for browser work. + +There is no separate navigate or start tool. Open URLs with `stagehand__run`, for example +`await page.goto("https://example.com"); return { url: await page.url() };`. Use +`stagehand__snapshot` for the accessibility tree and element IDs. Use `stagehand__screenshot` +only when pixels matter. Never launch another browser or use shell commands for browsing. diff --git a/packages/integrations/grok-build/README.md b/packages/integrations/grok-build/README.md index 7a70bb435b..f535a158c8 100644 --- a/packages/integrations/grok-build/README.md +++ b/packages/integrations/grok-build/README.md @@ -1,25 +1,48 @@ -# Grok Build + Stagehand over ACP +# Grok Build CLI + Stagehand facade over MCP/stdio -This example starts the packaged Grok Build CLI as an ACP v1 agent and mounts the persistent Stagehand facade as its only session MCP server. +Grok Build's `grok` CLI consumes the Stagehand facade (`run` / `snapshot` / `screenshot`) as a +project MCP server through `.grok/config.toml`. -## Run + + +## Setup -From the repository root: +Use Node.js 24 or newer. From the repository root, build the integrations package first: ```bash pnpm install --frozen-lockfile pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations -export XAI_API_KEY=xai-... -pnpm --dir packages/integrations/grok-build start -- \ - "Open https://example.com, snapshot it, request a screenshot, and report the title." +npm install --global @xai-official/grok +grok login +# or: export XAI_API_KEY=... ``` -An existing `grok login` can be used instead of `XAI_API_KEY`. Grok uses its configured default model. Browser selection continues to use `STAGEHAND_BROWSER` and `BROWSERBASE_API_KEY`; `BROWSERBASE_PROJECT_ID` is optional. +## Configure + +Copy `.grok/config.toml` from this directory to your project, then replace the facade path and +Browserbase key placeholders. Grok merges project MCP configuration over its user settings. + +## Run + +Run from the configured project so Grok sees both `.grok/config.toml` and `AGENTS.md`: -## Isolation +```bash +grok -p \ + --output-format streaming-json \ + --always-approve \ + --tools search_tool,use_tool \ + --disallowed-tools Agent \ + --no-plan \ + --no-subagents \ + --disable-web-search \ + "Use the stagehand MCP tools: open https://example.com, snapshot it, and report the heading citing the snapshot ID." +``` -Each run creates a disposable workspace, home directory, and `GROK_HOME`, copies only cached `auth.json` when API-key auth is unavailable, and disables user compatibility MCP imports. The ACP session supplies exactly one `stagehand` stdio server. A minimal launcher creates the actual facade runtime with non-empty `STAGEHAND_*` and `BROWSERBASE_*` values plus basic OS values needed to launch Node and local Chrome, so `XAI_API_KEY` and unrelated host secrets are unavailable to the facade. +The eval harness uses the same CLI path with an isolated temporary Grok home and project config: -The shared `@browserbasehq/stagehand-integrations/acp` transport handles protocol initialization, auth, session updates, permission decisions, cancellation, and process-tree cleanup. This package supplies only Grok's command and protocol-specific profile behavior. +```bash +evals run b:webvoyager --harness grok_build --tool stagehand_facade -l 1 -t 1 -e browserbase +``` -The Grok process is restricted to `search_tool` and `use_tool` through an ACP `--agent-profile` allowlist so it can discover and invoke the lazy Stagehand MCP tools. Shell, file, subagent, memory, plan, and web-search capabilities are disabled for this browser-only example, matching the Claude and Codex integrations. +Set `EVAL_GROK_BUILD_PATH` to override the binary, `EVAL_GROK_BUILD_MAX_TURNS` to change the +50-turn default, or `EVAL_GROK_BUILD_SANDBOX` to pass a Grok sandbox profile. diff --git a/packages/integrations/grok-build/package.json b/packages/integrations/grok-build/package.json deleted file mode 100644 index 6e14a4c1b4..0000000000 --- a/packages/integrations/grok-build/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@browserbasehq/stagehand-integrations-example-grok-build-facade", - "version": "4.0.1", - "private": true, - "type": "module", - "scripts": { - "start": "node src/agent.ts", - "test": "pnpm -w exec turbo run build --filter @browserbasehq/stagehand-integrations && vitest run", - "test:unit": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@browserbasehq/stagehand-integrations": "workspace:*", - "@xai-official/grok": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - }, - "engines": { - "node": ">=24" - } -} diff --git a/packages/integrations/grok-build/src/agent.ts b/packages/integrations/grok-build/src/agent.ts deleted file mode 100644 index 8fb9a76a28..0000000000 --- a/packages/integrations/grok-build/src/agent.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { - AcpFacadeAgentProfile, - RunAcpFacadeAgentOptions, -} from "@browserbasehq/stagehand-integrations/acp"; -import { runAcpFacadeAgent } from "@browserbasehq/stagehand-integrations/acp"; -import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; -import { sanitizeErrorMessage } from "@browserbasehq/stagehand-integrations/harness"; -import { createRequire } from "node:module"; -import { access, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const require = createRequire(import.meta.url); - -export const STAGEHAND_GROK_TOOL_NAMES = new Set( - FACADE_TOOLS.map((tool) => `stagehand__${tool.name}`), -); - -/** Grok ACP agent-profile filename. `--tools` is documented as headless-only; this allowlist applies in `agent stdio`. */ -export const STAGEHAND_GROK_AGENT_PROFILE = "stagehand-browser.md"; - -const GROK_AGENT_PROFILE = [ - "---", - "name: stagehand-browser", - "description: Browser-only Stagehand MCP agent. Discover and invoke Stagehand tools; do not use shell, files, or subagents.", - "tools:", - " - search_tool", - " - use_tool", - "disallowedTools:", - " - Agent", - "---", - "", - "Use only Stagehand browser tools discovered through MCP.", - "", -].join("\n"); - -export type GrokRuntime = { - root: string; - userHome: string; - cwd: string; - grokHome: string; - agentProfilePath: string; - cachedAuthAvailable: boolean; -}; - -export type RunGrokBuildOptions = { - env?: NodeJS.ProcessEnv; - grokExecutable?: string; - facadeServerPath?: string; - signal?: AbortSignal; - makeRuntime?: (env: NodeJS.ProcessEnv) => Promise; - runAcp?: (options: RunAcpFacadeAgentOptions) => Promise; -}; - -export function resolveGrokExecutable(): string { - return require.resolve("@xai-official/grok/bin/grok"); -} - -export function resolveGrokAuthHome( - env: NodeJS.ProcessEnv, - platform: NodeJS.Platform = process.platform, -): string | undefined { - const configured = env.GROK_HOME?.trim(); - if (configured) return configured; - const userHome = - platform === "win32" - ? env.USERPROFILE?.trim() || env.HOME?.trim() - : env.HOME?.trim() || env.USERPROFILE?.trim(); - return userHome ? join(userHome, ".grok") : undefined; -} - -export function grokAcpArgs(agentProfilePath: string): string[] { - return [ - "--tools", - "search_tool,use_tool", - "--deny", - "Bash", - "--deny", - "Edit", - "--deny", - "Write", - "--deny", - "Read", - "--deny", - "Grep", - "--deny", - "WebFetch", - "--no-plan", - "--no-subagents", - "--no-memory", - "--disable-web-search", - "agent", - "--no-leader", - "--agent-profile", - agentProfilePath, - "stdio", - ]; -} - -export function createGrokProfile(options: { - executable: string; - cachedAuthAvailable: boolean; - agentProfilePath: string; -}): AcpFacadeAgentProfile { - return { - id: "grok-build", - command: options.executable, - args: grokAcpArgs(options.agentProfilePath), - resolveAuthentication: ({ initialization, env }) => { - const advertised = new Set((initialization.authMethods ?? []).map((method) => method.id)); - if (env.XAI_API_KEY?.trim() && advertised.has("xai.api_key")) { - return { methodId: "xai.api_key", _meta: { headless: true } }; - } - if (options.cachedAuthAvailable && advertised.has("cached_token")) { - return { methodId: "cached_token", _meta: { headless: true } }; - } - return undefined; - }, - buildSessionMeta: (instructions) => ({ rules: instructions }), - buildPrompt: (instruction) => instruction, - isFacadeToolCall: (toolCall) => { - const metadata = readRecord(toolCall._meta); - const xaiTool = readRecord(metadata?.["x.ai/tool"]); - const rawInput = readRecord(toolCall.rawInput); - const toolName = - readString(rawInput?.tool_name) ?? readString(toolCall.name) ?? readString(toolCall.title); - if (!STAGEHAND_GROK_TOOL_NAMES.has(toolName ?? "")) return false; - return ( - xaiTool?.namespace === "mcp" || - (xaiTool?.namespace === "grok_build" && xaiTool?.name === "use_tool") - ); - }, - }; -} - -export async function createGrokRuntime( - env: NodeJS.ProcessEnv = process.env, -): Promise { - const root = await mkdtemp(join(tmpdir(), "stagehand-grok-build-")); - const userHome = join(root, "home"); - const cwd = join(root, "workspace"); - const grokHome = join(userHome, ".grok"); - const agentProfilePath = join(grokHome, STAGEHAND_GROK_AGENT_PROFILE); - await Promise.all([mkdir(cwd, { recursive: true }), mkdir(grokHome, { recursive: true })]); - await Promise.all([ - writeFile(agentProfilePath, GROK_AGENT_PROFILE, { mode: 0o600 }), - writeFile( - join(grokHome, "config.toml"), - [ - "[cli]", - "auto_update = false", - "", - "[compat.claude]", - "mcps = false", - "", - "[compat.cursor]", - "mcps = false", - "", - "[subagents]", - "enabled = false", - "", - "[ui]", - 'permission_mode = "ask"', - "", - ].join("\n"), - { mode: 0o600 }, - ), - ]); - - let cachedAuthAvailable = false; - if (!env.XAI_API_KEY?.trim()) { - const sourceHome = resolveGrokAuthHome(env); - if (sourceHome) { - const sourceAuth = join(sourceHome, "auth.json"); - try { - await access(sourceAuth); - await copyFile(sourceAuth, join(grokHome, "auth.json")); - cachedAuthAvailable = true; - } catch { - // Missing cached auth is reported after ACP initialization, when Grok's - // advertised methods are known. - } - } - } - return { root, userHome, cwd, grokHome, agentProfilePath, cachedAuthAvailable }; -} - -export function resolveInstruction(args: string[]): string { - return (args[0] === "--" ? args.slice(1) : args).join(" ").trim(); -} - -export async function runGrokBuild( - instruction: string, - options: RunGrokBuildOptions = {}, -): Promise { - const env = options.env ?? process.env; - const runtime = await (options.makeRuntime ?? createGrokRuntime)(env); - try { - const agentEnv = { - ...env, - HOME: runtime.userHome, - USERPROFILE: runtime.userHome, - GROK_HOME: runtime.grokHome, - }; - const profile = createGrokProfile({ - executable: options.grokExecutable ?? resolveGrokExecutable(), - cachedAuthAvailable: runtime.cachedAuthAvailable, - agentProfilePath: runtime.agentProfilePath, - }); - return await (options.runAcp ?? runAcpFacadeAgent)({ - profile, - instruction, - cwd: runtime.cwd, - env: agentEnv, - ...(options.facadeServerPath ? { facadeServerPath: options.facadeServerPath } : {}), - ...(options.signal ? { signal: options.signal } : {}), - }); - } finally { - await rm(runtime.root, { recursive: true, force: true }); - } -} - -function readRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -async function main(): Promise { - const instruction = resolveInstruction(process.argv.slice(2)); - if (!instruction) throw new Error('Usage: pnpm start "your instruction"'); - const controller = new AbortController(); - const onSignal = () => controller.abort(new Error("Grok Build run interrupted.")); - process.once("SIGINT", onSignal); - process.once("SIGTERM", onSignal); - try { - // oxlint-disable-next-line no-console -- CLI example prints the agent result. - console.log(await runGrokBuild(instruction, { signal: controller.signal })); - } finally { - process.removeListener("SIGINT", onSignal); - process.removeListener("SIGTERM", onSignal); - } -} - -if (import.meta.main) { - main().catch(handleFailure); -} - -function handleFailure(error: unknown): void { - // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. - console.error(sanitizeErrorMessage(error instanceof Error ? error.message : String(error))); - process.exitCode = 1; -} diff --git a/packages/integrations/grok-build/tests/agent.test.ts b/packages/integrations/grok-build/tests/agent.test.ts deleted file mode 100644 index be9e3c9cec..0000000000 --- a/packages/integrations/grok-build/tests/agent.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RunAcpFacadeAgentOptions } from "@browserbasehq/stagehand-integrations/acp"; -import { FACADE_TOOLS } from "@browserbasehq/stagehand-integrations/facade"; - -import { - createGrokProfile, - createGrokRuntime, - grokAcpArgs, - resolveGrokAuthHome, - resolveGrokExecutable, - resolveInstruction, - runGrokBuild, - STAGEHAND_GROK_AGENT_PROFILE, - STAGEHAND_GROK_TOOL_NAMES, - type GrokRuntime, -} from "../src/agent.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); -}); - -describe("Grok ACP profile", () => { - it("derives its Grok MCP identities from the shared facade contract", () => { - const expected = FACADE_TOOLS.map((tool) => `stagehand__${tool.name}`).sort(); - expect([...STAGEHAND_GROK_TOOL_NAMES].sort()).toStrictEqual(expected); - }); - - it("uses API-key auth before cached auth and keeps Grok's configured model", () => { - const profile = createGrokProfile({ - executable: "/grok", - cachedAuthAvailable: true, - agentProfilePath: "/tmp/stagehand-browser.md", - }); - const initialization = { - protocolVersion: 1, - authMethods: [ - { id: "xai.api_key", name: "API key" }, - { id: "cached_token", name: "Cached token" }, - ], - }; - expect(profile.args).toStrictEqual(grokAcpArgs("/tmp/stagehand-browser.md")); - expect( - profile.resolveAuthentication?.({ initialization, env: { XAI_API_KEY: "xai-secret" } }), - ).toStrictEqual({ methodId: "xai.api_key", _meta: { headless: true } }); - expect(profile.resolveAuthentication?.({ initialization, env: {} })).toStrictEqual({ - methodId: "cached_token", - _meta: { headless: true }, - }); - }); - - it("returns no auth method when credentials or advertised methods are missing", () => { - const profile = createGrokProfile({ - executable: "/grok", - cachedAuthAvailable: false, - agentProfilePath: "/tmp/stagehand-browser.md", - }); - expect( - profile.resolveAuthentication?.({ - initialization: { - protocolVersion: 1, - authMethods: [{ id: "cached_token", name: "Cached token" }], - }, - env: {}, - }), - ).toBeUndefined(); - }); - - it("sets Grok rules metadata and classifies exact legacy and current Grok MCP identities", () => { - const profile = createGrokProfile({ - executable: "/grok", - cachedAuthAvailable: false, - agentProfilePath: "/tmp/stagehand-browser.md", - }); - expect(profile.buildSessionMeta?.("rules")).toStrictEqual({ rules: "rules" }); - expect( - profile.isFacadeToolCall({ - toolCallId: "one", - title: "stagehand__run", - _meta: { "x.ai/tool": { namespace: "mcp" } }, - }), - ).toBe(true); - expect( - profile.isFacadeToolCall({ - toolCallId: "two", - title: "stagehand__snapshot", - rawInput: { tool_name: "stagehand__snapshot" }, - _meta: { "x.ai/tool": { namespace: "grok_build", name: "use_tool" } }, - }), - ).toBe(true); - expect( - profile.isFacadeToolCall({ - toolCallId: "three", - title: "bash", - _meta: { "x.ai/tool": { namespace: "grok_build", name: "use_tool" } }, - }), - ).toBe(false); - expect( - profile.isFacadeToolCall({ - toolCallId: "four", - title: "stagehand__run", - _meta: { "x.ai/tool": { namespace: "grok_build", name: "bash" } }, - }), - ).toBe(false); - }); - - it("resolves the packaged Grok executable", async () => { - const executable = resolveGrokExecutable(); - await expect(access(executable)).resolves.toBeUndefined(); - expect(executable.replaceAll("\\", "/")).toContain("@xai-official/grok/bin/grok"); - }); - - it("resolves cached auth only from the supplied environment", () => { - expect(resolveGrokAuthHome({ GROK_HOME: "/configured", HOME: "/home" })).toBe("/configured"); - expect(resolveGrokAuthHome({ HOME: "/home" })).toBe(join("/home", ".grok")); - expect(resolveGrokAuthHome({ USERPROFILE: "C:\\Users\\test" })).toBe( - join("C:\\Users\\test", ".grok"), - ); - expect( - resolveGrokAuthHome({ HOME: "/git-bash/home", USERPROFILE: "C:\\Users\\test" }, "win32"), - ).toBe(join("C:\\Users\\test", ".grok")); - expect(resolveGrokAuthHome({})).toBeUndefined(); - }); -}); - -describe("Grok isolated runtime", () => { - it("copies only cached auth and writes an MCP-compatible isolated config", async () => { - const sourceHome = await makeTemp("grok-source-"); - await mkdir(sourceHome, { recursive: true }); - await writeFile(join(sourceHome, "auth.json"), '{"token":"cached"}\n'); - await writeFile(join(sourceHome, "config.toml"), "[mcp_servers.other]\ncommand='other'\n"); - - const runtime = await createGrokRuntime({ GROK_HOME: sourceHome }); - temporaryDirectories.push(runtime.root); - expect(runtime.cachedAuthAvailable).toBe(true); - await expect(readFile(join(runtime.grokHome, "auth.json"), "utf8")).resolves.toContain( - "cached", - ); - expect(runtime.agentProfilePath).toBe(join(runtime.grokHome, STAGEHAND_GROK_AGENT_PROFILE)); - await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain( - "description: Browser-only Stagehand MCP agent", - ); - await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain("search_tool"); - await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.toContain("use_tool"); - await expect(readFile(runtime.agentProfilePath, "utf8")).resolves.not.toContain("bash"); - const config = await readFile(join(runtime.grokHome, "config.toml"), "utf8"); - expect(config).toContain("auto_update = false"); - expect(config).toContain("[compat.claude]"); - expect(config).toContain("[compat.cursor]"); - expect(config).toContain("[subagents]"); - expect(config).toContain("enabled = false"); - expect(config).not.toContain("mcp_servers.other"); - }); - - it("does not copy cached auth when an API key is supplied", async () => { - const sourceHome = await makeTemp("grok-source-"); - await writeFile(join(sourceHome, "auth.json"), '{"token":"cached"}\n'); - const runtime = await createGrokRuntime({ - GROK_HOME: sourceHome, - XAI_API_KEY: "xai-secret", - }); - temporaryDirectories.push(runtime.root); - expect(runtime.cachedAuthAvailable).toBe(false); - await expect(access(join(runtime.grokHome, "auth.json"))).rejects.toThrow(); - }); - - it("passes the isolated home to ACP and removes all runtime state", async () => { - const root = await makeTemp("grok-run-"); - const runtime: GrokRuntime = { - root, - userHome: join(root, "home"), - cwd: join(root, "workspace"), - grokHome: join(root, "grok-home"), - agentProfilePath: join(root, "grok-home", STAGEHAND_GROK_AGENT_PROFILE), - cachedAuthAvailable: true, - }; - await Promise.all([ - mkdir(runtime.cwd, { recursive: true }), - mkdir(runtime.userHome, { recursive: true }), - mkdir(runtime.grokHome, { recursive: true }), - ]); - const runAcp = vi.fn(async (_options: RunAcpFacadeAgentOptions) => "done"); - - await expect( - runGrokBuild("Open example.com", { - env: { XAI_API_KEY: "xai-secret" }, - grokExecutable: "/grok", - makeRuntime: async () => runtime, - runAcp, - }), - ).resolves.toBe("done"); - expect(runAcp).toHaveBeenCalledOnce(); - expect(runAcp.mock.calls[0]?.[0]).toMatchObject({ - instruction: "Open example.com", - cwd: runtime.cwd, - env: { - HOME: runtime.userHome, - USERPROFILE: runtime.userHome, - GROK_HOME: runtime.grokHome, - XAI_API_KEY: "xai-secret", - }, - profile: { - command: "/grok", - args: grokAcpArgs(runtime.agentProfilePath), - }, - }); - await expect(access(root)).rejects.toThrow(); - }); - - it("normalizes CLI instructions", () => { - expect(resolveInstruction(["--", "open", "example.com"])).toBe("open example.com"); - }); -}); - -async function makeTemp(prefix: string): Promise { - const path = await mkdtemp(join(tmpdir(), prefix)); - temporaryDirectories.push(path); - return path; -} diff --git a/packages/integrations/grok-build/vitest.config.ts b/packages/integrations/grok-build/vitest.config.ts deleted file mode 100644 index d82214c96a..0000000000 --- a/packages/integrations/grok-build/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.test.ts"], - hookTimeout: 20_000, - testTimeout: 20_000, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27b7c112c0..0561917452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,202 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: 11.10.0 - version: 11.10.0 - pnpm: - specifier: 11.10.0 - version: 11.10.0 - -packages: - - '@pnpm/exe@11.10.0': - resolution: {integrity: sha512-mrmfi2C7LpZkyq0voKKye6MzrK8/K7tYQRiSB/jqOiwnFtQxxcA3xGTY9cEsrGpNl2ClPecQofLuj+BO8AIsxw==} - hasBin: true - - '@pnpm/linux-arm64@11.10.0': - resolution: {integrity: sha512-NbvDeUfs0SJuli9OPvgVvmnlbo2DvJ861XGXKrzgLu5AuTnLDLXgbZEEUd8mJ5I0YNrqOVXSWVfWEqiAazWzPA==} - cpu: [arm64] - os: [linux] - - '@pnpm/linux-x64@11.10.0': - resolution: {integrity: sha512-kdgb8BXZ/3XQ0x2cOmgLmsij7+SUIqd1bcV6OZhdGzQiDrOY6FAPrR+Y2Bp+NjrrhjzMHVm5pZrrmdeC67ymSQ==} - cpu: [x64] - os: [linux] - - '@pnpm/linuxstatic-arm64@11.10.0': - resolution: {integrity: sha512-JE1WrSyKGvqGQgWzqrMXn//ehedpiRax3hi1oP+v6mhvcnlJD1lpfXsjSfIFl9weTWC5KVtFbxSNKbKWfP+v6g==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/linuxstatic-x64@11.10.0': - resolution: {integrity: sha512-1TBZVRkWb78GnsusIfVgwz20MOOW0fyehW+qLL3MxE9vT0IedNWsAhe3KWXgEvtC4M7NtMt55uQQJm+1egwBkA==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/macos-arm64@11.10.0': - resolution: {integrity: sha512-94AVpPixBqyNT6SHYvIKFb1bfaHR5vxBzZsiFJahNSlGkgyPrgzmcqDiloZ/Jl+zxJd8L5PU1ddP0Q9PZnRqlA==} - cpu: [arm64] - os: [darwin] - - '@pnpm/win-arm64@11.10.0': - resolution: {integrity: sha512-g2Ymnq+LgVyZaWsGBQSlpIcBCOOxyLky2UX+kTwGiIXnj6k/xXqZR0ZJLuxeiGNh/CVmhftOqokpyJNzyj8kng==} - cpu: [arm64] - os: [win32] - - '@pnpm/win-x64@11.10.0': - resolution: {integrity: sha512-kCHYZudUEBjrEchgnJUnNHCdvLXpUZun2z0rMAeP5DymsaXwEVvVzGj220iR/NyhgDocJUmgtTKtwL/ejL785Q==} - cpu: [x64] - os: [win32] - - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - pnpm@11.10.0: - resolution: {integrity: sha512-C3+LmAYAMZBMAX46QesYehbUDuuCm5XE+MsDaBdh/Eq1PdIZEVubRH9NzhoFohR2RGHn03AzkqnzL5URzoyGyA==} - engines: {node: '>=22.13'} - hasBin: true - -snapshots: - - '@pnpm/exe@11.10.0': - dependencies: - '@reflink/reflink': 0.1.19 - detect-libc: 2.1.2 - optionalDependencies: - '@pnpm/linux-arm64': 11.10.0 - '@pnpm/linux-x64': 11.10.0 - '@pnpm/linuxstatic-arm64': 11.10.0 - '@pnpm/linuxstatic-x64': 11.10.0 - '@pnpm/macos-arm64': 11.10.0 - '@pnpm/win-arm64': 11.10.0 - '@pnpm/win-x64': 11.10.0 - - '@pnpm/linux-arm64@11.10.0': - optional: true - - '@pnpm/linux-x64@11.10.0': - optional: true - - '@pnpm/linuxstatic-arm64@11.10.0': - optional: true - - '@pnpm/linuxstatic-x64@11.10.0': - optional: true - - '@pnpm/macos-arm64@11.10.0': - optional: true - - '@pnpm/win-arm64@11.10.0': - optional: true - - '@pnpm/win-x64@11.10.0': - optional: true - - '@reflink/reflink-darwin-arm64@0.1.19': - optional: true - - '@reflink/reflink-darwin-x64@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-musl@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-musl@0.1.19': - optional: true - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - optional: true - - '@reflink/reflink-win32-x64-msvc@0.1.19': - optional: true - - '@reflink/reflink@0.1.19': - optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 - - detect-libc@2.1.2: {} - - pnpm@11.10.0: {} - ---- lockfileVersion: '9.0' settings: @@ -205,9 +6,6 @@ settings: catalogs: default: - '@agentclientprotocol/sdk': - specifier: 1.3.0 - version: 1.3.0 '@ai-sdk/anthropic': specifier: ^4.0.8 version: 4.0.8 @@ -292,9 +90,6 @@ catalogs: '@types/node': specifier: ^24 version: 24.13.2 - '@xai-official/grok': - specifier: 1.0.5 - version: 1.0.5 ai: specifier: ^7.0.16 version: 7.0.16 @@ -446,7 +241,7 @@ importers: version: 3.1.1 mint: specifier: 'catalog:' - version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) + version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) packages/evals: dependencies: @@ -486,6 +281,9 @@ importers: '@browserbasehq/stagehand-integrations-fx-sdk': specifier: workspace:* version: link:../integrations/fx-sdk + '@browserbasehq/stagehand-integrations-grok-build-sdk': + specifier: workspace:* + version: link:../integrations/grok-build-sdk '@browserbasehq/stagehand-integrations-mastra-sdk': specifier: workspace:* version: link:../integrations/mastra-sdk @@ -494,7 +292,7 @@ importers: version: link:../integrations/pi-sdk '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 @@ -531,6 +329,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + smol-toml: + specifier: 'catalog:' + version: 1.7.0 stagehand-v3: specifier: npm:@browserbasehq/stagehand@3.7.1 version: '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(zod@4.4.3)' @@ -726,15 +527,12 @@ importers: packages/integrations/core: dependencies: - '@agentclientprotocol/sdk': - specifier: 'catalog:' - version: 1.3.0(zod@4.4.3) '@browserbasehq/stagehand': specifier: workspace:* version: link:../../sdk-ts '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) zod: specifier: 'catalog:' version: 4.4.3 @@ -834,7 +632,7 @@ importers: version: link:../core '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) ai: specifier: ^7.0.38 version: 7.0.77(zod@4.4.3) @@ -877,18 +675,18 @@ 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@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) - packages/integrations/grok-build: + packages/integrations/grok-build-sdk: dependencies: '@browserbasehq/stagehand-integrations': specifier: workspace:* version: link:../core - '@xai-official/grok': - specifier: 'catalog:' - version: 1.0.5 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 @@ -984,7 +782,7 @@ importers: version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) + version: 1.29.0(zod@4.4.3) typebox: specifier: 'catalog:' version: 1.3.7 @@ -1113,11 +911,6 @@ packages: express: optional: true - '@agentclientprotocol/sdk@1.3.0': - resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - '@ai-sdk/amazon-bedrock@3.0.111': resolution: {integrity: sha512-vkwKdIn8qLsAXEsr8IPp+6U0INmL0Z6aXz93SPCKU9pfWHVnZXEpBE6N4tVIpa6Y9EBqdRo9XGiLmaoYx5XhrQ==} engines: {node: '>=18'} @@ -2028,9 +1821,6 @@ packages: peerDependencies: hono: ^4 - '@iarna/toml@3.0.0': - resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3675,43 +3465,6 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} - '@xai-official/grok-darwin-arm64@1.0.5': - resolution: {integrity: sha512-akEtE93V7nOHEMfj16kkl3Nxl/AjcNaRkZCnY5HoIayy3VR6qQ6VTsQdUka4ZgT1bGJCZff4UIcTVpFGDaMAQQ==} - cpu: [arm64] - os: [darwin] - - '@xai-official/grok-darwin-x64@1.0.5': - resolution: {integrity: sha512-kIr1YUidX+Zl8Ti7qFBCmQyVzR/D779Q0/pNvCBo7enLObFaGiM7JUAuQ7Y23aaIC/m1rwPv7xzoQ3DGHOO1yA==} - cpu: [x64] - os: [darwin] - - '@xai-official/grok-linux-arm64@1.0.5': - resolution: {integrity: sha512-yPdBddzfqBNfmyKGEuy0Vy+8zr/0I9xTqX9aYzaaz5QUS3+3Xe3bIJ2EYbAZV6o/16WUC2ZbkLNUx3X3IkBSRA==} - cpu: [arm64] - os: [linux] - - '@xai-official/grok-linux-x64@1.0.5': - resolution: {integrity: sha512-JJ6ajCCdBeLc14jvcGKQmeOvoJdEvWMe2Bj82McNikup1d6QaX/E7MI7nmMmoHfQthqRfUSnFk/ExFRu4fodTw==} - cpu: [x64] - os: [linux] - - '@xai-official/grok-win32-arm64@1.0.5': - resolution: {integrity: sha512-B4cIPElA8KWpOX2fvIxFeb8U5Ifwal56ZNKtvqPgatflLMf9Quo+3KnBUXH7X8/Ohwe81ff8G+mcU6Ja6Z5RKQ==} - cpu: [arm64] - os: [win32] - - '@xai-official/grok-win32-x64@1.0.5': - resolution: {integrity: sha512-EqPxlKg5FMFymmnF+w58VQ8V+ZrghIp7Qsuc2VCurIY82H+6qvnAMgJXGCpo2qH6JSWfh0HPiHkmTk32kHstjw==} - cpu: [x64] - os: [win32] - - '@xai-official/grok@1.0.5': - resolution: {integrity: sha512-kk5hez+Oz5CvWonDGkMNmL483CWRIGRF2ki8jQzpIXH56P0fhCgaX9lrr0IUoFCKh/rYAm5vfCPgQsdIIYLu8Q==} - engines: {node: '>=20'} - cpu: [arm64, x64] - os: [darwin, linux, win32] - hasBin: true - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -8450,10 +8203,6 @@ snapshots: optionalDependencies: express: 5.2.1 - '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': - dependencies: - zod: 4.4.3 - '@ai-sdk/amazon-bedrock@3.0.111(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 2.0.91(zod@4.4.3) @@ -8734,7 +8483,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.3.224(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.224 @@ -9184,7 +8933,7 @@ snapshots: '@anthropic-ai/sdk': 0.39.0 '@browserbasehq/sdk': 2.16.0 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) ai: 5.0.220(zod@4.4.3) devtools-protocol: 0.0.1642743 fetch-cookie: 3.2.0 @@ -9225,7 +8974,7 @@ snapshots: '@anthropic-ai/sdk': 0.39.0 '@browserbasehq/sdk': 2.16.0 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) ai: 5.0.220(zod@4.4.3) devtools-protocol: 0.0.1642743 fetch-cookie: 3.2.0 @@ -9626,7 +9375,7 @@ snapshots: protobufjs: 7.6.5 ws: 8.21.0(bufferutil@4.1.0) optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -9636,8 +9385,6 @@ snapshots: dependencies: hono: 4.12.31 - '@iarna/toml@3.0.0': {} - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.33.5': @@ -9811,51 +9558,51 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.13.2)': + '@inquirer/checkbox@4.3.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/confirm@5.1.21(@types/node@24.13.2)': + '@inquirer/confirm@5.1.21(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/core@10.3.2(@types/node@24.13.2)': + '@inquirer/core@10.3.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/editor@4.2.23(@types/node@24.13.2)': + '@inquirer/editor@4.2.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/external-editor': 1.0.3(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/expand@4.0.23(@types/node@24.13.2)': + '@inquirer/expand@4.0.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 '@inquirer/external-editor@1.0.3(@types/node@24.13.2)': dependencies: @@ -9864,75 +9611,82 @@ snapshots: optionalDependencies: '@types/node': 24.13.2 + '@inquirer/external-editor@1.0.3(@types/node@25.9.4)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 25.9.4 + '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.13.2)': + '@inquirer/input@4.3.1(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/number@3.0.23(@types/node@24.13.2)': + '@inquirer/number@3.0.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/password@4.0.23(@types/node@24.13.2)': + '@inquirer/password@4.0.23(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/prompts@7.9.0(@types/node@24.13.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.13.2) - '@inquirer/confirm': 5.1.21(@types/node@24.13.2) - '@inquirer/editor': 4.2.23(@types/node@24.13.2) - '@inquirer/expand': 4.0.23(@types/node@24.13.2) - '@inquirer/input': 4.3.1(@types/node@24.13.2) - '@inquirer/number': 3.0.23(@types/node@24.13.2) - '@inquirer/password': 4.0.23(@types/node@24.13.2) - '@inquirer/rawlist': 4.1.11(@types/node@24.13.2) - '@inquirer/search': 3.2.2(@types/node@24.13.2) - '@inquirer/select': 4.4.2(@types/node@24.13.2) + '@inquirer/prompts@7.9.0(@types/node@25.9.4)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.4) + '@inquirer/confirm': 5.1.21(@types/node@25.9.4) + '@inquirer/editor': 4.2.23(@types/node@25.9.4) + '@inquirer/expand': 4.0.23(@types/node@25.9.4) + '@inquirer/input': 4.3.1(@types/node@25.9.4) + '@inquirer/number': 3.0.23(@types/node@25.9.4) + '@inquirer/password': 4.0.23(@types/node@25.9.4) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.4) + '@inquirer/search': 3.2.2(@types/node@25.9.4) + '@inquirer/select': 4.4.2(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/rawlist@4.1.11(@types/node@24.13.2)': + '@inquirer/rawlist@4.1.11(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/search@3.2.2(@types/node@24.13.2)': + '@inquirer/search@3.2.2(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/select@4.4.2(@types/node@24.13.2)': + '@inquirer/select@4.4.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/type@3.0.10(@types/node@24.13.2)': + '@inquirer/type@3.0.10(@types/node@25.9.4)': optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 '@isaacs/cliui@8.0.2': dependencies: @@ -10060,7 +9814,7 @@ snapshots: '@isaacs/ttlcache': 2.1.5 '@lukeed/uuid': 2.0.1 '@mastra/schema-compat': 1.3.5(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@sindresorhus/slugify': 2.2.1 '@standard-schema/spec': 1.1.0 ajv: 8.20.0 @@ -10098,7 +9852,7 @@ snapshots: dependencies: '@mastra/core': 1.57.0(ai@7.0.77(zod@4.4.3))(bufferutil@4.1.0)(express@5.2.1)(rxjs@7.8.2)(zod@4.4.3) '@modelcontextprotocol/ext-apps': 1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) exit-hook: 5.1.0 fast-deep-equal: 3.1.3 transitivePeerDependencies: @@ -10152,9 +9906,9 @@ snapshots: '@types/react': 19.2.17 react: 19.2.3 - '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3)': + '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@24.13.2) + '@inquirer/prompts': 7.9.0(@types/node@25.9.4) '@mintlify/common': 1.0.1080(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/link-rot': 3.0.1278(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/models': 0.0.347 @@ -10164,10 +9918,10 @@ snapshots: adm-zip: 0.6.0 chalk: 5.2.0 color: 4.2.3 - detect-port: 1.5.1(supports-color@8.1.1) + detect-port: 1.5.1 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.3) - inquirer: 12.3.0(@types/node@24.13.2) + inquirer: 12.3.0(@types/node@25.9.4) js-yaml: 4.3.1 jsonc-parser: 3.3.1 mdast-util-mdx-jsx: 3.2.0 @@ -10462,14 +10216,14 @@ snapshots: '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@standard-schema/spec': 1.1.0 zod: 4.4.3 optionalDependencies: react: 19.2.3 react-dom: 18.3.1(react@19.2.3) - '@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) ajv: 8.20.0 @@ -10480,7 +10234,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.0 express: 5.2.1 - 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) hono: 4.12.31 jose: 6.2.4 json-schema-typed: 8.0.2 @@ -11432,35 +11186,6 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} - '@xai-official/grok-darwin-arm64@1.0.5': - optional: true - - '@xai-official/grok-darwin-x64@1.0.5': - optional: true - - '@xai-official/grok-linux-arm64@1.0.5': - optional: true - - '@xai-official/grok-linux-x64@1.0.5': - optional: true - - '@xai-official/grok-win32-arm64@1.0.5': - optional: true - - '@xai-official/grok-win32-x64@1.0.5': - optional: true - - '@xai-official/grok@1.0.5': - dependencies: - '@iarna/toml': 3.0.0 - optionalDependencies: - '@xai-official/grok-darwin-arm64': 1.0.5 - '@xai-official/grok-darwin-x64': 1.0.5 - '@xai-official/grok-linux-arm64': 1.0.5 - '@xai-official/grok-linux-x64': 1.0.5 - '@xai-official/grok-win32-arm64': 1.0.5 - '@xai-official/grok-win32-x64': 1.0.5 - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -12295,7 +12020,7 @@ snapshots: detect-libc@2.1.2: {} - detect-port@1.5.1(supports-color@8.1.1): + detect-port@1.5.1: dependencies: address: 1.2.2 debug: 4.4.3(supports-color@8.1.1) @@ -12732,7 +12457,7 @@ 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 @@ -13569,12 +13294,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@12.3.0(@types/node@24.13.2): + inquirer@12.3.0(@types/node@25.9.4): dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/prompts': 7.9.0(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) - '@types/node': 24.13.2 + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/prompts': 7.9.0(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) + '@types/node': 25.9.4 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -14592,9 +14317,9 @@ snapshots: dependencies: minipass: 7.1.3 - mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3): + mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) + '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) transitivePeerDependencies: - '@base-ui/react' - '@types/node' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f51df3bb4..e4e95b596e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,8 +5,6 @@ packages: catalogMode: prefer catalog: - "@agentclientprotocol/sdk": 1.3.0 - "@xai-official/grok": 1.0.5 "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 @@ -61,7 +59,6 @@ catalog: overrides: vite: "catalog:" allowBuilds: - "@xai-official/grok": true "@ast-grep/lang-go": true "@ast-grep/lang-python": true "@google/genai": false diff --git a/turbo.json b/turbo.json index fcbb6d9bf3..1a14bc03bc 100644 --- a/turbo.json +++ b/turbo.json @@ -76,6 +76,11 @@ "inputs": ["$TURBO_DEFAULT$", "!dist/**"], "outputs": ["dist/**"] }, + "@browserbasehq/stagehand-integrations-grok-build-sdk#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "!dist/**"], + "outputs": ["dist/**"] + }, "@browserbasehq/stagehand-evals#build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**"], @@ -229,6 +234,16 @@ "$TURBO_ROOT$/vitest.config.ts" ] }, + "@browserbasehq/stagehand-integrations-grok-build-sdk#test:unit": { + "dependsOn": ["^build", "@browserbasehq/stagehand-integrations-grok-build-sdk#build"], + "inputs": [ + "$TURBO_DEFAULT$", + "tests/**", + "src/**", + "**/*.test.ts", + "$TURBO_ROOT$/vitest.config.ts" + ] + }, "@browserbasehq/stagehand-integrations-example-mastra-facade#typecheck": { "dependsOn": ["^build"] }, @@ -244,9 +259,6 @@ "@browserbasehq/stagehand-integrations-example-codex-facade#typecheck": { "dependsOn": ["^build"] }, - "@browserbasehq/stagehand-integrations-example-grok-build-facade#typecheck": { - "dependsOn": ["^build"] - }, "@browserbasehq/stagehand-integrations-eve-sdk#typecheck": { "dependsOn": ["^build"] }, @@ -259,6 +271,9 @@ "@browserbasehq/stagehand-integrations-cursor-sdk#typecheck": { "dependsOn": ["^build"] }, + "@browserbasehq/stagehand-integrations-grok-build-sdk#typecheck": { + "dependsOn": ["^build"] + }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { "dependsOn": ["^build"], diff --git a/vitest.config.ts b/vitest.config.ts index 14c0cba156..34156ed53b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "packages/integrations/deepagents-sdk/tests/**/*.test.ts", "packages/integrations/fx-sdk/tests/**/*.test.ts", "packages/integrations/cursor-sdk/tests/**/*.test.ts", + "packages/integrations/grok-build-sdk/tests/**/*.test.ts", "packages/extension/tests/**/*.test.ts", "packages/sdk-ts/tests/**/*.test.ts", "packages/extension/understudy/**/*.test.ts",