From 6a9d6aa09fe5c5e6f081c3c3df2615ca841b123f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:26 +0000 Subject: [PATCH 1/5] feat(cli): restore cursor overlay through page.evaluate --- packages/cli/src/lib/driver/commands/mouse.ts | 20 +++++ .../cli/src/lib/driver/commands/runtime.ts | 10 ++- .../src/lib/driver/commands/unavailable.ts | 9 -- packages/cli/src/lib/driver/cursor-overlay.ts | 65 ++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 10 +++ packages/cli/tests/driver-commands.test.ts | 85 ++++++++++++++++++- 6 files changed, 185 insertions(+), 14 deletions(-) delete mode 100644 packages/cli/src/lib/driver/commands/unavailable.ts create mode 100644 packages/cli/src/lib/driver/cursor-overlay.ts diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 62268b172f..cb0c309562 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { updateCursorOverlayPosition } from "../cursor-overlay.js"; +import type { DriverPage, DriverSessionManager } from "../session-manager.js"; import type { DriverCommandHandlers } from "./types.js"; const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); @@ -17,6 +19,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.click(x, y, { ...(button === undefined ? {} : { button }), ...(clickCount === undefined ? {} : { clickCount }), @@ -34,6 +37,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.hover(x, y); return { hovered: true }; }, @@ -50,6 +54,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.scroll(x, y, deltaX, deltaY); return { scrolled: true }; }, @@ -69,15 +74,30 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, fromX, fromY); await page.dragAndDrop(fromX, fromY, toX, toY, { ...(button === undefined ? {} : { button }), ...(delay === undefined ? {} : { delay }), ...(steps === undefined ? {} : { steps }), }); + // A successful drag may navigate and destroy the old execution context. + // The final marker position is visual-only, so do not turn that race into a + // reported drag failure. + await positionCursorOverlay(manager, page, toX, toY).catch(() => undefined); return { dragged: true }; }, }; +async function positionCursorOverlay( + manager: DriverSessionManager, + page: DriverPage, + x: number, + y: number, +): Promise { + if (!manager.isCursorOverlayEnabled(page)) return; + await page.evaluate(updateCursorOverlayPosition, { x, y }); +} + function assertXPathUnavailable(returnXPath: boolean | undefined): void { if (returnXPath) { throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 9f06c0fdd9..54dbe3f4f1 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -2,8 +2,8 @@ import { promises as fs } from "node:fs"; import { z } from "zod"; +import { CURSOR_OVERLAY_SCRIPT } from "../cursor-overlay.js"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableCursorOverlay } from "./unavailable.js"; export const runtimeHandlers: DriverCommandHandlers = { async screenshot(manager, params) { @@ -89,7 +89,13 @@ export const runtimeHandlers: DriverCommandHandlers = { return { waited: true }; }, - cursor: unavailableCursorOverlay, + async cursor(manager) { + const page = await manager.activePage(); + await page.addInitScript(CURSOR_OVERLAY_SCRIPT); + await page.evaluate(CURSOR_OVERLAY_SCRIPT); + manager.markCursorOverlayEnabled(page); + return { enabled: true }; + }, }; function parseTimeoutMs(value: string | undefined): number { diff --git a/packages/cli/src/lib/driver/commands/unavailable.ts b/packages/cli/src/lib/driver/commands/unavailable.ts deleted file mode 100644 index 6f1f3d86e0..0000000000 --- a/packages/cli/src/lib/driver/commands/unavailable.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DriverError } from "../errors.js"; -import type { DriverCommandHandler } from "./types.js"; - -export const unavailableCursorOverlay: DriverCommandHandler = async () => { - throw new DriverError( - "The visible cursor overlay has not been restored in this Stagehand V4 stack layer.", - { code: "cursor_overlay_unavailable" }, - ); -}; diff --git a/packages/cli/src/lib/driver/cursor-overlay.ts b/packages/cli/src/lib/driver/cursor-overlay.ts new file mode 100644 index 0000000000..de966a9d2d --- /dev/null +++ b/packages/cli/src/lib/driver/cursor-overlay.ts @@ -0,0 +1,65 @@ +export const CURSOR_OVERLAY_SCRIPT = `(() => { + if (globalThis !== globalThis.top) return; + + const cursorId = "__browse_cursor_overlay__"; + const ensureCursor = () => { + const existing = document.getElementById(cursorId); + if (existing instanceof HTMLDivElement) return existing; + + const root = document.documentElement || document.body; + if (!root) return null; + + const cursor = document.createElement("div"); + cursor.id = cursorId; + cursor.setAttribute("aria-hidden", "true"); + Object.assign(cursor.style, { + contain: "layout style paint", + height: "24px", + left: "0px", + mixBlendMode: "normal", + pointerEvents: "none", + position: "fixed", + top: "0px", + userSelect: "none", + width: "16px", + willChange: "left,top", + zIndex: "2147483647", + }); + cursor.innerHTML = + ''; + root.appendChild(cursor); + return cursor; + }; + + const moveCursor = (x, y) => { + const cursor = ensureCursor(); + if (!cursor) return; + cursor.style.left = Math.max(0, x) + "px"; + cursor.style.top = Math.max(0, y) + "px"; + }; + + globalThis.__browseMoveCursorOverlay__ = moveCursor; + ensureCursor(); + if (!globalThis.__browseCursorOverlayListenerInstalled__) { + document.addEventListener( + "mousemove", + (event) => { + moveCursor(event.clientX, event.clientY); + }, + { capture: true }, + ); + globalThis.__browseCursorOverlayListenerInstalled__ = true; + } +})()`; + +export function updateCursorOverlayPosition(position: { + x: number; + y: number; +}): void { + const moveCursor = ( + globalThis as typeof globalThis & { + __browseMoveCursorOverlay__?: (x: number, y: number) => void; + } + ).__browseMoveCursorOverlay__; + moveCursor?.(position.x, position.y); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 7f30ae3951..4e9381b70e 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -80,6 +80,7 @@ export class DriverSessionManager { private browserbaseIdentityValue: BrowserbaseIdentity = {}; private consecutiveInitFailures = 0; private context: DriverContext | null = null; + private cursorOverlayPageIds = new Set(); private lastForwardedEnvSignature: string | null = null; private pendingEnv: ForwardedEnv | undefined; private initFailure: InitFailure | null = null; @@ -207,6 +208,7 @@ export class DriverSessionManager { this.stagehand = null; this.browser = null; this.context = null; + this.cursorOverlayPageIds.clear(); this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; @@ -223,6 +225,14 @@ export class DriverSessionManager { return resolveCachedSelector(selector, this.refMaps); } + markCursorOverlayEnabled(page: DriverPage): void { + this.cursorOverlayPageIds.add(page.pageId); + } + + isCursorOverlayEnabled(page: DriverPage): boolean { + return this.cursorOverlayPageIds.has(page.pageId); + } + setRefMaps(refMaps: RefMaps): void { this.refMaps = refMaps; } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 58b9d6e55b..6861b6d2b3 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -326,6 +326,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -385,6 +386,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -410,6 +412,34 @@ describe("driver commands", () => { expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, {}); }); + it("keeps a successful drag successful when navigation races the final cursor update", async () => { + const page = { + dragAndDrop: vi.fn(), + evaluate: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Execution context was destroyed")), + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.drag"]> + >[0]; + + await expect( + mouseHandlers["mouse.drag"]!(manager, { + fromX: 10, + fromY: 20, + toX: 30, + toY: 40, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.dragAndDrop).toHaveBeenCalledWith(10, 20, 30, 40, {}); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + it("fails explicitly for the V4 coordinate XPath capability", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> @@ -447,10 +477,59 @@ describe("driver commands", () => { expect(network.enable).toHaveBeenCalledWith(page); }); - it("keeps cursor as an explicit capability gap", async () => { + it("installs the CLI-owned cursor overlay", async () => { + const page = { + addInitScript: vi.fn(), + evaluate: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + markCursorOverlayEnabled: vi.fn(), + } as unknown as Parameters< + NonNullable<(typeof runtimeHandlers)["cursor"]> + >[0]; + + await expect(runtimeHandlers.cursor!(manager, {})).resolves.toEqual({ + enabled: true, + }); + expect(page.addInitScript).toHaveBeenCalledOnce(); + expect(page.evaluate).toHaveBeenCalledOnce(); + expect(page.addInitScript).toHaveBeenCalledWith( + page.evaluate.mock.calls[0]?.[0], + ); + expect(manager.markCursorOverlayEnabled).toHaveBeenCalledWith(page); + const cursorInstaller = page.evaluate.mock.calls[0]?.[0]; + expect(cursorInstaller).toEqual(expect.any(String)); + expect(cursorInstaller).toContain("__browse_cursor_overlay__"); + expect(cursorInstaller).toContain("globalThis !== globalThis.top"); + expect(cursorInstaller).toContain('"mousemove"'); + }); + + it("moves an enabled overlay from coordinate input before iframe-targeted actions", async () => { + const page = { + evaluate: vi.fn(), + hover: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.hover"]> + >[0]; + await expect( - runtimeHandlers.cursor!({} as never, {}), - ).rejects.toMatchObject({ code: "cursor_overlay_unavailable" }); + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ hovered: true }); + + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), { + x: 30, + y: 40, + }); + expect(page.evaluate.mock.invocationCallOrder[0]).toBeLessThan( + page.hover.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); }); it("selects a remaining tab after closing the active tab", async () => { From 7861d65e18721df9e625c378a39f4e09327f0d21 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:47 +0000 Subject: [PATCH 2/5] fix(cli): capture network traffic through a CDP sidecar --- .../cli/src/lib/driver/commands/network.ts | 3 +- .../cli/src/lib/driver/network-capture.ts | 151 ++++++--- .../cli/src/lib/driver/network-cdp-sidecar.ts | 307 ++++++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 14 +- packages/cli/tests/driver-commands.test.ts | 16 +- packages/cli/tests/network-capture.test.ts | 239 ++++++++++++++ .../cli/tests/network-cdp-sidecar.test.ts | 91 ++++++ 7 files changed, 764 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/lib/driver/network-cdp-sidecar.ts create mode 100644 packages/cli/tests/network-capture.test.ts create mode 100644 packages/cli/tests/network-cdp-sidecar.test.ts diff --git a/packages/cli/src/lib/driver/commands/network.ts b/packages/cli/src/lib/driver/commands/network.ts index 5887f132c9..9e52c0f727 100644 --- a/packages/cli/src/lib/driver/commands/network.ts +++ b/packages/cli/src/lib/driver/commands/network.ts @@ -3,7 +3,8 @@ import type { DriverCommandHandlers } from "./types.js"; export const networkHandlers: DriverCommandHandlers = { async "network.on"(manager) { const page = await manager.activePage(); - return manager.network.enable(page); + const websocketUrl = await manager.networkWebSocketDebuggerUrl(); + return manager.network.enable(page, websocketUrl); }, async "network.off"(manager) { diff --git a/packages/cli/src/lib/driver/network-capture.ts b/packages/cli/src/lib/driver/network-capture.ts index 921f58afb2..7da01b35dd 100644 --- a/packages/cli/src/lib/driver/network-capture.ts +++ b/packages/cli/src/lib/driver/network-capture.ts @@ -7,7 +7,10 @@ import { getNetworkDir, writePrivateFile, } from "./daemon/paths.js"; -import { DriverError } from "./errors.js"; +import { + NetworkCdpSidecar, + type NetworkCdpSession, +} from "./network-cdp-sidecar.js"; interface PendingRequest { body: string | null; @@ -26,71 +29,102 @@ interface ResponseMetadata { statusText: string; } -type CdpSession = { - off?: (event: string, listener: (...args: unknown[]) => void) => void; - on: (event: string, listener: (...args: unknown[]) => void) => void; - send: ( - method: string, - params?: Record, - ) => Promise; +type StagehandV4Page = { + pageId: string; }; +/** + * The V3 Browse network writer, adapted only at the CDP-session boundary. + * Keeping request correlation and the on-disk request/response schema here + * unchanged gives the V4 CLI observable parity without adding a public + * Stagehand network-event API. + */ export class NetworkCapture { - private cdpSession: CdpSession | null = null; + private cdpSession: NetworkCdpSession | null = null; private counter = 0; private enabled = false; + private lifecycle: Promise = Promise.resolve(); private readonly pendingRequests = new Map(); private readonly requestDirs = new Map>(); private readonly requestStartTimes = new Map(); private readonly responseMetadata = new Map(); - private readonly listeners: Array<[string, (...args: unknown[]) => void]> = - []; + private readonly listeners: Array<[string, (params: unknown) => void]> = []; private networkDir: string | null = null; - constructor(private readonly session: string) {} + constructor( + private readonly session: string, + private readonly sidecar = new NetworkCdpSidecar(), + ) {} async enable( - page: unknown, + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { - if (this.enabled && this.networkDir) { + return this.runLifecycle(() => + this.enableNow(page, browserWebSocketDebuggerUrl), + ); + } + + async disable(): Promise<{ + alreadyDisabled?: boolean; + enabled: false; + path: string | null; + }> { + return this.runLifecycle(() => this.disableNow()); + } + + private async enableNow( + page: StagehandV4Page, + browserWebSocketDebuggerUrl: string, + ): Promise<{ alreadyEnabled?: boolean; enabled: true; path: string }> { + if (this.enabled && this.networkDir && this.cdpSession?.connected) { return { alreadyEnabled: true, enabled: true, path: this.networkDir }; } - - const cdpSession = await this.networkCdpSession(page); + if (this.enabled) await this.disableNow(); await ensureRuntimeDir(); this.networkDir = getNetworkDir(this.session); await ensurePrivateDir(this.networkDir); - this.counter = 0; + this.counter = await nextRequestCounter(this.networkDir); this.pendingRequests.clear(); this.requestDirs.clear(); this.requestStartTimes.clear(); this.responseMetadata.clear(); + const cdpSession = await this.sidecar.attach( + browserWebSocketDebuggerUrl, + page.pageId, + ); this.cdpSession = cdpSession; - await cdpSession.send("Network.enable", { - maxResourceBufferSize: 5_000_000, - maxTotalBufferSize: 10_000_000, - }); + try { + await cdpSession.send("Network.enable", { + maxResourceBufferSize: 5_000_000, + maxTotalBufferSize: 10_000_000, + }); - this.addListener("Network.requestWillBeSent", (params) => { - void this.handleRequestWillBeSent(params); - }); - this.addListener("Network.responseReceived", (params) => { - this.handleResponseReceived(params); - }); - this.addListener("Network.loadingFinished", (params) => { - void this.handleLoadingFinished(params); - }); - this.addListener("Network.loadingFailed", (params) => { - void this.handleLoadingFailed(params); - }); + this.addListener("Network.requestWillBeSent", (params) => { + void this.handleRequestWillBeSent(params); + }); + this.addListener("Network.responseReceived", (params) => { + this.handleResponseReceived(params); + }); + this.addListener("Network.loadingFinished", (params) => { + void this.handleLoadingFinished(params); + }); + this.addListener("Network.loadingFailed", (params) => { + void this.handleLoadingFailed(params); + }); + } catch (error) { + this.cdpSession = null; + await cdpSession.detach().catch(() => undefined); + throw error; + } this.enabled = true; return { enabled: true, path: this.networkDir }; } - async disable(): Promise<{ + private async disableNow(): Promise<{ alreadyDisabled?: boolean; enabled: false; path: string | null; @@ -99,20 +133,36 @@ export class NetworkCapture { return { alreadyDisabled: true, enabled: false, path: this.networkDir }; } + const cdpSession = this.cdpSession; + this.enabled = false; for (const [event, listener] of this.listeners) { - this.cdpSession?.off?.(event, listener); + cdpSession?.off(event, listener); } this.listeners.length = 0; - await this.cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.send("Network.disable").catch(() => undefined); + await cdpSession?.detach().catch(() => undefined); this.cdpSession = null; - this.enabled = false; return { enabled: false, path: this.networkDir }; } + private runLifecycle(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async close(): Promise { + await this.disable().catch(() => undefined); + this.sidecar.close(); + } + path(): { enabled: boolean; path: string } { return { - enabled: this.enabled, + enabled: this.enabled && (this.cdpSession?.connected ?? false), path: this.networkDir ?? getNetworkDir(this.session), }; } @@ -146,20 +196,12 @@ export class NetworkCapture { private addListener( event: string, - listener: (...args: unknown[]) => void, + listener: (params: unknown) => void, ): void { this.cdpSession?.on(event, listener); this.listeners.push([event, listener]); } - private async networkCdpSession(page: unknown): Promise { - void page; - throw new DriverError( - "Network capture is not available in this Stagehand V4 runtime. Apply the CLI CDP sidecar fast-follow to restore `browse network on`.", - { code: "network_capture_unavailable" }, - ); - } - private handleRequestWillBeSent(params: unknown): void { if (!this.enabled || !this.networkDir) return; const event = params as { @@ -330,6 +372,21 @@ function getRequestDirName( } } +async function nextRequestCounter(networkDir: string): Promise { + const entries = await fs.readdir(networkDir, { withFileTypes: true }); + let next = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const match = /^(\d+)-/u.exec(entry.name); + if (!match) continue; + const rawCounter = match[1]; + if (!rawCounter) continue; + const counter = Number.parseInt(rawCounter, 10); + if (Number.isSafeInteger(counter)) next = Math.max(next, counter + 1); + } + return next; +} + function sanitizeForFilename(value: string, maxLen: number): string { return value .replace(/[^a-zA-Z0-9.-]/g, "-") diff --git a/packages/cli/src/lib/driver/network-cdp-sidecar.ts b/packages/cli/src/lib/driver/network-cdp-sidecar.ts new file mode 100644 index 0000000000..76b4ce4729 --- /dev/null +++ b/packages/cli/src/lib/driver/network-cdp-sidecar.ts @@ -0,0 +1,307 @@ +import WebSocket from "ws"; + +import { DriverError } from "./errors.js"; + +type CdpEventListener = (params: unknown) => void; + +interface CdpMessage { + error?: { code: number; message: string }; + id?: number; + method?: string; + params?: unknown; + result?: unknown; + sessionId?: string; +} + +interface PendingCommand { + method: string; + reject: (error: Error) => void; + resolve: (result: unknown) => void; +} + +export interface NetworkCdpSession { + readonly connected: boolean; + detach(): Promise; + off(event: string, listener: CdpEventListener): void; + on(event: string, listener: CdpEventListener): void; + send( + method: string, + params?: Record, + ): Promise; +} + +export type NetworkCdpWebSocketFactory = (url: string) => WebSocket; + +/** + * A CLI-owned browser-level CDP connection used only for V3-compatible + * network capture. The connection is intentionally kept alive across + * `network off`/`network on`: Browserbase treats closing an auxiliary browser + * WebSocket as a browser-session disconnect. It is closed only with the Browse + * driver session. + */ +export class NetworkCdpSidecar { + private connecting: Promise | null = null; + private nextId = 1; + private readonly pending = new Map(); + private readonly sessions = new Map< + string, + Map> + >(); + private socket: WebSocket | null = null; + private websocketUrl: string | null = null; + + constructor( + private readonly createWebSocket: NetworkCdpWebSocketFactory = (url) => + new WebSocket(url), + ) {} + + async attach( + websocketUrl: string, + targetId: string, + ): Promise { + await this.ensureConnected(websocketUrl); + const { sessionId } = await this.sendCommand<{ sessionId: string }>( + "Target.attachToTarget", + { flatten: true, targetId }, + ); + this.sessions.set(sessionId, new Map()); + return new AttachedNetworkCdpSession(this, sessionId); + } + + close(): void { + const socket = this.socket; + this.disconnect( + socket, + new DriverError("Network capture CDP sidecar closed.", { + code: "network_sidecar_closed", + }), + ); + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + socket.close(); + } + } + + hasSession(sessionId: string): boolean { + return ( + this.socket?.readyState === WebSocket.OPEN && this.sessions.has(sessionId) + ); + } + + on(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + if (!listeners) return; + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(listener); + listeners.set(event, eventListeners); + } + + off(sessionId: string, event: string, listener: CdpEventListener): void { + const listeners = this.sessions.get(sessionId); + const eventListeners = listeners?.get(event); + eventListeners?.delete(listener); + if (eventListeners?.size === 0) listeners?.delete(event); + } + + async sendToSession( + sessionId: string, + method: string, + params: Record = {}, + ): Promise { + if (!this.sessions.has(sessionId)) { + throw new DriverError("Network capture CDP session is detached.", { + code: "network_sidecar_detached", + }); + } + return this.sendCommand(method, params, sessionId); + } + + async detach(sessionId: string): Promise { + if (!this.sessions.has(sessionId)) return; + this.sessions.delete(sessionId); + await this.sendCommand("Target.detachFromTarget", { sessionId }).catch( + () => undefined, + ); + } + + private async ensureConnected(websocketUrl: string): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + if (this.websocketUrl !== websocketUrl) { + throw new DriverError( + "The browser CDP endpoint changed during the Browse session.", + { code: "network_sidecar_endpoint_changed" }, + ); + } + return; + } + if (this.connecting) { + await this.connecting; + return; + } + + const connecting = this.open(websocketUrl); + this.connecting = connecting; + try { + await connecting; + } finally { + if (this.connecting === connecting) this.connecting = null; + } + } + + private async open(websocketUrl: string): Promise { + let socket: WebSocket; + try { + socket = this.createWebSocket(websocketUrl); + } catch (error) { + throw connectionError(error); + } + this.socket = socket; + this.websocketUrl = websocketUrl; + + await new Promise((resolve, reject) => { + let opened = false; + socket.on("message", (raw: WebSocket.RawData) => { + this.handleMessage(socket, raw); + }); + socket.on("error", (error: Error) => { + const failure = connectionError(error); + this.disconnect(socket, failure); + if (!opened) reject(failure); + }); + socket.on("close", () => { + const error = new DriverError( + "Network capture CDP sidecar disconnected.", + { code: "network_sidecar_disconnected" }, + ); + this.disconnect(socket, error); + if (!opened) reject(connectionError(error)); + }); + socket.once("open", () => { + opened = true; + resolve(); + }); + }); + } + + private sendCommand( + method: string, + params: Record = {}, + sessionId?: string, + ): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return Promise.reject( + new DriverError("Network capture CDP sidecar is not connected.", { + code: "network_sidecar_disconnected", + }), + ); + } + + const id = this.nextId++; + const message = sessionId + ? { id, method, params, sessionId } + : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { + method, + reject, + resolve: (result) => resolve(result as T), + }); + try { + socket.send(JSON.stringify(message)); + } catch (error) { + this.pending.delete(id); + reject(commandError(method, error)); + } + }); + } + + private handleMessage(socket: WebSocket, raw: WebSocket.RawData): void { + if (this.socket !== socket) return; + let message: CdpMessage; + try { + message = JSON.parse(raw.toString()) as CdpMessage; + } catch { + return; + } + + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(commandError(pending.method, message.error.message)); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "Target.detachedFromTarget") { + const detachedSessionId = (message.params as { sessionId?: string }) + ?.sessionId; + if (detachedSessionId) this.sessions.delete(detachedSessionId); + return; + } + + if (!message.sessionId || !message.method) return; + const listeners = this.sessions.get(message.sessionId)?.get(message.method); + for (const listener of listeners ?? []) listener(message.params); + } + + private disconnect(socket: WebSocket | null, error: Error): void { + if (!socket || this.socket !== socket) return; + this.socket = null; + this.websocketUrl = null; + this.sessions.clear(); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +class AttachedNetworkCdpSession implements NetworkCdpSession { + constructor( + private readonly sidecar: NetworkCdpSidecar, + private readonly sessionId: string, + ) {} + + get connected(): boolean { + return this.sidecar.hasSession(this.sessionId); + } + + detach(): Promise { + return this.sidecar.detach(this.sessionId); + } + + off(event: string, listener: CdpEventListener): void { + this.sidecar.off(this.sessionId, event, listener); + } + + on(event: string, listener: CdpEventListener): void { + this.sidecar.on(this.sessionId, event, listener); + } + + send( + method: string, + params?: Record, + ): Promise { + return this.sidecar.sendToSession(this.sessionId, method, params); + } +} + +function connectionError(cause: unknown): DriverError { + return new DriverError("Failed to connect the network capture CDP sidecar.", { + cause, + code: "network_sidecar_connect_failed", + }); +} + +function commandError(method: string, cause: unknown): DriverError { + return new DriverError(`Network capture CDP command ${method} failed.`, { + cause, + code: "network_sidecar_command_failed", + }); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 4e9381b70e..8c8aae72d3 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -161,6 +161,18 @@ export class DriverSessionManager { return this.stagehand; } + async networkWebSocketDebuggerUrl(): Promise { + const stagehand = await this.stagehandInstance(); + const websocketUrl = stagehand.rpcClient?.browserWebSocketDebuggerUrl; + if (!websocketUrl) { + throw new DriverError( + "Stagehand did not expose the browser CDP endpoint required for network capture.", + { code: "network_sidecar_endpoint_unavailable" }, + ); + } + return websocketUrl; + } + async status(): Promise { if (!this.stagehand || !this.context) { return { @@ -212,7 +224,7 @@ export class DriverSessionManager { this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; - await this.network.disable().catch(() => undefined); + await this.network.close().catch(() => undefined); if (stagehand) { await stagehand.close().catch(() => undefined); } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 6861b6d2b3..dc55048ee9 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -457,24 +457,24 @@ describe("driver commands", () => { } }); - it("reports the isolated V4 network-capture gap", async () => { + it("enables sidecar network capture", async () => { const page = {}; const network = { - enable: vi.fn(async () => { - throw new Error("Network capture is not available"); - }), + enable: vi.fn(async () => ({ enabled: true, path: "/tmp/network" })), }; const manager = { activePage: vi.fn(async () => page), network, + networkWebSocketDebuggerUrl: vi.fn(async () => "ws://sidecar.test"), } as unknown as Parameters< NonNullable<(typeof networkHandlers)["network.on"]> >[0]; - await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( - "Network capture is not available", - ); - expect(network.enable).toHaveBeenCalledWith(page); + await expect(networkHandlers["network.on"]!(manager, {})).resolves.toEqual({ + enabled: true, + path: "/tmp/network", + }); + expect(network.enable).toHaveBeenCalledWith(page, "ws://sidecar.test"); }); it("installs the CLI-owned cursor overlay", async () => { diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts new file mode 100644 index 0000000000..6fe871351d --- /dev/null +++ b/packages/cli/tests/network-capture.test.ts @@ -0,0 +1,239 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + NetworkCdpSession, + NetworkCdpSidecar, +} from "../src/lib/driver/network-cdp-sidecar.js"; +import { NetworkCapture } from "../src/lib/driver/network-capture.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length > 0) { + const cleanupPath = cleanupPaths.pop(); + if (cleanupPath) { + await fs.rm(cleanupPath, { force: true, recursive: true }); + } + } +}); + +describe("NetworkCapture", () => { + it("preserves the V3 files and keeps its sidecar alive across on/off/on", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-sidecar-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "sidecar", + sidecar as unknown as NetworkCdpSidecar, + ); + const originalWriteFile = fs.writeFile.bind(fs); + const writeFileSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation(async (...args) => { + if (String(args[0]).endsWith("request.json")) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return originalWriteFile(...args); + }); + + try { + await expect( + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ).resolves.toMatchObject({ enabled: true }); + expect(sidecar.attach).toHaveBeenCalledWith( + "ws://browser.test", + "page-1", + ); + + session.emit("Network.requestWillBeSent", { + request: { + headers: { accept: "text/plain" }, + method: "POST", + postData: "hello=world", + url: "https://example.com/fast", + }, + requestId: "request-1", + type: "Fetch", + }); + session.emit("Network.responseReceived", { + requestId: "request-1", + response: { + headers: { "content-type": "text/plain" }, + mimeType: "text/plain", + status: 200, + statusText: "OK", + }, + }); + session.emit("Network.loadingFinished", { requestId: "request-1" }); + + const requestDir = join( + daemonDir, + "sidecar-network", + "000-POST-example.com-fast", + ); + const responsePath = join(requestDir, "response.json"); + await waitForFile(responsePath); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + body: "hello=world", + method: "POST", + resourceType: "Fetch", + url: "https://example.com/fast", + }); + await expect(readJson(responsePath)).resolves.toMatchObject({ + body: "ok", + mimeType: "text/plain", + status: 200, + statusText: "OK", + }); + + await expect(capture.disable()).resolves.toMatchObject({ + enabled: false, + }); + expect(session.detach).toHaveBeenCalledOnce(); + expect(sidecar.close).not.toHaveBeenCalled(); + + session.connected = true; + await capture.enable({ pageId: "page-2" }, "ws://browser.test"); + expect(sidecar.attach).toHaveBeenLastCalledWith( + "ws://browser.test", + "page-2", + ); + expect(sidecar.attach).toHaveBeenCalledTimes(2); + + session.emit("Network.requestWillBeSent", { + request: { + method: "POST", + url: "https://example.com/fast", + }, + requestId: "request-2", + type: "Fetch", + }); + session.emit("Network.loadingFinished", { requestId: "request-2" }); + await waitForFile( + join( + daemonDir, + "sidecar-network", + "001-POST-example.com-fast", + "response.json", + ), + ); + await expect( + readJson(join(requestDir, "request.json")), + ).resolves.toMatchObject({ + id: "request-1", + }); + + await capture.close(); + expect(sidecar.close).toHaveBeenCalledOnce(); + } finally { + writeFileSpy.mockRestore(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); + + it("serializes overlapping enable operations", async () => { + const daemonDir = await fs.mkdtemp( + join(tmpdir(), "browse-network-overlap-"), + ); + cleanupPaths.push(daemonDir); + const previousDaemonDir = process.env.BROWSE_DAEMON_DIR; + process.env.BROWSE_DAEMON_DIR = daemonDir; + const session = new FakeCdpSession(); + const sidecar = { + attach: vi.fn(async () => session), + close: vi.fn(), + }; + const capture = new NetworkCapture( + "overlap", + sidecar as unknown as NetworkCdpSidecar, + ); + + try { + const [first, second] = await Promise.all([ + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + capture.enable({ pageId: "page-1" }, "ws://browser.test"), + ]); + + expect(first).toMatchObject({ enabled: true }); + expect(second).toMatchObject({ alreadyEnabled: true, enabled: true }); + expect(sidecar.attach).toHaveBeenCalledOnce(); + } finally { + await capture.close(); + restoreEnv("BROWSE_DAEMON_DIR", previousDaemonDir); + } + }); +}); + +class FakeCdpSession implements NetworkCdpSession { + connected = true; + readonly detach = vi.fn(async () => { + this.connected = false; + }); + private readonly listeners = new Map< + string, + Set<(params: unknown) => void> + >(); + + async send(method: string): Promise { + if (method === "Network.getResponseBody") { + return { body: "ok" } as T; + } + return {} as T; + } + + on(event: string, listener: (params: unknown) => void): void { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + } + + off(event: string, listener: (params: unknown) => void): void { + this.listeners.get(event)?.delete(listener); + } + + emit(event: string, params: unknown): void { + for (const listener of this.listeners.get(event) ?? []) listener(params); + } +} + +async function readJson(filePath: string): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< + string, + unknown + >; +} + +async function waitForFile(filePath: string): Promise { + const start = Date.now(); + while (Date.now() - start < 1_000) { + try { + await fs.access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} diff --git a/packages/cli/tests/network-cdp-sidecar.test.ts b/packages/cli/tests/network-cdp-sidecar.test.ts new file mode 100644 index 0000000000..5692183d28 --- /dev/null +++ b/packages/cli/tests/network-cdp-sidecar.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; + +import type WebSocket from "ws"; +import { describe, expect, it, vi } from "vitest"; + +import { NetworkCdpSidecar } from "../src/lib/driver/network-cdp-sidecar.js"; + +describe("NetworkCdpSidecar", () => { + it("reuses one browser WebSocket across detached page sessions", async () => { + const socket = new FakeWebSocket(); + const factory = vi.fn(() => { + queueMicrotask(() => socket.open()); + return socket as unknown as WebSocket; + }); + const sidecar = new NetworkCdpSidecar(factory); + + const first = await sidecar.attach("ws://browser.test", "page-1"); + const listener = vi.fn(); + first.on("Network.requestWillBeSent", listener); + socket.emitCdp({ + method: "Network.requestWillBeSent", + params: { requestId: "request-1" }, + sessionId: "sidecar-session-1", + }); + + expect(listener).toHaveBeenCalledWith({ requestId: "request-1" }); + await expect(first.send("Network.enable")).resolves.toEqual({}); + await first.detach(); + expect(first.connected).toBe(false); + + const second = await sidecar.attach("ws://browser.test", "page-2"); + expect(second.connected).toBe(true); + expect(factory).toHaveBeenCalledOnce(); + expect(socket.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-1" }, + }), + expect.objectContaining({ + method: "Target.detachFromTarget", + params: { sessionId: "sidecar-session-1" }, + }), + expect.objectContaining({ + method: "Target.attachToTarget", + params: { flatten: true, targetId: "page-2" }, + }), + ]), + ); + + sidecar.close(); + expect(socket.closed).toBe(true); + }); +}); + +class FakeWebSocket extends EventEmitter { + closed = false; + readyState = 0; + readonly sent: Array<{ + id: number; + method: string; + params: Record; + sessionId?: string; + }> = []; + private sessionCounter = 0; + + open(): void { + this.readyState = 1; + this.emit("open"); + } + + send(raw: string): void { + const message = JSON.parse(raw) as (typeof this.sent)[number]; + this.sent.push(message); + const result = + message.method === "Target.attachToTarget" + ? { sessionId: `sidecar-session-${++this.sessionCounter}` } + : {}; + queueMicrotask(() => this.emitCdp({ id: message.id, result })); + } + + close(): void { + this.closed = true; + this.readyState = 3; + this.emit("close"); + } + + emitCdp(message: Record): void { + this.emit("message", Buffer.from(JSON.stringify(message))); + } +} From eb42f45eaac6a43a35e65c608cd1ccc698810665 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 20:17:44 +0000 Subject: [PATCH 3/5] test(cli): include network wait timeout duration --- packages/cli/tests/network-capture.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/network-capture.test.ts b/packages/cli/tests/network-capture.test.ts index 6fe871351d..6a25da8348 100644 --- a/packages/cli/tests/network-capture.test.ts +++ b/packages/cli/tests/network-capture.test.ts @@ -218,8 +218,9 @@ async function readJson(filePath: string): Promise> { } async function waitForFile(filePath: string): Promise { + const timeoutMs = 1_000; const start = Date.now(); - while (Date.now() - start < 1_000) { + while (Date.now() - start < timeoutMs) { try { await fs.access(filePath); return; @@ -227,7 +228,7 @@ async function waitForFile(filePath: string): Promise { await new Promise((resolve) => setTimeout(resolve, 10)); } } - throw new Error(`Timed out waiting for ${filePath}`); + throw new Error(`Timed out waiting for ${filePath} after ${timeoutMs}ms`); } function restoreEnv(key: string, value: string | undefined): void { From 210433cb8e570262a8c3ab0160237408b1ddedf3 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:15:08 +0000 Subject: [PATCH 4/5] refactor(cli): remove coordinate XPath return flag --- .changeset/brave-browsers-migrate.md | 5 ++++ packages/cli/README.md | 2 +- packages/cli/src/commands/mouse/click.ts | 7 +---- packages/cli/src/commands/mouse/drag.ts | 6 ----- packages/cli/src/commands/mouse/hover.ts | 8 +----- packages/cli/src/commands/mouse/scroll.ts | 8 +----- packages/cli/src/lib/driver/commands/mouse.ts | 26 ++++++------------- packages/cli/tests/driver-commands.test.ts | 18 ++++++++++--- 8 files changed, 31 insertions(+), 49 deletions(-) create mode 100644 .changeset/brave-browsers-migrate.md diff --git a/.changeset/brave-browsers-migrate.md b/.changeset/brave-browsers-migrate.md new file mode 100644 index 0000000000..608dbe48d8 --- /dev/null +++ b/.changeset/brave-browsers-migrate.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +migrate the Browse CLI runtime to Stagehand V4 and remove the `--return-xpath` option from coordinate actions diff --git a/packages/cli/README.md b/packages/cli/README.md index 416dc1b3a9..58e8b5b142 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -111,7 +111,7 @@ browse highlight @0-12 # Highlight an element (--duratio ### Mouse (raw coordinates) -Use these when you need pixel coordinates instead of a ref. Add `--return-xpath` to get the XPath under the cursor. +Use these when you need pixel coordinates instead of a ref. ```bash browse mouse click 240 320 # Click coordinates (--button, --click-count) diff --git a/packages/cli/src/commands/mouse/click.ts b/packages/cli/src/commands/mouse/click.ts index ad0f21d35b..32a981c6bf 100644 --- a/packages/cli/src/commands/mouse/click.ts +++ b/packages/cli/src/commands/mouse/click.ts @@ -15,7 +15,7 @@ export default class MouseClick extends BrowseCommand { static override examples = [ "browse mouse click 240 320", "browse mouse click 240 320 --button right", - "browse mouse click 240 320 --click-count 2 --return-xpath", + "browse mouse click 240 320 --click-count 2", ]; static override args = { @@ -31,10 +31,6 @@ export default class MouseClick extends BrowseCommand { description: "Number of clicks to send.", helpValue: "", }), - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -44,7 +40,6 @@ export default class MouseClick extends BrowseCommand { { button: flags.button, clickCount: flags["click-count"], - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/commands/mouse/drag.ts b/packages/cli/src/commands/mouse/drag.ts index d2a57cff0d..a056abf05f 100644 --- a/packages/cli/src/commands/mouse/drag.ts +++ b/packages/cli/src/commands/mouse/drag.ts @@ -15,7 +15,6 @@ export default class MouseDrag extends BrowseCommand { static override examples = [ "browse mouse drag 100 100 400 400", "browse mouse drag 100 100 400 400 --steps 20 --delay 10", - "browse mouse drag 100 100 400 400 --return-xpath", ]; static override args = { @@ -45,10 +44,6 @@ export default class MouseDrag extends BrowseCommand { description: "Delay between drag steps in milliseconds.", helpValue: "", }), - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the start/end coordinates when the driver can return it.", - }), steps: Flags.integer({ default: 10, description: "Number of intermediate drag steps.", @@ -65,7 +60,6 @@ export default class MouseDrag extends BrowseCommand { delay: flags.delay, fromX: parseNumber(args.fromX, "fromX"), fromY: parseNumber(args.fromY, "fromY"), - returnXPath: flags["return-xpath"], steps: flags.steps, toX: parseNumber(args.toX, "toX"), toY: parseNumber(args.toY, "toY"), diff --git a/packages/cli/src/commands/mouse/hover.ts b/packages/cli/src/commands/mouse/hover.ts index 16c7d537bb..ed7fd2d03a 100644 --- a/packages/cli/src/commands/mouse/hover.ts +++ b/packages/cli/src/commands/mouse/hover.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { BrowseCommand } from "../../base.js"; import { @@ -13,7 +13,6 @@ export default class MouseHover extends BrowseCommand { static override examples = [ "browse mouse hover 240 320", - "browse mouse hover 240 320 --return-xpath", "browse mouse hover 240 320 --session research", ]; @@ -24,10 +23,6 @@ export default class MouseHover extends BrowseCommand { static override flags = { ...driverCommandFlags, - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -35,7 +30,6 @@ export default class MouseHover extends BrowseCommand { await runDriverCommandFromFlags( "mouse.hover", { - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/commands/mouse/scroll.ts b/packages/cli/src/commands/mouse/scroll.ts index 882d963826..9a135c7833 100644 --- a/packages/cli/src/commands/mouse/scroll.ts +++ b/packages/cli/src/commands/mouse/scroll.ts @@ -1,4 +1,4 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { BrowseCommand } from "../../base.js"; import { @@ -14,7 +14,6 @@ export default class MouseScroll extends BrowseCommand { static override examples = [ "browse mouse scroll 400 500 0 600", "browse mouse scroll 400 500 0 -600", - "browse mouse scroll 400 500 0 600 --return-xpath", ]; static override args = { @@ -32,10 +31,6 @@ export default class MouseScroll extends BrowseCommand { static override flags = { ...driverCommandFlags, - "return-xpath": Flags.boolean({ - description: - "Include the XPath under the coordinate when the driver can return it.", - }), }; async run(): Promise { @@ -45,7 +40,6 @@ export default class MouseScroll extends BrowseCommand { { deltaX: parseNumber(args.deltaX, "deltaX"), deltaY: parseNumber(args.deltaY, "deltaY"), - returnXPath: flags["return-xpath"], x: parseNumber(args.x, "x"), y: parseNumber(args.y, "y"), }, diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index cb0c309562..ae958a6b67 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -8,16 +8,15 @@ const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); export const mouseHandlers: DriverCommandHandlers = { async "mouse.click"(manager, params) { - const { button, clickCount, returnXPath, x, y } = z + const { button, clickCount, x, y } = z .object({ button: ButtonSchema, clickCount: z.number().int().positive().optional(), - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.click(x, y, { @@ -28,14 +27,13 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.hover"(manager, params) { - const { returnXPath, x, y } = z + const { x, y } = z .object({ - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.hover(x, y); @@ -43,16 +41,15 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.scroll"(manager, params) { - const { deltaX, deltaY, returnXPath, x, y } = z + const { deltaX, deltaY, x, y } = z .object({ deltaX: z.number(), deltaY: z.number(), - returnXPath: z.boolean().optional(), x: z.number(), y: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, x, y); await page.scroll(x, y, deltaX, deltaY); @@ -60,19 +57,18 @@ export const mouseHandlers: DriverCommandHandlers = { }, async "mouse.drag"(manager, params) { - const { button, delay, fromX, fromY, returnXPath, steps, toX, toY } = z + const { button, delay, fromX, fromY, steps, toX, toY } = z .object({ button: ButtonSchema, delay: z.number().int().nonnegative().optional(), fromX: z.number(), fromY: z.number(), - returnXPath: z.boolean().optional(), steps: z.number().int().positive().optional(), toX: z.number(), toY: z.number(), }) + .strict() .parse(params); - assertXPathUnavailable(returnXPath); const page = await manager.activePage(); await positionCursorOverlay(manager, page, fromX, fromY); await page.dragAndDrop(fromX, fromY, toX, toY, { @@ -97,9 +93,3 @@ async function positionCursorOverlay( if (!manager.isCursorOverlayEnabled(page)) return; await page.evaluate(updateCursorOverlayPosition, { x, y }); } - -function assertXPathUnavailable(returnXPath: boolean | undefined): void { - if (returnXPath) { - throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); - } -} diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index dc55048ee9..57c218bbe3 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -440,19 +440,22 @@ describe("driver commands", () => { expect(page.evaluate).toHaveBeenCalledTimes(2); }); - it("fails explicitly for the V4 coordinate XPath capability", async () => { + it("rejects removed coordinate XPath fields at the driver boundary", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; for (const [command, params] of [ ["mouse.click", { returnXPath: true, x: 1, y: 2 }], - ["mouse.hover", { returnXPath: true, x: 1, y: 2 }], + ["mouse.hover", { returnXPath: false, x: 1, y: 2 }], ["mouse.scroll", { deltaX: 0, deltaY: 1, returnXPath: true, x: 1, y: 2 }], - ["mouse.drag", { fromX: 1, fromY: 2, returnXPath: true, toX: 3, toY: 4 }], + [ + "mouse.drag", + { fromX: 1, fromY: 2, returnXPath: false, toX: 3, toY: 4 }, + ], ] as const) { await expect(mouseHandlers[command]!(manager, params)).rejects.toThrow( - "Coordinate XPath lookup is not exposed by Stagehand V4", + /returnXPath/, ); } }); @@ -792,6 +795,13 @@ describe("driver commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Prefer targetId"); }); + it("does not expose the removed coordinate XPath flag", async () => { + for (const command of ["click", "hover", "scroll", "drag"]) { + const result = await runCli(["mouse", command, "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("--return-xpath"); + } + }); }); type FakeTabPage = { From d57f6ffdb1b128d5c0462ac4316360a092de50f1 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 17:39:36 +0000 Subject: [PATCH 5/5] test(evals): consume the workspace Browse CLI Pin eval agents to the V4 workspace CLI, isolate per-run sessions, restrict the wrapper to driver commands, and guarantee cleanup on abort and process shutdown. --- packages/evals/browseCliPaths.ts | 6 + packages/evals/core/tools/browse_cli.ts | 104 ++++---- packages/evals/framework/benchHarness.ts | 16 +- .../evals/framework/claudeCodeToolAdapter.ts | 194 ++++++++++----- packages/evals/package.json | 2 +- .../tests/core/browse-cli-adapter.test.ts | 116 +++++++++ .../framework/claudeCodeToolAdapter.test.ts | 51 +++- packages/evals/tests/tui/run.test.ts | 5 +- pnpm-lock.yaml | 224 ++++++------------ 9 files changed, 450 insertions(+), 268 deletions(-) create mode 100644 packages/evals/tests/core/browse-cli-adapter.test.ts diff --git a/packages/evals/browseCliPaths.ts b/packages/evals/browseCliPaths.ts index fe2ca89e3b..817b1bbb04 100644 --- a/packages/evals/browseCliPaths.ts +++ b/packages/evals/browseCliPaths.ts @@ -10,3 +10,9 @@ export const BROWSE_CLI_BUILD_ARTIFACTS = [ ]; export const BROWSE_CLI_PACKAGE_JSON = path.join(browseCliRoot, "package.json"); export const BROWSE_SKILL_SOURCE = path.join(browseCliRoot, "skills", "browse", "SKILL.md"); + +export function createBrowseCliSessionName(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).slice(2, 6); + return `eval-${process.pid}-${timestamp}-${random}`; +} diff --git a/packages/evals/core/tools/browse_cli.ts b/packages/evals/core/tools/browse_cli.ts index 4e3d7371f3..9e09cbe1d8 100644 --- a/packages/evals/core/tools/browse_cli.ts +++ b/packages/evals/core/tools/browse_cli.ts @@ -14,7 +14,11 @@ import type { ToolStartInput, ToolStartResult, } from "../contracts/tool.js"; -import { BROWSE_CLI_BUILD_ARTIFACTS, BROWSE_CLI_ENTRYPOINT } from "../../browseCliPaths.js"; +import { + BROWSE_CLI_BUILD_ARTIFACTS, + BROWSE_CLI_ENTRYPOINT, + createBrowseCliSessionName, +} from "../../browseCliPaths.js"; import { getRepoRootDir } from "../../runtimePaths.js"; const execFileAsync = promisify(execFile); @@ -79,25 +83,38 @@ function buildSelectorQuery(selector: string): string { `; } -type BrowseCliPagesResult = { - pages: Array<{ +type BrowseCliTabsResult = { + tabs: Array<{ index: number; url: string; - targetId: string; + targetId?: string; }>; }; +export function buildBrowseCliProcessArgs( + entrypoint: string, + session: string, + args: string[], +): string[] { + return [entrypoint, ...args, "--session", session]; +} + +export function browseCliStartupArgs(environment: ToolStartInput["environment"]): string[] { + return ["open", "about:blank", environment === "BROWSERBASE" ? "--remote" : "--local"]; +} + class BrowseCliRuntime { constructor(private readonly session: string) {} - async runJson(args: string[]): Promise { + async runJson(args: string[], timeoutMs?: number): Promise { const { stdout, stderr } = await execFileAsync( process.execPath, - [resolveBrowseCliEntrypoint(), "--json", "--session", this.session, ...args], + buildBrowseCliProcessArgs(resolveBrowseCliEntrypoint(), this.session, args), { cwd: getRepoRootDir(), env: process.env, maxBuffer: 10 * 1024 * 1024, + ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }), }, ); @@ -162,7 +179,7 @@ class BrowseCliLocatorHandle implements CoreLocatorHandle { } } -class BrowseCliPageHandle implements CorePageHandle { +export class BrowseCliPageHandle implements CorePageHandle { constructor( private readonly session: BrowseCliSession, readonly id: string, @@ -208,7 +225,7 @@ class BrowseCliPageHandle implements CorePageHandle { args.push("--wait", opts.waitUntil); } if (typeof opts?.timeoutMs === "number") { - args.push("-t", String(opts.timeoutMs)); + args.push("--timeout", String(opts.timeoutMs)); } const result = await this.runCommandAfterSelecting<{ url: string }>(args); this.cachedUrl = result.url; @@ -280,15 +297,15 @@ class BrowseCliPageHandle implements CorePageHandle { type?: "png" | "jpeg"; quality?: number; }): Promise { - const args = ["screenshot"]; + const args = ["screenshot", "--base64"]; if (opts?.fullPage) { - args.push("-f"); + args.push("--full-page"); } if (opts?.type) { - args.push("-t", opts.type); + args.push("--type", opts.type); } if (typeof opts?.quality === "number") { - args.push("-q", String(opts.quality)); + args.push("--quality", String(opts.quality)); } const result = await this.runCommandAfterSelecting<{ base64: string }>(args); @@ -310,9 +327,9 @@ class BrowseCliPageHandle implements CorePageHandle { "wait", "selector", spec.selector, - "-t", + "--timeout", String(spec.timeoutMs ?? 30_000), - "-s", + "--state", spec.state ?? "visible", ]); return; @@ -324,7 +341,7 @@ class BrowseCliPageHandle implements CorePageHandle { "wait", "load", spec.state, - "-t", + "--timeout", String(spec.timeoutMs ?? 30_000), ]); return; @@ -372,7 +389,7 @@ class BrowseCliPageHandle implements CorePageHandle { if (typeof y !== "number") { throw new Error("click(x, y) requires both numeric coordinates"); } - await this.runCommandAfterSelecting(["click_xy", String(targetOrX), String(y)]); + await this.runCommandAfterSelecting(["mouse", "click", String(targetOrX), String(y)]); return; } @@ -387,7 +404,7 @@ class BrowseCliPageHandle implements CorePageHandle { await this.runCommandAfterSelecting(["click", this.refSelector(target.value)]); return; case "coords": - await this.runCommandAfterSelecting(["click_xy", String(target.x), String(target.y)]); + await this.runCommandAfterSelecting(["mouse", "click", String(target.x), String(target.y)]); return; default: throw new Error(`browse_cli does not support click target kind "${target.kind}" yet`); @@ -399,7 +416,7 @@ class BrowseCliPageHandle implements CorePageHandle { if (typeof y !== "number") { throw new Error("hover(x, y) requires both numeric coordinates"); } - await this.runCommandAfterSelecting(["hover", String(targetOrX), String(y)]); + await this.runCommandAfterSelecting(["mouse", "hover", String(targetOrX), String(y)]); return; } @@ -409,11 +426,11 @@ class BrowseCliPageHandle implements CorePageHandle { switch (target.kind) { case "selector": { const point = await this.resolveHoverPoint(target.value); - await this.runCommandAfterSelecting(["hover", String(point.x), String(point.y)]); + await this.runCommandAfterSelecting(["mouse", "hover", String(point.x), String(point.y)]); return; } case "coords": - await this.runCommandAfterSelecting(["hover", String(target.x), String(target.y)]); + await this.runCommandAfterSelecting(["mouse", "hover", String(target.x), String(target.y)]); return; default: throw new Error(`browse_cli does not support hover target kind "${target.kind}" yet`); @@ -422,6 +439,7 @@ class BrowseCliPageHandle implements CorePageHandle { async scroll(x: number, y: number, deltaX: number, deltaY: number): Promise { await this.runCommandAfterSelecting([ + "mouse", "scroll", String(x), String(y), @@ -450,7 +468,7 @@ class BrowseCliPageHandle implements CorePageHandle { await this.runCommandAfterSelecting(["type", text]); return; case "selector": - await this.runCommandAfterSelecting(["fill", target.value, text, "--no-press-enter"]); + await this.runCommandAfterSelecting(["fill", target.value, text]); return; default: throw new Error(`browse_cli does not support type target kind "${target.kind}" yet`); @@ -485,7 +503,7 @@ class BrowseCliPageHandle implements CorePageHandle { await this.runCommandAfterSelecting(["press", key]); return; case "coords": - await this.runCommandAfterSelecting(["click_xy", String(target.x), String(target.y)]); + await this.runCommandAfterSelecting(["mouse", "click", String(target.x), String(target.y)]); await this.runCommandAfterSelecting(["press", key]); return; default: @@ -519,7 +537,7 @@ class BrowseCliPageHandle implements CorePageHandle { } } -class BrowseCliSession implements CoreSession { +export class BrowseCliSession implements CoreSession { readonly runtime: BrowseCliRuntime; private readonly handles = new Map(); private activePageId: string | null = null; @@ -541,9 +559,14 @@ class BrowseCliSession implements CoreSession { return handle; } - private async fetchPages(): Promise { - const result = await this.runtime.runJson(["pages"]); - const pages = result.pages ?? []; + private async fetchPages(): Promise> { + const result = await this.runtime.runJson(["tab", "list"]); + const pages = (result.tabs ?? []).map((tab) => { + if (!tab.targetId) { + throw new Error(`browse tab list returned no targetId for tab index ${tab.index}`); + } + return { ...tab, targetId: tab.targetId }; + }); for (const page of pages) { this.wrap(page); @@ -583,12 +606,11 @@ class BrowseCliSession implements CoreSession { } async newPage(url?: string): Promise { - const args = ["newpage"]; + const args = ["tab", "new"]; if (url) { args.push(url); } const result = await this.runtime.runJson<{ - created: boolean; url: string; targetId: string; }>(args); @@ -604,7 +626,7 @@ class BrowseCliSession implements CoreSession { throw new Error(`Unknown page id "${pageId}"`); } - await this.runtime.runJson(["tab_switch", String(page.index)]); + await this.runtime.runJson(["tab", "switch", page.targetId]); this.activePageId = pageId; } @@ -615,7 +637,7 @@ class BrowseCliSession implements CoreSession { throw new Error(`Unknown page id "${pageId}"`); } - await this.runtime.runJson(["tab_close", String(page.index)]); + await this.runtime.runJson(["tab", "close", page.targetId]); this.handles.delete(pageId); const remaining = await this.fetchPages(); this.activePageId = remaining[0]?.targetId ?? null; @@ -625,11 +647,7 @@ class BrowseCliSession implements CoreSession { if (this.closed) return; this.closed = true; - try { - await this.runtime.runJson(["stop", "--force"]); - } catch { - // best-effort only - } + await this.runtime.runJson(["stop", "--force"], 5_000); } async getArtifacts(): Promise { @@ -655,10 +673,6 @@ function connectionModeFromProfile(startupProfile: StartupProfile): ConnectionMo return "launch"; } -function createSessionName(): string { - return `evals-browse-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - export class BrowseCliTool implements CoreTool { readonly id = "browse_cli"; readonly surface = "cli"; @@ -687,11 +701,13 @@ export class BrowseCliTool implements CoreTool { ); } - const session = new BrowseCliSession(createSessionName()); - await session.runtime.runJson([ - "env", - input.environment === "BROWSERBASE" ? "remote" : "local", - ]); + const session = new BrowseCliSession(createBrowseCliSessionName()); + try { + await session.runtime.runJson(browseCliStartupArgs(input.environment)); + } catch (error) { + await session.close().catch(() => {}); + throw error; + } return { session, diff --git a/packages/evals/framework/benchHarness.ts b/packages/evals/framework/benchHarness.ts index 99c8f3d610..e4ee718f03 100644 --- a/packages/evals/framework/benchHarness.ts +++ b/packages/evals/framework/benchHarness.ts @@ -30,6 +30,7 @@ import { withHarnessAgentSpan } from "./otel.js"; import type { DiscoveredTask, TaskResult } from "./types.js"; import type { BenchMatrixRow, BenchTaskKind, Harness } from "./benchTypes.js"; import { DEFAULT_BENCH_HARNESS } from "./benchTypes.js"; +import { onceAsync, registerActiveRunCleanup } from "./activeRunCleanup.js"; import type { StartupProfile, ToolSurface } from "../core/contracts/tool.js"; import type { ExternalHarnessVerifierConfig } from "./verifierAdapter.js"; @@ -148,6 +149,14 @@ export function defineExternalHarness Promise< // the adapter and the carrier. const carrierV3 = buildVerifierCarrierV3(logger); let toolAdapter: TAdapter | undefined; + const cleanup = onceAsync(async () => { + try { + await toolAdapter?.cleanup(); + } finally { + await carrierV3.close().catch(() => {}); + } + }); + const unregisterCleanup = registerActiveRunCleanup(cleanup); try { toolAdapter = await prepareToolAdapter({ toolSurface: row.config.toolSurface, @@ -180,12 +189,9 @@ export function defineExternalHarness Promise< ); } finally { try { - await toolAdapter?.cleanup(); + await cleanup(); } finally { - // Deregister the never-init()-ed carrier (instance registry, event - // store, logger binding) so long matrix runs don't accumulate one - // V3 object graph per task. - await carrierV3.close().catch(() => {}); + unregisterCleanup(); } } }, diff --git a/packages/evals/framework/claudeCodeToolAdapter.ts b/packages/evals/framework/claudeCodeToolAdapter.ts index d0e7feb152..f666c5653b 100644 --- a/packages/evals/framework/claudeCodeToolAdapter.ts +++ b/packages/evals/framework/claudeCodeToolAdapter.ts @@ -11,6 +11,7 @@ import { BROWSE_CLI_ENTRYPOINT, BROWSE_CLI_PACKAGE_JSON, BROWSE_SKILL_SOURCE, + createBrowseCliSessionName, } from "../browseCliPaths.js"; import { AGENT_RUN_TOOL_NAME, @@ -125,6 +126,34 @@ the guidance below: const ALLOW_UNSANDBOXED_LOCAL_ENV = "EVAL_CLAUDE_CODE_ALLOW_UNSANDBOXED_LOCAL"; const RUN_TOOL_SERVER = AGENT_RUN_TOOL_SERVER; const RUN_TOOL_NAME = AGENT_RUN_TOOL_NAME; +const BROWSE_DRIVER_COMMANDS = [ + "back", + "click", + "cursor", + "doctor", + "eval", + "fill", + "forward", + "get", + "highlight", + "is", + "mouse", + "network", + "open", + "press", + "reload", + "screenshot", + "select", + "snapshot", + "status", + "stop", + "tab", + "type", + "upload", + "viewport", + "wait", +] as const; +const BROWSE_DRIVER_COMMAND_SET = new Set(BROWSE_DRIVER_COMMANDS); type ClaudeToolResult = { content: Array<{ type: "text"; text: string }>; @@ -277,57 +306,87 @@ export async function prepareBrowseCliHarnessAdapter( ); } - const session = createBrowseSessionName(); + const session = createBrowseCliSessionName(); const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), "stagehand-evals-claude-browse-")); - const wrapperPath = path.join(cwd, "browse"); - await installBrowseSkill(cwd); - input.logger.log({ - category: input.logCategory, - message: `Installed browse skill at ${path.join(cwd, ".claude", "skills", "browse", "SKILL.md")}`, - level: 1, - }); - const env = { - ...process.env, - BROWSE_SESSION: session, - PATH: `${cwd}${path.delimiter}${process.env.PATH ?? ""}`, - } as Record; + try { + const wrapperPath = path.join(cwd, "browse"); + await installBrowseSkill(cwd); + input.logger.log({ + category: input.logCategory, + message: `Installed browse skill at ${path.join(cwd, ".claude", "skills", "browse", "SKILL.md")}`, + level: 1, + }); + const env = { + ...process.env, + BROWSE_SESSION: session, + PATH: `${cwd}${path.delimiter}${process.env.PATH ?? ""}`, + } as Record; + + const modeFlag = input.environment === "BROWSERBASE" ? "--remote" : "--local"; + await fsp.writeFile( + wrapperPath, + buildBrowseCliWrapperScript({ + entrypoint: BROWSE_CLI_ENTRYPOINT, + modeFlag, + nodePath: process.execPath, + session, + }), + { mode: 0o755 }, + ); - const modeFlag = input.environment === "BROWSERBASE" ? "--remote" : "--local"; - await fsp.writeFile( - wrapperPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - // The mode flag (--local/--remote) selects the environment when the daemon - // is first started and must be explicit so a set BROWSERBASE_API_KEY does - // not silently auto-select remote. It is only accepted by the driver - // commands, so skip it for the few subcommands that reject it (stop, - // status). The session name is safe on every command. - "cmd=${1:-}", - "mode=()", - 'if [[ "$cmd" != "stop" && "$cmd" != "status" ]]; then', - ` mode=(${JSON.stringify(modeFlag)})`, - "fi", - `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(BROWSE_CLI_ENTRYPOINT)} "$@" "\${mode[@]+\${mode[@]}}" --session ${JSON.stringify(session)}`, - "", - ].join("\n"), - { mode: 0o755 }, - ); + return { + toolSurface: "browse_cli", + startupProfile: input.startupProfile, + cwd, + env, + promptInstructions: buildBrowseCliPromptInstructions(input.plan), + metadata: getBrowseCliToolMetadata(), + cleanup: async () => { + try { + await runBrowseCommand(wrapperPath, ["stop", "--force"], input.logger, env, cwd, 5_000); + } catch (error) { + input.logger.warn({ + category: input.logCategory, + message: `browse_cli cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + level: 0, + }); + } finally { + await fsp.rm(cwd, { recursive: true, force: true }); + } + }, + }; + } catch (error) { + await fsp.rm(cwd, { recursive: true, force: true }); + throw error; + } +} - return { - toolSurface: "browse_cli", - startupProfile: input.startupProfile, - cwd, - env, - promptInstructions: buildBrowseCliPromptInstructions(input.plan), - metadata: getBrowseCliToolMetadata(), - cleanup: async () => { - await runBrowseCommand(wrapperPath, ["stop", "--force"], input.logger, env, cwd).catch( - (): undefined => undefined, - ); - await fsp.rm(cwd, { recursive: true, force: true }); - }, - }; +export function buildBrowseCliWrapperScript({ + entrypoint, + modeFlag, + nodePath, + session, +}: { + entrypoint: string; + modeFlag: "--local" | "--remote"; + nodePath: string; + session: string; +}): string { + return [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "cmd=${1:-}", + 'case "$cmd" in', + ` ${BROWSE_DRIVER_COMMANDS.join("|")}) ;;`, + ' *) echo "The browse eval harness only supports browser driver commands (received: ${cmd:-})." >&2; exit 64 ;;', + "esac", + "mode=()", + 'if [[ "$cmd" != "stop" && "$cmd" != "status" ]]; then', + ` mode=(${JSON.stringify(modeFlag)})`, + "fi", + `exec ${JSON.stringify(nodePath)} ${JSON.stringify(entrypoint)} "$@" "\${mode[@]+\${mode[@]}}" --session ${JSON.stringify(session)}`, + "", + ].join("\n"); } /** @@ -680,7 +739,14 @@ export function isAllowedBrowseCommand(command: string): boolean { const trimmed = command.trim(); if (/[\r\n]/.test(trimmed)) return false; if (trimmed !== "browse" && !trimmed.startsWith("browse ")) return false; - return !/[;&|`$<>]/.test(trimmed); + if (/[;&|`$<>]/.test(trimmed)) return false; + const subcommand = trimmed.split(/\s+/u)[1]; + return ( + subcommand === undefined || + subcommand === "-h" || + subcommand === "--help" || + BROWSE_DRIVER_COMMAND_SET.has(subcommand) + ); } function readCommand(input: Record): string { @@ -726,16 +792,13 @@ function clip(value: string, maxLength: number): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`; } -function createBrowseSessionName(): string { - return `evals-claude-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - async function runBrowseCommand( wrapperPath: string, args: string[], logger: EvalLogger, env: Record, cwd: string, + timeoutMs: number, ): Promise { const { spawn } = await import("node:child_process"); await new Promise((resolve, reject) => { @@ -745,6 +808,19 @@ async function runBrowseCommand( stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; + let timedOut = false; + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { logger.log({ category: "browse_cli", message: chunk, level: 1 }); @@ -754,13 +830,19 @@ async function runBrowseCommand( stderr += chunk; logger.log({ category: "browse_cli", message: chunk, level: 1 }); }); - child.on("error", reject); + child.on("error", (error) => finish(error)); child.on("close", (code) => { + if (timedOut) { + finish( + new EvalsError(`browse_cli command timed out after ${timeoutMs}ms (${args.join(" ")})`), + ); + return; + } if (code === 0) { - resolve(); + finish(); return; } - reject(new EvalsError(`browse_cli command failed (${args.join(" ")}): ${stderr.trim()}`)); + finish(new EvalsError(`browse_cli command failed (${args.join(" ")}): ${stderr.trim()}`)); }); }); } diff --git a/packages/evals/package.json b/packages/evals/package.json index 3b6c143ead..0e26ace598 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -40,7 +40,7 @@ "@opentelemetry/sdk-trace-node": "^2.9.0", "ai": "^5.0.133", "braintrust": "^3.21.0", - "browse": "0.9.5", + "browse": "workspace:*", "dotenv": "^17.3.1", "langsmith": "^0.5.16", "openai": "^4.104.0", diff --git a/packages/evals/tests/core/browse-cli-adapter.test.ts b/packages/evals/tests/core/browse-cli-adapter.test.ts new file mode 100644 index 0000000000..f23b2f958c --- /dev/null +++ b/packages/evals/tests/core/browse-cli-adapter.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BrowseCliPageHandle, + BrowseCliSession, + browseCliStartupArgs, + buildBrowseCliProcessArgs, +} from "../../core/tools/browse_cli.js"; +import { createBrowseCliSessionName } from "../../browseCliPaths.js"; +import { getSocketPath } from "../../../cli/src/lib/driver/daemon/paths.js"; + +describe("browse_cli V4 adapter", () => { + it("places session selection on each V4 command and uses mode-specific startup commands", () => { + expect(buildBrowseCliProcessArgs("/tmp/browse", "eval-session", ["tab", "list"])).toEqual([ + "/tmp/browse", + "tab", + "list", + "--session", + "eval-session", + ]); + expect(browseCliStartupArgs("LOCAL")).toEqual(["open", "about:blank", "--local"]); + expect(browseCliStartupArgs("BROWSERBASE")).toEqual(["open", "about:blank", "--remote"]); + }); + + it("keeps eval session names short enough for temporary Unix socket paths", () => { + const sessionName = createBrowseCliSessionName(); + + expect(sessionName).toMatch(/^eval-\d+-[a-z0-9]+-[a-z0-9]{4}$/u); + expect(sessionName.length).toBeLessThanOrEqual(32); + if (process.platform !== "win32") { + expect(Buffer.byteLength(getSocketPath(sessionName))).toBeLessThanOrEqual(103); + } + }); + + it("maps page capabilities onto the V4 command tree and flags", async () => { + const runJson = vi.fn(async (args: string[]) => { + if (args[0] === "open") return { url: args[1] }; + if (args[0] === "screenshot") { + return { base64: Buffer.from("screenshot").toString("base64") }; + } + if (args[0] === "get" && args[1] === "box") return { x: 12, y: 34 }; + return {}; + }); + const session = { + runtime: { runJson }, + selectIfNeeded: vi.fn(), + } as unknown as BrowseCliSession; + const page = new BrowseCliPageHandle(session, "page-1"); + + await page.goto("https://example.com", { + timeoutMs: 1_234, + waitUntil: "networkidle", + }); + await expect(page.screenshot({ fullPage: true, quality: 80, type: "jpeg" })).resolves.toEqual( + Buffer.from("screenshot"), + ); + await page.wait({ + kind: "selector", + selector: "#ready", + state: "attached", + timeoutMs: 2_345, + }); + await page.click(10, 20); + await page.hover({ kind: "selector", value: "#hover-target" }); + await page.scroll(30, 40, 0, 500); + await page.type({ kind: "selector", value: "#text-input" }, "hello"); + await page.press({ kind: "coords", x: 50, y: 60 }, "Enter"); + + expect(runJson.mock.calls.map(([args]) => args)).toEqual([ + ["open", "https://example.com", "--wait", "networkidle", "--timeout", "1234"], + ["screenshot", "--base64", "--full-page", "--type", "jpeg", "--quality", "80"], + ["wait", "selector", "#ready", "--timeout", "2345", "--state", "attached"], + ["mouse", "click", "10", "20"], + ["get", "box", "#hover-target"], + ["mouse", "hover", "12", "34"], + ["mouse", "scroll", "30", "40", "0", "500"], + ["fill", "#text-input", "hello"], + ["mouse", "click", "50", "60"], + ["press", "Enter"], + ]); + }); + + it("reads V4 tab-list results and addresses tabs by stable targetId", async () => { + const session = new BrowseCliSession("eval-session"); + const runJson = vi.spyOn(session.runtime, "runJson").mockImplementation(async (args) => { + if (args[0] === "tab" && args[1] === "list") { + return { + tabs: [ + { index: 0, targetId: "tab-a", url: "about:blank" }, + { index: 1, targetId: "tab-b", url: "https://example.com" }, + ], + }; + } + return {}; + }); + + await expect(session.listPages()).resolves.toEqual([ + expect.objectContaining({ id: "tab-a" }), + expect.objectContaining({ id: "tab-b" }), + ]); + await session.selectPage("tab-b"); + + expect(runJson).toHaveBeenLastCalledWith(["tab", "switch", "tab-b"]); + }); + + it("rejects V4 tab results without a stable targetId", async () => { + const session = new BrowseCliSession("eval-session"); + vi.spyOn(session.runtime, "runJson").mockResolvedValue({ + tabs: [{ index: 0, url: "about:blank" }], + }); + + await expect(session.listPages()).rejects.toThrow( + "browse tab list returned no targetId for tab index 0", + ); + }); +}); diff --git a/packages/evals/tests/framework/claudeCodeToolAdapter.test.ts b/packages/evals/tests/framework/claudeCodeToolAdapter.test.ts index 5acd0d939a..e93b8bfd13 100644 --- a/packages/evals/tests/framework/claudeCodeToolAdapter.test.ts +++ b/packages/evals/tests/framework/claudeCodeToolAdapter.test.ts @@ -1,8 +1,11 @@ +import { execFile } from "node:child_process"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { + buildBrowseCliWrapperScript, getBrowseCliAllowedTools, getBrowseCliToolMetadata, insertAfterFrontmatter, @@ -16,6 +19,9 @@ import { resolveStartupProfile, resolveToolSurface, } from "../../framework/harnesses/toolSurfaceResolution.js"; +import { BROWSE_CLI_ENTRYPOINT } from "../../browseCliPaths.js"; + +const execFileAsync = promisify(execFile); describe("claude code tool adapter resolution", () => { afterEach(() => { @@ -99,6 +105,7 @@ describe("claude code tool adapter resolution", () => { expect(isAllowedBrowseCommand("./browse -h")).toBe(false); expect(isAllowedBrowseCommand("npm test")).toBe(false); expect(isAllowedBrowseCommand("browse status; rm -rf /")).toBe(false); + expect(isAllowedBrowseCommand("browse cloud contexts list")).toBe(false); expect(isAllowedBrowseCommand("browse status\ncat ~/.ssh/id_rsa")).toBe(false); expect(isAllowedBrowseCommand("browse status\r\ncat ~/.ssh/id_rsa")).toBe(false); }); @@ -113,8 +120,8 @@ describe("claude code tool adapter resolution", () => { it("exposes browse cli metadata for Braintrust rows", () => { expect(getBrowseCliToolMetadata()).toMatchObject({ toolCommand: "browse", - browseCliVersion: "0.9.5", - browseCliEntrypoint: expect.stringMatching(/browse[/\\]bin[/\\]run\.js$/u), + browseCliVersion: "0.9.6", + browseCliEntrypoint: BROWSE_CLI_ENTRYPOINT, }); }); @@ -241,6 +248,46 @@ describe("claude code tool adapter resolution", () => { } }); + it("routes only driver commands through the pinned browse wrapper", async () => { + const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), "stagehand-evals-wrapper-test-")); + const entrypoint = path.join(cwd, "capture.mjs"); + const wrapper = path.join(cwd, "browse"); + try { + await fsp.writeFile( + entrypoint, + "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n", + ); + await fsp.writeFile( + wrapper, + buildBrowseCliWrapperScript({ + entrypoint, + modeFlag: "--local", + nodePath: process.execPath, + session: "eval-session", + }), + { mode: 0o755 }, + ); + + const open = await execFileAsync(wrapper, ["open", "about:blank"]); + expect(JSON.parse(open.stdout)).toEqual([ + "open", + "about:blank", + "--local", + "--session", + "eval-session", + ]); + + const stop = await execFileAsync(wrapper, ["stop", "--force"]); + expect(JSON.parse(stop.stdout)).toEqual(["stop", "--force", "--session", "eval-session"]); + + await expect(execFileAsync(wrapper, ["cloud", "projects", "list"])).rejects.toMatchObject({ + code: 64, + }); + } finally { + await fsp.rm(cwd, { recursive: true, force: true }); + } + }); + it("keeps abandoned CDP event waits from becoming unhandled rejections", async () => { const listeners = new Set<(event: CdpEventMessage) => void>(); const connection = { diff --git a/packages/evals/tests/tui/run.test.ts b/packages/evals/tests/tui/run.test.ts index 8b5494f8df..2aa4e9947a 100644 --- a/packages/evals/tests/tui/run.test.ts +++ b/packages/evals/tests/tui/run.test.ts @@ -10,6 +10,7 @@ import { listBenchHarnessesForTaskKind, registerBenchHarness, } from "../../framework/benchHarness.js"; +import { BROWSE_CLI_ENTRYPOINT } from "../../browseCliPaths.js"; const runEvalsMock = vi.hoisted(() => vi.fn(async () => ({ @@ -257,7 +258,7 @@ describe("deriveCategoryFilter", () => { startupProfile: "tool_create_browserbase", toolCommand: "browse", browseCliVersion: expect.any(String), - browseCliEntrypoint: expect.stringMatching(/browse[/\\]bin[/\\]run\.js$/u), + browseCliEntrypoint: BROWSE_CLI_ENTRYPOINT, harnessConfig: { harness: "claude_code", model: "anthropic/claude-sonnet-4-20250514", @@ -315,7 +316,7 @@ describe("deriveCategoryFilter", () => { startupProfile: "tool_create_browserbase", toolCommand: "browse", browseCliVersion: expect.any(String), - browseCliEntrypoint: expect.stringMatching(/browse[/\\]bin[/\\]run\.js$/u), + browseCliEntrypoint: BROWSE_CLI_ENTRYPOINT, harnessConfig: { harness: "codex", model: "openai/gpt-5.4-mini", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07bebd10de..e69479a250 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,7 +483,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.10.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)) '@types/archiver': specifier: ^6.0.3 version: 6.0.4 @@ -498,7 +498,7 @@ importers: version: 8.18.1 eslint: specifier: ^10.4.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) eslint-plugin-security: specifier: ^4.0.0 version: 4.0.1 @@ -507,7 +507,7 @@ importers: version: 17.12.0 oclif: specifier: ^4.23.1 - version: 4.24.0(@types/node@20.19.43) + version: 4.24.0(@types/node@20.19.43)(supports-color@8.1.1) prettier: specifier: 3.5.3 version: 3.5.3 @@ -516,7 +516,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.59.3 - version: 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) + version: 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) vitest: specifier: ^4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(vite@8.1.3(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -528,7 +528,7 @@ importers: version: 3.1.1 mint: specifier: 'catalog:' - version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) + version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) packages/evals: dependencies: @@ -576,7 +576,7 @@ importers: version: link:../integrations/pi-sdk '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 @@ -596,8 +596,8 @@ importers: specifier: ^3.21.0 version: 3.28.0(@aws-sdk/credential-provider-web-identity@3.972.74)(zod@4.4.3) browse: - specifier: 0.9.5 - version: 0.9.5(bufferutil@4.1.0)(playwright-core@1.56.1) + specifier: workspace:* + version: link:../cli dotenv: specifier: ^17.3.1 version: 17.4.2 @@ -615,7 +615,7 @@ importers: version: 0.34.5 stagehand-v3: specifier: npm:@browserbasehq/stagehand@3.7.1 - version: '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(zod@4.4.3)' + version: '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(supports-color@8.1.1)(zod@4.4.3)' tsx: specifier: 'catalog:' version: 4.23.1 @@ -813,7 +813,7 @@ importers: version: link:../../sdk-ts '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) zod: specifier: 'catalog:' version: 4.4.3 @@ -913,7 +913,7 @@ importers: version: link:../core '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) ai: specifier: ^7.0.38 version: 7.0.77(zod@4.4.3) @@ -1044,7 +1044,7 @@ importers: version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@8.1.1)(zod@4.4.3) typebox: specifier: 'catalog:' version: 1.3.7 @@ -1826,22 +1826,6 @@ packages: '@browserbasehq/sdk@2.16.0': resolution: {integrity: sha512-mPAuLRU9jWR7o0KJi9+gQnOBDUSIkoKbbFv4HjrA+80qWVcFacrNPlZmf4mguQnfZ0oP2t5c3ws6yuFyAX9vpA==} - '@browserbasehq/stagehand@3.6.0': - resolution: {integrity: sha512-wQ3Mv5b2xy64SntWNUbvgd7liWPbQRvGa8KEb1LorwheTkpUZFkt3BpZE8ovMY/lIleV9DfYjbvqghbeOOla4w==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - patchright-core: ^1.55.2 - playwright-core: ^1.55.1 - puppeteer-core: ^24.43.0 - zod: ^3.25.76 || ^4.2.0 - peerDependenciesMeta: - patchright-core: - optional: true - playwright-core: - optional: true - puppeteer-core: - optional: true - '@browserbasehq/stagehand@3.7.1': resolution: {integrity: sha512-vAuYSZWIhh3d76BxwppNVE3dB0ztEBLBi85G6TWulZNiebdWptNoANOMuprOB/cw5dE+80b/ZZQo4G33Pc9i6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4393,11 +4377,6 @@ packages: peerDependencies: zod: ^3.25.34 || ^4.0 - browse@0.9.5: - resolution: {integrity: sha512-5icxS7urivYaSaAfFxj+vSWU2zNPol8aBIU3S+3m1ervovG3qnQ0nfHEG9BhV2YXdAAACo5jT+sA0xYkugwFjA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -9361,7 +9340,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.3.224(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) zod: 4.4.3 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.224 @@ -9964,54 +9943,13 @@ snapshots: transitivePeerDependencies: - encoding - '@browserbasehq/stagehand@3.6.0(playwright-core@1.56.1)(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 2.0.3 - '@anthropic-ai/sdk': 0.39.0 - '@browserbasehq/sdk': 2.16.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) - ai: 5.0.220(zod@4.4.3) - devtools-protocol: 0.0.1642743 - fetch-cookie: 3.2.0 - openai: 4.104.0(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3) - pino: 9.14.0 - pino-pretty: 13.1.3 - uuid: 11.1.1 - ws: 8.21.0(bufferutil@4.1.0) - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - optionalDependencies: - '@ai-sdk/amazon-bedrock': 3.0.111(zod@4.4.3) - '@ai-sdk/anthropic': 2.0.91(zod@4.4.3) - '@ai-sdk/azure': 2.0.120(zod@4.4.3) - '@ai-sdk/cerebras': 1.0.51(zod@4.4.3) - '@ai-sdk/deepseek': 1.0.48(zod@4.4.3) - '@ai-sdk/google': 2.0.85(zod@4.4.3) - '@ai-sdk/google-vertex': 3.0.158(zod@4.4.3) - '@ai-sdk/groq': 2.0.45(zod@4.4.3) - '@ai-sdk/mistral': 2.0.40(zod@4.4.3) - '@ai-sdk/openai': 2.0.115(zod@4.4.3) - '@ai-sdk/perplexity': 2.0.36(zod@4.4.3) - '@ai-sdk/togetherai': 1.0.49(zod@4.4.3) - '@ai-sdk/xai': 2.0.82(zod@4.4.3) - bufferutil: 4.1.0 - chrome-launcher: 1.2.1 - ollama-ai-provider-v2: 1.5.5(zod@4.4.3) - playwright-core: 1.56.1 - transitivePeerDependencies: - - '@cfworker/json-schema' - - encoding - - supports-color - - utf-8-validate - - '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(zod@4.4.3)': + '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(supports-color@8.1.1)(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 '@anthropic-ai/sdk': 0.39.0 '@browserbasehq/sdk': 2.16.0 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) ai: 5.0.220(zod@4.4.3) devtools-protocol: 0.0.1642743 fetch-cookie: 3.2.0 @@ -10037,7 +9975,7 @@ snapshots: '@ai-sdk/togetherai': 1.0.49(zod@4.4.3) '@ai-sdk/xai': 2.0.82(zod@4.4.3) bufferutil: 4.1.0 - chrome-launcher: 1.2.1 + chrome-launcher: 1.2.1(supports-color@8.1.1) ollama-ai-provider-v2: 1.5.5(zod@4.4.3) playwright-core: 1.56.1 transitivePeerDependencies: @@ -10400,14 +10338,14 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@8.1.1) @@ -10423,9 +10361,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.10.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))': optionalDependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) '@eslint/object-schema@3.0.5': {} @@ -10458,7 +10396,7 @@ snapshots: protobufjs: 7.6.5 ws: 8.21.0(bufferutil@4.1.0) optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -11076,7 +11014,7 @@ snapshots: '@isaacs/ttlcache': 2.1.5 '@lukeed/uuid': 2.0.1 '@mastra/schema-compat': 1.3.5(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) '@sindresorhus/slugify': 2.2.1 '@standard-schema/spec': 1.1.0 ajv: 8.20.0 @@ -11114,7 +11052,7 @@ snapshots: dependencies: '@mastra/core': 1.57.0(ai@7.0.77(zod@4.4.3))(bufferutil@4.1.0)(express@5.2.1)(rxjs@7.8.2)(zod@4.4.3) '@modelcontextprotocol/ext-apps': 1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) exit-hook: 5.1.0 fast-deep-equal: 3.1.3 transitivePeerDependencies: @@ -11168,7 +11106,7 @@ snapshots: '@types/react': 19.2.17 react: 19.2.3 - '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3)': + '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@inquirer/prompts': 7.9.0(@types/node@24.13.2) '@mintlify/common': 1.0.1080(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -11180,7 +11118,7 @@ snapshots: adm-zip: 0.6.0 chalk: 5.2.0 color: 4.2.3 - detect-port: 1.5.1 + detect-port: 1.5.1(supports-color@8.1.1) fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.3) inquirer: 12.3.0(@types/node@24.13.2) @@ -11478,14 +11416,14 @@ snapshots: '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) '@standard-schema/spec': 1.1.0 zod: 4.4.3 optionalDependencies: react: 19.2.3 react-dom: 18.3.1(react@19.2.3) - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) ajv: 8.20.0 @@ -11496,7 +11434,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.6.0(express@5.2.1) + express-rate-limit: 8.6.0(express@5.2.1)(supports-color@8.1.1) hono: 4.12.31 jose: 6.2.4 json-schema-typed: 8.0.2 @@ -11585,12 +11523,12 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@oclif/plugin-warn-if-update-available@3.2.0': + '@oclif/plugin-warn-if-update-available@3.2.0(supports-color@8.1.1)': dependencies: '@oclif/core': 4.13.0 ansis: 3.17.0 debug: 4.4.3(supports-color@8.1.1) - http-call: 5.3.0 + http-call: 5.3.0(supports-color@8.1.1) lodash: 4.18.1 registry-auth-token: 5.1.1 transitivePeerDependencies: @@ -12098,8 +12036,8 @@ snapshots: '@smithy/credential-provider-imds@4.5.0': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/eventstream-codec@4.4.14': @@ -12144,8 +12082,8 @@ snapshots: '@smithy/signature-v4@5.7.0': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 tslib: 2.8.1 '@smithy/types@4.17.0': @@ -12482,15 +12420,15 @@ snapshots: '@types/node': 25.9.4 optional: true - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.0 - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -12498,14 +12436,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.0 '@typescript-eslint/types': 8.70.0 '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -12528,13 +12466,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.70.0 '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(typescript@5.9.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -12557,13 +12495,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.70.0 '@typescript-eslint/types': 8.70.0 '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -13099,36 +13037,6 @@ snapshots: - supports-color optional: true - browse@0.9.5(bufferutil@4.1.0)(playwright-core@1.56.1): - dependencies: - '@browserbasehq/sdk': 2.16.0 - '@browserbasehq/stagehand': 3.6.0(playwright-core@1.56.1)(zod@4.4.3) - '@oclif/core': 4.13.0 - '@vercel/detect-agent': 1.2.3 - archiver: 7.0.1 - deepmerge: 4.3.1 - dotenv: 16.6.1 - fastest-levenshtein: 1.0.16 - http-status-codes: 2.3.0 - ignore: 7.0.5 - node-html-markdown: 1.3.0 - semver: 7.8.5 - tsx: 4.23.1 - ws: 8.21.0(bufferutil@4.1.0) - zod: 4.4.3 - transitivePeerDependencies: - - '@cfworker/json-schema' - - bare-abort-controller - - bare-buffer - - bufferutil - - encoding - - patchright-core - - playwright-core - - puppeteer-core - - react-native-b4a - - supports-color - - utf-8-validate - buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -13297,12 +13205,12 @@ snapshots: chownr@3.0.0: {} - chrome-launcher@1.2.1: + chrome-launcher@1.2.1(supports-color@8.1.1): dependencies: '@types/node': 25.9.4 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 - lighthouse-logger: 2.0.2 + lighthouse-logger: 2.0.2(supports-color@8.1.1) transitivePeerDependencies: - supports-color optional: true @@ -13590,7 +13498,7 @@ snapshots: detect-libc@2.1.2: {} - detect-port@1.5.1: + detect-port@1.5.1(supports-color@8.1.1): dependencies: address: 1.2.2 debug: 4.4.3(supports-color@8.1.1) @@ -13912,11 +13820,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.10.0(jiti@2.7.0): + eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.3 @@ -14096,7 +14004,7 @@ snapshots: expr-eval-fork@3.0.3: {} - express-rate-limit@8.6.0(express@5.2.1): + express-rate-limit@8.6.0(express@5.2.1)(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) express: 5.2.1 @@ -14860,7 +14768,7 @@ snapshots: http-cache-semantics@4.2.0: {} - http-call@5.3.0: + http-call@5.3.0(supports-color@8.1.1): dependencies: content-type: 1.0.5 debug: 4.4.3(supports-color@8.1.1) @@ -15364,7 +15272,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lighthouse-logger@2.0.2: + lighthouse-logger@2.0.2(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) marky: 1.3.0 @@ -16055,9 +15963,9 @@ snapshots: dependencies: minipass: 7.1.3 - mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3): + mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) + '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) transitivePeerDependencies: - '@base-ui/react' - '@types/node' @@ -16292,7 +16200,7 @@ snapshots: dependencies: ohash: 2.0.11 - oclif@4.24.0(@types/node@20.19.43): + oclif@4.24.0(@types/node@20.19.43)(supports-color@8.1.1): dependencies: '@aws-sdk/client-cloudfront': 3.1129.0 '@aws-sdk/client-s3': 3.1129.0 @@ -16302,7 +16210,7 @@ snapshots: '@oclif/core': 4.13.0 '@oclif/plugin-help': 6.3.0 '@oclif/plugin-not-found': 3.3.0(@types/node@20.19.43) - '@oclif/plugin-warn-if-update-available': 3.2.0 + '@oclif/plugin-warn-if-update-available': 3.2.0(supports-color@8.1.1) ansis: 3.17.0 async-retry: 1.3.3 change-case: 4.1.2 @@ -18148,13 +18056,13 @@ snapshots: typed-query-selector@2.12.2: {} - typescript-eslint@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@5.9.3) - eslint: 10.10.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(typescript@5.9.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color