import { optimizeImageForTransport } from "../../agent/image-optimize.js"; import { getConfig } from "../../config/loader.js"; import { HostBrowserProxy } from "../../daemon/host-browser-proxy.js"; import type { ImageContent } from "../../providers/types.js"; import { wrapUntrustedContent } from "../../security/untrusted-content.js"; import { getLogger } from "../../util/logger.js"; import { truncate } from "../../util/truncate.js"; import { safeStringSlice } from "../../util/unicode.js"; import { credentialBroker } from "../credentials/broker.js"; import { BROWSER_FILL_CAPABILITY } from "../credentials/tool-policy.js"; import { isPrivateOrLocalHost, parseUrl, resolveHostAddresses, resolveRequestAddress, sanitizeUrlForOutput, sanitizeUrlStringForOutput, } from "../network/url-safety.js"; import type { ToolContext, ToolExecutionResult } from "../types.js"; import { type AuthChallenge, detectAuthChallenge, detectCaptchaChallenge, formatAuthChallenge, } from "./auth-detector.js"; import type { RouteHandler } from "./browser-manager.js"; import { browserManager } from "./browser-manager.js"; import { type BrowserMode, normalizeBrowserMode } from "./browser-mode.js"; import { BROWSER_MODE } from "./browser-mode-constants.js"; import { ensureScreencast, getSender, stopAllScreencasts, stopBrowserScreencast, } from "./browser-screencast.js"; import { BROWSER_STATUS_INPUT_FIELD, BROWSER_STATUS_MODE, BROWSER_STATUS_MODES, type BrowserStatusMode, CDP_INSPECT_STATUS_DISCOVERY_CODE, EXTENSION_STATUS_ERROR_MARKER, } from "./browser-status-constants.js"; import { formatAxSnapshot, transformAxTree, } from "./cdp-client/accessibility-snapshot.js"; import { captureScreenshotJpeg, dispatchClickAt, dispatchHoverAt, dispatchInsertText, dispatchKeyPress, dispatchWheelScroll, evaluateExpression, focusElement, getCenterPoint, getCurrentUrl, getPageTitle, navigateAndWait, querySelectorBackendNodeId, scrollIntoViewIfNeeded, waitForSelector as cdpWaitForSelector, waitForText as cdpWaitForText, } from "./cdp-client/cdp-dom-helpers.js"; import { CdpError } from "./cdp-client/errors.js"; import { buildCandidateList, getCdpClient, isDesktopAutoCooldownActive, } from "./cdp-client/factory.js"; import type { AttemptDiagnostic, CdpClient, CdpClientKind, InternalBrowserMode, } from "./cdp-client/types.js"; import { clearPinnedTab, getPinnedTab, setPinnedTab } from "./pinned-tabs.js"; import { checkBrowserRuntime } from "./runtime-check.js"; const log = getLogger("headless-browser"); // ── Constants ──────────────────────────────────────────────────────── const NAVIGATE_TIMEOUT_MS = 15_000; const ACTION_TIMEOUT_MS = 10_000; const MAX_WAIT_MS = 30_000; const MAX_EXTRACT_LENGTH = 50_000; /** * Character budget for the fenced payload returned by `browser_extract`. * Sized above {@link MAX_EXTRACT_LENGTH} so the innerText cap stays the * effective limit for body text, while still bounding the header and the * page-controlled (otherwise unbounded) links list. */ const MAX_EXTRACT_FENCE_CHARS = MAX_EXTRACT_LENGTH + 10_000; /** * Caps on the link list `browser_extract` returns with `include_links`. * Anchor text and hrefs are page-authored and unbounded (a data-URI href * runs to megabytes), so one link could otherwise spend the whole extract * budget and truncate away every link after it. */ const MAX_EXTRACTED_LINKS = 200; const MAX_LINK_TEXT_CHARS = 80; const MAX_LINK_HREF_CHARS = 200; /** * Character budget for the fenced payload returned by `browser_snapshot`. * * A snapshot's usefulness is all-or-nothing per element: truncating the * list drops trailing element ids the model needs to act on. The AX * transform bounds a snapshot to 150 elements with capped names, values * and attribute strings, so this sits above that worst case and the fence * cannot cut the element list short — while still bounding what a * pathological page can push into context. */ const MAX_SNAPSHOT_FENCE_CHARS = 100_000; /** * Maximum length of a page-authored URL or title echoed into a tool * result. * * These render ahead of the payload they head, and the page controls both * (`history.pushState` to a megabyte-long URL, a `document.title` of * arbitrary length). Left unbounded, a hostile page could push the header * alone past a tool's fence budget and truncate away the element list or * body text that follows. */ const MAX_PAGE_HEADER_CHARS = 500; /** Read the current page URL, credential-stripped and length-bounded. */ async function readPageUrl( cdp: CdpClient, signal?: AbortSignal, ): Promise { const url = sanitizeUrlStringForOutput(await getCurrentUrl(cdp, signal)); return truncate(url, MAX_PAGE_HEADER_CHARS); } /** Read the current page title, length-bounded. */ async function readPageTitle( cdp: CdpClient, signal?: AbortSignal, ): Promise { return truncate(await getPageTitle(cdp, signal), MAX_PAGE_HEADER_CHARS); } /** * Fence page-derived text before it reaches the model. * * Everything a browser tool reads out of a live page — titles, accessible * names, body text, link labels, form-field labels — is authored by * whoever controls that page, so it carries the same prompt-injection risk * as an inbound email or a fetched web page. `wrapUntrustedContent` marks * it as third-party data, escapes attempts to close the fence from inside, * and caps its size. */ function fencePageContent( content: string, pageUrl: string, maxChars?: number, ): string { return wrapUntrustedContent(content, { source: "web", sourceDetail: pageUrl, ...(maxChars === undefined ? {} : { maxChars }), }); } /** * Origin to attribute a detected auth challenge to. * * The detector reads the live DOM, which may sit at a different URL than * the one navigation settled on (an SPA login redirect, a modal, the * post-CAPTCHA page). Prefer the URL the detector actually inspected so * the fence metadata names the origin that authored the labels, falling * back to the navigation's final URL when the detector reports none. */ function authChallengeOrigin( challenge: AuthChallenge, fallbackUrl: string, ): string { return challenge.url ? sanitizeUrlStringForOutput(challenge.url) : fallbackUrl; } type StatusCheckMode = BrowserStatusMode; const MODE_TRADEOFFS: Record = { [BROWSER_STATUS_MODE.EXTENSION]: [ "This is the preferred approach for all things browser-use.", "On macOS, the host browser proxy is provisioned automatically via the desktop client's SSE bridge — no extension install required.", "When the Chrome extension is also installed, it takes priority for direct WebSocket routing to the active Chrome session.", "More secure than relying on Chrome's native remote debugging functionality.", ], [BROWSER_STATUS_MODE.CDP_INSPECT]: [ "This is the second-best approach for all things browser-use, after the native Vellum Assistant Chrome Extension.", "It requires Chrome version 146 or greater", "It requires toggling on remote debugging in Chrome Settings", "It's prone to phishing attacks from other local processes that may try to do their own remote debugging.", ], [BROWSER_STATUS_MODE.LOCAL]: [ "The least-preferred approach for all things browser-use.", "Considered a last-resort fallback when the Chrome Extension is not installed, remote debugging in Chrome is not enabled, and neither will be enabled.", "Does not use the existing browser profile, so sessions/cookies may differ.", "Requires that Playwright and Chromium are installed on the host machine,", ], }; interface BrowserStatusModeResult { mode: StatusCheckMode; available: boolean; verified: "active_probe" | "preflight"; autoCandidate: boolean; summary: string; userActions: string[]; tradeoffs: string[]; details: Record; } /** * IIFE evaluated inside the page via `Runtime.evaluate` to auto-dismiss * common blocker modals (regulatory notices, cookie banners) that * aren't exposed in the accessibility tree. Runs silently - if no * matching modal is present the expression is a no-op. */ const DISMISS_MODALS_EXPRESSION = `(() => { const dismissPatterns = /^(got it|accept|ok|dismiss|i understand|close)$/i; const buttons = document.querySelectorAll('button, [role="button"], input[type="submit"]'); for (const btn of buttons) { const text = (btn.textContent || '').trim(); if (dismissPatterns.test(text)) { const modal = btn.closest('[role="dialog"], [class*="modal"], [class*="Modal"], [class*="overlay"], [class*="Overlay"]'); if (modal) { btn.click(); break; } } } })()`; /** * IIFE evaluated by {@link executeBrowserExtract} when `include_links` * is true. Walks `document.querySelectorAll('a[href]')`, caps at 200 * anchors, and shapes each entry as `{ text, href }`. Extracted to a * module-level constant so the expression is shared between the * runtime call site and any future refactors / tests that need to * reason about the evaluated source. */ export const EXTRACT_LINKS_EXPRESSION = ` (() => { const anchors = Array.from(document.querySelectorAll('a[href]')); return anchors.slice(0, 200).map(a => ({ text: (a.textContent || '').trim().slice(0, 80), href: a.href, })); })() `; // ── browser_mode parsing ───────────────────────────────────────────── /** * Parse the `browser_mode` field from a tool input map. Returns either * a normalized {@link BrowserMode} or a pre-formatted error string * suitable for returning directly in a tool response. * * When the value is absent, undefined, or empty the default `"auto"` * is returned. Invalid values produce a descriptive error listing * accepted values and aliases. */ export function parseBrowserMode( input: Record, ): { ok: true; mode: BrowserMode } | { ok: false; error: string } { const raw = input.browser_mode; const result = normalizeBrowserMode(raw); if ("error" in result) { return { ok: false, error: `Error: ${result.error}` }; } return { ok: true, mode: result.mode }; } // ── Mode-selection failure formatter ───────────────────────────────── /** * Remediation hints keyed by (candidateKind, discoveryCode | errorCode). * Discovery codes come from DevToolsDiscoveryError; error codes come * from CdpError. The formatter walks these in priority order: exact * (kind, discoveryCode) first, then (kind, errorCode), then a generic * per-kind fallback. */ const REMEDIATION_HINTS: Record = { // Extension backend "extension:transport_error": [ "Ensure the Vellum browser extension is installed and enabled, or that the macOS desktop client is running for host browser proxy mode.", "For extension mode: check that the extension WebSocket connection is active (extension popup → status).", "For macOS host browser proxy: verify the desktop client is running and has an active SSE connection to the assistant.", "Try reconnecting the extension or restarting the desktop client.", ], // cdp-inspect backend — discovery-level failures "cdp-inspect:unreachable": [ "Ensure that Chrome is on version 146 or higher by going to chrome://settings/help.", 'Ensure that you have toggled on "Allow remote debugging for this browser instance" by going to chrome://inspect/#remote-debugging', "Verify no firewall or antivirus is blocking localhost:9222.", ], "cdp-inspect:non_chrome": [ "The process listening on the configured port is not Chrome/Chromium.", "Check if another application (dev server, proxy) is using port 9222.", "Ensure Chrome is launched with --remote-debugging-port=9222.", ], "cdp-inspect:timeout": [ "Chrome DevTools endpoint did not respond within the probe timeout.", "Ensure Chrome is running and listening on the configured port.", "Try increasing hostBrowser.cdpInspect.probeTimeoutMs in config.", ], "cdp-inspect:no_targets": [ "Chrome is reachable but has no open page targets.", "Open at least one browser tab, then retry.", ], "cdp-inspect:non_loopback": [ "CDP inspect only allows loopback hosts (localhost, 127.0.0.1, ::1).", "Update hostBrowser.cdpInspect.host in config to a loopback address.", ], "cdp-inspect:transport_error": [ "CDP endpoint unreachable. Ensure Chrome is running with --remote-debugging-port.", "Verify the configured host:port matches Chrome's DevTools listener.", "Consider using browser_mode: 'extension' or 'local' as an alternative.", ], // Host-bridge backend (desktop SSE bridge → user's Chrome debug port) "host-bridge:unreachable": [ "Ensure Chrome on the user's machine is on version 146 or higher (chrome://settings/help).", 'Ensure "Allow remote debugging for this browser instance" is toggled on at chrome://inspect/#remote-debugging.', "Verify the desktop client is running and has an active SSE connection to the assistant.", "Installing the Vellum Chrome extension is the preferred path and avoids the debug-port requirement.", ], "host-bridge:transport_error": [ "The desktop client could not reach Chrome's remote-debugging endpoint on the user's machine.", "Ensure Chrome is running with remote debugging enabled, or install the Vellum Chrome extension (preferred).", "Verify the desktop client is running and connected.", ], // Local/Playwright backend "local:transport_error": [ "The local Playwright-managed browser failed to start or connect.", "Check that the Playwright browser binary is downloaded (bun run install).", "Try closing any stale Chromium processes and retrying.", ], }; /** * Build a human-readable, tool-response-ready error string from a * pinned-mode failure. Includes: * - the requested mode * - ordered attempted modes with exact failure reasons * - a remediation checklist tailored by backend and failure code * * Exported for testing. */ export function formatModeSelectionFailure( requestedMode: BrowserMode, error: CdpError, ): string { const lines: string[] = []; lines.push(`Error: Browser mode "${requestedMode}" failed.`); lines.push(""); const diagnostics: readonly AttemptDiagnostic[] = error.attemptDiagnostics ?? []; if (diagnostics.length > 0) { lines.push("Attempted backends:"); for (const diag of diagnostics) { const status = diag.stage === "success" ? "OK" : `FAILED at ${diag.stage}`; lines.push(` - ${diag.candidateKind}: ${status}`); if (diag.errorMessage) { lines.push(` Reason: ${diag.errorMessage}`); } if (diag.discoveryCode) { lines.push(` Discovery code: ${diag.discoveryCode}`); } } lines.push(""); } // Collect remediation hints const hints = collectRemediationHints(diagnostics, error); if (hints.length > 0) { lines.push("Remediation:"); for (const hint of hints) { lines.push(` - ${hint}`); } } return lines.join("\n"); } /** * Gather remediation hints based on attempt diagnostics and the error. * Walks each diagnostic and looks up hints by (kind, discoveryCode), * then (kind, errorCode), then generic kind-level fallback. */ function collectRemediationHints( diagnostics: readonly AttemptDiagnostic[], error: CdpError, ): string[] { const seen = new Set(); const hints: string[] = []; const addHints = (key: string) => { const list = REMEDIATION_HINTS[key]; if (!list) { return; } for (const hint of list) { if (!seen.has(hint)) { seen.add(hint); hints.push(hint); } } }; for (const diag of diagnostics) { if (diag.stage === "success") { continue; } if (diag.discoveryCode) { addHints(`${diag.candidateKind}:${diag.discoveryCode}`); } if (diag.errorCode) { addHints(`${diag.candidateKind}:${diag.errorCode}`); } } // Fallback: if no diagnostics but we have a top-level error, use // the error code with a generic candidate kind derived from the mode. if (diagnostics.length === 0 && error.code) { // Try to infer the candidate kind from the error message for (const kind of BROWSER_STATUS_MODES) { if (error.message.toLowerCase().includes(kind)) { addHints(`${kind}:${error.code}`); } } } return hints; } /** * Detect the common extension CDP failure where the active tab is a * page Chrome forbids extensions from scripting — either a privileged * `chrome://` internal page (e.g. `chrome://newtab`) or the Chrome Web * Store / extensions gallery (which yields "The extensions gallery * cannot be scripted."). The latter is especially common right after * install, when the Web Store page is still the active tab. * * Keep this restricted-error match in sync with the Page.navigate * recovery in the chrome-extension dispatcher (host-browser-dispatcher.ts, * separate package, duplicated by necessity): the status side reports the * tab as recoverable, and the navigate side must actually recover it. */ function isRestrictedChromePageProbeError(error: CdpError): boolean { const message = error.message.toLowerCase(); return ( message.includes("chrome://") || message.includes("cannot be scripted") ); } /** * Parse browser_mode from input and acquire a CdpClient. Returns * either a `{ cdp, browserMode }` pair on success or a pre-formatted * `{ errorResult }` on failure (invalid mode or pinned-mode * precondition not met). * * This is the single integration point for all CDP-backed tool * functions. Using it ensures every tool: * - normalizes aliases (`cdp-debugger` -> `cdp-inspect`, etc.) * - passes the mode preference to the factory * - surfaces a remediation-rich error on pinned-mode failures * * Per-conversation stickiness: when the incoming `browser_mode` is * `"auto"` and the conversation has already resolved to a backend * kind on a prior call, the factory is pinned to that kind instead * of re-running the auto priority list. This prevents * `browser_navigate` (e.g. pinned to `local`) and `browser_screenshot` * (default auto) in the same conversation from landing on different * Chrome instances. Explicit non-auto modes override and update the * memo; teardown via browser_close / browser_detach clears it. * * The returned client is wrapped so its first successful `send()` * writes the resolved kind back to the conversation memo. */ async function acquireCdpClientWithMode( input: Record, context: ToolContext, ): Promise< | { cdp: ReturnType; browserMode: BrowserMode; errorResult?: never; } | { cdp?: never; browserMode?: never; errorResult: ToolExecutionResult } > { const modeResult = parseBrowserMode(input); if (!modeResult.ok) { return { errorResult: { content: modeResult.error, isError: true }, }; } const browserMode = modeResult.mode; const targetClientId = typeof input.target_client_id === "string" && input.target_client_id !== "" ? input.target_client_id : undefined; const rememberedKind = browserManager.getPreferredBackendKind( context.conversationId, ); // target_client_id requires the extension proxy path — bypass any sticky // backend remembered from prior turns so the explicit target always wins. // InternalBrowserMode because the memo may hold "host-bridge", which is // pinnable by the factory but never user-requestable. const effectiveMode: InternalBrowserMode = targetClientId != null ? "extension" : browserMode === "auto" && rememberedKind !== null ? rememberedKind : browserMode; // Extension-pinned dispatch (explicit `--browser-mode extension` or a // `target_client_id`) hard-fails when the extension is momentarily // absent. Absorb a brief reconnect blip before selecting the backend. if (effectiveMode === "extension") { await HostBrowserProxy.instance.waitForExtensionClient( context.sourceActorPrincipalId, targetClientId, ); } try { const raw = getCdpClient(context, { mode: effectiveMode, targetClientId }); const cdp = wrapWithKindMemo(raw, context.conversationId); return { cdp, browserMode }; } catch (err) { // Sticky-mode fallback: the caller requested "auto" but we pinned to // a remembered backend kind that has since become unavailable. Drop // the stale memo and retry with fresh auto selection so a dead // sticky preference doesn't surface as a hard failure. // Do not apply this fallback when target_client_id is set — a targeting // failure must surface as an error, not silently route elsewhere. if ( browserMode === "auto" && effectiveMode !== "auto" && targetClientId == null ) { browserManager.clearPreferredBackendKind(context.conversationId); try { const raw = getCdpClient(context, { mode: "auto", targetClientId }); const cdp = wrapWithKindMemo(raw, context.conversationId); return { cdp, browserMode }; } catch (retryErr) { if (retryErr instanceof CdpError) { return { errorResult: { content: formatModeSelectionFailure("auto", retryErr), isError: true, }, }; } throw retryErr; } } if (err instanceof CdpError && browserMode !== "auto") { return { errorResult: { content: formatModeSelectionFailure(browserMode, err), isError: true, }, }; } throw err; } } /** * Wrap a {@link ScopedCdpClient} so the first successful `send()` * records the resolved backend kind in the conversation's * `preferredBackendKinds` memo. Subsequent sends are no-ops for the * memo; dispose() delegates to the underlying client. */ function wrapWithKindMemo( inner: ReturnType, conversationId: string, ): ReturnType { let recorded = false; return { get kind() { return inner.kind; }, conversationId: inner.conversationId, async send( method: string, params?: Record, signal?: AbortSignal, ): Promise { const result = await inner.send(method, params, signal); if (!recorded) { browserManager.setPreferredBackendKind(conversationId, inner.kind); recorded = true; } return result; }, dispose(): void { inner.dispose(); }, // Proxy the optional setCdpSessionId through to the underlying // client so the navigate executor's --new-tab path can pin the // freshly-created tab onto the extension CDP client. We define // the method unconditionally here (rather than only when the // inner client implements it) so callers can use a simple optional // chain on this wrapper without re-walking the inner reference. setCdpSessionId(cdpSessionId: string): void { inner.setCdpSessionId?.(cdpSessionId); }, listTabs() { return inner.listTabs(); }, selectTab(tabId: number) { return inner.selectTab(tabId); }, closeTab(tabId: number) { return inner.closeTab(tabId); }, }; } // ── CDP error diagnostics helper ───────────────────────────────────── /** * Check whether a caught error is a {@link CdpError} carrying * {@link AttemptDiagnostic attempt diagnostics} from the factory's * failover walk. When the browser_mode is pinned (not "auto") and * diagnostics are present, format the error with the full remediation * checklist via {@link formatModeSelectionFailure}. Otherwise return * `null` so the caller falls through to its generic error message. * * This handles the case where pinned-mode unavailability is surfaced * on the first `cdp.send()` (via `sendWithFailover`) rather than * during client construction (which `acquireCdpClientWithMode` already * covers). */ function formatCdpSendDiagnostics( err: unknown, browserMode: BrowserMode, ): string | null { if ( err instanceof CdpError && browserMode !== "auto" && err.code === "transport_error" && err.attemptDiagnostics ) { return formatModeSelectionFailure(browserMode, err); } return null; } // ── Shared element resolution ──────────────────────────────────────── /** * Discriminated union returned by {@link resolveElement}. The * `"backend"` variant is produced when an `element_id` from the most * recent AX-tree snapshot is resolved to a CDP `backendNodeId`; the * `"selector"` variant is produced when the caller passed a raw CSS * `selector` that should be resolved via `DOM.querySelector` at * send-time by the individual tool. * * Consumed by CDP-native interaction tools (click, hover, type, …) * that talk to CDP directly. */ export type ResolvedElement = | { kind: "backend"; backendNodeId: number; eid: string } | { kind: "selector"; selector: string }; /** * Resolve an element reference (either `element_id` from a prior * snapshot or a raw `selector`) for CDP-native tools. Returns a * {@link ResolvedElement} discriminated union so callers can branch * on whether a backendNodeId was recovered from the snapshot map. * Returns `{ resolved: null, error: "Error: …" }` on invalid input * or when an `element_id` is provided but the snapshot map is * empty/stale. */ function resolveElement( conversationId: string, input: Record, ): { resolved: ResolvedElement | null; error: string | null } { const elementId = typeof input.element_id === "string" ? input.element_id : null; const rawSelector = typeof input.selector === "string" ? input.selector : null; if (!elementId && !rawSelector) { return { resolved: null, error: "Error: Either element_id or selector is required.", }; } if (elementId) { const backendNodeId = browserManager.resolveSnapshotBackendNodeId( conversationId, elementId, ); if (backendNodeId !== null) { return { resolved: { kind: "backend", backendNodeId, eid: elementId }, error: null, }; } return { resolved: null, error: `Error: element_id "${elementId}" not found. Run a snapshot first to get current element IDs.`, }; } return { resolved: { kind: "selector", selector: rawSelector! }, error: null, }; } // ── browser_navigate ───────────────────────────────────────────────── export async function executeBrowserNavigate( input: Record, context: ToolContext, ): Promise { if (context.signal?.aborted) { return { content: "Error: operation was cancelled", isError: true }; } // Pre-flight URL validation runs before CDP acquisition so we fail // fast on obviously invalid URLs without opening a browser session. const parsedUrl = parseUrl(input.url); if (!parsedUrl) { return { content: "Error: url is required and must be a valid HTTP(S) URL", isError: true, }; } if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { return { content: "Error: url must use http or https", isError: true }; } const allowPrivateNetwork = input.allow_private_network === true; const safeRequestedUrl = sanitizeUrlForOutput(parsedUrl); // Block private/local targets by default. Runs before any CDP session // is opened so we fail fast on obviously invalid URLs. if (!allowPrivateNetwork && isPrivateOrLocalHost(parsedUrl.hostname)) { return { content: `Error: Refusing to navigate to local/private network target (${parsedUrl.hostname}). Set allow_private_network=true if you explicitly need it.`, isError: true, }; } // DNS resolution check for non-literal hostnames. if (!allowPrivateNetwork) { const resolution = await resolveRequestAddress( parsedUrl.hostname, resolveHostAddresses, allowPrivateNetwork, ); if (resolution.blockedAddress) { return { content: `Error: Refusing to navigate to target (${parsedUrl.hostname}) because it resolves to local/private network address ${resolution.blockedAddress}. Set allow_private_network=true if you explicitly need it.`, isError: true, }; } } // URL validation passed — acquire the CDP client. const acquired = await acquireCdpClientWithMode(input, context); if (acquired.errorResult) { return acquired.errorResult; } const { cdp, browserMode } = acquired; // Tab routing on the extension backend. By default the assistant // navigates in its own dedicated tab so it never clobbers the tab the // user is on (frequently the very tab they're chatting with the // assistant from). The dedicated tab is opened once per conversation // via the extension's `Vellum.createTab` pseudo-CDP method and pinned, // so this Page.navigate and every subsequent command route to it: // - first navigate (no pin yet) → open + pin a fresh tab. // - later navigates (pin exists) → reuse the pinned tab; the `cdp` // client was already constructed routed to it. // - `--new-tab` → force a brand-new tab even when one is pinned. // - `--use-active-tab` → opt out and navigate the currently-active // tab instead. // Extension backend only; the local (Playwright) backend manages its // own isolated browser and the cdp-inspect backend connects to a // single tab by URL pattern, so neither has a user tab to disturb. const useActiveTab = input.use_active_tab === true; const forceNewTab = input.new_tab === true; const targetClientId = typeof input.target_client_id === "string" && input.target_client_id !== "" ? input.target_client_id : undefined; if (cdp.kind === "extension" && useActiveTab) { // Explicit opt-out: target the currently-active tab. Clear any // conversation pin and reset the live session so this navigate is // authoritative — otherwise a pin from an earlier navigate would // still capture the command and route it to the dedicated tab. clearPinnedTab(context.conversationId, targetClientId); cdp.setCdpSessionId?.(undefined); } else if (cdp.kind === "extension") { const alreadyPinned = getPinnedTab(context.conversationId, targetClientId) !== undefined; if (forceNewTab || !alreadyPinned) { try { const result = await cdp.send<{ tabId?: number | string; clientId?: string; }>("Vellum.createTab", {}, context.signal); const tabId = typeof result?.tabId === "number" ? String(result.tabId) : typeof result?.tabId === "string" ? result.tabId : undefined; const clientId = typeof result?.clientId === "string" && result.clientId.length > 0 ? result.clientId : undefined; if (!tabId) { // Malformed createTab response (no tabId). We're nominally falling // back to active-tab routing — but the live `cdp` instance was // already constructed with whatever pin was in scope for this // conversation, AND the pin store still holds it for future // client construction. Clear both: the pin store (so the next // executeBrowserNavigate builds a clean client) AND the current // cdp instance's session (so the Page.navigate that runs in a // few lines targets the active tab rather than the stale pin). // Without the setCdpSessionId(undefined) call, the warn message // is a lie: navigation would still route to the dead tab via the // already-injected cdpSessionId and likely fail with // cdp_session_not_found. clearPinnedTab(context.conversationId, targetClientId); cdp.setCdpSessionId?.(undefined); log.warn( { conversationId: context.conversationId, result }, "Vellum.createTab returned no tabId; cleared stale pin and live session, falling back to active-tab routing", ); } else { cdp.setCdpSessionId?.(tabId); setPinnedTab(context.conversationId, tabId, clientId); log.debug( { conversationId: context.conversationId, tabId, clientId }, "Opened dedicated tab for navigation; pinned subsequent ops to it", ); } } catch (err) { // Surface the failure rather than silently clobbering the active // tab — that's exactly the behavior a dedicated tab is supposed // to avoid. Clear any stale pin so subsequent ops don't route to // a dead tab. Note: an old extension build without Vellum.createTab // support will land here (CDP returns an "unknown method" error). // We're early-returning before the main try/finally block below, // so we must dispose the cdp client manually to avoid leaking it. clearPinnedTab(context.conversationId, targetClientId); const message = err instanceof Error ? err.message : String(err); log.warn( { conversationId: context.conversationId, err }, "Vellum.createTab failed; aborting navigate", ); try { cdp.dispose(); } catch (disposeErr) { log.warn( { conversationId: context.conversationId, err: disposeErr }, "Failed to dispose CDP client after Vellum.createTab failure", ); } return { content: `Error: Failed to open a new tab for navigation: ${message}. The Chrome extension may need an update to support opening tabs. Pass --use-active-tab to navigate the currently-active tab instead.`, isError: true, }; } } } else if (forceNewTab) { log.debug( { conversationId: context.conversationId, backendKind: cdp.kind }, "--new-tab requested but backend does not support it; ignoring", ); } // Screencast + handoff are Playwright-backed and only meaningful // for the local sacrificial-profile path. On the extension path the // user already has their own Chrome window, so both are no-ops. const sender = cdp.kind === "local" ? getSender(context.conversationId) : null; if (cdp.kind === "local" && sender) { await ensureScreencast(context.conversationId); } // SSRF route interception uses the Playwright page.route() API to // block redirect-time requests to private networks. This only works // on the local path where Playwright manages the browser; on the // extension/cdp-inspect paths, CDP navigates a different browser so // the Playwright route handler would be a no-op. The post-navigation // final URL check below provides defense-in-depth for all paths. let routeHandler: RouteHandler | null = null; let blockedUrl: string | null = null; try { log.debug( { url: safeRequestedUrl, conversationId: context.conversationId }, "Navigating", ); if ( cdp.kind === "local" && !allowPrivateNetwork && browserManager.supportsRouteInterception ) { // Cache DNS results per-hostname to avoid redundant lookups on subrequests // (heavy sites like DoorDash fire hundreds of requests to the same CDN hostnames). // Use a short TTL to mitigate DNS rebinding attacks where a hostname first // resolves to a public IP then later to a private one. Blocked results are // never cached so they are always re-resolved. const DNS_CACHE_TTL_MS = 5_000; const dnsCache = new Map< string, { addresses: string[]; blockedAddress?: string; cachedAt: number } >(); routeHandler = async (route, request) => { try { const reqUrl = request.url(); let reqParsed: URL; try { reqParsed = new URL(reqUrl); } catch { await route.continue(); return; } // Check hostname against private/local patterns if (isPrivateOrLocalHost(reqParsed.hostname)) { blockedUrl = sanitizeUrlForOutput(reqParsed); log.warn( { blockedUrl }, "Blocked navigation to private network target via redirect", ); await route.abort("blockedbyclient"); return; } // Resolve DNS and check resolved addresses (cached per hostname with TTL). // Blocked results are never cached to ensure re-resolution catches // DNS rebinding where a hostname flips from public to private IP. let cached = dnsCache.get(reqParsed.hostname); const now = Date.now(); if (cached && now - cached.cachedAt > DNS_CACHE_TTL_MS) { dnsCache.delete(reqParsed.hostname); cached = undefined; } const resolution = cached ?? (await (async () => { const res = await resolveRequestAddress( reqParsed.hostname, resolveHostAddresses, false, ); // Only cache allowed results; blocked results must be re-resolved if (!res.blockedAddress) { dnsCache.set(reqParsed.hostname, { ...res, cachedAt: now }); } return res; })()); if (resolution.blockedAddress) { blockedUrl = sanitizeUrlForOutput(reqParsed); log.warn( { blockedUrl, resolvedTo: resolution.blockedAddress }, "Blocked navigation: DNS resolves to private address", ); await route.abort("blockedbyclient"); return; } await route.continue(); } catch (err) { // Route may already be handled if the page navigated or was closed log.debug( { err }, "Route handler error (route likely already handled)", ); } }; // Bridge through browserManager to reach the Playwright Page for // route installation. The route handler intercepts redirect-time // requests before Page.navigate's network fetches can hit them. const page = await browserManager.getOrCreateSessionPage( context.conversationId, ); await page.route("**/*", routeHandler); } // Read the current URL BEFORE calling navigateAndWait so we can // detect the "page never moved" case on timeout. This may fail if // the active tab is on a chrome:// or other privileged URL where // Runtime.evaluate is blocked — in that case we proceed without the // baseline and skip the "page never moved" timeout heuristic. let urlBeforeNav = ""; try { urlBeforeNav = await getCurrentUrl(cdp, context.signal); } catch { log.debug( { conversationId: context.conversationId }, "Could not read current URL before navigation (tab may be on a privileged page)", ); } // Navigate via CDP Page.navigate + document.readyState polling. // navigateAndWait returns { finalUrl, timedOut }; HTTP status is // not available on the CDP path because Page.navigate does not // surface the response status. const { finalUrl, timedOut: navigationTimedOut } = await navigateAndWait( cdp, parsedUrl.href, { timeoutMs: NAVIGATE_TIMEOUT_MS }, context.signal, ); // Defense-in-depth: check the final URL after navigation completes. // This catches redirect-based SSRF even when Playwright route // interception is unavailable (e.g. extension-backed sessions where // the CDP transport is separate from the Playwright page). if (!allowPrivateNetwork) { const finalParsed = parseUrl(finalUrl); if ( finalParsed && (isPrivateOrLocalHost(finalParsed.hostname) || ( await resolveRequestAddress( finalParsed.hostname, resolveHostAddresses, false, ) ).blockedAddress) ) { // Navigate the page away from the private target to prevent // follow-up operations (e.g. snapshot) from reading the // already-loaded private content. try { await navigateAndWait( cdp, "about:blank", { timeoutMs: 3_000 }, context.signal, ); } catch { // Best-effort — if the reset fails, the CDP session will be // disposed in the finally block anyway. } // Clean up the route handler before returning to avoid leaking // a stale interception handler on the session page. if (routeHandler) { const page = await browserManager.getOrCreateSessionPage( context.conversationId, ); await page.unroute("**/*", routeHandler); routeHandler = null; } return { content: `Error: Navigation blocked. Final URL resolved to a local/private network target (${sanitizeUrlForOutput(finalParsed)}). Set allow_private_network=true if you explicitly need it.`, isError: true, }; } } if (navigationTimedOut) { // If the page URL never changed from before navigation, the page // never actually loaded - re-throw instead of reporting success. if (finalUrl === urlBeforeNav && urlBeforeNav !== parsedUrl.href) { throw new Error( `Navigation to ${parsedUrl.href} timed out after ${NAVIGATE_TIMEOUT_MS}ms`, ); } log.info( { url: safeRequestedUrl }, "Navigation timed out waiting for document.readyState, continuing with partial load", ); } // Remove the Playwright route handler now that navigation is // complete (local path only — route interception is gated above). if (routeHandler) { const page = await browserManager.getOrCreateSessionPage( context.conversationId, ); await page.unroute("**/*", routeHandler); routeHandler = null; } // Window positioning is a Playwright-internal affordance - on the // extension path the user owns their Chrome window, so positioning // is a no-op. if ( cdp.kind === "local" && !browserManager.isInteractive(context.conversationId) ) { await browserManager.positionWindowSidebar(); } if (blockedUrl) { return { content: `Error: Navigation blocked. A request targeted a local/private network address (${blockedUrl}). Set allow_private_network=true if you explicitly need it.`, isError: true, }; } // Navigation changed the page content, so clear stale snapshot // mappings regardless of backend. The backendNodeId map is shared // per-conversation state that needs to be invalidated on any nav. browserManager.clearSnapshotBackendNodeMap(context.conversationId); // Auto-dismiss common blocker modals (regulatory notices, cookie // banners) that aren't exposed in the accessibility tree. Runs // silently - if no modal is present the evaluate is a no-op. try { await evaluateExpression( cdp, DISMISS_MODALS_EXPRESSION, {}, context.signal, ); } catch { // Page may have navigated during evaluate - safe to ignore } const safeFinalUrl = truncate( sanitizeUrlForOutput(new URL(finalUrl)), MAX_PAGE_HEADER_CHARS, ); const title = await readPageTitle(cdp, context.signal); // The document title is page-authored, so it is fenced separately // from the tool's own scaffolding lines. const lines: string[] = [ `Requested URL: ${safeRequestedUrl}`, `Final URL: ${safeFinalUrl}`, fencePageContent(`Title: ${title || "(none)"}`, safeFinalUrl), ]; if (navigationTimedOut) { lines.push( `Note: Page is still loading (document.readyState timed out). The page should still be interactive - take a snapshot to check.`, ); } if (finalUrl !== parsedUrl.href) { lines.push(`Note: Page redirected from the requested URL.`); } // Detect auth challenges (login pages, 2FA, OAuth consent) and CAPTCHA // challenges via the CDP-migrated auth-detector helpers. try { const authChallenge = await detectAuthChallenge(cdp, context.signal); const captchaChallenge = await detectCaptchaChallenge( cdp, context.signal, ); // CAPTCHA takes priority - it blocks all interaction including login let challenge = captchaChallenge ?? authChallenge; // Many CAPTCHA interstitials (e.g. Cloudflare "Just a moment") auto-resolve // within a few seconds. Wait and re-check before handing off to the user. if (challenge?.type === "captcha") { log.info("CAPTCHA detected, waiting up to 5s for auto-resolve"); for (let i = 0; i < 5; i++) { if (context.signal?.aborted) { return { content: "Navigation cancelled.", isError: true }; } await new Promise((r) => setTimeout(r, 1000)); const still = await detectCaptchaChallenge(cdp, context.signal); if (!still) { log.info("CAPTCHA auto-resolved"); // Re-check for auth challenge now that CAPTCHA is gone - // the page may have loaded a login form behind it. challenge = await detectAuthChallenge(cdp, context.signal); break; } } } if (challenge) { if (challenge.type === "captcha") { // CAPTCHA persisted after auto-resolve wait - hand off to user // only when we have a local Playwright-managed Chrome window // AND a sender is registered. The extension path falls back // to the text-only "solve manually" branch because the user // already owns their Chrome window. if (cdp.kind === "local" && sender) { const { startHandoff } = await import("./browser-handoff.js"); await startHandoff(context.conversationId, { reason: "captcha", message: "Cloudflare verification detected. Please solve the CAPTCHA in the Chrome window. The browser will automatically detect when you're done and resume.", bringToFront: true, }); const newUrl = await readPageUrl(cdp, context.signal); const newTitle = await readPageTitle(cdp, context.signal); lines.push(""); lines.push("CAPTCHA solved by user. Current page:"); lines.push(fencePageContent(`${newTitle} (${newUrl})`, newUrl)); // Re-check for auth challenges - the page behind the CAPTCHA may have a login form const postCaptchaAuth = await detectAuthChallenge( cdp, context.signal, ); if (postCaptchaAuth) { lines.push(""); lines.push( fencePageContent( formatAuthChallenge(postCaptchaAuth), authChallengeOrigin(postCaptchaAuth, safeFinalUrl), ), ); lines.push(""); lines.push("Handle this by interacting with the login form:"); lines.push( "1. Take a snapshot to find the sign-in form elements", ); lines.push( "2. Use credential fill to enter email/password from the credential vault", ); lines.push( "3. For email verification codes, use ui_show with a form to request the code mid-turn", ); lines.push( "4. Do NOT give up or suggest manual sign-in - handle the login flow yourself", ); } } else { lines.push(""); lines.push( "⚠️ CAPTCHA/Cloudflare verification detected on this page.", ); lines.push( "This challenge requires human verification. Surface this clearly: the page cannot be accessed until the verification is solved manually.", ); } } else { // Login / 2FA / OAuth - the agent should handle these itself // using browser operations + stored credentials. Don't hand off. // The service name and field labels come from the page, so the // formatted challenge is fenced; the remediation steps below are // the tool's own instructions and stay outside. lines.push(""); lines.push( fencePageContent( formatAuthChallenge(challenge), authChallengeOrigin(challenge, safeFinalUrl), ), ); lines.push(""); lines.push("Handle this by interacting with the login form:"); lines.push("1. Take a snapshot to find the sign-in form elements"); lines.push( "2. Use credential fill to enter email/password from the credential vault", ); lines.push( "3. For email verification codes, use ui_show with a form to request the code mid-turn", ); lines.push( "4. Do NOT give up or suggest manual sign-in - handle the login flow yourself", ); } } } catch { // Auth/CAPTCHA detection is best-effort; don't fail navigation } return { content: lines.join("\n"), isError: false }; } catch (err) { // Best-effort cleanup of route handler on error (local path only) if (routeHandler) { try { const page = await browserManager.getOrCreateSessionPage( context.conversationId, ); await page.unroute("**/*", routeHandler); } catch { /* ignore cleanup errors */ } } // If the route handler blocked a redirect to a private network address, // Page.navigate throws. Return the clear security message instead of // the raw underlying error (which could leak credentials from the URL). if (blockedUrl) { return { content: `Error: Navigation blocked. A request targeted a local/private network address (${blockedUrl}). Set allow_private_network=true if you explicitly need it.`, isError: true, }; } const diagnosticMessage = formatCdpSendDiagnostics(err, browserMode); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err, url: safeRequestedUrl }, "Navigation failed"); return { content: `Error: Navigation failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── snapshot ───────────────────────────────────────────────────────── export async function executeBrowserSnapshot( _input: Record, context: ToolContext, ): Promise { const acquired = await acquireCdpClientWithMode(_input, context); if (acquired.errorResult) { return acquired.errorResult; } const { cdp, browserMode } = acquired; try { const currentUrl = await readPageUrl(cdp, context.signal); const title = await readPageTitle(cdp, context.signal); // Pull the full accessibility tree via CDP and fold it into typed // interactive elements + an `eid → backendNodeId` map. Interaction // tools (click, hover, type, …) resolve element_id against this map // and jump straight to CDP DOM commands without another round-trip // through any selector engine. await cdp.send("Accessibility.enable", {}, context.signal); const rawTree = await cdp.send( "Accessibility.getFullAXTree", {}, context.signal, ); const { elements, selectorMap: backendNodeMap } = transformAxTree(rawTree); browserManager.storeSnapshotBackendNodeMap( context.conversationId, backendNodeMap, ); // The whole snapshot is page-authored — element roles, accessible // names, attribute values and the title all come from the DOM — so // the entire payload goes inside the fence. Element ids stay usable: // fencing marks the block as data, it does not hide it. return { content: fencePageContent( formatAxSnapshot( { elements, selectorMap: backendNodeMap }, { url: currentUrl, title }, ), currentUrl, MAX_SNAPSHOT_FENCE_CHARS, ), isError: false, }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics(err, browserMode); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Snapshot failed"); return { content: `Error: Snapshot failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── browser_screenshot ─────────────────────────────────────────────── export async function executeBrowserScreenshot( input: Record, context: ToolContext, ): Promise { const acquired = await acquireCdpClientWithMode(input, context); if (acquired.errorResult) { return acquired.errorResult; } const { cdp, browserMode } = acquired; const fullPage = input.full_page === true; try { const buffer = await captureScreenshotJpeg( cdp, { quality: 80, fullPage }, context.signal, ); const rawBase64 = buffer.toString("base64"); // Downscale before handing the screenshot to the model. A full-page // capture of a tall or high-DPI page can exceed Anthropic's 5 MB // per-image cap, which would otherwise persist into the conversation // history inside this tool_result and reject every subsequent turn. const { data: base64Data, mediaType } = await optimizeImageForTransport( rawBase64, "image/jpeg", ); const imageBlock: ImageContent = { type: "image" as const, source: { type: "base64" as const, media_type: mediaType, data: base64Data, }, }; return { content: `Screenshot captured (${buffer.length} bytes, ${ fullPage ? "full page" : "viewport" })`, isError: false, contentBlocks: [imageBlock], }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics(err, browserMode); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Screenshot failed"); return { content: `Error: Screenshot failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── browser_attach ─────────────────────────────────────────────────── export async function executeBrowserAttach( _input: Record, context: ToolContext, ): Promise { const acquired = await acquireCdpClientWithMode(_input, context); if (acquired.errorResult) { return acquired.errorResult; } const cdp = acquired.cdp; try { if (cdp.kind === "extension") { // Extension path: explicitly attach the debugger via a synthetic // Vellum.attach command so the debugging session is established // before any navigation or interaction. const result = await cdp.send<{ attached?: boolean; target?: unknown }>( "Vellum.attach", {}, context.signal, ); log.debug( { conversationId: context.conversationId, result }, "Browser debugger attached (extension)", ); return { content: "Browser debugger attached.", isError: false, }; } // Non-extension backends (local / cdp-inspect): explicit attach is // not required — the backend manages its own connection lifecycle. // Return a deterministic no-op success. return { content: "Browser session ready. (Explicit attach is not required on this backend.)", isError: false, }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics( err, acquired.browserMode, ); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Attach failed"); return { content: `Error: Attach failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── browser_detach ────────────────────────────────────────────────── export async function executeBrowserDetach( _input: Record, context: ToolContext, ): Promise { const acquired = await acquireCdpClientWithMode(_input, context); if (acquired.errorResult) { return acquired.errorResult; } const cdp = acquired.cdp; try { if (cdp.kind === "extension") { // Extension path: explicitly detach the debugger via a synthetic // Vellum.detach command so the Chrome debugging banner clears. const result = await cdp.send<{ detached?: boolean; target?: unknown }>( "Vellum.detach", {}, context.signal, ); log.debug( { conversationId: context.conversationId, result }, "Browser debugger detached (extension)", ); } return { content: "Browser debugger detached and snapshot state cleared.", isError: false, }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics( err, acquired.browserMode, ); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Detach failed"); return { content: `Error: Detach failed: ${msg}`, isError: true }; } finally { // Always reset conversation-scoped browser state, even if the // Vellum.detach round-trip failed (target gone, transport dropped). // browser_detach is the user's recovery path — leaving a stale // sticky backend or snapshot map behind would defeat its purpose. browserManager.clearSnapshotBackendNodeMap(context.conversationId); browserManager.clearPreferredBackendKind(context.conversationId); cdp.dispose(); } } // ── browser_close ──────────────────────────────────────────────────── export async function executeBrowserClose( input: Record, context: ToolContext, ): Promise { const acquired = await acquireCdpClientWithMode(input, context); if (acquired.errorResult) { return acquired.errorResult; } const cdp = acquired.cdp; try { if (cdp.kind === "local") { // Local/sacrificial-profile path: tear down the Playwright page, // screencast, and associated CDP state for this conversation. const sender = getSender(context.conversationId); if (sender) { await stopBrowserScreencast(context.conversationId); } if (input.close_all_pages === true) { await stopAllScreencasts(); await browserManager.closeAllPages(); return { content: "All browser pages and context closed.", isError: false, }; } await browserManager.closeSessionPage(context.conversationId); return { content: "Browser page closed for this conversation.", isError: false, }; } // Non-local path: the user owns their Chrome tab — we must not // close it. On the extension backend, detach the debugger (so the // Chrome debugging banner clears promptly); other backends have no // Vellum.detach. Either way drop the cached snapshot state so stale // eids from prior snapshots cannot be resolved by later tool calls. if (cdp.kind === "extension") { try { await cdp.send("Vellum.detach", {}, context.signal); } catch { // Tolerate detach failures (already detached, tab closed, etc.) } } browserManager.clearSnapshotBackendNodeMap(context.conversationId); browserManager.clearPreferredBackendKind(context.conversationId); return { content: "Browser session cleared. (Your Chrome tab was not closed — close it yourself if desired.)", isError: false, }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics( err, acquired.browserMode, ); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Close failed"); return { content: `Error: Close failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── browser_click ──────────────────────────────────────────────────── export async function executeBrowserClick( input: Record, context: ToolContext, ): Promise { const { resolved, error } = resolveElement(context.conversationId, input); if (error) { return { content: error, isError: true }; } const acquired = await acquireCdpClientWithMode(input, context); if (acquired.errorResult) { return acquired.errorResult; } const cdp = acquired.cdp; try { let backendNodeId: number; if (resolved!.kind === "backend") { backendNodeId = resolved!.backendNodeId; } else { // Wait until the selector matches a visible element. Mirrors // Playwright's `page.click(selector, { timeout })` semantics // and lets click work on async-hydrated pages where the // target may not yet exist when the tool is invoked. // cdpWaitForSelector returns the backendNodeId so we don't // need a separate querySelectorBackendNodeId round-trip. backendNodeId = await cdpWaitForSelector( cdp, resolved!.selector, ACTION_TIMEOUT_MS, context.signal, ); } await scrollIntoViewIfNeeded(cdp, backendNodeId, context.signal); const point = await getCenterPoint(cdp, backendNodeId, context.signal); await dispatchClickAt(cdp, point, context.signal); const desc = resolved!.kind === "backend" ? `eid=${resolved!.eid}` : resolved!.selector; return { content: `Clicked element: ${desc}`, isError: false }; } catch (err) { const diagnosticMessage = formatCdpSendDiagnostics( err, acquired.browserMode, ); if (diagnosticMessage) { return { content: diagnosticMessage, isError: true }; } const msg = err instanceof Error ? err.message : String(err); log.error({ err }, "Click failed"); return { content: `Error: Click failed: ${msg}`, isError: true }; } finally { cdp.dispose(); } } // ── Shared input helpers ───────────────────────────────────────────── /** * Focus an element, clear its existing value (handling both * ``/`