From c314f9d8e85a8104130e0e330102e9cc4b759073 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 14:26:22 -0700 Subject: [PATCH 1/6] feat: add local Stagehand code-mode tool --- .github/workflows/stagehand-codemode.yml | 33 ++ packages/stagehand-codemode/README.md | 47 ++ .../STAGEHAND_CODEMODE_SKILL.md | 87 ++++ packages/stagehand-codemode/package.json | 44 ++ .../stagehand-codemode/src/child-runtime.ts | 450 ++++++++++++++++++ packages/stagehand-codemode/src/cli.ts | 48 ++ packages/stagehand-codemode/src/config.ts | 44 ++ packages/stagehand-codemode/src/executor.ts | 167 +++++++ packages/stagehand-codemode/src/index.ts | 21 + packages/stagehand-codemode/src/mcp-server.ts | 68 +++ .../stagehand-codemode/src/runtime-child.ts | 277 +++++++++++ .../src/runtime-protocol.ts | 46 ++ packages/stagehand-codemode/src/skill.ts | 6 + .../stagehand-codemode/src/tool-contract.ts | 32 ++ packages/stagehand-codemode/src/types.ts | 90 ++++ .../stagehand-codemode/tests/executor.test.ts | 171 +++++++ .../tests/fixtures/controlled-child.mjs | 44 ++ .../tests/live-mcp-smoke.mjs | 124 +++++ .../tests/mcp-server.test.ts | 56 +++ packages/stagehand-codemode/tsconfig.json | 13 + packages/stagehand-codemode/tsdown.config.ts | 15 + 21 files changed, 1883 insertions(+) create mode 100644 .github/workflows/stagehand-codemode.yml create mode 100644 packages/stagehand-codemode/README.md create mode 100644 packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md create mode 100644 packages/stagehand-codemode/package.json create mode 100644 packages/stagehand-codemode/src/child-runtime.ts create mode 100644 packages/stagehand-codemode/src/cli.ts create mode 100644 packages/stagehand-codemode/src/config.ts create mode 100644 packages/stagehand-codemode/src/executor.ts create mode 100644 packages/stagehand-codemode/src/index.ts create mode 100644 packages/stagehand-codemode/src/mcp-server.ts create mode 100644 packages/stagehand-codemode/src/runtime-child.ts create mode 100644 packages/stagehand-codemode/src/runtime-protocol.ts create mode 100644 packages/stagehand-codemode/src/skill.ts create mode 100644 packages/stagehand-codemode/src/tool-contract.ts create mode 100644 packages/stagehand-codemode/src/types.ts create mode 100644 packages/stagehand-codemode/tests/executor.test.ts create mode 100644 packages/stagehand-codemode/tests/fixtures/controlled-child.mjs create mode 100644 packages/stagehand-codemode/tests/live-mcp-smoke.mjs create mode 100644 packages/stagehand-codemode/tests/mcp-server.test.ts create mode 100644 packages/stagehand-codemode/tsconfig.json create mode 100644 packages/stagehand-codemode/tsdown.config.ts diff --git a/.github/workflows/stagehand-codemode.yml b/.github/workflows/stagehand-codemode.yml new file mode 100644 index 0000000..3b720d0 --- /dev/null +++ b/.github/workflows/stagehand-codemode.yml @@ -0,0 +1,33 @@ +name: Stagehand code-mode tool + +on: + pull_request: + paths: + - '.github/workflows/stagehand-codemode.yml' + - 'packages/stagehand-codemode/**' + push: + branches: [main] + paths: + - '.github/workflows/stagehand-codemode.yml' + - 'packages/stagehand-codemode/**' + +permissions: + contents: read + +jobs: + core: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.9.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + + - run: pnpm install --no-frozen-lockfile + - run: pnpm --filter @browserbasehq/stagehand-codemode run typecheck + - run: pnpm --filter @browserbasehq/stagehand-codemode run test + - run: pnpm --filter @browserbasehq/stagehand-codemode run build diff --git a/packages/stagehand-codemode/README.md b/packages/stagehand-codemode/README.md new file mode 100644 index 0000000..7b6e008 --- /dev/null +++ b/packages/stagehand-codemode/README.md @@ -0,0 +1,47 @@ +# Stagehand code mode + +This private spike exposes one `code_execute` tool through a local MCP server over stdio for +frameworks with local-process MCP support. + +The first tool call lazily creates one Browserbase browser. Calls are serialized and reuse that +browser until the owning process closes. The model writes an async JavaScript function body with +`page`, `context`, `stagehand`, `z`, and `console` already in scope. + +`page`, `context`, and `stagehand` are the public V4 SDK objects, which already route their methods +over the extension's JSON-RPC protocol. The executor does not expose raw JSON-RPC or maintain a +second method allowlist that can drift from the SDK. + +[`STAGEHAND_CODEMODE_SKILL.md`](./STAGEHAND_CODEMODE_SKILL.md) is the canonical syntax reference. +The MCP server includes it in the tool description so the model does not need to infer the V4 API. + +```json +{ + "code": "await page.goto('https://example.com'); return { title: await page.title() };", + "timeout_ms": 120000 +} +``` + +## Trust boundary + +The executor runs model-authored JavaScript in a child process so a timeout can terminate a hung +snippet. That process boundary is lifecycle containment, not a security sandbox: code can still +access the local filesystem, network, and in-process SDK state with the permissions of the parent +process. Only use this with trusted agents in a trusted local environment. + +Secrets are not copied into the child process environment, reducing accidental exposure through +`process.env`. Browserbase and model configuration is sent over the private parent-child IPC channel, +but this is defense in depth rather than secret isolation because the snippet shares a process with +the configured SDK. A timeout or abort kills the child, requests release of its Browserbase session, +and returns `browser_state: "discarded"`; the next call starts fresh. + +## Local stdio MCP + +Build the package, then configure the framework to launch: + +```text +node packages/stagehand-codemode/dist/cli.js +``` + +Set `BROWSERBASE_API_KEY` in the parent framework's environment. Stagehand V4 is an optional peer +dependency until V4 is published, so local development must make a V4 build resolvable as +`@browserbasehq/stagehand`. diff --git a/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md b/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md new file mode 100644 index 0000000..573847d --- /dev/null +++ b/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md @@ -0,0 +1,87 @@ +# Stagehand V4 code-mode syntax + +You have one `code_execute` tool. Its `code` argument is the body of an async JavaScript function, +not a complete program. Write direct `await` statements and finish with a JSON-serializable return +value. + +The following objects are already in scope: + +- `page`: the active Stagehand `Page`. +- `context`: the Stagehand `BrowserContext` shared across calls. +- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`. +- `z`: Zod V4, for `stagehand.extract` schemas. +- `console`: captured `log`, `warn`, and `error` methods. + +Do not import packages, read environment variables, construct Stagehand, call `stagehand.init()`, or +close the page/browser. The tool process owns initialization and cleanup. + +## Direct browser syntax + +Use deterministic page and locator methods when you know the target: + +```js +await page.goto('https://example.com', { waitUntil: 'load' }); +const heading = await page.locator('h1').innerText(); +const visible = await page.locator('a').first().isVisible(); +return { heading, visible, url: await page.url(), title: await page.title() }; +``` + +Common page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`, +`dragAndDrop`, `type`, `keyPress`, `evaluate`, `waitForLoadState`, `waitForTimeout`, +`waitForSelector`, `screenshot`, `snapshot`, `url`, `title`, and `locator`. + +Common locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`, +`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `type`, `selectOption`, `first`, +and `nth`. + +## Stagehand AI syntax + +Use `act` for an interaction described in natural language: + +```js +const result = await stagehand.act('Click the sign-in button'); +if (!result.success) throw new Error(result.message); +return result; +``` + +Use `observe` to find candidate actions without performing them: + +```js +const actions = await stagehand.observe('Find the checkout button'); +return { actions }; +``` + +Use `extract` with a Zod schema for structured page data: + +```js +const product = await stagehand.extract( + 'Extract the product name and price', + z.object({ name: z.string(), price: z.string() }) +); +return product; +``` + +Pass `{ page: anotherPage }` as the final options object to `act`, `observe`, or `extract` when the +active page is not the intended target. + +## Pages and state across calls + +```js +const pages = await context.pages(); +const secondPage = pages[1] ?? (await context.newPage()); +await context.setActivePage(secondPage); +return { + pageCount: (await context.pages()).length, + activeUrl: await secondPage.url(), +}; +``` + +The same browser, pages, cookies, and navigation state persist across successful tool calls. Local +JavaScript variables do not persist, so rediscover pages and elements each call. A timeout or abort +returns `browser_state: "discarded"`; the next call starts a new browser. + +## Return discipline + +Return only the compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, +and plain objects. Do not return page, locator, context, Stagehand, or Zod objects. Await asynchronous +methods before returning. Logs and oversized return values are bounded by the executor. diff --git a/packages/stagehand-codemode/package.json b/packages/stagehand-codemode/package.json new file mode 100644 index 0000000..da16af9 --- /dev/null +++ b/packages/stagehand-codemode/package.json @@ -0,0 +1,44 @@ +{ + "name": "@browserbasehq/stagehand-codemode", + "version": "0.0.0", + "private": true, + "description": "Local stdio MCP tool for Stagehand code mode", + "type": "module", + "bin": { + "stagehand-codemode": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:live": "pnpm run build && node tests/live-mcp-smoke.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/sdk": "^2.16.0", + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@browserbasehq/stagehand": "*" + }, + "peerDependenciesMeta": { + "@browserbasehq/stagehand": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^25.0.9", + "tsdown": "^0.15.4", + "typescript": "^5.9.3", + "vitest": "^4.0.6" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/stagehand-codemode/src/child-runtime.ts b/packages/stagehand-codemode/src/child-runtime.ts new file mode 100644 index 0000000..c5f30e8 --- /dev/null +++ b/packages/stagehand-codemode/src/child-runtime.ts @@ -0,0 +1,450 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import Browserbase from '@browserbasehq/sdk'; +import type { ChildRequest, ChildResponse } from './runtime-protocol.js'; +import type { + CodeRuntime, + RuntimeRunResult, + StagehandCodeRuntimeConfig, +} from './types.js'; +import { CodeModeRuntimeError } from './types.js'; + +type ChildRequestWithoutId = ChildRequest extends infer Request + ? Request extends { id: string } + ? Omit + : never + : never; + +type PendingRequest = { + resolve(value: unknown): void; + reject(error: Error): void; +}; + +type RequestControl = { + hardTimeoutMs?: number; + terminateOnAbort?: boolean; + timeoutError?: () => CodeModeRuntimeError; +}; + +export type StagehandChildRuntimeOptions = { + childModuleUrl?: URL; +}; + +const CHILD_EXIT_GRACE_MS = 2_000; +const PARENT_WATCHDOG_GRACE_MS = 250; +const CONFIGURE_TIMEOUT_MS = 10_000; +const RELEASE_SWEEP_DELAYS_MS = [0, 500, 1_500] as const; +const CHILD_ENV_KEYS = [ + 'PATH', + 'NODE_PATH', + 'TMPDIR', + 'TEMP', + 'TMP', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'NO_COLOR', + 'TZ', +] as const; + +export class StagehandChildRuntime implements CodeRuntime { + private child?: ChildProcess; + private configurePromise?: Promise; + private readonly pending = new Map(); + private closePromise?: Promise; + private forceTerminationPromise?: Promise; + private releasePromise?: Promise; + private closed = false; + private readonly runtimeTag = randomUUID(); + + constructor( + private readonly config: StagehandCodeRuntimeConfig, + private readonly options: StagehandChildRuntimeOptions = {} + ) {} + + async run( + code: string, + timeoutMs: number, + signal?: AbortSignal + ): Promise { + await this.ensureConfigured(signal); + return (await this.request( + { type: 'run', code, timeoutMs }, + signal, + false, + { + hardTimeoutMs: timeoutMs + PARENT_WATCHDOG_GRACE_MS, + terminateOnAbort: true, + timeoutError: () => + new CodeModeRuntimeError( + 'timeout', + `Code execution exceeded ${timeoutMs}ms.`, + true, + { + mayHaveSideEffects: true, + browserStateLost: true, + } + ), + } + )) as RuntimeRunResult; + } + + close(): Promise { + this.closePromise ??= this.closeInternal(); + return this.closePromise; + } + + private async closeInternal(): Promise { + if (this.closed) return; + this.closed = true; + await this.forceTerminationPromise; + const child = this.child; + if (!child) return; + let acknowledged = false; + try { + if (child.connected) { + await this.request({ type: 'close' }, undefined, true, { + hardTimeoutMs: CHILD_EXIT_GRACE_MS, + timeoutError: () => + new CodeModeRuntimeError( + 'runtime', + 'Stagehand child did not acknowledge close.' + ), + }); + acknowledged = true; + } + } catch { + // The child may already have exited; cleanup continues below. + } finally { + await terminateChild(child, 'SIGTERM'); + if (!acknowledged) await this.releaseBrowserbaseSessions(); + if (this.child === child) this.child = undefined; + } + } + + private async ensureConfigured(signal?: AbortSignal): Promise { + await this.forceTerminationPromise; + if (this.closed) + throw new CodeModeRuntimeError('closed', 'Code executor is closed.'); + if (!this.configurePromise) { + const configuring = (async () => { + this.spawnChild(); + await this.request( + { + type: 'configure', + runtimeTag: this.runtimeTag, + config: this.config, + }, + signal, + false, + { + hardTimeoutMs: CONFIGURE_TIMEOUT_MS, + terminateOnAbort: true, + timeoutError: () => + new CodeModeRuntimeError( + 'runtime', + 'Stagehand child configuration timed out.', + true, + { + browserStateLost: true, + } + ), + } + ); + })(); + this.configurePromise = configuring; + void configuring.catch(() => { + if (this.configurePromise === configuring) + this.configurePromise = undefined; + }); + } + await this.configurePromise; + } + + private spawnChild(): void { + if (this.child) return; + this.forceTerminationPromise = undefined; + const modulePath = fileURLToPath( + this.options.childModuleUrl ?? + new URL('./runtime-child.js', import.meta.url) + ); + const child = fork(modulePath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + execArgv: [], + env: childEnvironment(process.env), + }); + this.child = child; + child.stdout?.on('data', chunk => + process.stderr.write(`[codemode child] ${chunk}`) + ); + child.stderr?.on('data', chunk => + process.stderr.write(`[codemode child] ${chunk}`) + ); + child.on('message', message => this.handleMessage(message)); + child.once('exit', (code, signal) => { + const pending = [...this.pending.values()]; + this.pending.clear(); + if (this.child === child) { + this.child = undefined; + if (!this.closed) this.configurePromise = undefined; + } + const error = new CodeModeRuntimeError( + 'runtime', + `Stagehand child exited${signal ? ` with signal ${signal}` : ` with code ${code}`}.`, + true, + { mayHaveSideEffects: true, browserStateLost: true } + ); + const recovery = this.closed + ? Promise.resolve() + : (this.forceTerminationPromise ?? this.forceTerminate()); + void recovery.finally(() => + pending.forEach(request => request.reject(error)) + ); + }); + child.once('error', cause => { + const pending = [...this.pending.values()]; + this.pending.clear(); + const error = new CodeModeRuntimeError('runtime', cause.message, true, { + cause, + mayHaveSideEffects: true, + browserStateLost: true, + }); + void this.forceTerminate().finally(() => + pending.forEach(request => request.reject(error)) + ); + }); + } + + private handleMessage(message: unknown): void { + if (!isChildResponse(message)) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.ok) { + pending.resolve(message.result); + return; + } + const error = new CodeModeRuntimeError( + message.error.kind, + message.error.message, + message.error.retryable, + { + cause: message.error, + mayHaveSideEffects: message.error.mayHaveSideEffects, + browserStateLost: message.error.browserStateLost, + } + ); + if (!message.error.browserStateLost) { + pending.reject(error); + return; + } + void this.forceTerminate().finally(() => pending.reject(error)); + } + + private request( + request: ChildRequestWithoutId, + signal?: AbortSignal, + allowClosed = false, + control: RequestControl = {} + ): Promise { + if (this.closed && !allowClosed) { + return Promise.reject( + new CodeModeRuntimeError('closed', 'Code executor is closed.') + ); + } + const child = this.child; + if (!child?.connected) { + return Promise.reject( + new CodeModeRuntimeError( + 'runtime', + 'Stagehand child is not connected.', + true, + { + browserStateLost: true, + } + ) + ); + } + if (signal?.aborted) { + if (control.terminateOnAbort) void this.forceTerminate(); + return Promise.reject( + abortError(signal.reason, control.terminateOnAbort === true) + ); + } + + const id = randomUUID(); + return new Promise((resolve, reject) => { + let watchdog: NodeJS.Timeout | undefined; + const cleanup = () => { + signal?.removeEventListener('abort', onAbort); + if (watchdog) clearTimeout(watchdog); + }; + const onAbort = () => { + if (!this.pending.delete(id)) return; + cleanup(); + if (control.terminateOnAbort) void this.forceTerminate(); + reject(abortError(signal?.reason, control.terminateOnAbort === true)); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.pending.set(id, { + resolve: value => { + cleanup(); + resolve(value); + }, + reject: error => { + cleanup(); + reject(error); + }, + }); + if (control.hardTimeoutMs !== undefined) { + watchdog = setTimeout(() => { + if (!this.pending.delete(id)) return; + cleanup(); + void this.forceTerminate(); + reject( + control.timeoutError?.() ?? + new CodeModeRuntimeError( + 'runtime', + 'Stagehand child request timed out.', + true, + { + browserStateLost: true, + } + ) + ); + }, control.hardTimeoutMs); + } + child.send({ ...request, id } as ChildRequest, error => { + if (!error) return; + this.pending.delete(id); + cleanup(); + void this.forceTerminate(); + reject(error); + }); + }); + } + + private forceTerminate(): Promise { + this.forceTerminationPromise ??= (async () => { + const child = this.child; + if (child) await terminateChild(child, 'SIGKILL'); + if (this.child === child) this.child = undefined; + if (!this.closed) this.configurePromise = undefined; + await this.releaseBrowserbaseSessions(); + })(); + return this.forceTerminationPromise; + } + + private releaseBrowserbaseSessions(): Promise { + if (this.releasePromise) return this.releasePromise; + const release = this.releaseBrowserbaseSessionsInternal(); + this.releasePromise = release; + void release.finally(() => { + if (this.releasePromise === release) this.releasePromise = undefined; + }); + return release; + } + + private async releaseBrowserbaseSessionsInternal(): Promise { + const apiKey = this.config.browserbaseApiKey; + if (!apiKey) return; + const browserbase = new Browserbase({ apiKey }); + let lastError: unknown; + for (const delayMs of RELEASE_SWEEP_DELAYS_MS) { + if (delayMs) + await new Promise(resolve => setTimeout(resolve, delayMs)); + try { + const sessions = await browserbase.sessions.list({ status: 'RUNNING' }); + const ids = sessions + .filter( + session => + session.userMetadata?.integration === 'stagehand-codemode' && + session.userMetadata?.runtimeTagHash === this.runtimeTagHash + ) + .map(session => session.id); + await Promise.all( + ids.map(id => + browserbase.sessions.update(id, { status: 'REQUEST_RELEASE' }) + ) + ); + lastError = undefined; + } catch (error) { + lastError = error; + } + } + if (lastError) { + const message = + lastError instanceof Error ? lastError.message : String(lastError); + process.stderr.write( + `Failed to release a Stagehand code-mode browser: ${message}\n` + ); + } + } + + private get runtimeTagHash(): string { + return createHash('sha256') + .update(this.runtimeTag) + .digest('hex') + .slice(0, 16); + } +} + +export function createStagehandChildRuntime( + config: StagehandCodeRuntimeConfig, + options?: StagehandChildRuntimeOptions +): CodeRuntime { + return new StagehandChildRuntime(config, options); +} + +function abortError(reason: unknown, stateLost: boolean): CodeModeRuntimeError { + return new CodeModeRuntimeError( + 'aborted', + 'Code execution was aborted.', + true, + { + cause: reason, + mayHaveSideEffects: stateLost, + browserStateLost: stateLost, + } + ); +} + +function childEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const safe: NodeJS.ProcessEnv = {}; + for (const key of CHILD_ENV_KEYS) { + if (env[key] !== undefined) safe[key] = env[key]; + } + return safe; +} + +async function terminateChild( + child: ChildProcess, + signal: NodeJS.Signals +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise(resolve => + child.once('exit', () => resolve()) + ); + child.kill(signal); + await Promise.race([ + exited, + new Promise(resolve => setTimeout(resolve, CHILD_EXIT_GRACE_MS)), + ]); + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); +} + +function isChildResponse(value: unknown): value is ChildResponse { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'string' && + 'ok' in value && + typeof value.ok === 'boolean' + ); +} diff --git a/packages/stagehand-codemode/src/cli.ts b/packages/stagehand-codemode/src/cli.ts new file mode 100644 index 0000000..d7fdb84 --- /dev/null +++ b/packages/stagehand-codemode/src/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { runtimeConfigFromEnv } from './config.js'; +import { StagehandCodeExecutor } from './executor.js'; +import { connectCodeModeStdio } from './mcp-server.js'; + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + process.stdout.write( + [ + 'Usage: stagehand-codemode', + '', + 'Starts a local MCP server over stdio. The parent agent framework owns the process.', + '', + 'Environment:', + ' BROWSERBASE_API_KEY Required before the first code_execute call', + ' STAGEHAND_MODEL_NAME Optional provider/model name for Stagehand AI methods', + ' STAGEHAND_MODEL_API_KEY Optional model-provider API key', + ' STAGEHAND_MODEL_BASE_URL Optional model-provider base URL', + ' CODEMODE_DEFAULT_TIMEOUT_MS Optional default timeout (120000)', + '', + ].join('\n') + ); + process.exit(0); +} +if (args.length > 0) throw new Error(`Unknown argument: ${args[0]}`); + +const executor = new StagehandCodeExecutor(runtimeConfigFromEnv()); +const server = await connectCodeModeStdio(executor); +let closing = false; + +async function shutdown(code: number): Promise { + if (closing) return; + closing = true; + await server.close().catch(() => undefined); + await executor.close().catch(error => { + process.stderr.write( + `Failed to close Stagehand code mode: ${String(error)}\n` + ); + }); + process.exit(code); +} + +process.once('SIGINT', () => void shutdown(0)); +process.once('SIGTERM', () => void shutdown(0)); +process.stdin.once('end', () => void shutdown(0)); +process.stdin.once('close', () => void shutdown(0)); +process.stderr.write('Stagehand code-mode MCP listening on stdio\n'); diff --git a/packages/stagehand-codemode/src/config.ts b/packages/stagehand-codemode/src/config.ts new file mode 100644 index 0000000..8414b4a --- /dev/null +++ b/packages/stagehand-codemode/src/config.ts @@ -0,0 +1,44 @@ +import type { StagehandCodeRuntimeConfig } from './types.js'; + +export function runtimeConfigFromEnv( + env: NodeJS.ProcessEnv = process.env +): StagehandCodeRuntimeConfig { + const explicitModelName = nonEmpty(env.STAGEHAND_MODEL_NAME); + const explicitModelApiKey = nonEmpty(env.STAGEHAND_MODEL_API_KEY); + const inferredGoogleKey = + nonEmpty(env.GEMINI_API_KEY) ?? + nonEmpty(env.GOOGLE_API_KEY) ?? + nonEmpty(env.GOOGLE_GENERATIVE_AI_API_KEY); + const modelName = + explicitModelName ?? + (inferredGoogleKey ? 'google/gemini-2.5-flash-lite' : undefined); + const modelApiKey = explicitModelApiKey ?? inferredGoogleKey; + const defaultTimeoutMs = positiveInt(env.CODEMODE_DEFAULT_TIMEOUT_MS); + + return { + browserbaseApiKey: nonEmpty(env.BROWSERBASE_API_KEY), + ...(modelName + ? { + model: { + modelName, + ...(modelApiKey ? { apiKey: modelApiKey } : {}), + ...(nonEmpty(env.STAGEHAND_MODEL_BASE_URL) + ? { baseURL: nonEmpty(env.STAGEHAND_MODEL_BASE_URL) } + : {}), + }, + } + : {}), + ...(defaultTimeoutMs ? { defaultTimeoutMs } : {}), + }; +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function positiveInt(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} diff --git a/packages/stagehand-codemode/src/executor.ts b/packages/stagehand-codemode/src/executor.ts new file mode 100644 index 0000000..124ea87 --- /dev/null +++ b/packages/stagehand-codemode/src/executor.ts @@ -0,0 +1,167 @@ +import { createStagehandChildRuntime } from './child-runtime.js'; +import type { + CodeExecuteFailure, + CodeExecuteInput, + CodeExecuteResult, + CodeRuntime, + StagehandCodeRuntimeConfig, +} from './types.js'; +import { CodeModeRuntimeError } from './types.js'; + +export type StagehandCodeExecutorOptions = StagehandCodeRuntimeConfig & { + runtimeFactory?: () => CodeRuntime; +}; + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 300_000; +const MAX_CODE_BYTES = 100_000; + +export class StagehandCodeExecutor { + private runtime?: CodeRuntime; + private queue = Promise.resolve(); + private closePromise?: Promise; + private readonly defaultTimeoutMs: number; + + constructor(private readonly options: StagehandCodeExecutorOptions) { + this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + requireTimeout(this.defaultTimeoutMs, 'defaultTimeoutMs'); + } + + execute( + input: CodeExecuteInput, + signal?: AbortSignal + ): Promise { + const validation = validate(input); + if (validation) return Promise.resolve(validation); + const operation = this.queue.then(() => this.executeQueued(input, signal)); + this.queue = operation.then( + () => undefined, + () => undefined + ); + return operation; + } + + close(): Promise { + this.closePromise ??= this.queue.then(async () => { + const runtime = this.runtime; + this.runtime = undefined; + await runtime?.close(); + }); + return this.closePromise; + } + + private async executeQueued( + input: CodeExecuteInput, + signal?: AbortSignal + ): Promise { + if (this.closePromise) + return failure('closed', 'Code executor is closed.', false, false); + if (signal?.aborted) + return failure('aborted', 'Code execution was aborted.', true, false); + const runtime = (this.runtime ??= this.createRuntime()); + try { + const result = await runtime.run( + input.code, + input.timeout_ms ?? this.defaultTimeoutMs, + signal + ); + return { + ok: true, + browser_state: 'preserved', + page: result.page, + ...(result.value === undefined ? {} : { value: result.value }), + ...(result.logs.length === 0 ? {} : { logs: result.logs }), + }; + } catch (error) { + const stateLost = + error instanceof CodeModeRuntimeError && error.browserStateLost; + if (stateLost) { + this.runtime = undefined; + await runtime.close().catch(() => undefined); + } + return failureFromError(error, stateLost); + } + } + + private createRuntime(): CodeRuntime { + if (this.options.runtimeFactory) return this.options.runtimeFactory(); + return createStagehandChildRuntime(this.options); + } +} + +function validate(input: CodeExecuteInput): CodeExecuteFailure | undefined { + if ( + !input || + typeof input.code !== 'string' || + input.code.trim().length === 0 + ) { + return failure( + 'validation', + 'code must be a non-empty JavaScript function body.', + false, + false + ); + } + if (Buffer.byteLength(input.code) > MAX_CODE_BYTES) { + return failure( + 'validation', + `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes.`, + false, + false + ); + } + if (input.timeout_ms !== undefined) { + try { + requireTimeout(input.timeout_ms, 'timeout_ms'); + } catch (error) { + return failure('validation', (error as Error).message, false, false); + } + } + return undefined; +} + +function requireTimeout(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) { + throw new Error( + `${label} must be an integer from 1 through ${MAX_TIMEOUT_MS}.` + ); + } +} + +function failureFromError( + error: unknown, + stateLost: boolean +): CodeExecuteFailure { + const normalized = error instanceof Error ? error : new Error(String(error)); + const runtimeError = + error instanceof CodeModeRuntimeError ? error : undefined; + return failure( + runtimeError?.kind ?? 'runtime', + normalized.message, + runtimeError?.retryable ?? true, + stateLost, + runtimeError?.mayHaveSideEffects ?? false, + normalized.name + ); +} + +function failure( + kind: CodeExecuteFailure['error']['kind'], + message: string, + retryable: boolean, + stateLost: boolean, + mayHaveSideEffects = false, + name = 'CodeModeRuntimeError' +): CodeExecuteFailure { + return { + ok: false, + browser_state: stateLost ? 'discarded' : 'preserved', + error: { + kind, + name, + message, + retryable, + ...(mayHaveSideEffects ? { may_have_side_effects: true } : {}), + }, + }; +} diff --git a/packages/stagehand-codemode/src/index.ts b/packages/stagehand-codemode/src/index.ts new file mode 100644 index 0000000..9d539b5 --- /dev/null +++ b/packages/stagehand-codemode/src/index.ts @@ -0,0 +1,21 @@ +export { + createStagehandChildRuntime, + StagehandChildRuntime, +} from './child-runtime.js'; +export { runtimeConfigFromEnv } from './config.js'; +export { + StagehandCodeExecutor, + type StagehandCodeExecutorOptions, +} from './executor.js'; +export { + connectCodeModeStdio, + createCodeModeMcp, + createCodeModeMcpServer, +} from './mcp-server.js'; +export { STAGEHAND_CODEMODE_SKILL } from './skill.js'; +export { + CODE_EXECUTE_DESCRIPTION, + codeExecuteResultText, + codeExecuteSchema, +} from './tool-contract.js'; +export * from './types.js'; diff --git a/packages/stagehand-codemode/src/mcp-server.ts b/packages/stagehand-codemode/src/mcp-server.ts new file mode 100644 index 0000000..9e06a29 --- /dev/null +++ b/packages/stagehand-codemode/src/mcp-server.ts @@ -0,0 +1,68 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod/v4'; +import { + StagehandCodeExecutor, + type StagehandCodeExecutorOptions, +} from './executor.js'; +import { + CODE_EXECUTE_DESCRIPTION, + codeExecuteResultText, + codeExecuteSchema, +} from './tool-contract.js'; +import type { CodeExecuteInput, CodeExecuteResult } from './types.js'; + +export function createCodeModeMcpServer( + executor: StagehandCodeExecutor +): McpServer { + const server = new McpServer({ + name: 'stagehand-codemode', + version: '0.0.0', + }); + server.registerTool( + 'code_execute', + { + title: 'Execute Stagehand V4 code', + description: CODE_EXECUTE_DESCRIPTION, + inputSchema: codeExecuteSchema.shape, + outputSchema: z + .object({ + ok: z.boolean(), + browser_state: z.enum(['preserved', 'discarded']), + }) + .loose(), + }, + async (input, extra) => { + const result = await executor.execute( + input as CodeExecuteInput, + extra.signal + ); + return mcpResult(result); + } + ); + return server; +} + +export async function connectCodeModeStdio( + executor: StagehandCodeExecutor +): Promise { + const server = createCodeModeMcpServer(executor); + await server.connect(new StdioServerTransport()); + return server; +} + +export function createCodeModeMcp(options: StagehandCodeExecutorOptions): { + executor: StagehandCodeExecutor; + server: McpServer; +} { + const executor = new StagehandCodeExecutor(options); + return { executor, server: createCodeModeMcpServer(executor) }; +} + +function mcpResult(result: CodeExecuteResult) { + return { + content: [{ type: 'text' as const, text: codeExecuteResultText(result) }], + structuredContent: result as unknown as Record, + isError: !result.ok, + }; +} diff --git a/packages/stagehand-codemode/src/runtime-child.ts b/packages/stagehand-codemode/src/runtime-child.ts new file mode 100644 index 0000000..3d61dbf --- /dev/null +++ b/packages/stagehand-codemode/src/runtime-child.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod/v4'; +import type { ChildRequest, ChildResponse } from './runtime-protocol.js'; +import type { + CodeLogEntry, + CodePageState, + RuntimeRunResult, + StagehandCodeRuntimeConfig, +} from './types.js'; + +type PageLike = { + url(): Promise; + title(): Promise; +}; + +type ContextLike = { + activePage(): Promise; + pages(): Promise; + newPage(): Promise; +}; + +type StagehandLike = Record & { + context: ContextLike; + init(): Promise; + close(): Promise; +}; + +type StagehandConstructor = new ( + options: Record +) => StagehandLike; + +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new ( + ...args: string[] +) => (...values: unknown[]) => Promise; +const MAX_LOG_BYTES = 64 * 1024; +const MAX_RESULT_BYTES = 256 * 1024; + +let config: StagehandCodeRuntimeConfig | undefined; +let runtimeTag: string | undefined; +let stagehand: StagehandLike | undefined; +let closed = false; +let queue = Promise.resolve(); + +process.on('message', (message: unknown) => { + if (!isChildRequest(message)) return; + queue = queue.then(() => handle(message)); +}); +process.once('SIGTERM', () => void shutdown(0)); +process.once('SIGINT', () => void shutdown(0)); +process.once('disconnect', () => void shutdown(0)); + +async function handle(request: ChildRequest): Promise { + try { + if (request.type === 'configure') { + if (config) + throw new Error('Stagehand code runtime is already configured.'); + config = request.config; + runtimeTag = request.runtimeTag; + send({ id: request.id, ok: true }); + return; + } + if (request.type === 'close') { + closed = true; + await closeStagehand(); + send({ id: request.id, ok: true, result: { closed: true } }); + setImmediate(() => process.exit(0)); + return; + } + requireConfigured(); + const result = await run(request.code, request.timeoutMs); + send({ id: request.id, ok: true, result }); + } catch (error) { + const normalized = normalizeError(error); + send({ + id: request.id, + ok: false, + error: normalized, + page: await readPageState().catch(() => undefined), + }); + if (normalized.browserStateLost) { + closed = true; + void closeStagehand() + .catch(() => undefined) + .finally(() => process.exit(1)); + setTimeout(() => process.exit(1), 2_000).unref(); + } + } +} + +async function ensureStagehand(): Promise { + requireConfigured(); + const configuredRuntimeTag = runtimeTag; + if (!configuredRuntimeTag) { + throw new Error('Stagehand code runtime is not configured.'); + } + if (stagehand) return stagehand; + if (!config?.browserbaseApiKey) { + throw new Error( + 'BROWSERBASE_API_KEY is required before the first code_execute call.' + ); + } + const packageName = '@browserbasehq/stagehand'; + const imported = (await import(packageName)) as { + Stagehand?: StagehandConstructor; + StagehandClientInitParamsSchema?: unknown; + }; + if (!imported.Stagehand || !imported.StagehandClientInitParamsSchema) { + throw new Error( + 'Stagehand code mode requires a local Stagehand V4 build resolvable as @browserbasehq/stagehand.' + ); + } + const options: Record = { + apiKey: config.browserbaseApiKey, + browser: { + type: 'browserbase', + userMetadata: { + integration: 'stagehand-codemode', + runtimeTagHash: createHash('sha256') + .update(configuredRuntimeTag) + .digest('hex') + .slice(0, 16), + }, + }, + logging: { level: 'off' }, + ...(config.model ? { model: config.model } : {}), + }; + const next = new imported.Stagehand(options); + await next.init(); + stagehand = next; + return next; +} + +async function run(code: string, timeoutMs: number): Promise { + if (closed) throw new Error('Stagehand code runtime is closed.'); + const runtime = await ensureStagehand(); + const context = runtime.context; + const page = + (await context.activePage()) ?? + (await context.pages())[0] ?? + (await context.newPage()); + const logs: CodeLogEntry[] = []; + let logBytes = 0; + const appendLog = (level: CodeLogEntry['level'], values: unknown[]) => { + if (logBytes >= MAX_LOG_BYTES) return; + const text = formatLog(values); + const remaining = MAX_LOG_BYTES - logBytes; + const bounded = Buffer.from(text).subarray(0, remaining).toString(); + logBytes += Buffer.byteLength(bounded); + logs.push({ level, text: bounded }); + }; + const codeConsole = Object.freeze({ + log: (...values: unknown[]) => appendLog('log', values), + warn: (...values: unknown[]) => appendLog('warn', values), + error: (...values: unknown[]) => appendLog('error', values), + }); + const fn = new AsyncFunction( + 'page', + 'context', + 'stagehand', + 'z', + 'console', + code + ); + let timeout: NodeJS.Timeout | undefined; + try { + const value = await Promise.race([ + fn(page, context, runtime, z, codeConsole), + new Promise((_, reject) => { + timeout = setTimeout(() => { + const error = new Error(`Code execution exceeded ${timeoutMs}ms.`); + error.name = 'CodeExecutionTimeoutError'; + reject(error); + }, timeoutMs); + }), + ]); + return { + value: jsonSafe(value), + logs, + page: await readRequiredPageState(page), + }; + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function readPageState(): Promise { + if (!stagehand) return undefined; + const context = stagehand.context; + const page = (await context.activePage()) ?? (await context.pages())[0]; + return page ? readRequiredPageState(page) : undefined; +} + +async function readRequiredPageState(page: PageLike): Promise { + const [url, title] = await Promise.all([page.url(), page.title()]); + return { url, title }; +} + +async function closeStagehand(): Promise { + const current = stagehand; + stagehand = undefined; + await current?.close(); +} + +async function shutdown(code: number): Promise { + closed = true; + await closeStagehand().catch(() => undefined); + process.exit(code); +} + +function requireConfigured(): void { + if (!config || !runtimeTag) + throw new Error('Stagehand code runtime is not configured.'); +} + +function normalizeError( + error: unknown +): Extract['error'] { + const normalized = error instanceof Error ? error : new Error(String(error)); + const timeout = normalized.name === 'CodeExecutionTimeoutError'; + return { + name: normalized.name, + message: normalized.message, + kind: timeout ? 'timeout' : closed ? 'closed' : 'runtime', + retryable: timeout, + mayHaveSideEffects: timeout, + browserStateLost: timeout, + ...(normalized.stack ? { stack: normalized.stack } : {}), + }; +} + +function jsonSafe(value: unknown): unknown { + if (value === undefined) return undefined; + const serialized = JSON.stringify(value, (_key, nested) => { + if (typeof nested === 'bigint') return nested.toString(); + if (nested instanceof Uint8Array) { + return { + type: 'bytes', + encoding: 'base64', + data: Buffer.from(nested).toString('base64'), + }; + } + return nested; + }); + const bytes = Buffer.byteLength(serialized); + if (bytes <= MAX_RESULT_BYTES) return JSON.parse(serialized); + return { + truncated: true, + original_bytes: bytes, + preview: Buffer.from(serialized).subarray(0, MAX_RESULT_BYTES).toString(), + }; +} + +function formatLog(values: unknown[]): string { + return values + .map(value => + typeof value === 'string' ? value : JSON.stringify(jsonSafe(value)) + ) + .join(' '); +} + +function send(response: ChildResponse): void { + if (process.connected) process.send?.(response); +} + +function isChildRequest(value: unknown): value is ChildRequest { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'string' && + 'type' in value && + (value.type === 'configure' || + value.type === 'run' || + value.type === 'close') + ); +} diff --git a/packages/stagehand-codemode/src/runtime-protocol.ts b/packages/stagehand-codemode/src/runtime-protocol.ts new file mode 100644 index 0000000..2fcc43a --- /dev/null +++ b/packages/stagehand-codemode/src/runtime-protocol.ts @@ -0,0 +1,46 @@ +import type { + CodeLogEntry, + CodePageState, + RuntimeRunResult, + StagehandCodeRuntimeConfig, +} from './types.js'; + +export type ChildRequest = + | { + id: string; + type: 'configure'; + runtimeTag: string; + config: StagehandCodeRuntimeConfig; + } + | { + id: string; + type: 'run'; + code: string; + timeoutMs: number; + } + | { + id: string; + type: 'close'; + }; + +export type ChildResponse = + | { + id: string; + ok: true; + result?: RuntimeRunResult | { closed: true }; + } + | { + id: string; + ok: false; + error: { + name: string; + message: string; + kind: 'runtime' | 'timeout' | 'closed'; + retryable: boolean; + mayHaveSideEffects: boolean; + browserStateLost: boolean; + stack?: string; + }; + page?: CodePageState; + logs?: CodeLogEntry[]; + }; diff --git a/packages/stagehand-codemode/src/skill.ts b/packages/stagehand-codemode/src/skill.ts new file mode 100644 index 0000000..3f74892 --- /dev/null +++ b/packages/stagehand-codemode/src/skill.ts @@ -0,0 +1,6 @@ +import { readFileSync } from 'node:fs'; + +export const STAGEHAND_CODEMODE_SKILL = readFileSync( + new URL('../STAGEHAND_CODEMODE_SKILL.md', import.meta.url), + 'utf8' +).trim(); diff --git a/packages/stagehand-codemode/src/tool-contract.ts b/packages/stagehand-codemode/src/tool-contract.ts new file mode 100644 index 0000000..078134f --- /dev/null +++ b/packages/stagehand-codemode/src/tool-contract.ts @@ -0,0 +1,32 @@ +import { z } from 'zod/v4'; +import { STAGEHAND_CODEMODE_SKILL } from './skill.js'; +import type { CodeExecuteResult } from './types.js'; + +export const CODE_EXECUTE_DESCRIPTION = [ + 'Execute an async JavaScript function body against one long-lived Stagehand V4 browser on Browserbase.', + 'The local executor lazily creates the browser on the first call and reuses it for later calls.', + 'This runs trusted local code with local filesystem, network, and in-process SDK access; it is not a security sandbox.', + '', + STAGEHAND_CODEMODE_SKILL, +].join('\n'); + +export const codeExecuteSchema = z.object({ + code: z + .string() + .min(1) + .max(100_000) + .describe( + 'Async JavaScript function body. page, context, stagehand, z, and console are in scope.' + ), + timeout_ms: z + .number() + .int() + .positive() + .max(300_000) + .optional() + .describe('Per-call execution timeout in milliseconds.'), +}); + +export function codeExecuteResultText(result: CodeExecuteResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/packages/stagehand-codemode/src/types.ts b/packages/stagehand-codemode/src/types.ts new file mode 100644 index 0000000..cb194cc --- /dev/null +++ b/packages/stagehand-codemode/src/types.ts @@ -0,0 +1,90 @@ +export type CodeExecuteInput = { + code: string; + timeout_ms?: number; +}; + +export type CodePageState = { + url: string; + title: string; +}; + +export type CodeLogEntry = { + level: 'log' | 'warn' | 'error'; + text: string; +}; + +export type CodeExecuteErrorKind = + | 'validation' + | 'runtime' + | 'timeout' + | 'aborted' + | 'closed'; + +export type CodeExecuteSuccess = { + ok: true; + browser_state: 'preserved'; + page: CodePageState; + value?: unknown; + logs?: CodeLogEntry[]; +}; + +export type CodeExecuteFailure = { + ok: false; + browser_state: 'preserved' | 'discarded'; + page?: CodePageState; + logs?: CodeLogEntry[]; + error: { + kind: CodeExecuteErrorKind; + name: string; + message: string; + retryable: boolean; + may_have_side_effects?: boolean; + }; +}; + +export type CodeExecuteResult = CodeExecuteSuccess | CodeExecuteFailure; + +export type RuntimeRunResult = { + value?: unknown; + logs: CodeLogEntry[]; + page: CodePageState; +}; + +export interface CodeRuntime { + run( + code: string, + timeoutMs: number, + signal?: AbortSignal + ): Promise; + close(): Promise; +} + +export type StagehandCodeRuntimeConfig = { + browserbaseApiKey?: string; + model?: { + modelName: string; + apiKey?: string; + baseURL?: string; + }; + defaultTimeoutMs?: number; +}; + +export class CodeModeRuntimeError extends Error { + readonly mayHaveSideEffects: boolean; + readonly browserStateLost: boolean; + + constructor( + readonly kind: CodeExecuteErrorKind, + message: string, + readonly retryable = false, + options?: ErrorOptions & { + mayHaveSideEffects?: boolean; + browserStateLost?: boolean; + } + ) { + super(message, options); + this.name = 'CodeModeRuntimeError'; + this.mayHaveSideEffects = options?.mayHaveSideEffects ?? false; + this.browserStateLost = options?.browserStateLost ?? false; + } +} diff --git a/packages/stagehand-codemode/tests/executor.test.ts b/packages/stagehand-codemode/tests/executor.test.ts new file mode 100644 index 0000000..6f538d1 --- /dev/null +++ b/packages/stagehand-codemode/tests/executor.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { StagehandChildRuntime } from '../src/child-runtime.js'; +import { StagehandCodeExecutor } from '../src/executor.js'; +import type { CodeRuntime, RuntimeRunResult } from '../src/types.js'; +import { CodeModeRuntimeError } from '../src/types.js'; + +class FakeRuntime implements CodeRuntime { + active = 0; + maxActive = 0; + calls: string[] = []; + closed = false; + + async run(code: string): Promise { + this.active += 1; + this.maxActive = Math.max(this.maxActive, this.active); + this.calls.push(code); + await new Promise(resolve => setTimeout(resolve, 5)); + this.active -= 1; + if (code === 'lose-state') { + throw new CodeModeRuntimeError('timeout', 'timed out', true, { + mayHaveSideEffects: true, + browserStateLost: true, + }); + } + if (code === 'runtime-error') { + throw new CodeModeRuntimeError('runtime', 'snippet failed'); + } + return { + value: { code }, + logs: [], + page: { url: 'https://example.com/', title: 'Example Domain' }, + }; + } + + async close(): Promise { + this.closed = true; + } +} + +describe('StagehandCodeExecutor', () => { + it('creates lazily, serializes calls, and reuses one runtime', async () => { + const runtimes: FakeRuntime[] = []; + const executor = new StagehandCodeExecutor({ + runtimeFactory: () => { + const runtime = new FakeRuntime(); + runtimes.push(runtime); + return runtime; + }, + }); + + expect(runtimes).toHaveLength(0); + const [first, second] = await Promise.all([ + executor.execute({ code: 'first' }), + executor.execute({ code: 'second' }), + ]); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(runtimes).toHaveLength(1); + expect(runtimes[0]?.calls).toEqual(['first', 'second']); + expect(runtimes[0]?.maxActive).toBe(1); + await executor.close(); + expect(runtimes[0]?.closed).toBe(true); + }); + + it('discards a lost browser and creates a fresh runtime on the next call', async () => { + const runtimes: FakeRuntime[] = []; + const executor = new StagehandCodeExecutor({ + runtimeFactory: () => { + const runtime = new FakeRuntime(); + runtimes.push(runtime); + return runtime; + }, + }); + + const failed = await executor.execute({ code: 'lose-state' }); + expect(failed).toMatchObject({ + ok: false, + browser_state: 'discarded', + error: { kind: 'timeout', may_have_side_effects: true }, + }); + expect(runtimes[0]?.closed).toBe(true); + + const recovered = await executor.execute({ code: 'fresh' }); + expect(recovered.ok).toBe(true); + expect(runtimes).toHaveLength(2); + await executor.close(); + }); + + it('preserves the browser after an ordinary snippet error', async () => { + const runtimes: FakeRuntime[] = []; + const executor = new StagehandCodeExecutor({ + runtimeFactory: () => { + const runtime = new FakeRuntime(); + runtimes.push(runtime); + return runtime; + }, + }); + + const failed = await executor.execute({ code: 'runtime-error' }); + expect(failed).toMatchObject({ + ok: false, + browser_state: 'preserved', + error: { kind: 'runtime', retryable: false }, + }); + const recovered = await executor.execute({ code: 'same-browser' }); + expect(recovered.ok).toBe(true); + expect(runtimes).toHaveLength(1); + expect(runtimes[0]?.closed).toBe(false); + await executor.close(); + }); + + it('rejects invalid input without creating a runtime', async () => { + let created = 0; + const executor = new StagehandCodeExecutor({ + runtimeFactory: () => { + created += 1; + return new FakeRuntime(); + }, + }); + + await expect(executor.execute({ code: '' })).resolves.toMatchObject({ + ok: false, + error: { kind: 'validation' }, + }); + await expect( + executor.execute({ code: 'return 1', timeout_ms: 300_001 }) + ).resolves.toMatchObject({ ok: false, error: { kind: 'validation' } }); + expect(created).toBe(0); + }); +}); + +describe('StagehandChildRuntime', () => { + it('filters secrets from the child environment', async () => { + process.env.CODEMODE_TEST_SECRET = 'must-not-cross-ipc-boundary'; + const runtime = new StagehandChildRuntime( + {}, + { + childModuleUrl: new URL( + './fixtures/controlled-child.mjs', + import.meta.url + ), + } + ); + try { + const result = await runtime.run('inspect-env', 2_000); + expect(result.value).toEqual({ pathPresent: true }); + } finally { + delete process.env.CODEMODE_TEST_SECRET; + await runtime.close(); + } + }); + + it('kills a hung child and reports that browser state was lost', async () => { + const runtime = new StagehandChildRuntime( + {}, + { + childModuleUrl: new URL( + './fixtures/controlled-child.mjs', + import.meta.url + ), + } + ); + await expect(runtime.run('hang', 20)).rejects.toMatchObject({ + kind: 'timeout', + browserStateLost: true, + mayHaveSideEffects: true, + }); + await runtime.close(); + }); +}); diff --git a/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs b/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs new file mode 100644 index 0000000..19b96e5 --- /dev/null +++ b/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs @@ -0,0 +1,44 @@ +let configured = false; + +process.on('message', message => { + if (!message || typeof message !== 'object') return; + if (message.type === 'configure') { + configured = true; + process.send?.({ id: message.id, ok: true }); + return; + } + if (message.type === 'close') { + process.send?.({ id: message.id, ok: true, result: { closed: true } }); + setImmediate(() => process.exit(0)); + return; + } + if (!configured || message.type !== 'run') return; + if (message.code === 'hang') return; + if (message.code === 'runtime-error') { + process.send?.({ + id: message.id, + ok: false, + error: { + name: 'Error', + message: 'controlled failure', + kind: 'runtime', + retryable: false, + mayHaveSideEffects: false, + browserStateLost: false, + }, + }); + return; + } + process.send?.({ + id: message.id, + ok: true, + result: { + value: { + pathPresent: typeof process.env.PATH === 'string', + testSecret: process.env.CODEMODE_TEST_SECRET, + }, + logs: [], + page: { url: 'https://example.com/', title: 'Example Domain' }, + }, + }); +}); diff --git a/packages/stagehand-codemode/tests/live-mcp-smoke.mjs b/packages/stagehand-codemode/tests/live-mcp-smoke.mjs new file mode 100644 index 0000000..817a1df --- /dev/null +++ b/packages/stagehand-codemode/tests/live-mcp-smoke.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +if (!process.env.BROWSERBASE_API_KEY) { + throw new Error('BROWSERBASE_API_KEY is required for the live smoke test.'); +} + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [fileURLToPath(new URL('../dist/cli.js', import.meta.url))], + env: Object.fromEntries( + Object.entries(process.env).filter(([, value]) => value !== undefined) + ), +}); +const client = new Client({ + name: 'stagehand-codemode-live-smoke', + version: '0.0.0', +}); + +try { + await client.connect(transport); + const discovered = await client.listTools(); + assert.deepEqual( + discovered.tools.map(tool => tool.name), + ['code_execute'] + ); + + const first = structured( + await client.callTool({ + name: 'code_execute', + arguments: { + code: ` + await page.goto("https://browserbase.github.io/stagehand-eval-sites/sites/new-tab/", { + waitUntil: "load", + }); + return { + phase: "opened", + title: await page.title(), + url: await page.url(), + pageCount: (await context.pages()).length, + }; + `, + }, + }) + ); + assert.equal(first.ok, true, JSON.stringify(first)); + + const second = structured( + await client.callTool({ + name: 'code_execute', + arguments: { + code: ` + return { + phase: "reused", + title: await page.title(), + url: await page.url(), + bodyIncludesWelcome: (await page.locator("body").innerText()).includes("Welcome"), + pageCount: (await context.pages()).length, + }; + `, + }, + }) + ); + assert.equal(second.ok, true, JSON.stringify(second)); + assert.equal(first.value.url, second.value.url); + assert.equal(second.value.bodyIncludesWelcome, true); + assert.equal(first.value.pageCount, 1); + assert.equal(second.value.pageCount, 1); + + const third = structured( + await client.callTool({ + name: 'code_execute', + arguments: { + code: ` + return { + phase: "stagehand-syntax", + actType: typeof stagehand.act, + observeType: typeof stagehand.observe, + extractType: typeof stagehand.extract, + zObjectType: typeof z.object, + }; + `, + }, + }) + ); + assert.equal(third.ok, true, JSON.stringify(third)); + assert.deepEqual(third.value, { + phase: 'stagehand-syntax', + actType: 'function', + observeType: 'function', + extractType: 'function', + zObjectType: 'function', + }); + + process.stdout.write( + `${JSON.stringify( + { + status: 'PASS', + transport: 'local stdio MCP', + discoveredTools: ['code_execute'], + lazyBrowserCreated: true, + browserStateReused: true, + stagehandSyntaxAvailable: true, + first: first.value, + second: second.value, + third: third.value, + }, + null, + 2 + )}\n` + ); +} finally { + await client.close(); +} + +function structured(result) { + if (result.structuredContent) return result.structuredContent; + const text = result.content?.find(block => block.type === 'text')?.text; + if (!text) + throw new Error('code_execute returned no structured or text result.'); + return JSON.parse(text); +} diff --git a/packages/stagehand-codemode/tests/mcp-server.test.ts b/packages/stagehand-codemode/tests/mcp-server.test.ts new file mode 100644 index 0000000..da0c6f1 --- /dev/null +++ b/packages/stagehand-codemode/tests/mcp-server.test.ts @@ -0,0 +1,56 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { describe, expect, it } from 'vitest'; +import { StagehandCodeExecutor } from '../src/executor.js'; +import { createCodeModeMcpServer } from '../src/mcp-server.js'; +import type { CodeRuntime } from '../src/types.js'; + +describe('code-mode MCP', () => { + it('discovers and invokes exactly one code_execute tool', async () => { + const runtime: CodeRuntime = { + run: async code => ({ + value: { echoed: code }, + logs: [], + page: { url: 'https://example.com/', title: 'Example Domain' }, + }), + close: async () => undefined, + }; + const executor = new StagehandCodeExecutor({ + runtimeFactory: () => runtime, + }); + const server = createCodeModeMcpServer(executor); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + try { + const discovered = await client.listTools(); + expect(discovered.tools.map(entry => entry.name)).toEqual([ + 'code_execute', + ]); + expect(discovered.tools[0]?.inputSchema.required).toEqual(['code']); + expect(discovered.tools[0]?.description).toContain( + 'Stagehand V4 code-mode syntax' + ); + + const result = await client.callTool({ + name: 'code_execute', + arguments: { code: 'return 42' }, + }); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + ok: true, + browser_state: 'preserved', + value: { echoed: 'return 42' }, + }); + } finally { + await client.close(); + await server.close(); + await executor.close(); + } + }); +}); diff --git a/packages/stagehand-codemode/tsconfig.json b/packages/stagehand-codemode/tsconfig.json new file mode 100644 index 0000000..36876ff --- /dev/null +++ b/packages/stagehand-codemode/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/stagehand-codemode/tsdown.config.ts b/packages/stagehand-codemode/tsdown.config.ts new file mode 100644 index 0000000..49acac2 --- /dev/null +++ b/packages/stagehand-codemode/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/cli.ts', + 'src/runtime-child.ts', + ], + format: ['esm'], + platform: 'node', + target: 'node22', + dts: { sourcemap: true }, + sourcemap: true, + outDir: 'dist', +}); From a5503f7a9096d4a5469ebd54158ce84b8d0f49f2 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 14:27:47 -0700 Subject: [PATCH 2/6] style: format code-mode package --- packages/stagehand-codemode/src/types.ts | 6 +----- packages/stagehand-codemode/tsdown.config.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/stagehand-codemode/src/types.ts b/packages/stagehand-codemode/src/types.ts index cb194cc..d7b5ca0 100644 --- a/packages/stagehand-codemode/src/types.ts +++ b/packages/stagehand-codemode/src/types.ts @@ -14,11 +14,7 @@ export type CodeLogEntry = { }; export type CodeExecuteErrorKind = - | 'validation' - | 'runtime' - | 'timeout' - | 'aborted' - | 'closed'; + 'validation' | 'runtime' | 'timeout' | 'aborted' | 'closed'; export type CodeExecuteSuccess = { ok: true; diff --git a/packages/stagehand-codemode/tsdown.config.ts b/packages/stagehand-codemode/tsdown.config.ts index 49acac2..4edecd8 100644 --- a/packages/stagehand-codemode/tsdown.config.ts +++ b/packages/stagehand-codemode/tsdown.config.ts @@ -1,11 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: [ - 'src/index.ts', - 'src/cli.ts', - 'src/runtime-child.ts', - ], + entry: ['src/index.ts', 'src/cli.ts', 'src/runtime-child.ts'], format: ['esm'], platform: 'node', target: 'node22', From 9123ecd6210c35dc61e5fd691b779ced33eb16b0 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 15:17:47 -0700 Subject: [PATCH 3/6] refactor: simplify local Stagehand code-mode tool --- .github/workflows/stagehand-codemode.yml | 1 - packages/stagehand-codemode/README.md | 19 +- .../STAGEHAND_CODEMODE_SKILL.md | 5 +- packages/stagehand-codemode/package.json | 6 +- .../stagehand-codemode/src/child-runtime.ts | 450 ------------------ packages/stagehand-codemode/src/cli.ts | 5 +- packages/stagehand-codemode/src/config.ts | 15 +- packages/stagehand-codemode/src/executor.ts | 267 +++++++---- packages/stagehand-codemode/src/index.ts | 6 +- packages/stagehand-codemode/src/mcp-server.ts | 1 - .../stagehand-codemode/src/runtime-child.ts | 277 ----------- .../src/runtime-protocol.ts | 46 -- .../stagehand-codemode/src/tool-contract.ts | 10 +- packages/stagehand-codemode/src/types.ts | 48 +- .../stagehand-codemode/tests/executor.test.ts | 171 ------- .../tests/fixtures/controlled-child.mjs | 44 -- .../tests/live-mcp-smoke.mjs | 124 ----- .../tests/mcp-server.test.ts | 56 --- packages/stagehand-codemode/tsconfig.json | 2 +- packages/stagehand-codemode/tsdown.config.ts | 2 +- 20 files changed, 208 insertions(+), 1347 deletions(-) delete mode 100644 packages/stagehand-codemode/src/child-runtime.ts delete mode 100644 packages/stagehand-codemode/src/runtime-child.ts delete mode 100644 packages/stagehand-codemode/src/runtime-protocol.ts delete mode 100644 packages/stagehand-codemode/tests/executor.test.ts delete mode 100644 packages/stagehand-codemode/tests/fixtures/controlled-child.mjs delete mode 100644 packages/stagehand-codemode/tests/live-mcp-smoke.mjs delete mode 100644 packages/stagehand-codemode/tests/mcp-server.test.ts diff --git a/.github/workflows/stagehand-codemode.yml b/.github/workflows/stagehand-codemode.yml index 3b720d0..826e92e 100644 --- a/.github/workflows/stagehand-codemode.yml +++ b/.github/workflows/stagehand-codemode.yml @@ -29,5 +29,4 @@ jobs: - run: pnpm install --no-frozen-lockfile - run: pnpm --filter @browserbasehq/stagehand-codemode run typecheck - - run: pnpm --filter @browserbasehq/stagehand-codemode run test - run: pnpm --filter @browserbasehq/stagehand-codemode run build diff --git a/packages/stagehand-codemode/README.md b/packages/stagehand-codemode/README.md index 7b6e008..843967f 100644 --- a/packages/stagehand-codemode/README.md +++ b/packages/stagehand-codemode/README.md @@ -16,23 +16,20 @@ The MCP server includes it in the tool description so the model does not need to ```json { - "code": "await page.goto('https://example.com'); return { title: await page.title() };", - "timeout_ms": 120000 + "code": "await page.goto('https://example.com'); return { title: await page.title() };" } ``` ## Trust boundary -The executor runs model-authored JavaScript in a child process so a timeout can terminate a hung -snippet. That process boundary is lifecycle containment, not a security sandbox: code can still -access the local filesystem, network, and in-process SDK state with the permissions of the parent -process. Only use this with trusted agents in a trusted local environment. +The executor runs model-authored JavaScript directly in the local MCP process. This is not a security +sandbox: code can access the local filesystem, network, environment, and in-process SDK state with +that process's permissions. Only use it with trusted agents in a trusted local environment. -Secrets are not copied into the child process environment, reducing accidental exposure through -`process.env`. Browserbase and model configuration is sent over the private parent-child IPC channel, -but this is defense in depth rather than secret isolation because the snippet shares a process with -the configured SDK. A timeout or abort kills the child, requests release of its Browserbase session, -and returns `browser_state: "discarded"`; the next call starts fresh. +The agent framework owns the stdio MCP process. If generated code stops responding, the framework +should terminate and restart the process. Restarting also starts a fresh browser, so the previous +browser state is lost. The tool intentionally does not add a second worker, IPC protocol, or timeout +supervisor around this local single-agent session. ## Local stdio MCP diff --git a/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md b/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md index 573847d..d9e0a14 100644 --- a/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md +++ b/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md @@ -77,8 +77,9 @@ return { ``` The same browser, pages, cookies, and navigation state persist across successful tool calls. Local -JavaScript variables do not persist, so rediscover pages and elements each call. A timeout or abort -returns `browser_state: "discarded"`; the next call starts a new browser. +JavaScript variables do not persist, so rediscover pages and elements each call. If a call stops +responding, the owning agent framework should terminate and restart the local MCP process. That +restart begins a fresh browser and loses the previous browser state. ## Return discipline diff --git a/packages/stagehand-codemode/package.json b/packages/stagehand-codemode/package.json index da16af9..a6b0a59 100644 --- a/packages/stagehand-codemode/package.json +++ b/packages/stagehand-codemode/package.json @@ -15,12 +15,9 @@ }, "scripts": { "build": "tsdown", - "test": "vitest run", - "test:live": "pnpm run build && node tests/live-mcp-smoke.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { - "@browserbasehq/sdk": "^2.16.0", "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^4.4.3" }, @@ -35,8 +32,7 @@ "devDependencies": { "@types/node": "^25.0.9", "tsdown": "^0.15.4", - "typescript": "^5.9.3", - "vitest": "^4.0.6" + "typescript": "^5.9.3" }, "engines": { "node": ">=22.18.0" diff --git a/packages/stagehand-codemode/src/child-runtime.ts b/packages/stagehand-codemode/src/child-runtime.ts deleted file mode 100644 index c5f30e8..0000000 --- a/packages/stagehand-codemode/src/child-runtime.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process'; -import { createHash, randomUUID } from 'node:crypto'; -import { fileURLToPath } from 'node:url'; -import Browserbase from '@browserbasehq/sdk'; -import type { ChildRequest, ChildResponse } from './runtime-protocol.js'; -import type { - CodeRuntime, - RuntimeRunResult, - StagehandCodeRuntimeConfig, -} from './types.js'; -import { CodeModeRuntimeError } from './types.js'; - -type ChildRequestWithoutId = ChildRequest extends infer Request - ? Request extends { id: string } - ? Omit - : never - : never; - -type PendingRequest = { - resolve(value: unknown): void; - reject(error: Error): void; -}; - -type RequestControl = { - hardTimeoutMs?: number; - terminateOnAbort?: boolean; - timeoutError?: () => CodeModeRuntimeError; -}; - -export type StagehandChildRuntimeOptions = { - childModuleUrl?: URL; -}; - -const CHILD_EXIT_GRACE_MS = 2_000; -const PARENT_WATCHDOG_GRACE_MS = 250; -const CONFIGURE_TIMEOUT_MS = 10_000; -const RELEASE_SWEEP_DELAYS_MS = [0, 500, 1_500] as const; -const CHILD_ENV_KEYS = [ - 'PATH', - 'NODE_PATH', - 'TMPDIR', - 'TEMP', - 'TMP', - 'NODE_EXTRA_CA_CERTS', - 'SSL_CERT_FILE', - 'SSL_CERT_DIR', - 'HTTP_PROXY', - 'HTTPS_PROXY', - 'NO_PROXY', - 'NO_COLOR', - 'TZ', -] as const; - -export class StagehandChildRuntime implements CodeRuntime { - private child?: ChildProcess; - private configurePromise?: Promise; - private readonly pending = new Map(); - private closePromise?: Promise; - private forceTerminationPromise?: Promise; - private releasePromise?: Promise; - private closed = false; - private readonly runtimeTag = randomUUID(); - - constructor( - private readonly config: StagehandCodeRuntimeConfig, - private readonly options: StagehandChildRuntimeOptions = {} - ) {} - - async run( - code: string, - timeoutMs: number, - signal?: AbortSignal - ): Promise { - await this.ensureConfigured(signal); - return (await this.request( - { type: 'run', code, timeoutMs }, - signal, - false, - { - hardTimeoutMs: timeoutMs + PARENT_WATCHDOG_GRACE_MS, - terminateOnAbort: true, - timeoutError: () => - new CodeModeRuntimeError( - 'timeout', - `Code execution exceeded ${timeoutMs}ms.`, - true, - { - mayHaveSideEffects: true, - browserStateLost: true, - } - ), - } - )) as RuntimeRunResult; - } - - close(): Promise { - this.closePromise ??= this.closeInternal(); - return this.closePromise; - } - - private async closeInternal(): Promise { - if (this.closed) return; - this.closed = true; - await this.forceTerminationPromise; - const child = this.child; - if (!child) return; - let acknowledged = false; - try { - if (child.connected) { - await this.request({ type: 'close' }, undefined, true, { - hardTimeoutMs: CHILD_EXIT_GRACE_MS, - timeoutError: () => - new CodeModeRuntimeError( - 'runtime', - 'Stagehand child did not acknowledge close.' - ), - }); - acknowledged = true; - } - } catch { - // The child may already have exited; cleanup continues below. - } finally { - await terminateChild(child, 'SIGTERM'); - if (!acknowledged) await this.releaseBrowserbaseSessions(); - if (this.child === child) this.child = undefined; - } - } - - private async ensureConfigured(signal?: AbortSignal): Promise { - await this.forceTerminationPromise; - if (this.closed) - throw new CodeModeRuntimeError('closed', 'Code executor is closed.'); - if (!this.configurePromise) { - const configuring = (async () => { - this.spawnChild(); - await this.request( - { - type: 'configure', - runtimeTag: this.runtimeTag, - config: this.config, - }, - signal, - false, - { - hardTimeoutMs: CONFIGURE_TIMEOUT_MS, - terminateOnAbort: true, - timeoutError: () => - new CodeModeRuntimeError( - 'runtime', - 'Stagehand child configuration timed out.', - true, - { - browserStateLost: true, - } - ), - } - ); - })(); - this.configurePromise = configuring; - void configuring.catch(() => { - if (this.configurePromise === configuring) - this.configurePromise = undefined; - }); - } - await this.configurePromise; - } - - private spawnChild(): void { - if (this.child) return; - this.forceTerminationPromise = undefined; - const modulePath = fileURLToPath( - this.options.childModuleUrl ?? - new URL('./runtime-child.js', import.meta.url) - ); - const child = fork(modulePath, [], { - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - execArgv: [], - env: childEnvironment(process.env), - }); - this.child = child; - child.stdout?.on('data', chunk => - process.stderr.write(`[codemode child] ${chunk}`) - ); - child.stderr?.on('data', chunk => - process.stderr.write(`[codemode child] ${chunk}`) - ); - child.on('message', message => this.handleMessage(message)); - child.once('exit', (code, signal) => { - const pending = [...this.pending.values()]; - this.pending.clear(); - if (this.child === child) { - this.child = undefined; - if (!this.closed) this.configurePromise = undefined; - } - const error = new CodeModeRuntimeError( - 'runtime', - `Stagehand child exited${signal ? ` with signal ${signal}` : ` with code ${code}`}.`, - true, - { mayHaveSideEffects: true, browserStateLost: true } - ); - const recovery = this.closed - ? Promise.resolve() - : (this.forceTerminationPromise ?? this.forceTerminate()); - void recovery.finally(() => - pending.forEach(request => request.reject(error)) - ); - }); - child.once('error', cause => { - const pending = [...this.pending.values()]; - this.pending.clear(); - const error = new CodeModeRuntimeError('runtime', cause.message, true, { - cause, - mayHaveSideEffects: true, - browserStateLost: true, - }); - void this.forceTerminate().finally(() => - pending.forEach(request => request.reject(error)) - ); - }); - } - - private handleMessage(message: unknown): void { - if (!isChildResponse(message)) return; - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.ok) { - pending.resolve(message.result); - return; - } - const error = new CodeModeRuntimeError( - message.error.kind, - message.error.message, - message.error.retryable, - { - cause: message.error, - mayHaveSideEffects: message.error.mayHaveSideEffects, - browserStateLost: message.error.browserStateLost, - } - ); - if (!message.error.browserStateLost) { - pending.reject(error); - return; - } - void this.forceTerminate().finally(() => pending.reject(error)); - } - - private request( - request: ChildRequestWithoutId, - signal?: AbortSignal, - allowClosed = false, - control: RequestControl = {} - ): Promise { - if (this.closed && !allowClosed) { - return Promise.reject( - new CodeModeRuntimeError('closed', 'Code executor is closed.') - ); - } - const child = this.child; - if (!child?.connected) { - return Promise.reject( - new CodeModeRuntimeError( - 'runtime', - 'Stagehand child is not connected.', - true, - { - browserStateLost: true, - } - ) - ); - } - if (signal?.aborted) { - if (control.terminateOnAbort) void this.forceTerminate(); - return Promise.reject( - abortError(signal.reason, control.terminateOnAbort === true) - ); - } - - const id = randomUUID(); - return new Promise((resolve, reject) => { - let watchdog: NodeJS.Timeout | undefined; - const cleanup = () => { - signal?.removeEventListener('abort', onAbort); - if (watchdog) clearTimeout(watchdog); - }; - const onAbort = () => { - if (!this.pending.delete(id)) return; - cleanup(); - if (control.terminateOnAbort) void this.forceTerminate(); - reject(abortError(signal?.reason, control.terminateOnAbort === true)); - }; - signal?.addEventListener('abort', onAbort, { once: true }); - this.pending.set(id, { - resolve: value => { - cleanup(); - resolve(value); - }, - reject: error => { - cleanup(); - reject(error); - }, - }); - if (control.hardTimeoutMs !== undefined) { - watchdog = setTimeout(() => { - if (!this.pending.delete(id)) return; - cleanup(); - void this.forceTerminate(); - reject( - control.timeoutError?.() ?? - new CodeModeRuntimeError( - 'runtime', - 'Stagehand child request timed out.', - true, - { - browserStateLost: true, - } - ) - ); - }, control.hardTimeoutMs); - } - child.send({ ...request, id } as ChildRequest, error => { - if (!error) return; - this.pending.delete(id); - cleanup(); - void this.forceTerminate(); - reject(error); - }); - }); - } - - private forceTerminate(): Promise { - this.forceTerminationPromise ??= (async () => { - const child = this.child; - if (child) await terminateChild(child, 'SIGKILL'); - if (this.child === child) this.child = undefined; - if (!this.closed) this.configurePromise = undefined; - await this.releaseBrowserbaseSessions(); - })(); - return this.forceTerminationPromise; - } - - private releaseBrowserbaseSessions(): Promise { - if (this.releasePromise) return this.releasePromise; - const release = this.releaseBrowserbaseSessionsInternal(); - this.releasePromise = release; - void release.finally(() => { - if (this.releasePromise === release) this.releasePromise = undefined; - }); - return release; - } - - private async releaseBrowserbaseSessionsInternal(): Promise { - const apiKey = this.config.browserbaseApiKey; - if (!apiKey) return; - const browserbase = new Browserbase({ apiKey }); - let lastError: unknown; - for (const delayMs of RELEASE_SWEEP_DELAYS_MS) { - if (delayMs) - await new Promise(resolve => setTimeout(resolve, delayMs)); - try { - const sessions = await browserbase.sessions.list({ status: 'RUNNING' }); - const ids = sessions - .filter( - session => - session.userMetadata?.integration === 'stagehand-codemode' && - session.userMetadata?.runtimeTagHash === this.runtimeTagHash - ) - .map(session => session.id); - await Promise.all( - ids.map(id => - browserbase.sessions.update(id, { status: 'REQUEST_RELEASE' }) - ) - ); - lastError = undefined; - } catch (error) { - lastError = error; - } - } - if (lastError) { - const message = - lastError instanceof Error ? lastError.message : String(lastError); - process.stderr.write( - `Failed to release a Stagehand code-mode browser: ${message}\n` - ); - } - } - - private get runtimeTagHash(): string { - return createHash('sha256') - .update(this.runtimeTag) - .digest('hex') - .slice(0, 16); - } -} - -export function createStagehandChildRuntime( - config: StagehandCodeRuntimeConfig, - options?: StagehandChildRuntimeOptions -): CodeRuntime { - return new StagehandChildRuntime(config, options); -} - -function abortError(reason: unknown, stateLost: boolean): CodeModeRuntimeError { - return new CodeModeRuntimeError( - 'aborted', - 'Code execution was aborted.', - true, - { - cause: reason, - mayHaveSideEffects: stateLost, - browserStateLost: stateLost, - } - ); -} - -function childEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const safe: NodeJS.ProcessEnv = {}; - for (const key of CHILD_ENV_KEYS) { - if (env[key] !== undefined) safe[key] = env[key]; - } - return safe; -} - -async function terminateChild( - child: ChildProcess, - signal: NodeJS.Signals -): Promise { - if (child.exitCode !== null || child.signalCode !== null) return; - const exited = new Promise(resolve => - child.once('exit', () => resolve()) - ); - child.kill(signal); - await Promise.race([ - exited, - new Promise(resolve => setTimeout(resolve, CHILD_EXIT_GRACE_MS)), - ]); - if (child.exitCode === null && child.signalCode === null) - child.kill('SIGKILL'); -} - -function isChildResponse(value: unknown): value is ChildResponse { - return ( - typeof value === 'object' && - value !== null && - 'id' in value && - typeof value.id === 'string' && - 'ok' in value && - typeof value.ok === 'boolean' - ); -} diff --git a/packages/stagehand-codemode/src/cli.ts b/packages/stagehand-codemode/src/cli.ts index d7fdb84..3ebd6b0 100644 --- a/packages/stagehand-codemode/src/cli.ts +++ b/packages/stagehand-codemode/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { runtimeConfigFromEnv } from './config.js'; +import { stagehandCodeConfigFromEnv } from './config.js'; import { StagehandCodeExecutor } from './executor.js'; import { connectCodeModeStdio } from './mcp-server.js'; @@ -17,7 +17,6 @@ if (args.includes('--help') || args.includes('-h')) { ' STAGEHAND_MODEL_NAME Optional provider/model name for Stagehand AI methods', ' STAGEHAND_MODEL_API_KEY Optional model-provider API key', ' STAGEHAND_MODEL_BASE_URL Optional model-provider base URL', - ' CODEMODE_DEFAULT_TIMEOUT_MS Optional default timeout (120000)', '', ].join('\n') ); @@ -25,7 +24,7 @@ if (args.includes('--help') || args.includes('-h')) { } if (args.length > 0) throw new Error(`Unknown argument: ${args[0]}`); -const executor = new StagehandCodeExecutor(runtimeConfigFromEnv()); +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); const server = await connectCodeModeStdio(executor); let closing = false; diff --git a/packages/stagehand-codemode/src/config.ts b/packages/stagehand-codemode/src/config.ts index 8414b4a..4fea077 100644 --- a/packages/stagehand-codemode/src/config.ts +++ b/packages/stagehand-codemode/src/config.ts @@ -1,8 +1,8 @@ -import type { StagehandCodeRuntimeConfig } from './types.js'; +import type { StagehandCodeConfig } from './types.js'; -export function runtimeConfigFromEnv( +export function stagehandCodeConfigFromEnv( env: NodeJS.ProcessEnv = process.env -): StagehandCodeRuntimeConfig { +): StagehandCodeConfig { const explicitModelName = nonEmpty(env.STAGEHAND_MODEL_NAME); const explicitModelApiKey = nonEmpty(env.STAGEHAND_MODEL_API_KEY); const inferredGoogleKey = @@ -13,8 +13,6 @@ export function runtimeConfigFromEnv( explicitModelName ?? (inferredGoogleKey ? 'google/gemini-2.5-flash-lite' : undefined); const modelApiKey = explicitModelApiKey ?? inferredGoogleKey; - const defaultTimeoutMs = positiveInt(env.CODEMODE_DEFAULT_TIMEOUT_MS); - return { browserbaseApiKey: nonEmpty(env.BROWSERBASE_API_KEY), ...(modelName @@ -28,7 +26,6 @@ export function runtimeConfigFromEnv( }, } : {}), - ...(defaultTimeoutMs ? { defaultTimeoutMs } : {}), }; } @@ -36,9 +33,3 @@ function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } - -function positiveInt(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; -} diff --git a/packages/stagehand-codemode/src/executor.ts b/packages/stagehand-codemode/src/executor.ts index 124ea87..9610eef 100644 --- a/packages/stagehand-codemode/src/executor.ts +++ b/packages/stagehand-codemode/src/executor.ts @@ -1,31 +1,51 @@ -import { createStagehandChildRuntime } from './child-runtime.js'; +import { z } from 'zod/v4'; import type { CodeExecuteFailure, CodeExecuteInput, CodeExecuteResult, - CodeRuntime, - StagehandCodeRuntimeConfig, + CodeLogEntry, + CodePageState, + StagehandCodeConfig, } from './types.js'; -import { CodeModeRuntimeError } from './types.js'; -export type StagehandCodeExecutorOptions = StagehandCodeRuntimeConfig & { - runtimeFactory?: () => CodeRuntime; +type PageLike = { + url(): Promise; + title(): Promise; }; -const DEFAULT_TIMEOUT_MS = 120_000; -const MAX_TIMEOUT_MS = 300_000; +type ContextLike = { + activePage(): Promise; + pages(): Promise; + newPage(): Promise; +}; + +type StagehandLike = Record & { + context: ContextLike; + init(): Promise; + close(): Promise; +}; + +type StagehandConstructor = new ( + options: Record +) => StagehandLike; + +export type StagehandCodeExecutorOptions = StagehandCodeConfig; + +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new ( + ...args: string[] +) => (...values: unknown[]) => Promise; const MAX_CODE_BYTES = 100_000; +const MAX_LOG_BYTES = 64 * 1024; +const MAX_RESULT_BYTES = 256 * 1024; export class StagehandCodeExecutor { - private runtime?: CodeRuntime; + private stagehand?: StagehandLike; private queue = Promise.resolve(); + private closed = false; private closePromise?: Promise; - private readonly defaultTimeoutMs: number; - constructor(private readonly options: StagehandCodeExecutorOptions) { - this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; - requireTimeout(this.defaultTimeoutMs, 'defaultTimeoutMs'); - } + constructor(private readonly options: StagehandCodeExecutorOptions) {} execute( input: CodeExecuteInput, @@ -33,6 +53,7 @@ export class StagehandCodeExecutor { ): Promise { const validation = validate(input); if (validation) return Promise.resolve(validation); + const operation = this.queue.then(() => this.executeQueued(input, signal)); this.queue = operation.then( () => undefined, @@ -42,10 +63,11 @@ export class StagehandCodeExecutor { } close(): Promise { + this.closed = true; this.closePromise ??= this.queue.then(async () => { - const runtime = this.runtime; - this.runtime = undefined; - await runtime?.close(); + const current = this.stagehand; + this.stagehand = undefined; + await current?.close(); }); return this.closePromise; } @@ -54,38 +76,100 @@ export class StagehandCodeExecutor { input: CodeExecuteInput, signal?: AbortSignal ): Promise { - if (this.closePromise) - return failure('closed', 'Code executor is closed.', false, false); - if (signal?.aborted) - return failure('aborted', 'Code execution was aborted.', true, false); - const runtime = (this.runtime ??= this.createRuntime()); + if (this.closed) { + return failure('closed', 'Code executor is closed.'); + } + if (signal?.aborted) { + return failure('aborted', 'Code execution was aborted before it began.'); + } + + const logs: CodeLogEntry[] = []; + let page: PageLike | undefined; try { - const result = await runtime.run( - input.code, - input.timeout_ms ?? this.defaultTimeoutMs, - signal + const stagehand = await this.ensureStagehand(); + const context = stagehand.context; + page = + (await context.activePage()) ?? + (await context.pages())[0] ?? + (await context.newPage()); + + const fn = new AsyncFunction( + 'page', + 'context', + 'stagehand', + 'z', + 'console', + input.code + ); + const value = await fn( + page, + context, + stagehand, + z, + createCodeConsole(logs) ); + return { ok: true, - browser_state: 'preserved', - page: result.page, - ...(result.value === undefined ? {} : { value: result.value }), - ...(result.logs.length === 0 ? {} : { logs: result.logs }), + page: await readPageState(page), + ...(value === undefined ? {} : { value: jsonSafe(value) }), + ...(logs.length === 0 ? {} : { logs }), }; } catch (error) { - const stateLost = - error instanceof CodeModeRuntimeError && error.browserStateLost; - if (stateLost) { - this.runtime = undefined; - await runtime.close().catch(() => undefined); - } - return failureFromError(error, stateLost); + const normalized = + error instanceof Error ? error : new Error(String(error)); + const currentPage = + page ?? (await this.activePage().catch(() => undefined)); + return failure('runtime', normalized.message, normalized.name, { + ...(currentPage + ? { page: await readPageState(currentPage).catch(() => undefined) } + : {}), + ...(logs.length === 0 ? {} : { logs }), + }); + } + } + + private async ensureStagehand(): Promise { + if (this.stagehand) return this.stagehand; + if (!this.options.browserbaseApiKey) { + throw new Error( + 'BROWSERBASE_API_KEY is required before the first code_execute call.' + ); + } + + const packageName = '@browserbasehq/stagehand'; + const imported = (await import(packageName)) as { + Stagehand?: StagehandConstructor; + StagehandClientInitParamsSchema?: unknown; + }; + if (!imported.Stagehand || !imported.StagehandClientInitParamsSchema) { + throw new Error( + 'Stagehand code mode requires a local Stagehand V4 build resolvable as @browserbasehq/stagehand.' + ); + } + + const next = new imported.Stagehand({ + apiKey: this.options.browserbaseApiKey, + browser: { type: 'browserbase' }, + logging: { level: 'off' }, + ...(this.options.model ? { model: this.options.model } : {}), + }); + try { + await next.init(); + } catch (error) { + await next.close().catch(() => undefined); + throw error; } + this.stagehand = next; + return next; } - private createRuntime(): CodeRuntime { - if (this.options.runtimeFactory) return this.options.runtimeFactory(); - return createStagehandChildRuntime(this.options); + private async activePage(): Promise { + if (!this.stagehand) return undefined; + return ( + (await this.stagehand.context.activePage()) ?? + (await this.stagehand.context.pages())[0] + ); } } @@ -97,71 +181,82 @@ function validate(input: CodeExecuteInput): CodeExecuteFailure | undefined { ) { return failure( 'validation', - 'code must be a non-empty JavaScript function body.', - false, - false + 'code must be a non-empty JavaScript function body.' ); } if (Buffer.byteLength(input.code) > MAX_CODE_BYTES) { return failure( 'validation', - `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes.`, - false, - false + `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes.` ); } - if (input.timeout_ms !== undefined) { - try { - requireTimeout(input.timeout_ms, 'timeout_ms'); - } catch (error) { - return failure('validation', (error as Error).message, false, false); - } - } return undefined; } -function requireTimeout(value: number, label: string): void { - if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) { - throw new Error( - `${label} must be an integer from 1 through ${MAX_TIMEOUT_MS}.` - ); - } +function createCodeConsole(logs: CodeLogEntry[]) { + let logBytes = 0; + const append = (level: CodeLogEntry['level'], values: unknown[]) => { + if (logBytes >= MAX_LOG_BYTES) return; + const text = formatLog(values); + const remaining = MAX_LOG_BYTES - logBytes; + const bounded = Buffer.from(text).subarray(0, remaining).toString(); + logBytes += Buffer.byteLength(bounded); + logs.push({ level, text: bounded }); + }; + return Object.freeze({ + log: (...values: unknown[]) => append('log', values), + warn: (...values: unknown[]) => append('warn', values), + error: (...values: unknown[]) => append('error', values), + }); } -function failureFromError( - error: unknown, - stateLost: boolean -): CodeExecuteFailure { - const normalized = error instanceof Error ? error : new Error(String(error)); - const runtimeError = - error instanceof CodeModeRuntimeError ? error : undefined; - return failure( - runtimeError?.kind ?? 'runtime', - normalized.message, - runtimeError?.retryable ?? true, - stateLost, - runtimeError?.mayHaveSideEffects ?? false, - normalized.name - ); +async function readPageState(page: PageLike): Promise { + const [url, title] = await Promise.all([page.url(), page.title()]); + return { url, title }; +} + +function jsonSafe(value: unknown): unknown { + if (value === undefined) return undefined; + const serialized = JSON.stringify(value, (_key, nested) => { + if (typeof nested === 'bigint') return nested.toString(); + if (nested instanceof Uint8Array) { + return { + type: 'bytes', + encoding: 'base64', + data: Buffer.from(nested).toString('base64'), + }; + } + return nested; + }); + if (serialized === undefined) return undefined; + const bytes = Buffer.byteLength(serialized); + if (bytes <= MAX_RESULT_BYTES) return JSON.parse(serialized); + return { + truncated: true, + original_bytes: bytes, + preview: Buffer.from(serialized).subarray(0, MAX_RESULT_BYTES).toString(), + }; +} + +function formatLog(values: unknown[]): string { + return values + .map(value => { + if (typeof value === 'string') return value; + const safe = jsonSafe(value); + return safe === undefined ? String(value) : JSON.stringify(safe); + }) + .join(' '); } function failure( kind: CodeExecuteFailure['error']['kind'], message: string, - retryable: boolean, - stateLost: boolean, - mayHaveSideEffects = false, - name = 'CodeModeRuntimeError' + name = 'CodeModeError', + evidence: Pick = {} ): CodeExecuteFailure { return { ok: false, - browser_state: stateLost ? 'discarded' : 'preserved', - error: { - kind, - name, - message, - retryable, - ...(mayHaveSideEffects ? { may_have_side_effects: true } : {}), - }, + ...evidence, + error: { kind, name, message }, }; } diff --git a/packages/stagehand-codemode/src/index.ts b/packages/stagehand-codemode/src/index.ts index 9d539b5..160b5c0 100644 --- a/packages/stagehand-codemode/src/index.ts +++ b/packages/stagehand-codemode/src/index.ts @@ -1,8 +1,4 @@ -export { - createStagehandChildRuntime, - StagehandChildRuntime, -} from './child-runtime.js'; -export { runtimeConfigFromEnv } from './config.js'; +export { stagehandCodeConfigFromEnv } from './config.js'; export { StagehandCodeExecutor, type StagehandCodeExecutorOptions, diff --git a/packages/stagehand-codemode/src/mcp-server.ts b/packages/stagehand-codemode/src/mcp-server.ts index 9e06a29..ec3305d 100644 --- a/packages/stagehand-codemode/src/mcp-server.ts +++ b/packages/stagehand-codemode/src/mcp-server.ts @@ -28,7 +28,6 @@ export function createCodeModeMcpServer( outputSchema: z .object({ ok: z.boolean(), - browser_state: z.enum(['preserved', 'discarded']), }) .loose(), }, diff --git a/packages/stagehand-codemode/src/runtime-child.ts b/packages/stagehand-codemode/src/runtime-child.ts deleted file mode 100644 index 3d61dbf..0000000 --- a/packages/stagehand-codemode/src/runtime-child.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { createHash } from 'node:crypto'; -import { z } from 'zod/v4'; -import type { ChildRequest, ChildResponse } from './runtime-protocol.js'; -import type { - CodeLogEntry, - CodePageState, - RuntimeRunResult, - StagehandCodeRuntimeConfig, -} from './types.js'; - -type PageLike = { - url(): Promise; - title(): Promise; -}; - -type ContextLike = { - activePage(): Promise; - pages(): Promise; - newPage(): Promise; -}; - -type StagehandLike = Record & { - context: ContextLike; - init(): Promise; - close(): Promise; -}; - -type StagehandConstructor = new ( - options: Record -) => StagehandLike; - -const AsyncFunction = Object.getPrototypeOf(async function () {}) - .constructor as new ( - ...args: string[] -) => (...values: unknown[]) => Promise; -const MAX_LOG_BYTES = 64 * 1024; -const MAX_RESULT_BYTES = 256 * 1024; - -let config: StagehandCodeRuntimeConfig | undefined; -let runtimeTag: string | undefined; -let stagehand: StagehandLike | undefined; -let closed = false; -let queue = Promise.resolve(); - -process.on('message', (message: unknown) => { - if (!isChildRequest(message)) return; - queue = queue.then(() => handle(message)); -}); -process.once('SIGTERM', () => void shutdown(0)); -process.once('SIGINT', () => void shutdown(0)); -process.once('disconnect', () => void shutdown(0)); - -async function handle(request: ChildRequest): Promise { - try { - if (request.type === 'configure') { - if (config) - throw new Error('Stagehand code runtime is already configured.'); - config = request.config; - runtimeTag = request.runtimeTag; - send({ id: request.id, ok: true }); - return; - } - if (request.type === 'close') { - closed = true; - await closeStagehand(); - send({ id: request.id, ok: true, result: { closed: true } }); - setImmediate(() => process.exit(0)); - return; - } - requireConfigured(); - const result = await run(request.code, request.timeoutMs); - send({ id: request.id, ok: true, result }); - } catch (error) { - const normalized = normalizeError(error); - send({ - id: request.id, - ok: false, - error: normalized, - page: await readPageState().catch(() => undefined), - }); - if (normalized.browserStateLost) { - closed = true; - void closeStagehand() - .catch(() => undefined) - .finally(() => process.exit(1)); - setTimeout(() => process.exit(1), 2_000).unref(); - } - } -} - -async function ensureStagehand(): Promise { - requireConfigured(); - const configuredRuntimeTag = runtimeTag; - if (!configuredRuntimeTag) { - throw new Error('Stagehand code runtime is not configured.'); - } - if (stagehand) return stagehand; - if (!config?.browserbaseApiKey) { - throw new Error( - 'BROWSERBASE_API_KEY is required before the first code_execute call.' - ); - } - const packageName = '@browserbasehq/stagehand'; - const imported = (await import(packageName)) as { - Stagehand?: StagehandConstructor; - StagehandClientInitParamsSchema?: unknown; - }; - if (!imported.Stagehand || !imported.StagehandClientInitParamsSchema) { - throw new Error( - 'Stagehand code mode requires a local Stagehand V4 build resolvable as @browserbasehq/stagehand.' - ); - } - const options: Record = { - apiKey: config.browserbaseApiKey, - browser: { - type: 'browserbase', - userMetadata: { - integration: 'stagehand-codemode', - runtimeTagHash: createHash('sha256') - .update(configuredRuntimeTag) - .digest('hex') - .slice(0, 16), - }, - }, - logging: { level: 'off' }, - ...(config.model ? { model: config.model } : {}), - }; - const next = new imported.Stagehand(options); - await next.init(); - stagehand = next; - return next; -} - -async function run(code: string, timeoutMs: number): Promise { - if (closed) throw new Error('Stagehand code runtime is closed.'); - const runtime = await ensureStagehand(); - const context = runtime.context; - const page = - (await context.activePage()) ?? - (await context.pages())[0] ?? - (await context.newPage()); - const logs: CodeLogEntry[] = []; - let logBytes = 0; - const appendLog = (level: CodeLogEntry['level'], values: unknown[]) => { - if (logBytes >= MAX_LOG_BYTES) return; - const text = formatLog(values); - const remaining = MAX_LOG_BYTES - logBytes; - const bounded = Buffer.from(text).subarray(0, remaining).toString(); - logBytes += Buffer.byteLength(bounded); - logs.push({ level, text: bounded }); - }; - const codeConsole = Object.freeze({ - log: (...values: unknown[]) => appendLog('log', values), - warn: (...values: unknown[]) => appendLog('warn', values), - error: (...values: unknown[]) => appendLog('error', values), - }); - const fn = new AsyncFunction( - 'page', - 'context', - 'stagehand', - 'z', - 'console', - code - ); - let timeout: NodeJS.Timeout | undefined; - try { - const value = await Promise.race([ - fn(page, context, runtime, z, codeConsole), - new Promise((_, reject) => { - timeout = setTimeout(() => { - const error = new Error(`Code execution exceeded ${timeoutMs}ms.`); - error.name = 'CodeExecutionTimeoutError'; - reject(error); - }, timeoutMs); - }), - ]); - return { - value: jsonSafe(value), - logs, - page: await readRequiredPageState(page), - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} - -async function readPageState(): Promise { - if (!stagehand) return undefined; - const context = stagehand.context; - const page = (await context.activePage()) ?? (await context.pages())[0]; - return page ? readRequiredPageState(page) : undefined; -} - -async function readRequiredPageState(page: PageLike): Promise { - const [url, title] = await Promise.all([page.url(), page.title()]); - return { url, title }; -} - -async function closeStagehand(): Promise { - const current = stagehand; - stagehand = undefined; - await current?.close(); -} - -async function shutdown(code: number): Promise { - closed = true; - await closeStagehand().catch(() => undefined); - process.exit(code); -} - -function requireConfigured(): void { - if (!config || !runtimeTag) - throw new Error('Stagehand code runtime is not configured.'); -} - -function normalizeError( - error: unknown -): Extract['error'] { - const normalized = error instanceof Error ? error : new Error(String(error)); - const timeout = normalized.name === 'CodeExecutionTimeoutError'; - return { - name: normalized.name, - message: normalized.message, - kind: timeout ? 'timeout' : closed ? 'closed' : 'runtime', - retryable: timeout, - mayHaveSideEffects: timeout, - browserStateLost: timeout, - ...(normalized.stack ? { stack: normalized.stack } : {}), - }; -} - -function jsonSafe(value: unknown): unknown { - if (value === undefined) return undefined; - const serialized = JSON.stringify(value, (_key, nested) => { - if (typeof nested === 'bigint') return nested.toString(); - if (nested instanceof Uint8Array) { - return { - type: 'bytes', - encoding: 'base64', - data: Buffer.from(nested).toString('base64'), - }; - } - return nested; - }); - const bytes = Buffer.byteLength(serialized); - if (bytes <= MAX_RESULT_BYTES) return JSON.parse(serialized); - return { - truncated: true, - original_bytes: bytes, - preview: Buffer.from(serialized).subarray(0, MAX_RESULT_BYTES).toString(), - }; -} - -function formatLog(values: unknown[]): string { - return values - .map(value => - typeof value === 'string' ? value : JSON.stringify(jsonSafe(value)) - ) - .join(' '); -} - -function send(response: ChildResponse): void { - if (process.connected) process.send?.(response); -} - -function isChildRequest(value: unknown): value is ChildRequest { - return ( - typeof value === 'object' && - value !== null && - 'id' in value && - typeof value.id === 'string' && - 'type' in value && - (value.type === 'configure' || - value.type === 'run' || - value.type === 'close') - ); -} diff --git a/packages/stagehand-codemode/src/runtime-protocol.ts b/packages/stagehand-codemode/src/runtime-protocol.ts deleted file mode 100644 index 2fcc43a..0000000 --- a/packages/stagehand-codemode/src/runtime-protocol.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - CodeLogEntry, - CodePageState, - RuntimeRunResult, - StagehandCodeRuntimeConfig, -} from './types.js'; - -export type ChildRequest = - | { - id: string; - type: 'configure'; - runtimeTag: string; - config: StagehandCodeRuntimeConfig; - } - | { - id: string; - type: 'run'; - code: string; - timeoutMs: number; - } - | { - id: string; - type: 'close'; - }; - -export type ChildResponse = - | { - id: string; - ok: true; - result?: RuntimeRunResult | { closed: true }; - } - | { - id: string; - ok: false; - error: { - name: string; - message: string; - kind: 'runtime' | 'timeout' | 'closed'; - retryable: boolean; - mayHaveSideEffects: boolean; - browserStateLost: boolean; - stack?: string; - }; - page?: CodePageState; - logs?: CodeLogEntry[]; - }; diff --git a/packages/stagehand-codemode/src/tool-contract.ts b/packages/stagehand-codemode/src/tool-contract.ts index 078134f..e71613f 100644 --- a/packages/stagehand-codemode/src/tool-contract.ts +++ b/packages/stagehand-codemode/src/tool-contract.ts @@ -5,7 +5,8 @@ import type { CodeExecuteResult } from './types.js'; export const CODE_EXECUTE_DESCRIPTION = [ 'Execute an async JavaScript function body against one long-lived Stagehand V4 browser on Browserbase.', 'The local executor lazily creates the browser on the first call and reuses it for later calls.', - 'This runs trusted local code with local filesystem, network, and in-process SDK access; it is not a security sandbox.', + 'Code runs directly in the local MCP process. The owning agent framework should terminate and restart that process if it stops responding.', + 'This is trusted local code with filesystem, network, and in-process SDK access; it is not a security sandbox.', '', STAGEHAND_CODEMODE_SKILL, ].join('\n'); @@ -18,13 +19,6 @@ export const codeExecuteSchema = z.object({ .describe( 'Async JavaScript function body. page, context, stagehand, z, and console are in scope.' ), - timeout_ms: z - .number() - .int() - .positive() - .max(300_000) - .optional() - .describe('Per-call execution timeout in milliseconds.'), }); export function codeExecuteResultText(result: CodeExecuteResult): string { diff --git a/packages/stagehand-codemode/src/types.ts b/packages/stagehand-codemode/src/types.ts index d7b5ca0..69693eb 100644 --- a/packages/stagehand-codemode/src/types.ts +++ b/packages/stagehand-codemode/src/types.ts @@ -1,6 +1,5 @@ export type CodeExecuteInput = { code: string; - timeout_ms?: number; }; export type CodePageState = { @@ -14,11 +13,13 @@ export type CodeLogEntry = { }; export type CodeExecuteErrorKind = - 'validation' | 'runtime' | 'timeout' | 'aborted' | 'closed'; + | 'validation' + | 'runtime' + | 'aborted' + | 'closed'; export type CodeExecuteSuccess = { ok: true; - browser_state: 'preserved'; page: CodePageState; value?: unknown; logs?: CodeLogEntry[]; @@ -26,61 +27,22 @@ export type CodeExecuteSuccess = { export type CodeExecuteFailure = { ok: false; - browser_state: 'preserved' | 'discarded'; page?: CodePageState; logs?: CodeLogEntry[]; error: { kind: CodeExecuteErrorKind; name: string; message: string; - retryable: boolean; - may_have_side_effects?: boolean; }; }; export type CodeExecuteResult = CodeExecuteSuccess | CodeExecuteFailure; -export type RuntimeRunResult = { - value?: unknown; - logs: CodeLogEntry[]; - page: CodePageState; -}; - -export interface CodeRuntime { - run( - code: string, - timeoutMs: number, - signal?: AbortSignal - ): Promise; - close(): Promise; -} - -export type StagehandCodeRuntimeConfig = { +export type StagehandCodeConfig = { browserbaseApiKey?: string; model?: { modelName: string; apiKey?: string; baseURL?: string; }; - defaultTimeoutMs?: number; }; - -export class CodeModeRuntimeError extends Error { - readonly mayHaveSideEffects: boolean; - readonly browserStateLost: boolean; - - constructor( - readonly kind: CodeExecuteErrorKind, - message: string, - readonly retryable = false, - options?: ErrorOptions & { - mayHaveSideEffects?: boolean; - browserStateLost?: boolean; - } - ) { - super(message, options); - this.name = 'CodeModeRuntimeError'; - this.mayHaveSideEffects = options?.mayHaveSideEffects ?? false; - this.browserStateLost = options?.browserStateLost ?? false; - } -} diff --git a/packages/stagehand-codemode/tests/executor.test.ts b/packages/stagehand-codemode/tests/executor.test.ts deleted file mode 100644 index 6f538d1..0000000 --- a/packages/stagehand-codemode/tests/executor.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { StagehandChildRuntime } from '../src/child-runtime.js'; -import { StagehandCodeExecutor } from '../src/executor.js'; -import type { CodeRuntime, RuntimeRunResult } from '../src/types.js'; -import { CodeModeRuntimeError } from '../src/types.js'; - -class FakeRuntime implements CodeRuntime { - active = 0; - maxActive = 0; - calls: string[] = []; - closed = false; - - async run(code: string): Promise { - this.active += 1; - this.maxActive = Math.max(this.maxActive, this.active); - this.calls.push(code); - await new Promise(resolve => setTimeout(resolve, 5)); - this.active -= 1; - if (code === 'lose-state') { - throw new CodeModeRuntimeError('timeout', 'timed out', true, { - mayHaveSideEffects: true, - browserStateLost: true, - }); - } - if (code === 'runtime-error') { - throw new CodeModeRuntimeError('runtime', 'snippet failed'); - } - return { - value: { code }, - logs: [], - page: { url: 'https://example.com/', title: 'Example Domain' }, - }; - } - - async close(): Promise { - this.closed = true; - } -} - -describe('StagehandCodeExecutor', () => { - it('creates lazily, serializes calls, and reuses one runtime', async () => { - const runtimes: FakeRuntime[] = []; - const executor = new StagehandCodeExecutor({ - runtimeFactory: () => { - const runtime = new FakeRuntime(); - runtimes.push(runtime); - return runtime; - }, - }); - - expect(runtimes).toHaveLength(0); - const [first, second] = await Promise.all([ - executor.execute({ code: 'first' }), - executor.execute({ code: 'second' }), - ]); - - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - expect(runtimes).toHaveLength(1); - expect(runtimes[0]?.calls).toEqual(['first', 'second']); - expect(runtimes[0]?.maxActive).toBe(1); - await executor.close(); - expect(runtimes[0]?.closed).toBe(true); - }); - - it('discards a lost browser and creates a fresh runtime on the next call', async () => { - const runtimes: FakeRuntime[] = []; - const executor = new StagehandCodeExecutor({ - runtimeFactory: () => { - const runtime = new FakeRuntime(); - runtimes.push(runtime); - return runtime; - }, - }); - - const failed = await executor.execute({ code: 'lose-state' }); - expect(failed).toMatchObject({ - ok: false, - browser_state: 'discarded', - error: { kind: 'timeout', may_have_side_effects: true }, - }); - expect(runtimes[0]?.closed).toBe(true); - - const recovered = await executor.execute({ code: 'fresh' }); - expect(recovered.ok).toBe(true); - expect(runtimes).toHaveLength(2); - await executor.close(); - }); - - it('preserves the browser after an ordinary snippet error', async () => { - const runtimes: FakeRuntime[] = []; - const executor = new StagehandCodeExecutor({ - runtimeFactory: () => { - const runtime = new FakeRuntime(); - runtimes.push(runtime); - return runtime; - }, - }); - - const failed = await executor.execute({ code: 'runtime-error' }); - expect(failed).toMatchObject({ - ok: false, - browser_state: 'preserved', - error: { kind: 'runtime', retryable: false }, - }); - const recovered = await executor.execute({ code: 'same-browser' }); - expect(recovered.ok).toBe(true); - expect(runtimes).toHaveLength(1); - expect(runtimes[0]?.closed).toBe(false); - await executor.close(); - }); - - it('rejects invalid input without creating a runtime', async () => { - let created = 0; - const executor = new StagehandCodeExecutor({ - runtimeFactory: () => { - created += 1; - return new FakeRuntime(); - }, - }); - - await expect(executor.execute({ code: '' })).resolves.toMatchObject({ - ok: false, - error: { kind: 'validation' }, - }); - await expect( - executor.execute({ code: 'return 1', timeout_ms: 300_001 }) - ).resolves.toMatchObject({ ok: false, error: { kind: 'validation' } }); - expect(created).toBe(0); - }); -}); - -describe('StagehandChildRuntime', () => { - it('filters secrets from the child environment', async () => { - process.env.CODEMODE_TEST_SECRET = 'must-not-cross-ipc-boundary'; - const runtime = new StagehandChildRuntime( - {}, - { - childModuleUrl: new URL( - './fixtures/controlled-child.mjs', - import.meta.url - ), - } - ); - try { - const result = await runtime.run('inspect-env', 2_000); - expect(result.value).toEqual({ pathPresent: true }); - } finally { - delete process.env.CODEMODE_TEST_SECRET; - await runtime.close(); - } - }); - - it('kills a hung child and reports that browser state was lost', async () => { - const runtime = new StagehandChildRuntime( - {}, - { - childModuleUrl: new URL( - './fixtures/controlled-child.mjs', - import.meta.url - ), - } - ); - await expect(runtime.run('hang', 20)).rejects.toMatchObject({ - kind: 'timeout', - browserStateLost: true, - mayHaveSideEffects: true, - }); - await runtime.close(); - }); -}); diff --git a/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs b/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs deleted file mode 100644 index 19b96e5..0000000 --- a/packages/stagehand-codemode/tests/fixtures/controlled-child.mjs +++ /dev/null @@ -1,44 +0,0 @@ -let configured = false; - -process.on('message', message => { - if (!message || typeof message !== 'object') return; - if (message.type === 'configure') { - configured = true; - process.send?.({ id: message.id, ok: true }); - return; - } - if (message.type === 'close') { - process.send?.({ id: message.id, ok: true, result: { closed: true } }); - setImmediate(() => process.exit(0)); - return; - } - if (!configured || message.type !== 'run') return; - if (message.code === 'hang') return; - if (message.code === 'runtime-error') { - process.send?.({ - id: message.id, - ok: false, - error: { - name: 'Error', - message: 'controlled failure', - kind: 'runtime', - retryable: false, - mayHaveSideEffects: false, - browserStateLost: false, - }, - }); - return; - } - process.send?.({ - id: message.id, - ok: true, - result: { - value: { - pathPresent: typeof process.env.PATH === 'string', - testSecret: process.env.CODEMODE_TEST_SECRET, - }, - logs: [], - page: { url: 'https://example.com/', title: 'Example Domain' }, - }, - }); -}); diff --git a/packages/stagehand-codemode/tests/live-mcp-smoke.mjs b/packages/stagehand-codemode/tests/live-mcp-smoke.mjs deleted file mode 100644 index 817a1df..0000000 --- a/packages/stagehand-codemode/tests/live-mcp-smoke.mjs +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import { fileURLToPath } from 'node:url'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - -if (!process.env.BROWSERBASE_API_KEY) { - throw new Error('BROWSERBASE_API_KEY is required for the live smoke test.'); -} - -const transport = new StdioClientTransport({ - command: process.execPath, - args: [fileURLToPath(new URL('../dist/cli.js', import.meta.url))], - env: Object.fromEntries( - Object.entries(process.env).filter(([, value]) => value !== undefined) - ), -}); -const client = new Client({ - name: 'stagehand-codemode-live-smoke', - version: '0.0.0', -}); - -try { - await client.connect(transport); - const discovered = await client.listTools(); - assert.deepEqual( - discovered.tools.map(tool => tool.name), - ['code_execute'] - ); - - const first = structured( - await client.callTool({ - name: 'code_execute', - arguments: { - code: ` - await page.goto("https://browserbase.github.io/stagehand-eval-sites/sites/new-tab/", { - waitUntil: "load", - }); - return { - phase: "opened", - title: await page.title(), - url: await page.url(), - pageCount: (await context.pages()).length, - }; - `, - }, - }) - ); - assert.equal(first.ok, true, JSON.stringify(first)); - - const second = structured( - await client.callTool({ - name: 'code_execute', - arguments: { - code: ` - return { - phase: "reused", - title: await page.title(), - url: await page.url(), - bodyIncludesWelcome: (await page.locator("body").innerText()).includes("Welcome"), - pageCount: (await context.pages()).length, - }; - `, - }, - }) - ); - assert.equal(second.ok, true, JSON.stringify(second)); - assert.equal(first.value.url, second.value.url); - assert.equal(second.value.bodyIncludesWelcome, true); - assert.equal(first.value.pageCount, 1); - assert.equal(second.value.pageCount, 1); - - const third = structured( - await client.callTool({ - name: 'code_execute', - arguments: { - code: ` - return { - phase: "stagehand-syntax", - actType: typeof stagehand.act, - observeType: typeof stagehand.observe, - extractType: typeof stagehand.extract, - zObjectType: typeof z.object, - }; - `, - }, - }) - ); - assert.equal(third.ok, true, JSON.stringify(third)); - assert.deepEqual(third.value, { - phase: 'stagehand-syntax', - actType: 'function', - observeType: 'function', - extractType: 'function', - zObjectType: 'function', - }); - - process.stdout.write( - `${JSON.stringify( - { - status: 'PASS', - transport: 'local stdio MCP', - discoveredTools: ['code_execute'], - lazyBrowserCreated: true, - browserStateReused: true, - stagehandSyntaxAvailable: true, - first: first.value, - second: second.value, - third: third.value, - }, - null, - 2 - )}\n` - ); -} finally { - await client.close(); -} - -function structured(result) { - if (result.structuredContent) return result.structuredContent; - const text = result.content?.find(block => block.type === 'text')?.text; - if (!text) - throw new Error('code_execute returned no structured or text result.'); - return JSON.parse(text); -} diff --git a/packages/stagehand-codemode/tests/mcp-server.test.ts b/packages/stagehand-codemode/tests/mcp-server.test.ts deleted file mode 100644 index da0c6f1..0000000 --- a/packages/stagehand-codemode/tests/mcp-server.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { describe, expect, it } from 'vitest'; -import { StagehandCodeExecutor } from '../src/executor.js'; -import { createCodeModeMcpServer } from '../src/mcp-server.js'; -import type { CodeRuntime } from '../src/types.js'; - -describe('code-mode MCP', () => { - it('discovers and invokes exactly one code_execute tool', async () => { - const runtime: CodeRuntime = { - run: async code => ({ - value: { echoed: code }, - logs: [], - page: { url: 'https://example.com/', title: 'Example Domain' }, - }), - close: async () => undefined, - }; - const executor = new StagehandCodeExecutor({ - runtimeFactory: () => runtime, - }); - const server = createCodeModeMcpServer(executor); - const client = new Client({ name: 'test-client', version: '0.0.0' }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await Promise.all([ - server.connect(serverTransport), - client.connect(clientTransport), - ]); - - try { - const discovered = await client.listTools(); - expect(discovered.tools.map(entry => entry.name)).toEqual([ - 'code_execute', - ]); - expect(discovered.tools[0]?.inputSchema.required).toEqual(['code']); - expect(discovered.tools[0]?.description).toContain( - 'Stagehand V4 code-mode syntax' - ); - - const result = await client.callTool({ - name: 'code_execute', - arguments: { code: 'return 42' }, - }); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ - ok: true, - browser_state: 'preserved', - value: { echoed: 'return 42' }, - }); - } finally { - await client.close(); - await server.close(); - await executor.close(); - } - }); -}); diff --git a/packages/stagehand-codemode/tsconfig.json b/packages/stagehand-codemode/tsconfig.json index 36876ff..fb71fd3 100644 --- a/packages/stagehand-codemode/tsconfig.json +++ b/packages/stagehand-codemode/tsconfig.json @@ -8,6 +8,6 @@ "rootDir": ".", "noEmit": true }, - "include": ["src/**/*.ts", "tests/**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/stagehand-codemode/tsdown.config.ts b/packages/stagehand-codemode/tsdown.config.ts index 4edecd8..849cfbc 100644 --- a/packages/stagehand-codemode/tsdown.config.ts +++ b/packages/stagehand-codemode/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/cli.ts', 'src/runtime-child.ts'], + entry: ['src/index.ts', 'src/cli.ts'], format: ['esm'], platform: 'node', target: 'node22', From e9edc2c31947805b1b329dfe545176016e9ddac4 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 15:28:58 -0700 Subject: [PATCH 4/6] docs: use conventional skill filename --- packages/stagehand-codemode/README.md | 4 ++-- .../{STAGEHAND_CODEMODE_SKILL.md => SKILL.md} | 2 +- packages/stagehand-codemode/src/skill.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename packages/stagehand-codemode/{STAGEHAND_CODEMODE_SKILL.md => SKILL.md} (98%) diff --git a/packages/stagehand-codemode/README.md b/packages/stagehand-codemode/README.md index 843967f..33f413e 100644 --- a/packages/stagehand-codemode/README.md +++ b/packages/stagehand-codemode/README.md @@ -11,8 +11,8 @@ browser until the owning process closes. The model writes an async JavaScript fu over the extension's JSON-RPC protocol. The executor does not expose raw JSON-RPC or maintain a second method allowlist that can drift from the SDK. -[`STAGEHAND_CODEMODE_SKILL.md`](./STAGEHAND_CODEMODE_SKILL.md) is the canonical syntax reference. -The MCP server includes it in the tool description so the model does not need to infer the V4 API. +[`SKILL.md`](./SKILL.md) is the canonical syntax reference. The MCP server includes it in the tool +description so the model does not need to infer the V4 API. ```json { diff --git a/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md b/packages/stagehand-codemode/SKILL.md similarity index 98% rename from packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md rename to packages/stagehand-codemode/SKILL.md index d9e0a14..248c894 100644 --- a/packages/stagehand-codemode/STAGEHAND_CODEMODE_SKILL.md +++ b/packages/stagehand-codemode/SKILL.md @@ -1,4 +1,4 @@ -# Stagehand V4 code-mode syntax +# Stagehand V4 code-mode syntax skill You have one `code_execute` tool. Its `code` argument is the body of an async JavaScript function, not a complete program. Write direct `await` statements and finish with a JSON-serializable return diff --git a/packages/stagehand-codemode/src/skill.ts b/packages/stagehand-codemode/src/skill.ts index 3f74892..921bb7f 100644 --- a/packages/stagehand-codemode/src/skill.ts +++ b/packages/stagehand-codemode/src/skill.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; export const STAGEHAND_CODEMODE_SKILL = readFileSync( - new URL('../STAGEHAND_CODEMODE_SKILL.md', import.meta.url), + new URL('../SKILL.md', import.meta.url), 'utf8' ).trim(); From e4b085dc3acdbaa6ba254932a315b18b03b1554f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 15:53:08 -0700 Subject: [PATCH 5/6] refactor: make stdio server an internal entrypoint --- packages/stagehand-codemode/README.md | 2 +- packages/stagehand-codemode/package.json | 3 --- .../src/{cli.ts => stdio-server.ts} | 22 ------------------- packages/stagehand-codemode/tsdown.config.ts | 2 +- 4 files changed, 2 insertions(+), 27 deletions(-) rename packages/stagehand-codemode/src/{cli.ts => stdio-server.ts} (54%) diff --git a/packages/stagehand-codemode/README.md b/packages/stagehand-codemode/README.md index 33f413e..e39c69d 100644 --- a/packages/stagehand-codemode/README.md +++ b/packages/stagehand-codemode/README.md @@ -36,7 +36,7 @@ supervisor around this local single-agent session. Build the package, then configure the framework to launch: ```text -node packages/stagehand-codemode/dist/cli.js +node packages/stagehand-codemode/dist/stdio-server.js ``` Set `BROWSERBASE_API_KEY` in the parent framework's environment. Stagehand V4 is an optional peer diff --git a/packages/stagehand-codemode/package.json b/packages/stagehand-codemode/package.json index a6b0a59..6ec966c 100644 --- a/packages/stagehand-codemode/package.json +++ b/packages/stagehand-codemode/package.json @@ -4,9 +4,6 @@ "private": true, "description": "Local stdio MCP tool for Stagehand code mode", "type": "module", - "bin": { - "stagehand-codemode": "./dist/cli.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/stagehand-codemode/src/cli.ts b/packages/stagehand-codemode/src/stdio-server.ts similarity index 54% rename from packages/stagehand-codemode/src/cli.ts rename to packages/stagehand-codemode/src/stdio-server.ts index 3ebd6b0..75400ba 100644 --- a/packages/stagehand-codemode/src/cli.ts +++ b/packages/stagehand-codemode/src/stdio-server.ts @@ -1,29 +1,7 @@ -#!/usr/bin/env node - import { stagehandCodeConfigFromEnv } from './config.js'; import { StagehandCodeExecutor } from './executor.js'; import { connectCodeModeStdio } from './mcp-server.js'; -const args = process.argv.slice(2); -if (args.includes('--help') || args.includes('-h')) { - process.stdout.write( - [ - 'Usage: stagehand-codemode', - '', - 'Starts a local MCP server over stdio. The parent agent framework owns the process.', - '', - 'Environment:', - ' BROWSERBASE_API_KEY Required before the first code_execute call', - ' STAGEHAND_MODEL_NAME Optional provider/model name for Stagehand AI methods', - ' STAGEHAND_MODEL_API_KEY Optional model-provider API key', - ' STAGEHAND_MODEL_BASE_URL Optional model-provider base URL', - '', - ].join('\n') - ); - process.exit(0); -} -if (args.length > 0) throw new Error(`Unknown argument: ${args[0]}`); - const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); const server = await connectCodeModeStdio(executor); let closing = false; diff --git a/packages/stagehand-codemode/tsdown.config.ts b/packages/stagehand-codemode/tsdown.config.ts index 849cfbc..191135e 100644 --- a/packages/stagehand-codemode/tsdown.config.ts +++ b/packages/stagehand-codemode/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/cli.ts'], + entry: ['src/index.ts', 'src/stdio-server.ts'], format: ['esm'], platform: 'node', target: 'node22', From 14d6268b05e38cb60d60ded52aaca6c7e1c6673f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 4 Aug 2026 16:04:34 -0700 Subject: [PATCH 6/6] docs: document Stagehand code mode integration --- packages/stagehand-codemode/README.md | 168 +++++++++++++++++++++----- 1 file changed, 140 insertions(+), 28 deletions(-) diff --git a/packages/stagehand-codemode/README.md b/packages/stagehand-codemode/README.md index e39c69d..09fa708 100644 --- a/packages/stagehand-codemode/README.md +++ b/packages/stagehand-codemode/README.md @@ -1,44 +1,156 @@ -# Stagehand code mode +# Stagehand Code Mode -This private spike exposes one `code_execute` tool through a local MCP server over stdio for -frameworks with local-process MCP support. +Stagehand Code Mode gives an agent one `code_execute` tool for operating a Browserbase browser with +Stagehand V4 JavaScript. It is framework-neutral: agent frameworks can launch the included local +MCP server over stdio or wrap `StagehandCodeExecutor` in a native tool binding. -The first tool call lazily creates one Browserbase browser. Calls are serialized and reuse that -browser until the owning process closes. The model writes an async JavaScript function body with -`page`, `context`, `stagehand`, `z`, and `console` already in scope. +The executor creates a Browserbase browser on the first valid call, serializes calls, and reuses the +same browser until its owner closes it. This lets an agent complete multi-step tasks across tool calls +without managing a browser or session identifier. -`page`, `context`, and `stagehand` are the public V4 SDK objects, which already route their methods -over the extension's JSON-RPC protocol. The executor does not expose raw JSON-RPC or maintain a -second method allowlist that can drift from the SDK. +## Tool contract -[`SKILL.md`](./SKILL.md) is the canonical syntax reference. The MCP server includes it in the tool -description so the model does not need to infer the V4 API. +`code_execute` accepts the body of an async JavaScript function: -```json -{ - "code": "await page.goto('https://example.com'); return { title: await page.title() };" -} +```ts +type CodeExecuteInput = { + code: string; +}; ``` -## Trust boundary +The generated function receives these objects: + +- `page`: the active Stagehand V4 `Page`; +- `context`: the shared Stagehand V4 `BrowserContext`; +- `stagehand`: the Stagehand V4 `act`, `observe`, and `extract` methods; +- `z`: Zod V4 for structured extraction schemas; and +- `console`: captured `log`, `warn`, and `error` methods. + +For example: + +```js +await page.goto('https://example.com', { waitUntil: 'load' }); +return { + title: await page.title(), + url: await page.url(), +}; +``` -The executor runs model-authored JavaScript directly in the local MCP process. This is not a security -sandbox: code can access the local filesystem, network, environment, and in-process SDK state with -that process's permissions. Only use it with trusted agents in a trusted local environment. +Calls return a JSON-safe result containing the active page state, the generated function's return +value, and any captured logs: -The agent framework owns the stdio MCP process. If generated code stops responding, the framework -should terminate and restart the process. Restarting also starts a fresh browser, so the previous -browser state is lost. The tool intentionally does not add a second worker, IPC protocol, or timeout -supervisor around this local single-agent session. +```ts +type CodeExecuteResult = + | { + ok: true; + page: { url: string; title: string }; + value?: unknown; + logs?: Array<{ level: 'log' | 'warn' | 'error'; text: string }>; + } + | { + ok: false; + page?: { url: string; title: string }; + logs?: Array<{ level: 'log' | 'warn' | 'error'; text: string }>; + error: { + kind: 'validation' | 'runtime' | 'aborted' | 'closed'; + name: string; + message: string; + }; + }; +``` -## Local stdio MCP +## Local MCP integration -Build the package, then configure the framework to launch: +Frameworks with local-process MCP support should launch the built stdio server and keep that process +alive for the complete agent run: ```text node packages/stagehand-codemode/dist/stdio-server.js ``` -Set `BROWSERBASE_API_KEY` in the parent framework's environment. Stagehand V4 is an optional peer -dependency until V4 is published, so local development must make a V4 build resolvable as -`@browserbasehq/stagehand`. +The stdio server is an internal process entrypoint, not a user-facing CLI. The package does not +publish a `bin` command or accept command-line arguments. + +The framework owns the process lifecycle: + +1. Launch one stdio server for the agent run. +2. Reuse it for every `code_execute` call that should share browser state. +3. Terminate and relaunch it if a call stops responding. +4. Close it when the agent run finishes. + +Restarting the process creates a new browser, so browser state from the previous process is not +preserved. + +## Native tool integration + +Frameworks that do not launch local MCP servers can wrap the executor directly: + +```ts +import { + StagehandCodeExecutor, + stagehandCodeConfigFromEnv, +} from '@browserbasehq/stagehand-codemode'; + +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); + +try { + const result = await executor.execute({ + code: ` + await page.goto("https://example.com", { waitUntil: "load" }); + return { title: await page.title() }; + `, + }); + console.log(result); +} finally { + await executor.close(); +} +``` + +Create one executor per agent run and close it in `finally` so the Browserbase browser is released +when the run succeeds, fails, or is cancelled. + +## Configuration + +Stagehand Code Mode reads configuration from the owning framework's environment: + +| Variable | Required | Purpose | +| --------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------- | +| `BROWSERBASE_API_KEY` | Yes, before the first `code_execute` call | Creates the Browserbase browser | +| `STAGEHAND_MODEL_NAME` | Only for Stagehand AI methods | Provider and model name used by `act`, `observe`, and `extract` | +| `STAGEHAND_MODEL_API_KEY` | Provider-dependent | Explicit model-provider API key | +| `STAGEHAND_MODEL_BASE_URL` | No | Custom model-provider base URL | +| `GEMINI_API_KEY`, `GOOGLE_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY` | No | Selects `google/gemini-2.5-flash-lite` when no explicit model is configured | + +The consuming application must also make a compatible `@browserbasehq/stagehand` V4 package +available to the executor. + +## Model syntax guide + +[`SKILL.md`](./SKILL.md) is the canonical Stagehand V4 syntax guide. The MCP server includes the +complete guide in the `code_execute` tool description. Native integrations should also add the +exported `STAGEHAND_CODEMODE_SKILL` string to the agent's system instructions or equivalent +high-priority context. + +The guide covers deterministic page and locator methods, `act`, `observe`, `extract`, Zod schemas, +multiple pages, cross-call state, and return-value discipline. + +## Lifecycle and limits + +- Browser creation is lazy; MCP discovery does not create a Browserbase browser. +- Calls are serialized because they operate on one shared browser context. +- Pages, cookies, and navigation state persist across successful calls in the same process. +- JavaScript variables declared inside generated code do not persist between calls. +- Input code is limited to 100,000 UTF-8 bytes. +- Captured logs are limited to 64 KiB. +- Returned values are limited to 256 KiB and are truncated with metadata when necessary. +- BigInt and byte-array values are converted into JSON-safe representations. + +## Security + +Generated JavaScript runs directly in the MCP or native-tool process. Stagehand Code Mode is not a +security sandbox: generated code can access the filesystem, network, environment variables, Node +globals, and in-process SDK state available to that process. + +Use Stagehand Code Mode only with trusted agents in a trusted execution environment. Applications +that execute untrusted code must provide a real isolation boundary, such as a restricted container, +virtual machine, or purpose-built code sandbox.