/** * The agent-facing page API. * * Design goal: an LLM agent should never write a CSS/XPath selector. Selectors * are the #1 source of automation breakage. Instead, `snapshot()` returns the * page as a flat, numbered list of meaningful elements pulled from Chrome's * accessibility tree — the same semantic layer a screen reader sees. The agent * acts on a stable integer `ref`; we resolve the geometry and drive real input. */ import type { CDP } from "./cdp.js"; import { buildStealth } from "./stealth.js"; import { buildFingerprintStealth, buildClientHints, buildAcceptLanguage, type Fingerprint } from "./fingerprint.js"; import { Rng, mousePath, moveDelay, keyDelay, sleep, type Point } from "./human.js"; export interface Element { ref: number; role: string; name: string; value?: string; center: Point; } export interface Snapshot { url: string; title: string; /** Human/agent-readable index, e.g. `[3] button "Sign in"`. */ text: string; elements: Element[]; } /** One actionable element from domSnapshot() — located in-page, addressed by * viewport coordinates (feed x,y straight to clickAt), not an AX-tree ref. */ export interface DomElement { /** Position in this snapshot's list (not stable across snapshots). */ i: number; tag: string; role: string; /** Best available label: aria-label ▸ innerText ▸ value ▸ placeholder ▸ title ▸ name. */ t: string; /** Center of the element in viewport coordinates — pass to clickAt(x, y). */ x: number; y: number; w: number; h: number; editable: boolean; } export interface DomSnapshot { url: string; title: string; /** `[0] button "Sign in" @120,340` per line. */ text: string; elements: DomElement[]; } /** One account offered in a FedCM account chooser. */ export interface FedCmAccount { accountId: string; email?: string; name?: string; givenName?: string; idpConfigUrl?: string; } /** A FedCM dialog Chrome would normally render as native browser UI. */ export interface FedCmDialog { dialogId: string; /** "AccountChooser" | "AutoReauthn" | "ConfirmIdpLogin" | "SelectAccount" ... */ type: string; title?: string; subtitle?: string; accounts: FedCmAccount[]; } const INTERESTING = new Set([ "button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio", "menuitem", "menuitemcheckbox", "tab", "switch", "slider", "option", "listbox", "spinbutton", "textarea", ]); /** * True if `url` targets a loopback / private-network host. Fingerprinters * (iphey, pixelscan, …) port-scan these from page JS to profile the machine's * OTHER software — VNC on :5900, a local automation API on :3001, etc. — which * also leaks your LAN to every site you visit. Exotic IP encodings (decimal, * hex) are a known gap; real-world scanners use the canonical forms below. */ /** * Is this URL inside the agent's permitted origins? * * The problem (raised by u/zhonglin on r/AI_Agents): `Browser.connect()` to a * signed-in Chrome puts EVERY logged-in session inside the agent's reach. An agent * told "check my Amazon order" can navigate to mail.google.com and read the mailbox, * because it is the same browser with the same cookie jar. Documentation asks the * operator to be careful; this is the mechanism that doesn't. * * An entry matches its own host and its subdomains — `"github.com"` allows * `github.com` and `api.github.com`, but NOT `notgithub.com` (suffix matching without * the dot boundary is the classic hole). A scheme, port or path in an entry is * ignored: this gates WHERE the agent may go, not how it gets there. * * Non-http(s) URLs are always allowed — `about:blank` is how a tab starts and * `data:`/`blob:` carry no ambient credentials, so gating them buys nothing and * breaks newPage(). */ export function isOriginAllowed(url: string, allow: string[]): boolean { if (!allow.length) return true; // no list configured = no restriction let u: URL; try { u = new URL(url); } catch { return true; // unparseable: leave it to the other guards rather than hard-failing } if (u.protocol !== "http:" && u.protocol !== "https:") return true; const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, ""); return allow.some((entry) => { let e = entry.trim().toLowerCase(); if (!e) return false; if (e.includes("://")) e = e.slice(e.indexOf("://") + 3); // tolerate a full origin e = e.split("/")[0].replace(/:\d+$/, "").replace(/^\*\./, ""); // drop path, port, leading *. e = e.replace(/^\[|\]$/g, ""); return host === e || host.endsWith(`.${e}`); }); } export function isPrivateHost(url: string): boolean { let host: string; try { host = new URL(url).hostname.toLowerCase(); } catch { return false; } host = host.replace(/^\[|\]$/g, ""); // strip IPv6 brackets if (host === "localhost" || host.endsWith(".localhost")) return true; if (host === "::1" || host === "0.0.0.0") return true; // IPv6 unique-local (fc00::/7) — the v6 analog of RFC1918 LAN space. The // first hextet is fc00–fdff (never abbreviated, so exactly 4 hex chars). if (/^f[cd][0-9a-f]{2}:/.test(host)) return true; // IPv4-mapped IPv6 (::ffff:a.b.c.d) — same IPv4 host wearing a v6 hat; a // public page could reach ::ffff:127.0.0.1 to hit loopback. The URL parser // normalizes the tail to two hex hextets (::ffff:7f00:1), so fold it back to // a dotted quad and re-apply the IPv4 rules. if (host.startsWith("::ffff:")) return isPrivateIPv4(mappedToIPv4(host.slice(7))); return isPrivateIPv4(host); } /** Fold the tail of an ::ffff: IPv4-mapped address to a dotted quad. Accepts an * already-dotted tail (a.b.c.d) or the parser's two-hextet hex form (7f00:1). */ function mappedToIPv4(tail: string): string { if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tail)) return tail; const g = tail.split(":"); if (g.length !== 2) return ""; const hi = parseInt(g[0]!, 16), lo = parseInt(g[1]!, 16); if (!Number.isFinite(hi) || !Number.isFinite(lo)) return ""; return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; } /** IPv4 dotted-quad private/loopback classifier (also reused for ::ffff: maps). */ function isPrivateIPv4(host: string): boolean { const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/); if (!m) return false; const a = +m[1]!, b = +m[2]!; return ( a === 127 || // 127.0.0.0/8 loopback a === 10 || // 10.0.0.0/8 a === 0 || // 0.0.0.0/8 (a === 100 && b >= 64 && b <= 127) ||// 100.64.0.0/10 CGNAT / Tailscale tailnet (a === 192 && b === 168) || // 192.168.0.0/16 (a === 172 && b >= 16 && b <= 31) ||// 172.16.0.0/12 (a === 169 && b === 254) // 169.254.0.0/16 link-local ); } // Only these (private) requests are intercepted, so normal browsing keeps its // exact timing — no global request pause. Globs over-capture slightly (e.g. // 172.1*); isPrivateHost() is the real gate in the handler. http/https only: // CDP's Fetch domain does not intercept WebSocket handshakes, so raw ws:// to a // private host falls back to Chrome's own Private Network Access (a timeout, not // a uniform block). Real port-scanners (and the :3001/:5900 probes) use HTTP. // 100.6/7/8/9 + 100.1 coarsely cover the CGNAT range 100.64.0.0/10 (over-capture // gated by isPrivateHost, same as 172.1*); [fc/[fd cover IPv6 unique-local // fc00::/7; [::ffff: covers IPv4-mapped IPv6. const PRIVATE_URL_PATTERNS = [ "localhost", "127.", "0.0.0.0", "10.", "192.168.", "172.1", "172.2", "172.3", "169.254.", "100.6", "100.7", "100.8", "100.9", "100.1", "[::1]", "[fc", "[fd", "[::ffff:", ].flatMap((h) => ["http", "https"].map((s) => ({ urlPattern: `${s}://${h}*` }))); /** * Friendly resource-type names → the CDP Network.ResourceType values that * Fetch.requestPaused reports and Fetch patterns filter on. Lets blockResources() * take `"image"` instead of `"Image"` and forgive a few common aliases. */ const RESOURCE_TYPES: Record = { image: "Image", images: "Image", img: "Image", font: "Font", fonts: "Font", media: "Media", video: "Media", audio: "Media", stylesheet: "Stylesheet", css: "Stylesheet", style: "Stylesheet", script: "Script", js: "Script", xhr: "XHR", fetch: "Fetch", document: "Document", doc: "Document", websocket: "WebSocket", ws: "WebSocket", eventsource: "EventSource", manifest: "Manifest", other: "Other", }; /** * US-layout physical-key descriptor for the printable symbol keys — the ones we * can't derive from the character itself. A shifted symbol shares its base key's * `code` and `windowsVirtualKeyCode` (one physical key makes both `2` and `@`), * which is exactly what a real keyboard reports. Letters, digits, and Enter are * handled programmatically in keyInfo(). */ const SYMBOL_KEYS: Record = { " ": { code: "Space", vk: 32 }, "`": { code: "Backquote", vk: 192 }, "~": { code: "Backquote", vk: 192 }, "-": { code: "Minus", vk: 189 }, "_": { code: "Minus", vk: 189 }, "=": { code: "Equal", vk: 187 }, "+": { code: "Equal", vk: 187 }, "[": { code: "BracketLeft", vk: 219 }, "{": { code: "BracketLeft", vk: 219 }, "]": { code: "BracketRight", vk: 221 }, "}": { code: "BracketRight", vk: 221 }, "\\": { code: "Backslash", vk: 220 }, "|": { code: "Backslash", vk: 220 }, ";": { code: "Semicolon", vk: 186 }, ":": { code: "Semicolon", vk: 186 }, "'": { code: "Quote", vk: 222 }, "\"": { code: "Quote", vk: 222 }, ",": { code: "Comma", vk: 188 }, "<": { code: "Comma", vk: 188 }, ".": { code: "Period", vk: 190 }, ">": { code: "Period", vk: 190 }, "/": { code: "Slash", vk: 191 }, "?": { code: "Slash", vk: 191 }, // Shifted digit row — code/vk of the underlying digit key. "!": { code: "Digit1", vk: 49 }, "@": { code: "Digit2", vk: 50 }, "#": { code: "Digit3", vk: 51 }, "$": { code: "Digit4", vk: 52 }, "%": { code: "Digit5", vk: 53 }, "^": { code: "Digit6", vk: 54 }, "&": { code: "Digit7", vk: 55 }, "*": { code: "Digit8", vk: 56 }, "(": { code: "Digit9", vk: 57 }, ")": { code: "Digit0", vk: 48 }, }; /** * Printable symbols that require Shift on a US layout — the shifted half of each * physical key. Typing one must carry the Shift modifier so `KeyboardEvent * .shiftKey` / `getModifierState('Shift')` agree with the character produced: a * "!" or "@" reported with shiftKey:false is a self-contradiction (that symbol * is unreachable without Shift) — a behavioural bot-tell, and it breaks handlers * that gate on the shift state. Uppercase A–Z are detected directly in keyInfo. */ const SHIFTED_SYMBOLS = new Set('~!@#$%^&*()_+{}|:"<>?'.split("")); /** * Resolve one character to a well-formed keystroke: the DOM `key`, the physical * `code`, the legacy `windowsVirtualKeyCode` (`vk`), the `text` to commit, and * whether the character needs the `shift` modifier held. * Filling these in is the whole point — a bare `text`-only key event leaves * `KeyboardEvent.keyCode === 0` and `code === ""`, which breaks keydown-driven * UIs and is a hard bot-tell on login forms. Letters, digits, Enter and the US * symbol keys are covered; anything else (accented/CJK/emoji) degrades to a * plain text commit, the way an IME delivers a composed character. */ export function keyInfo(ch: string): { key: string; code: string; vk: number; text: string; shift: boolean } { if (ch === "\n" || ch === "\r") return { key: "Enter", code: "Enter", vk: 13, text: "\r", shift: false }; if (/^[a-z]$/i.test(ch)) { const upper = ch.toUpperCase(); return { key: ch, code: `Key${upper}`, vk: upper.charCodeAt(0), text: ch, shift: /^[A-Z]$/.test(ch) }; } if (/^[0-9]$/.test(ch)) return { key: ch, code: `Digit${ch}`, vk: ch.charCodeAt(0), text: ch, shift: false }; const sym = SYMBOL_KEYS[ch]; if (sym) return { key: ch, code: sym.code, vk: sym.vk, text: ch, shift: SHIFTED_SYMBOLS.has(ch) }; return { key: ch, code: "", vk: 0, text: ch, shift: false }; } /** Run `fn` over `items` with at most `limit` in flight, preserving input order. * Bounded on purpose: firing 500 CDP commands at once is a good way to make * one slow page look like a hung browser. */ async function mapConcurrent(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out = new Array(items.length); let next = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { for (;;) { const i = next++; if (i >= items.length) return; out[i] = await fn(items[i]); } }); await Promise.all(workers); return out; } /** How many DOM.getBoxModel calls snapshot() keeps in flight. */ const BOX_BATCH = 24; export class Page { private rng = new Rng(); private mouse: Point = { x: 100, y: 100 }; private refs = new Map(); /** * Stable ref identity. A backendNodeId keeps the SAME number for the lifetime of * the document, so a number is NEVER reused for a different element. * * Refs used to be assigned 1..N per snapshot, which made them positional: removing * an element earlier in the tree shifted every later ref down one, and an agent * reusing a remembered number then actuated a DIFFERENT element and got success * back. Measured 2026-07-27 — and insertion is not predictable either * (`body.prepend` shifts, `appendChild` into a div above does not), so "did the DOM * change in a way that renumbers?" is not a question a caller can answer. * Memoising identity makes a stale ref either still correct, or loudly absent. */ private refByNode = new Map(); private nextRef = 0; private closed = false; // Cross-origin iframe support: CDP site-isolates a cross-origin child frame into // its own renderer target ("OOPIF"), invisible to Accessibility/Runtime/Input // commands sent on the main page's session — the #1 wall a drag-and-drop page // builder (GHL, Webflow, etc.) hits, since the whole canvas is one child iframe. // Target.setAutoAttach (scoped to this page's own session) discovers those child // targets and hands us a session for each; `activeSessionId` is a single "which // session do commands go to" pointer that every existing method already goes // through via send(), so useFrame() retargets snapshot/click/fill/eval/type for // free without duplicating each method. private activeSessionId: string; private frameSessions: Array<{ sessionId: string; url: string; targetId: string }> = []; private frameOff?: () => void; // Download state (see enableDownloads / waitForDownload). Chrome under CDP // cancels downloads unless a target opts in, so this stays inert until asked. // `finishedDownload` exists because a small file can complete before the // caller awaits — without it, that download is lost to a race. // Recent Network.responseReceived, so waitForResponse() can be called AFTER // the click that caused the request without losing the race. Cleared on nav. private recentResponses: Array<{ url: string; status: number; ok: boolean }> = []; private downloadDir?: string; private pendingDownloads = new Map(); private downloadWaiter: ((err: Error | null, result?: { filename: string; url: string; path: string }) => void) | null = null; private finishedDownload: { filename: string; url: string; path: string } | null = null; // FedCM interception state (see enableFedCm). private fedcmOff?: () => void; private fedcmQueue: FedCmDialog[] = []; private fedcmWaiters: Array<(d: FedCmDialog) => void> = []; private lastFedcmDialogId?: string; // Fetch-interception state. The private-network guard and resource blocking // share ONE requestPaused handler: two independent handlers would both try to // answer the same paused request and the second continue/fail fails with // "Invalid InterceptionId". applyFetchInterception() reconciles both. private blockPrivateOn = false; private allowOrigins: string[] = []; // empty = unrestricted // The active coherent identity, if applyFingerprint()/launch({fingerprint}) set one. private fingerprint?: Fingerprint; private blockedResourceTypes = new Set(); // CDP ResourceType values, e.g. "Image" private blockedUrlSubstrings: string[] = []; private fetchOff?: () => void; // tears down the shared requestPaused + frameNavigated listeners private mainFrameId?: string; private topPrivate = false; // is the page's own top-level origin private? constructor( private cdp: CDP, public readonly sessionId: string, private targetId?: string, ) { this.activeSessionId = sessionId; } /** Enable the domains we use and arm stealth injection on every document. */ async init(opts: { maskWebgl?: boolean; blockPrivateNetwork?: boolean; allowOrigins?: string[]; fingerprint?: Fingerprint } = {}) { await this.send("Page.enable"); await this.send("DOM.enable"); await this.send("Accessibility.enable"); this.trackDownloads(); // listeners only — downloads stay off until enableDownloads() this.trackResponses(); // A fingerprint owns the UA + client hints; without one, just scrub any // leaked HeadlessChrome token from the real UA. if (!opts.fingerprint) await this.normalizeUserAgent(); // Inject stealth before any page script runs, on every navigation/frame. // Only mask WebGL on SwiftShader hosts; with a real GPU the authentic vendor // is consistent and masking it would be a detectable lie. When a fingerprint // is active it owns the WebGL vendor/renderer, so skip the base SwiftShader // mask — otherwise two getParameter overrides would stack. const source = buildStealth({ maskWebgl: (opts.maskWebgl ?? false) && !opts.fingerprint }); await this.send("Page.addScriptToEvaluateOnNewDocument", { source }); // Apply the fingerprint AFTER the base stealth so its masked getters win over // any self-gating backfill, and before the first navigation. if (opts.fingerprint) await this.applyFingerprint(opts.fingerprint); if (opts.blockPrivateNetwork) await this.blockPrivateNetwork(); if (opts.allowOrigins?.length) await this.restrictOrigins(opts.allowOrigins); // Auto-attach to cross-origin child iframes of THIS page (scoped by sessionId // on the command envelope, same as every other domain-enable here) so their // Accessibility/Runtime/Input traffic becomes reachable via useFrame(). await this.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }); const offAttach = this.cdp.on( "Target.attachedToTarget", (p: any) => { if (p.targetInfo?.type === "iframe") { this.frameSessions.push({ sessionId: p.sessionId, url: p.targetInfo.url, targetId: p.targetInfo.targetId }); } }, "*", // the event's own sessionId varies by Chrome version; match any and filter by type ); // Prune child sessions when their iframe unmounts or navigates away, so // frames() never lists a dead frame and useFrame() can't retarget to a // destroyed session. detachedFromTarget carries the sessionId; targetDestroyed // only the targetId — handle both. const offDetach = this.cdp.on( "Target.detachedFromTarget", (p: any) => { if (p.sessionId) this.removeFrameSession((f) => f.sessionId === p.sessionId); }, "*", ); const offDestroyed = this.cdp.on( "Target.targetDestroyed", (p: any) => { if (p.targetId) this.removeFrameSession((f) => f.targetId === p.targetId); }, "*", ); this.frameOff = () => { offAttach(); offDetach(); offDestroyed(); }; } /** Drop a dead child-frame session (its iframe unmounted or navigated). If it * was the active target, fall back to the main page and clear now-meaningless * refs so the next call errors cleanly instead of acting on a stale frame. */ private removeFrameSession(match: (f: { sessionId: string; targetId: string }) => boolean) { const i = this.frameSessions.findIndex(match); if (i < 0) return; const [gone] = this.frameSessions.splice(i, 1); if (gone && this.activeSessionId === gone.sessionId) { this.activeSessionId = this.sessionId; this.resetRefs(); } } /** List discovered cross-origin child iframes (same-origin iframes don't need * this — they're already visible to the main session's Accessibility tree). */ async frames(): Promise> { return this.frameSessions.map((f, i) => ({ index: i + 1, url: f.url })); } /** Point every subsequent snapshot/click/fill/type/eval call at a child iframe * (index from frames()), or back at the main page with null/undefined. Clears * refs — a snapshot ref is only ever valid for the frame it was taken in. */ useFrame(index?: number | null) { this.resetRefs(); if (index == null) { this.activeSessionId = this.sessionId; return; } const f = this.frameSessions[index - 1]; if (!f) throw new Error(`useFrame: no frame at index ${index} — call frames() first (found ${this.frameSessions.length})`); this.activeSessionId = f.sessionId; } /** * Inject cookies before navigating — e.g. a logged-in session transferred * from another browser. Each cookie is a CDP CookieParam ({name, value, * domain, path, secure, httpOnly, expires?, sameSite?}). Lets the browser * ride an existing session instead of re-doing a bot-walled login. */ async setCookies(cookies: Array>) { await this.send("Network.enable"); await this.send("Network.setCookies", { cookies }); } /** * Read the browser's current cookies (the symmetric counterpart to * setCookies) — e.g. to export a session established interactively and reuse * it elsewhere. Each entry is a CDP Cookie ({name, value, domain, path, * expires, size, httpOnly, secure, session, sameSite?, ...}). With no `urls`, * returns the cookies visible to the frames the page is currently on; pass * `urls` to scope the read to specific origins. */ async getCookies(urls?: string[]): Promise>> { await this.send("Network.enable"); const { cookies } = await this.send("Network.getCookies", urls ? { urls } : {}); return cookies ?? []; } /** * Scrub the "HeadlessChrome" token from the UA and the matching client-hint * brands. headless=new leaks it in both navigator.userAgent AND the Sec-CH-UA * request headers; setUserAgentOverride with metadata fixes both at once. A * no-op for headful Chrome, whose UA is already clean. */ private async normalizeUserAgent() { const realUA = await this.evaluate("navigator.userAgent"); const cleanUA = realUA.replace("HeadlessChrome", "Chrome"); if (cleanUA === realUA) return; await this.applyUserAgentOverride(cleanUA); } /** Set navigator.userAgent AND the matching Sec-CH-UA client-hint brands * consistently — a UA string without the aligned hints is itself a tell. * Shared by init's HeadlessChrome scrub and the public setUserAgent(). */ private async applyUserAgentOverride(ua: string) { const major = (ua.match(/Chrome\/(\d+)/)?.[1]) ?? "148"; await this.send("Emulation.setUserAgentOverride", { userAgent: ua, acceptLanguage: "en-US,en;q=0.9", platform: "Linux x86_64", userAgentMetadata: { brands: [ { brand: "Chromium", version: major }, { brand: "Google Chrome", version: major }, { brand: "Not?A_Brand", version: "99" }, ], fullVersion: `${major}.0.0.0`, platform: "Linux", platformVersion: "6.8.0", architecture: "x86", model: "", mobile: false, }, }); } /** * Override the User-Agent at runtime, keeping the Sec-CH-UA client-hint brands * aligned with it (reuses init's normalization path — a bare UA string with * mismatched hints is a fingerprint tell). Applies to subsequent requests. */ async setUserAgent(userAgent: string) { await this.applyUserAgentOverride(userAgent); } /** * Apply a coherent {@link Fingerprint} to this page. Two layers (see * fingerprint.ts): the CLEAN browser-level CDP overrides — UA + the full * `userAgentMetadata` client hints + the legacy `navigator.platform`, and the * screen dimensions + `devicePixelRatio` — plus a masked page-level getter * script for the values CDP can't reach (`hardwareConcurrency`, `deviceMemory`, * `languages`, and `screen.avail*` / colour depth). * * Coherence is the whole point: every derived value (client-hint platform, * brand versions, Accept-Language) is computed FROM the profile, so the UA, * client hints, `navigator.platform` and screen can't drift out of agreement — * an inconsistency between them is itself a detection signal. * * Apply it BEFORE the first navigation for full effect: the injected getters * take hold on the next document, the CDP overrides on subsequent requests. * `Browser.launch({ fingerprint })` wires this in automatically at page * creation. (Timezone/locale/geolocation and WebGL/canvas/audio noise are * layered on by later methods.) */ async applyFingerprint(fp: Fingerprint) { this.fingerprint = fp; // 1. UA + client hints. The top-level `platform` ALSO sets the legacy // navigator.platform, so it needs no injected getter (cleaner — a // browser-level value has no getter a page can unmask). await this.send("Emulation.setUserAgentOverride", { userAgent: fp.userAgent, acceptLanguage: buildAcceptLanguage(fp.languages), platform: fp.platform, userAgentMetadata: buildClientHints(fp), }); // 2. screen dimensions + devicePixelRatio, WITHOUT touching the viewport // (width/height:0 leave window.innerWidth/Height alone). availWidth/ // availHeight/colorDepth aren't settable this way — the injected getters // below carry them. await this.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: fp.devicePixelRatio, mobile: fp.mobile, screenWidth: fp.screen.width, screenHeight: fp.screen.height, }); // 3. timezone + locale + optional geolocation — all browser-level, so they // resolve natively (Intl.DateTimeFormat().resolvedOptions().timeZone, // navigator.language, navigator.geolocation) with no JS getter to unmask. // A US profile must carry a US timezone/locale (and, if given, US coords): // a Windows/en-US UA reporting Asia/Shanghai is a contradiction detectors // score. setLocaleOverride can reject if one is already mid-flight — that's // non-fatal to the rest of the profile, so it's tolerated. await this.send("Emulation.setTimezoneOverride", { timezoneId: fp.timezone }); try { await this.send("Emulation.setLocaleOverride", { locale: fp.locale }); } catch {} if (fp.geolocation) { await this.send("Emulation.setGeolocationOverride", { latitude: fp.geolocation.latitude, longitude: fp.geolocation.longitude, accuracy: fp.geolocation.accuracy ?? 100, }); } // 4. the masked page-level getter layer, armed on every future document. await this.send("Page.addScriptToEvaluateOnNewDocument", { source: buildFingerprintStealth(fp) }); } /** * Set the viewport (and optionally device pixel ratio / mobile emulation) via * Emulation.setDeviceMetricsOverride — the page sees this as its * window.innerWidth/Height, screen size, and devicePixelRatio. Use it to * emulate a phone (`{width:390,height:844,deviceScaleFactor:3,mobile:true}`) * or force a fixed desktop size for reproducible screenshots. */ async setViewport(opts: { width: number; height: number; deviceScaleFactor?: number; mobile?: boolean }) { await this.send("Emulation.setDeviceMetricsOverride", { width: opts.width, height: opts.height, deviceScaleFactor: opts.deviceScaleFactor ?? 1, mobile: opts.mobile ?? false, }); } /** Commands go to whichever session is "active" — the main page by default, * or a child iframe's own session after useFrame(). */ private send(method: string, params: Record = {}) { return this.cdp.send(method, params, this.activeSessionId); } /** Navigate and wait. Always the main page, regardless of any active useFrame() * — top-level navigation isn't a per-frame concept. `waitUntil` is "load" * (default) or "networkidle" (no network for ~500ms — better for SPAs that * fetch after the load event). Reset ref/session state so a prior useFrame() * can't leak across the nav and a leftover ref can't click wrong coords. */ async goto( url: string, opts: { timeout?: number; waitUntil?: "load" | "networkidle" } = {}, ): Promise<{ url: string; status?: number; ok?: boolean }> { this.resetRefs(); this.recentResponses = []; this.activeSessionId = this.sessionId; const timeout = opts.timeout ?? 30000; // Capture the main document's HTTP status so callers can detect 4xx/5xx: match // the Document response to THIS navigation's loaderId (redirects keep the // loaderId, so the final hop's status wins). Needs the Network domain enabled. await this.cdp.send("Network.enable", {}, this.sessionId).catch(() => {}); const docStatus = new Map(); // loaderId -> HTTP status const offResp = this.cdp.on( "Network.responseReceived", (p: any) => { if (p.type === "Document" && p.loaderId) docStatus.set(p.loaderId, p.response?.status); }, this.sessionId, ); let loaderId: string | undefined; let offWithin: (() => void) | undefined; try { // Arm the waiter BEFORE navigating: we can't miss loadEventFired, and a // networkidle waiter is already counting the navigation's own requests. const waiter = this.waitForLoad(opts.waitUntil ?? "load", timeout); // A SAME-DOCUMENT navigation — a #fragment, or history/pushState — never // fires loadEventFired, because no new document loads. Waiting for it hangs // for the whole timeout and then throws. Measured on Chrome 150: // goto("https://example.com/#x") FAILED after 15001ms while real navigations // to the same origin took ~300ms. Anchor links and SPA routes are ordinary // things for an agent to follow, so this was a 30s stall (default timeout) on // a completely common path. // // Race Chrome's own same-document signal against the load waiter rather than // string-comparing the URLs: a comparison would have to normalise trailing // slashes, relative hrefs and percent-encoding, and would STILL miss // pushState navigations that change the path with no document load. const withinDoc = new Promise<"within">((resolve) => { offWithin = this.cdp.on( "Page.navigatedWithinDocument", () => resolve("within"), this.sessionId, ); }); // Page.navigate resolves with { frameId, loaderId, errorText? }. On a DNS or // connection failure Chrome STILL fires loadEventFired for its own error page, // so the load event alone can't tell success from failure — errorText can. const nav = await this.cdp.send<{ errorText?: string; loaderId?: string }>("Page.navigate", { url }, this.sessionId); if (nav?.errorText) { waiter.catch(() => {}); // we're bailing; swallow the pending waiter rejection throw new Error(`goto(${url}) failed: ${nav.errorText}`); } loaderId = nav?.loaderId; // Whichever signal arrives first wins. On a same-document navigation the load // waiter will never settle, so swallow it — otherwise it surfaces as an // unhandled rejection long after goto() has already returned successfully. const how = await Promise.race([waiter.then(() => "loaded" as const), withinDoc]); if (how === "within") waiter.catch(() => {}); } finally { offWithin?.(); offResp(); } await sleep(this.rng.range(150, 400)); // settle, like a human reading // status stays undefined only when no Document response is observed for this // loaderId (e.g. about:blank) — additive, so callers that ignore it are fine. const status = loaderId ? docStatus.get(loaderId) : undefined; return { url, status, ok: status == null ? undefined : status >= 200 && status < 400 }; } /** Reload the current page (Page.reload), waiting per `waitUntil`. */ async reload(opts: { timeout?: number; waitUntil?: "load" | "networkidle" } = {}) { this.resetRefs(); this.activeSessionId = this.sessionId; const waiter = this.waitForLoad(opts.waitUntil ?? "load", opts.timeout ?? 30000); await this.cdp.send("Page.reload", {}, this.sessionId); await waiter; await sleep(this.rng.range(150, 400)); } /** Go back one entry in session history. Throws if there's nothing earlier. */ async back(opts: { timeout?: number; waitUntil?: "load" | "networkidle" } = {}) { await this.historyGo(-1, opts); } /** Go forward one entry in session history. Throws if there's nothing later. */ async forward(opts: { timeout?: number; waitUntil?: "load" | "networkidle" } = {}) { await this.historyGo(1, opts); } /** Navigate `delta` entries through session history via * Page.navigateToHistoryEntry (precise, and lets us reject cleanly when the * target entry doesn't exist instead of silently no-op'ing like history.go). */ private async historyGo(delta: -1 | 1, opts: { timeout?: number; waitUntil?: "load" | "networkidle" }) { const { currentIndex, entries } = await this.cdp.send<{ currentIndex: number; entries: Array<{ id: number; url: string }> }>( "Page.getNavigationHistory", {}, this.sessionId, ); const target = entries?.[currentIndex + delta]; if (!target) throw new Error(delta < 0 ? "back(): no earlier history entry" : "forward(): no later history entry"); this.resetRefs(); this.activeSessionId = this.sessionId; const waiter = this.waitForLoad(opts.waitUntil ?? "load", opts.timeout ?? 30000); await this.cdp.send("Page.navigateToHistoryEntry", { entryId: target.id }, this.sessionId); await waiter; await sleep(this.rng.range(150, 400)); } /** Resolve when the page finishes loading, per the `waitUntil` strategy. */ private waitForLoad(waitUntil: "load" | "networkidle", timeout: number): Promise { if (waitUntil === "networkidle") return this.waitForNetworkIdle(timeout); return this.cdp.once("Page.loadEventFired", { sessionId: this.sessionId, timeout }).then(() => {}); } /** Resolve once no network request has been in flight for `idleMs`, or reject * after `timeout`. Tracks in-flight requests over the Network domain — the * right signal for SPAs whose content arrives after the load event. */ private waitForNetworkIdle(timeout: number, idleMs = 500): Promise { // Enable Network first (idempotent); listeners below are registered // synchronously so no event is missed once it takes effect. this.cdp.send("Network.enable", {}, this.sessionId).catch(() => {}); return new Promise((resolve, reject) => { let inflight = 0; let idleTimer: ReturnType | undefined; let settled = false; const settle = (err?: Error) => { if (settled) return; settled = true; clearTimeout(idleTimer); clearTimeout(hardTimer); offReq(); offFin(); offFail(); err ? reject(err) : resolve(); }; const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => settle(), idleMs); }; const onReq = () => { inflight++; clearTimeout(idleTimer); }; const onDone = () => { inflight = Math.max(0, inflight - 1); if (inflight === 0) armIdle(); }; const offReq = this.cdp.on("Network.requestWillBeSent", onReq, this.sessionId); const offFin = this.cdp.on("Network.loadingFinished", onDone, this.sessionId); const offFail = this.cdp.on("Network.loadingFailed", onDone, this.sessionId); const hardTimer = setTimeout(() => settle(new Error(`waitUntil networkidle: timed out after ${timeout}ms`)), timeout); armIdle(); // start the idle window; the navigation's first request cancels it }); } /** Evaluate JS in the page WITHOUT Runtime.enable (avoids the CDP tell). * Bounded by `timeout` (default 30s): a wedged renderer — or an * awaitPromise expression that never settles — otherwise leaves this pending * forever, so we race the CDP send against a timer and reject cleanly. */ async evaluate( expression: string | ((...args: any[]) => any), opts: { timeout?: number } = {}, ): Promise { const timeout = opts.timeout ?? 30000; // Accept a FUNCTION as well as a string. Everyone arrives here from puppeteer or // playwright and writes `evaluate(() => document.title)`. That used to stringify // to "() => document.title", which Runtime.evaluate happily evaluates INTO A // FUNCTION OBJECT — and returnByValue cannot serialise a function, so CDP // answers "Invalid parameters (-32602)". // // That error is indistinguishable from a protocol regression, and it cost me // four probes hunting a Chrome 150 breakage that never existed. A wrong call // shape must not produce an error that blames the browser. Wrapping it in an // IIFE makes the intuitive form simply work. if (typeof expression === "function") { expression = `(${expression.toString()})()`; } let r: any; try { r = await this.cdp.send( "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, this.activeSessionId, timeout, ); } catch (e: any) { if (String(e?.message ?? "").includes("timed out")) { throw new Error(`evaluate: timed out after ${timeout}ms (page wedged?)`); } throw e; } if (r.exceptionDetails) throw new Error(`evaluate: ${r.exceptionDetails.text}`); return r.result?.value as T; } async url(): Promise { return this.evaluate("location.href"); } /** Drop every ref AND its identity map. Navigation and frame switches invalidate * backendNodeIds wholesale, so keeping the old numbers would be meaningless. */ private resetRefs() { this.refs.clear(); this.refByNode.clear(); this.nextRef = 0; } /** Build the numbered element index from the accessibility tree. */ async snapshot(): Promise { const { nodes } = await this.send("Accessibility.getFullAXTree"); // Clear the live map (a node that is gone must stop resolving, so acting on its // ref errors loudly) but KEEP refByNode, which is what makes a number mean the // same element on the next snapshot. this.refs.clear(); const elements: Element[] = []; // Two passes on purpose. Pass 1 filters cheaply in memory; pass 2 asks // Chrome for the layout boxes ALL AT ONCE. The old shape awaited one // DOM.getBoxModel per candidate inside the loop, so a 150-element page paid // 150 sequential round-trips and a real app page (500+) paid 500. The CDP // client keys pending replies by message id, so concurrent commands are // safe; batching keeps one slow reply from blocking the rest. const candidates: Array<{ backendNodeId: number; role: string; name: string; value?: string }> = []; for (const n of nodes) { if (n.ignored) continue; const role: string = n.role?.value ?? ""; const name: string = (n.name?.value ?? "").trim(); if (!INTERESTING.has(role)) continue; if (!name && role !== "textbox" && role !== "searchbox" && role !== "textarea") continue; const backendNodeId = n.backendDOMNodeId; if (!backendNodeId) continue; candidates.push({ backendNodeId, role, name, value: n.value?.value }); } const centers = await mapConcurrent(candidates, BOX_BATCH, (c) => this.boxCenter(c.backendNodeId)); // Elements are LISTED in accessibility-tree order, but a ref is an IDENTITY, not // a position: a node that has been seen before keeps the number it was given. // Numbers are therefore ascending on first sight and may contain gaps afterwards // (an element was removed) or appear out of order (a new element was inserted // above older ones). That is the point — the alternative is renumbering, and a // renumbered ref actuates the wrong element without failing. for (let i = 0; i < candidates.length; i++) { const center = centers[i]; if (!center) continue; // not visible / no layout box const c = candidates[i]; let ref = this.refByNode.get(c.backendNodeId); if (ref === undefined) { ref = ++this.nextRef; this.refByNode.set(c.backendNodeId, ref); } this.refs.set(ref, { backendNodeId: c.backendNodeId, center }); elements.push({ ref, role: c.role, name: c.name, value: c.value, center }); } const [url, title] = await Promise.all([ this.evaluate("location.href"), this.evaluate("document.title"), ]); const text = elements .map((e) => `[${e.ref}] ${e.role} ${JSON.stringify(e.name)}${e.value ? ` =${JSON.stringify(e.value)}` : ""}`) .join("\n"); return { url, title, text, elements }; } private async boxCenter(backendNodeId: number): Promise { try { const { model } = await this.send("DOM.getBoxModel", { backendNodeId }); const q = model.content as number[]; // [x1,y1, x2,y2, x3,y3, x4,y4] const x = (q[0] + q[2] + q[4] + q[6]) / 4; const y = (q[1] + q[3] + q[5] + q[7]) / 4; if ((model.width ?? 0) <= 0 || (model.height ?? 0) <= 0) return null; return { x, y }; } catch { return null; } } /** * Snapshot-free element locator — the one to reach for on heavy SPA editors * (Ancestry, Notion, Figma, YouTube Studio) where snapshot() stalls or times * out. snapshot() pulls Chrome's FULL accessibility tree * (Accessibility.getFullAXTree) plus a getBoxModel per node; on a big * virtualized DOM that is seconds of work or a hang. domSnapshot() does it in * ONE in-page evaluate: no AX tree, no per-node round-trips. * * It PIERCES shadow DOM (a plain document.querySelectorAll does not, so * web-component buttons — exactly the YouTube-Studio / Ancestry case — are * invisible to naive DOM scraping; that used to be snapshot()'s only edge). * * Every element carries its center in viewport coords: feed x,y straight to * clickAt() for a TRUSTED click (fires framework handlers a synthetic * .click() silently drops). clickText()/fillText() wrap the common cases. */ async domSnapshot(opts: { max?: number } = {}): Promise { const max = opts.max ?? 300; const js = `(() => { const SEL = 'a,button,input,textarea,select,summary,label,[role=button],[role=link],[role=option],[role=menuitem],[role=menuitemcheckbox],[role=tab],[role=checkbox],[role=radio],[role=switch],[role=combobox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]'; const out = [], seen = new Set(); const box = (e) => { const r = e.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return null; if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) return null; const s = getComputedStyle(e); if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) === 0) return null; return r; }; const label = (e) => ( e.getAttribute('aria-label') || (e.innerText || '').trim() || e.value || e.placeholder || e.getAttribute('title') || e.getAttribute('name') || '' ).replace(/\\s+/g, ' ').trim().slice(0, 100); const walk = (root) => { let all; try { all = root.querySelectorAll('*'); } catch { return; } for (const e of all) { if (e.matches && e.matches(SEL) && !seen.has(e)) { seen.add(e); const r = box(e); if (r) { const tag = e.tagName.toLowerCase(); out.push({ i: out.length, tag, role: e.getAttribute('role') || '', t: label(e), x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), w: Math.round(r.width), h: Math.round(r.height), editable: tag === 'input' || tag === 'textarea' || e.isContentEditable === true, }); } } if (e.shadowRoot) walk(e.shadowRoot); } }; walk(document); return out.slice(0, ${max}); })()`; const elements = await this.evaluate(js); const [url, title] = await Promise.all([ this.evaluate("location.href"), this.evaluate("document.title"), ]); const text = elements .map((e) => `[${e.i}] ${e.tag}${e.role ? `/${e.role}` : ""}${e.editable ? " (input)" : ""} ${JSON.stringify(e.t)} @${e.x},${e.y}`) .join("\n"); return { url, title, text, elements }; } /** * The matcher behind clickText/fillText. Ranks visible elements against `query` * by label: exact ▸ startsWith ▸ word-boundary ▸ substring; ties break toward * the shortest (most specific) label, then topmost. Returns the chosen element * or null. `nth` selects among equivalents; `editableOnly` restricts to fields. */ async findText( query: string, opts: { nth?: number; exact?: boolean; role?: string; editableOnly?: boolean } = {}, ): Promise { const { elements } = await this.domSnapshot(); const q = query.toLowerCase().replace(/\s+/g, " ").trim(); const esc = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const wb = new RegExp(`\\b${esc}`); const scored = elements .filter((e) => (!opts.editableOnly || e.editable) && (!opts.role || e.role === opts.role)) .map((e) => { const t = e.t.toLowerCase(); let score = -1; if (t === q) score = 100; else if (opts.exact) score = -1; else if (t.startsWith(q)) score = 80; else if (wb.test(t)) score = 60; else if (t.includes(q)) score = 40; return { e, score }; }) .filter((s) => s.score >= 0) .sort((a, b) => b.score - a.score || a.e.t.length - b.e.t.length || a.e.y - b.e.y); return scored[opts.nth ?? 0]?.e ?? null; } /** * The atomic locate-and-position primitive behind clickText/fillText — the one * that makes writing to a heavy SPA reliable instead of coin-flip. * * findText() ranks against a PRIOR domSnapshot: its coords are viewport-relative * and go stale the instant anything scrolls or re-lays-out (the exact bug that * made a live-tree hint-click land on nothing). locateText instead does the walk, * the scoring, the scroll-into-view AND the coord read in ONE in-page pass, so the * returned x,y are guaranteed fresh and on-screen for the trusted click that * follows. It also sees BELOW-THE-FOLD elements (its own walk isn't viewport-gated * like domSnapshot), so it can drive a form field the page hasn't scrolled to yet. * * `behavior:'instant'` defeats CSS scroll-behavior:smooth — otherwise getBounding- * ClientRect would read a mid-animation position and we'd click where the element * used to be. Returns the element (fresh center coords) or null. */ async locateText( query: string, opts: { nth?: number; exact?: boolean; role?: string; editableOnly?: boolean } = {}, ): Promise { if (typeof query !== "string" || !query.trim()) { // Fails loudly at the boundary instead of a cryptic "query.toLowerCase() of // undefined" — the real cause is almost always a caller passing the wrong // param name (e.g. `text` instead of `query`), which the MCP layer doesn't // validate before the handler runs. throw new Error(`locateText: "query" must be a non-empty string, got ${JSON.stringify(query)}`); } const spec = { q: query.toLowerCase().replace(/\s+/g, " ").trim(), exact: !!opts.exact, role: opts.role || "", editableOnly: !!opts.editableOnly, nth: opts.nth ?? 0, }; const js = `(() => { const SPEC = ${JSON.stringify(spec)}; const SEL = 'a,button,input,textarea,select,summary,label,[role=button],[role=link],[role=option],[role=menuitem],[role=menuitemcheckbox],[role=tab],[role=checkbox],[role=radio],[role=switch],[role=combobox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]'; const seen = new Set(), cand = []; const label = (e) => ( e.getAttribute('aria-label') || (e.innerText || '').trim() || e.value || e.placeholder || e.getAttribute('title') || e.getAttribute('name') || '' ).replace(/\\s+/g, ' ').trim(); const vis = (e) => { const r = e.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return false; const s = getComputedStyle(e); return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0; }; const walk = (root) => { let all; try { all = root.querySelectorAll('*'); } catch { return; } for (const e of all) { if (e.matches && e.matches(SEL) && !seen.has(e)) { seen.add(e); if (vis(e)) { const tag = e.tagName.toLowerCase(); const ed = tag === 'input' || tag === 'textarea' || e.isContentEditable === true; const role = e.getAttribute('role') || ''; if ((!SPEC.editableOnly || ed) && (!SPEC.role || role === SPEC.role)) { cand.push({ e, t: label(e).toLowerCase(), tag, role, ed }); } } } if (e.shadowRoot) walk(e.shadowRoot); } }; walk(document); const scored = cand.map((c) => { const t = c.t; let s = -1; if (t === SPEC.q) s = 100; else if (!SPEC.exact) { if (t.startsWith(SPEC.q)) s = 80; else if ((' ' + t).includes(' ' + SPEC.q)) s = 60; // word-start, no regex else if (t.includes(SPEC.q)) s = 40; } return { c, s }; }).filter((x) => x.s >= 0).sort((a, b) => b.s - a.s || a.c.t.length - b.c.t.length); const pick = scored[SPEC.nth]; if (!pick) return null; const el = pick.c.e; el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); const r = el.getBoundingClientRect(); return { i: 0, tag: pick.c.tag, role: pick.c.role, t: label(el).slice(0, 100), x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), w: Math.round(r.width), h: Math.round(r.height), editable: pick.c.ed, }; })()`; return this.evaluate(js); } /** * Locate the best element matching `query`, scroll it into view and TRUSTED-click * its FRESH center — no snapshot ref, works where snapshot() hangs. Throws if * nothing matches (so a miss is loud, not a silent no-op that reports success). * Returns the element. Verify the RESULT externally — a landed click is not a * completed action. */ async clickText( query: string, opts: { nth?: number; exact?: boolean; role?: string } = {}, ): Promise { const el = await this.locateText(query, opts); if (!el) throw new Error(`clickText: no visible element matching ${JSON.stringify(query)}`); await sleep(this.rng.range(280, 460)); // let scroll + any reflow settle before the click await this.clickAt(el.x, el.y); return el; } /** * Focus an editable field matched by its label/placeholder/aria text (scrolled * into view first), clear it (Ctrl+A) and type `value`. The snapshot-free analogue * of fill(ref, text). Throws if no editable field matches. Returns the field. */ async fillText(query: string, value: string, opts: { nth?: number } = {}): Promise { const el = await this.locateText(query, { nth: opts.nth, editableOnly: true }); if (!el) throw new Error(`fillText: no editable field matching ${JSON.stringify(query)}`); await sleep(this.rng.range(280, 460)); // let scroll settle await this.clickAt(el.x, el.y); await sleep(this.rng.range(40, 90)); await this.sendKey({ key: "a", code: "KeyA", vk: 65, modifiers: 2 }); // Ctrl+A select-all await this.type(value); return el; } /** Move the cursor along a human curve to a target point. `buttons` mirrors * CDP's bitmask (1 = left button down) — pass 1 while dragging so the move * itself carries mousemove-with-button-held events a drag-and-drop library * listens for, not plain hover moves. */ private async moveTo(target: Point, buttons: 0 | 1 = 0) { const path = mousePath(this.mouse, target, this.rng); for (const p of path) { await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y, buttons, }); await sleep(moveDelay(this.rng)); } this.mouse = target; } /** * Re-read an element's live centre, instead of trusting the one snapshot() * recorded. Any re-render between snapshot and action — a banner appearing, * a list growing, an accordion opening, lazy images landing — moves the * element, and the recorded centre then points at whatever slid into those * coordinates. Dispatching a real mouse event there clicks the wrong thing * and throws nothing, which is the worst shape a failure can take for an * agent: it looks like it worked. * * Throws if the node has detached or has no layout box (display:none, * removed, collapsed) rather than clicking empty space. Also refreshes the * cached centre so a later action on the same ref starts from truth. */ private async freshCenter(ref: number, action: string): Promise { const target = this.refs.get(ref); if (!target) { throw new Error( `${action}: no element with ref ${ref} — this snapshot has ${this.refs.size} refs. Call snapshot() first.`, ); } const center = await this.boxCenter(target.backendNodeId); if (!center) { throw new Error( `${action}: ref ${ref} is no longer clickable — the node has detached or has no layout box ` + `(removed, display:none, or zero-sized). Re-run snapshot() after the page changes.`, ); } target.center = center; // keep the cache honest for the next action on this ref return center; } /** * Hit-test the point we are about to click: is the target actually the * topmost element there? A real mouse event goes to whatever is on top, which * on a live page is routinely a cookie banner, a modal backdrop, a sticky * header, or a loading veil that hasn't torn down. The overlay takes the * click, the target never fires, and nothing throws. * * Deliberately tuned for PRECISION, not recall — this runs on every click, so * a false positive breaks working code, which is the failure that gets a * check deleted. Anything in the same containment chain (a inside the * button, or a wrapper around it) is treated as a hit. Only a genuinely * unrelated topmost element counts as occlusion. */ private async assertHittable(ref: number, p: Point, action: string) { const blocker = await this.callOnRef( ref, `function(x, y){ // elementFromPoint stops at a shadow HOST — it will not look inside an // open shadow root. A web component's real button therefore reads as // "covered by ", which is a false positive on every design // system, Salesforce Lightning, and most modern admin panels. Descend // through shadow roots to find what is genuinely topmost. let top = document.elementFromPoint(x, y); if (!top) return "(nothing — the point is outside the viewport; scroll it into view)"; for (let i = 0; i < 10 && top && top.shadowRoot; i++) { const inner = top.shadowRoot.elementFromPoint(x, y); if (!inner || inner === top) break; top = inner; } // Containment, walking across shadow boundaries in BOTH directions, // because contains() does not cross them. Strictly ancestor-or-self — // NOT "share an ancestor", which is true of every pair of nodes at // and would neuter the check entirely. const up = (n) => n.parentNode || n.host || null; for (let n = top; n; n = up(n)) if (n === this) return null; // top is us, or inside us for (let n = this; n; n = up(n)) if (n === top) return null; // we are inside top const cls = typeof top.className === "string" && top.className.trim() ? "." + top.className.trim().split(/\\s+/)[0] : ""; return "<" + top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") + cls + ">"; }`, [p.x, p.y], ); if (blocker) { throw new Error( `${action}: ref ${ref} is covered by ${blocker} — the click would go to that element instead. ` + `Dismiss the overlay (cookie banner, modal, sticky header) or scroll, then snapshot() again.`, ); } } /** Click an element by its snapshot ref. */ async click(ref: number) { const center = await this.freshCenter(ref, "click"); await this.assertHittable(ref, center, "click"); const target = { center }; await this.moveTo(target.center); await sleep(this.rng.range(30, 90)); const common = { x: target.center.x, y: target.center.y, button: "left", clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...common }); await sleep(this.rng.range(40, 110)); // press dwell await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...common }); } /** * Drag from one point to another. Many drag-drop site/page builders (GHL, * Webflow, most "drag a card onto a canvas" UIs) use pointer-based DnD * libraries that key off real mousedown -> mousemove(button held) -> mouseup, * not the legacy HTML5 dragstart/drop events, or any semantic role an a11y * tree would expose — click() alone can't reach these; this can. Both ends * are viewport coordinates, since the draggable card AND its drop target are * usually plain divs with no accessible ref of their own — read them off a * veil_screenshot. */ private async dragCore(fromX: number, fromY: number, toX: number, toY: number) { await this.moveTo({ x: fromX, y: fromY }); await sleep(this.rng.range(30, 90)); const downCommon = { x: fromX, y: fromY, button: "left" as const, clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...downCommon }); await sleep(this.rng.range(40, 100)); // dwell so the library's own drag-start threshold fires await this.moveTo({ x: toX, y: toY }, 1); // move WITH the button held — this is what a DnD listener sees as "dragging" await sleep(this.rng.range(40, 100)); const upCommon = { x: toX, y: toY, button: "left" as const, clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...upCommon }); } /** Drag an element by snapshot ref to an absolute viewport point. Re-reads the * source centre first — a drag from a stale coordinate grabs whatever moved * into that spot, which on a board UI means dragging the wrong card. */ async dragRefTo(ref: number, toX: number, toY: number) { const from = await this.freshCenter(ref, "dragRefTo"); await this.dragCore(from.x, from.y, toX, toY); } /** Drag between two absolute viewport points — for when neither the source * card nor the drop target has a resolvable snapshot ref. */ async dragAt(fromX: number, fromY: number, toX: number, toY: number) { await this.dragCore(fromX, fromY, toX, toY); } /** * Drag through a sequence of waypoints instead of straight to the target, * with a longer dwell at each stop. Two real problems on infinite-canvas * editors (n8n, GHL's builder) need this over a straight dragCore: * 1. Auto-scroll: dragging a node past the visible edge only pans the * canvas if the pointer LINGERS near the edge — a straight A-to-B move * passes through that zone in one frame and never triggers it. Route * through an edge point, dwell, then continue. * 2. Connector snapping: n8n's output->input connection drag highlights * the nearest valid target as you approach it; hovering just short of * the final point before completing gives that highlight a frame to * render, same as a human's hand slowing down on approach. */ private async dragPathCore(points: Point[]) { if (points.length < 2) throw new Error("dragPath: need at least 2 points (from, ...via, to)"); const [first, ...rest] = points; await this.moveTo(first); await sleep(this.rng.range(30, 90)); const downCommon = { x: first.x, y: first.y, button: "left" as const, clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...downCommon }); await sleep(this.rng.range(40, 100)); // dwell so the library's own drag-start threshold fires for (const p of rest) { await this.moveTo(p, 1); // move WITH the button held await sleep(this.rng.range(80, 180)); // longer dwell per waypoint than a plain 2-point drag } const last = points[points.length - 1]; await sleep(this.rng.range(40, 100)); const upCommon = { x: last.x, y: last.y, button: "left" as const, clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...upCommon }); } /** Drag an element by ref through a sequence of intermediate viewport points to a final drop. */ async dragRefToPath(ref: number, via: Point[]) { const from = await this.freshCenter(ref, "dragRefToPath"); await this.dragPathCore([from, ...via]); } /** Drag through a sequence of absolute viewport points (first = pickup, last = drop). */ async dragAtPath(points: Point[]) { await this.dragPathCore(points); } /** Bring this page's target to the foreground — CDP Input only routes to the active target. */ async bringToFront() { await this.send("Page.bringToFront"); } /** Trusted click at absolute viewport coords (when you can't resolve a snapshot ref). */ async clickAt(x: number, y: number) { await this.moveTo({ x, y }); await sleep(this.rng.range(30, 90)); const common = { x, y, button: "left", clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...common }); await sleep(this.rng.range(40, 110)); await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...common }); } /** * Double-click: two press/release pairs, second one carrying clickCount:2 — * that's the field Chrome actually keys `dblclick` off, not two fast * single clicks. Needed for n8n's canvas (double-click a node opens its * config panel) and GHL's builder (double-click an element to edit it * in place) — a plain click() only selects/focuses on both. */ private async doubleClickCore(x: number, y: number) { await this.moveTo({ x, y }); await sleep(this.rng.range(30, 90)); const base = { x, y, button: "left" as const }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, clickCount: 1, ...base }); await sleep(this.rng.range(20, 40)); await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, clickCount: 1, ...base }); await sleep(this.rng.range(30, 80)); // well inside the OS double-click window (~500ms), but not simultaneous await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, clickCount: 2, ...base }); await sleep(this.rng.range(20, 40)); await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, clickCount: 2, ...base }); } /** Double-click an element by snapshot ref. */ async doubleClick(ref: number) { const center = await this.freshCenter(ref, "doubleClick"); await this.assertHittable(ref, center, "doubleClick"); await this.doubleClickCore(center.x, center.y); } /** Double-click at absolute viewport coords (canvas nodes rarely have a resolvable ref). */ async doubleClickAt(x: number, y: number) { await this.doubleClickCore(x, y); } /** * Right-click: opens the context menu — n8n's "add node here" canvas menu, * a node's duplicate/delete/pin menu, GHL's element context menu. The menu * itself then just needs a normal click() or clickText() on the option. */ private async rightClickCore(x: number, y: number) { await this.moveTo({ x, y }); await sleep(this.rng.range(30, 90)); const common = { x, y, button: "right" as const, clickCount: 1 }; await this.send("Input.dispatchMouseEvent", { type: "mousePressed", buttons: 2, ...common }); await sleep(this.rng.range(40, 110)); await this.send("Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...common }); } /** Right-click an element by snapshot ref. */ async rightClick(ref: number) { const center = await this.freshCenter(ref, "rightClick"); await this.assertHittable(ref, center, "rightClick"); await this.rightClickCore(center.x, center.y); } /** Right-click at absolute viewport coords. */ async rightClickAt(x: number, y: number) { await this.rightClickCore(x, y); } /** * Dispatch one well-formed key: rawKeyDown, then a `char` event only if the * key produces text, then keyUp (no text) — the exact shape press() relies on, * always carrying key/code/windowsVirtualKeyCode/nativeVirtualKeyCode so the * page never sees a `keyCode === 0` bot-tell. `modifiers` is a CDP bitfield * (Alt 1, Ctrl 2, Meta 4, Shift 8) for shortcuts like Ctrl+A. */ private async sendKey(opts: { key: string; code: string; vk: number; text?: string; modifiers?: number }) { const base = { key: opts.key, code: opts.code, windowsVirtualKeyCode: opts.vk, nativeVirtualKeyCode: opts.vk, ...(opts.modifiers ? { modifiers: opts.modifiers } : {}), }; await this.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...base, ...(opts.text ? { text: opts.text } : {}) }); if (opts.text) await this.send("Input.dispatchKeyEvent", { type: "char", ...base, text: opts.text }); await this.send("Input.dispatchKeyEvent", { type: "keyUp", ...base }); } /** * Scroll the page by a pixel delta via a real mouse-wheel event dispatched at * the current cursor position (positive dy scrolls down, positive dx right). * Use it to reveal lazy-loaded / off-screen content before snapshot(). */ /** * `ctrl:true` sends the wheel event with the Ctrl modifier bit set — this is * how a real trackpad pinch-zoom (and Chrome's own Ctrl+scroll zoom) reads * at the DOM level, and it's what infinite-canvas libraries (n8n's Vue Flow, * most GHL/Webflow-style builders) key their zoom-vs-pan branch off. Plain * scroll pans/scrolls the canvas; Ctrl+scroll zooms it. */ async scroll(dx: number, dy: number, opts: { ctrl?: boolean } = {}) { await this.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: this.mouse.x, y: this.mouse.y, deltaX: dx, deltaY: dy, modifiers: opts.ctrl ? 2 : 0, // CDP bitfield: Ctrl=2 }); await sleep(this.rng.range(80, 200)); // settle, like a human watching content load } /** Type text into the focused element with human cadence. Each character is * dispatched as a real keydown/char/keyUp with the right key, code, and * virtual-key code (see keyInfo) — the bare text-only events this used to send * read as keyCode===0 and broke keydown-driven login forms. Characters that * need Shift (uppercase letters, "!"/"@"/…) carry the Shift modifier so * KeyboardEvent.shiftKey agrees with the produced character. */ async type(text: string) { for (const ch of text) { const k = keyInfo(ch); await this.sendKey({ key: k.key, code: k.code, vk: k.vk, text: k.text, modifiers: k.shift ? 8 : 0 }); await sleep(keyDelay(this.rng, ch)); } } /** Clear the focused field: select-all (Ctrl+A) then Delete, via the Input * domain like the rest of our key dispatch. Playwright's fill() clears first; * without this, filling a pre-populated input yields "oldnewvalue". */ private async clearField() { await this.sendKey({ key: "a", code: "KeyA", vk: 65, modifiers: 2 }); // Ctrl+A → select all await this.sendKey({ key: "Delete", code: "Delete", vk: 46 }); // delete the selection } /** * Click a field, clear any existing value, then type into it. * * Guarded at both ends, because every step after the click is a BLIND * keyboard dispatch to whatever currently holds focus. If focus never landed * in an editable field, Ctrl+A selects the whole document, Delete does * nothing useful, the text goes nowhere — and fill() used to return * successfully, leaving the agent believing a form was filled. * * - before touching the page: refuse a target that cannot accept text * (disabled, readonly, or simply not a field), so a bad ref can't clobber * the document selection * - after the click: confirm the element actually took focus. A cookie * banner, modal, or sticky header covering the field swallows the click, * and this is where that shows up as an error instead of as lost text. */ async fill(ref: number, text: string) { const state = await this.callOnRef<{ ok: boolean; why: string }>( ref, `function(){ const t = this.tagName; if (this.disabled) return { ok:false, why:"the field is disabled" }; if (this.readOnly) return { ok:false, why:"the field is readonly" }; const editable = this.isContentEditable || t === "INPUT" || t === "TEXTAREA"; if (!editable) return { ok:false, why:"<" + t.toLowerCase() + "> is not an editable field" }; return { ok:true, why:"" }; }`, ); if (!state.ok) throw new Error(`fill: ref ${ref} — ${state.why}. Nothing was typed.`); await this.click(ref); await sleep(this.rng.range(60, 160)); // document.activeElement stops at a shadow HOST — inside a web component it // returns the host, never the field. Descend through shadow roots or every // shadow-DOM fill reads as "focus didn't land". const focused = await this.callOnRef( ref, `function(){ let a = document.activeElement; for (let i = 0; i < 10 && a && a.shadowRoot && a.shadowRoot.activeElement; i++) a = a.shadowRoot.activeElement; return a === this; }`, ); if (!focused) { throw new Error( `fill: ref ${ref} — the click did not put focus in the field, so nothing was typed. ` + `Something is probably covering it (cookie banner, modal, sticky header).`, ); } await this.clearField(); await this.type(text); } /** Resolve a snapshot ref to a live JS object handle (objectId) — the bridge * from an accessibility-tree ref to callFunctionOn, so we can read/drive one * specific element instead of the whole page. */ private async resolveRefObject(ref: number): Promise { const target = this.refs.get(ref); if (!target) throw new Error(`No element with ref ${ref}. Call snapshot() first.`); const { object } = await this.send("DOM.resolveNode", { backendNodeId: target.backendNodeId }); if (!object?.objectId) throw new Error(`Could not resolve ref ${ref} to a live node (it may have detached).`); return object.objectId; } /** Call a function with `this` bound to the element behind a snapshot ref and * return its by-value result. Uses Runtime.callFunctionOn (no Runtime.enable * needed) and always releases the node handle so it can't leak. */ private async callOnRef(ref: number, fn: string, args: any[] = []): Promise { const objectId = await this.resolveRefObject(ref); try { const r = await this.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, arguments: args.map((value) => ({ value })), returnByValue: true, awaitPromise: true, }); if (r.exceptionDetails) { throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text ?? "callFunctionOn failed"); } return r.result?.value as T; } finally { this.send("Runtime.releaseObject", { objectId }).catch(() => {}); } } /** * Set a `` or nothing matched. */ async select(ref: number, value: string): Promise { return this.callOnRef( ref, `function(v){ if (this.tagName !== "SELECT") throw new Error("select: ref <" + this.tagName.toLowerCase() + "> is not a ` — even a hidden one — without an OS * file picker. Uses CDP DOM.setFileInputFiles (the same primitive Playwright * uses under the hood), which sets `input.files` and fires `change` directly. * `selector` defaults to the first file input; pass a more specific one if the * page has several. Paths must be absolute. */ async uploadFile(paths: string[], selector = 'input[type="file"]') { for (const p of paths) { if (!p.startsWith("/")) throw new Error(`uploadFile: paths must be absolute, got ${JSON.stringify(p)}`); } const { root } = await this.send("DOM.getDocument", { depth: 0 }); const { nodeId } = await this.send("DOM.querySelector", { nodeId: root.nodeId, selector }); if (nodeId) { await this.send("DOM.setFileInputFiles", { files: paths, nodeId }); return; } // DOM.querySelector does not cross shadow boundaries, so a file input owned // by a web component looked absent. Fall back to a shadow-piercing search // and drive it by objectId instead of nodeId. const { result } = await this.send("Runtime.evaluate", { expression: `(() => { const q = ${JSON.stringify(selector)};` + ` const walk = (root, depth) => { if (depth > 10) return null;` + ` const hit = root.querySelector(q); if (hit) return hit;` + ` for (const el of root.querySelectorAll("*")) {` + ` if (el.shadowRoot) { const deep = walk(el.shadowRoot, depth + 1); if (deep) return deep; } }` + ` return null; };` + ` return walk(document, 0); })()`, }); const objectId = result?.objectId; if (!objectId) { throw new Error(`uploadFile: no element matching ${selector} (searched the document and any open shadow roots)`); } try { await this.send("DOM.setFileInputFiles", { files: paths, objectId }); } finally { this.send("Runtime.releaseObject", { objectId }).catch(() => {}); } } /** * Attach files through a control that opens a file picker (e.g. an "Upload * files" menu item) WITHOUT an OS dialog. Intercepts the chooser via CDP, * clicks the trigger, then feeds the paths to the input it opened for. This is * the path for SPAs (like Gemini) that create the `` lazily on click. * Paths must be absolute. */ async uploadViaPicker(triggerRef: number, paths: string[], opts: { timeout?: number } = {}) { await this.send("Page.setInterceptFileChooserDialog", { enabled: true }); try { // Listen on any session — the chooser event can arrive without our page // sessionId attached, which silently filtered it out before. const chooser = this.cdp.once("Page.fileChooserOpened", { timeout: opts.timeout ?? 15000, }); await this.click(triggerRef); const ev = await chooser; await this.send("DOM.setFileInputFiles", { files: paths, backendNodeId: ev.backendNodeId }); } finally { await this.send("Page.setInterceptFileChooserDialog", { enabled: false }); } } /** Read the page's visible text (for scraping a model response, etc.). */ async innerText(): Promise { return this.evaluate("document.body ? document.body.innerText : ''"); } /** Press a single named key on the focused element (Enter, Tab, Escape, arrows, * Delete, Home/End, PageUp/PageDown...). */ async press(key: string) { const KEYS: Record = { Enter: { code: "Enter", vk: 13, text: "\r" }, Tab: { code: "Tab", vk: 9 }, Escape: { code: "Escape", vk: 27 }, Backspace: { code: "Backspace", vk: 8 }, Delete: { code: "Delete", vk: 46 }, ArrowDown: { code: "ArrowDown", vk: 40 }, ArrowUp: { code: "ArrowUp", vk: 38 }, ArrowLeft: { code: "ArrowLeft", vk: 37 }, ArrowRight: { code: "ArrowRight", vk: 39 }, Home: { code: "Home", vk: 36 }, End: { code: "End", vk: 35 }, PageUp: { code: "PageUp", vk: 33 }, PageDown: { code: "PageDown", vk: 34 }, }; // Modifier combos: "Control+a", "Shift+Tab", "Control+Shift+k", "Meta+c". // Agents need these constantly — select-all before retyping, copy/paste, // Shift+Tab to walk a form backwards, Ctrl+Enter to submit — and every one // of them was previously unreachable through the public API even though // sendKey already understood modifiers. const parts = key.split("+"); const bare = parts.pop() ?? ""; let modifiers = 0; for (const p of parts) { const m = p.trim().toLowerCase(); if (m === "alt") modifiers |= 1; else if (m === "control" || m === "ctrl") modifiers |= 2; else if (m === "meta" || m === "cmd" || m === "command") modifiers |= 4; else if (m === "shift") modifiers |= 8; else throw new Error(`press: unknown modifier ${JSON.stringify(p)} in ${JSON.stringify(key)} (Alt, Control, Meta, Shift)`); } const named = KEYS[bare]; if (!named && bare.length !== 1) { // Message shape is part of the contract — tests/key-dispatch.test.ts // matches on it. Keep "unsupported key " unquoted. throw new Error( `press: unsupported key ${bare} — use a single character or one of: ${Object.keys(KEYS).join(", ")}`, ); } // With a non-Shift modifier held, the chord is a COMMAND, not text: sending // a char event too would type a literal "a" into the field alongside the // Ctrl+A. Shift alone still produces text (an uppercase letter). const isCommand = (modifiers & ~8) !== 0; if (named) { await this.sendKey({ key: bare, code: named.code, vk: named.vk, text: isCommand ? undefined : named.text, modifiers }); return; } const info = keyInfo(bare); await this.sendKey({ key: info.key, code: info.code, vk: info.vk, text: isCommand ? undefined : info.text, modifiers: modifiers || (info.shift ? 8 : 0), }); } // --- FedCM: drive federated sign-in ("Sign in with Google" one-tap, etc.) --- // Chrome renders FedCM account choosers as native browser UI that no synthetic // mouse click can reach (the button is a cross-origin IdP iframe, and the // chooser itself is browser chrome). FedCm.enable routes the dialog to us over // CDP instead, so an agent can actually complete a federated login. // End-to-end run: examples/fedcm.ts. /** * Start intercepting FedCM on this page. Call it ON DEMAND, right before the * sign-in you're driving — never as blanket startup setup. Any page that * silently probes FedCM at load (GoHighLevel, many SaaS logins) will HANG if * interception is on and nothing resolves the probe, so keep it off until you * need it and disableFedCm() afterwards. * * With {autoSelectFirst:true} (default) veil selects account 0 on every dialog * automatically — the one-liner for "just sign me in". Pass false to inspect * accounts via waitForFedCmDialog() and choose with selectFedCmAccount(). */ async enableFedCm(opts: { autoSelectFirst?: boolean } = {}) { const autoSelect = opts.autoSelectFirst ?? true; // FedCM is a top-level-page flow: enable it (and every later FedCm.* command) // on the MAIN session explicitly, matching the dialogShown listener below — // otherwise a useFrame() before sign-in would point these at a child session // and the RP's navigator.credentials.get() would hang unresolved. await this.cdp.send("FedCm.enable", { disableRejectionDelay: true }, this.sessionId); // A prior dismissal drops the IdP into a cooldown where the dialog silently // won't reappear; clear it so the next trigger actually shows. try { await this.cdp.send("FedCm.resetCooldown", {}, this.sessionId); } catch {} if (this.fedcmOff) return; this.fedcmOff = this.cdp.on( "FedCm.dialogShown", (p: any) => { const dialog: FedCmDialog = { dialogId: p.dialogId, type: p.dialogType, title: p.title, subtitle: p.subtitle, accounts: (p.accounts ?? []).map((a: any) => ({ accountId: a.accountId, email: a.email, name: a.name, givenName: a.givenName, idpConfigUrl: a.idpConfigUrl, })), }; this.lastFedcmDialogId = dialog.dialogId; // Bind selection to THIS session. CDP strips the sessionId off the event // params, and selecting on the wrong target leaves the dialog — and the // RP page's navigator.credentials.get() — hanging unresolved. if (autoSelect && dialog.accounts.length) { this.selectFedCmAccount(0, dialog.dialogId).catch(() => {}); } const waiter = this.fedcmWaiters.shift(); if (waiter) waiter(dialog); else this.fedcmQueue.push(dialog); }, this.sessionId, ); } /** Resolve with the next FedCM dialog (or one already queued since enable). */ async waitForFedCmDialog(opts: { timeout?: number } = {}): Promise { const queued = this.fedcmQueue.shift(); if (queued) return queued; return new Promise((resolve, reject) => { const waiter = (d: FedCmDialog) => { clearTimeout(timer); resolve(d); }; const timer = setTimeout(() => { const i = this.fedcmWaiters.indexOf(waiter); if (i >= 0) this.fedcmWaiters.splice(i, 1); reject(new Error("waitForFedCmDialog: timed out (is FedCM enabled, and are you signed in to the IdP?)")); }, opts.timeout ?? 30000); this.fedcmWaiters.push(waiter); }); } /** Pick an account in the current FedCM dialog (index into dialog.accounts). */ async selectFedCmAccount(accountIndex = 0, dialogId = this.lastFedcmDialogId) { if (!dialogId) throw new Error("selectFedCmAccount: no FedCM dialog has appeared yet"); // Target the main session explicitly (not activeSessionId): this also runs // from the dialogShown auto-select handler, which can fire after a useFrame(). await this.cdp.send("FedCm.selectAccount", { dialogId, accountIndex }, this.sessionId); } /** Dismiss the current FedCM dialog (decline the sign-in). */ async dismissFedCm(dialogId = this.lastFedcmDialogId) { if (!dialogId) return; await this.cdp.send("FedCm.dismissDialog", { dialogId, triggerCooldown: false }, this.sessionId); } /** Stop intercepting FedCM. Call after a sign-in so a later navigation that * probes FedCM isn't left hanging on us. */ async disableFedCm() { this.fedcmOff?.(); this.fedcmOff = undefined; this.fedcmQueue = []; this.fedcmWaiters = []; try { await this.cdp.send("FedCm.disable", {}, this.sessionId); } catch {} } /** * One call to complete an active federated sign-in: enables FedCM, clicks the * "Sign in with Google" button (a snapshot ref), waits for the account * chooser, selects an account, and returns it. For passive/one-tap flows that * fire on page load, enableFedCm() BEFORE navigating, then * waitForFedCmDialog() — the default autoSelectFirst signs you straight in. */ async signInWithFedCm(opts: { triggerRef?: number; accountIndex?: number; timeout?: number } = {}): Promise { await this.enableFedCm({ autoSelectFirst: false }); if (opts.triggerRef != null) await this.click(opts.triggerRef); const dialog = await this.waitForFedCmDialog({ timeout: opts.timeout }); const idx = opts.accountIndex ?? 0; const account = dialog.accounts[idx]; if (!account) throw new Error(`signInWithFedCm: no account at index ${idx} (dialog had ${dialog.accounts.length})`); await this.selectFedCmAccount(idx, dialog.dialogId); return account; } /** * Stop the page — and any site it loads — from reaching loopback / private * hosts. Detectors port-scan 127.0.0.1 from JS to fingerprint the machine's * other software (and it leaks your LAN to every site). With this on, each * such request is failed UNIFORMLY (same instant error, open port or closed), * so the scan can't tell them apart and comes back empty. Only private-host * requests are intercepted, so normal browsing keeps its exact timing. * * Still allowed: the agent's own top-level navigation to a private host * (page.goto("http://localhost:3000")), and a localhost page loading its own * localhost resources — only a PUBLIC page reaching a private host is blocked. */ /** * Confine this page to a set of origins: any DOCUMENT navigation (top-level or * sub-frame) to a host outside the list fails with AccessDenied. * * This is the mechanism behind the `Browser.connect()` warning. Attaching to a * signed-in Chrome hands the agent every session in that profile; an allowlist is * how you say "this run may touch github.com and nothing else" instead of trusting * it to stay on task. * * Scope, stated so it is not over-trusted: * - gates NAVIGATION, not subresources. A real page loads images/fonts/scripts from * other origins; gating those breaks every site, and a guard that breaks sites * gets turned off. Cross-origin reads are already stopped by the same-origin policy. * - anything ON an allowed origin is fully reachable, as the signed-in user. * - a beacon-style POST to a non-allowed origin is NOT blocked (it is not a Document). */ async restrictOrigins(origins: string[]) { this.allowOrigins = origins.filter((o) => o.trim()); await this.applyFetchInterception(); } /** Lift the origin allowlist. */ async unrestrictOrigins() { this.allowOrigins = []; await this.applyFetchInterception(); } async blockPrivateNetwork() { if (this.blockPrivateOn) return; this.blockPrivateOn = true; // Learn the main frame so the handler can tell an agent nav from a page's own // probe. (Fetch runs on the MAIN session — see applyFetchInterception.) try { const { frameTree } = await this.cdp.send("Page.getFrameTree", {}, this.sessionId); this.mainFrameId = frameTree?.frame?.id; this.topPrivate = isPrivateHost(frameTree?.frame?.url ?? ""); } catch {} await this.applyFetchInterception(); } /** Lift the private-network block (re-allows localhost/LAN requests). Leaves any * resource blocking in place. */ async unblockPrivateNetwork() { if (!this.blockPrivateOn) return; this.blockPrivateOn = false; await this.applyFetchInterception(); } /** * Block resource loads — a big speed and footprint win for scraping. Block by * type (`["image","font","media","stylesheet"]`) and/or by URL substring * (`{ urls: ["analytics","doubleclick"] }`); matching requests are failed via * CDP's Fetch domain. Calls accumulate. Coexists with the private-network guard * (both share one interception handler). Lift it all with unblockResources(). * Types accept friendly names (image, font, media, stylesheet, script, xhr, * fetch, document, websocket, ...). */ async blockResources(types: string[] = [], opts: { urls?: string[] } = {}) { for (const t of types) { const cdpType = RESOURCE_TYPES[t.toLowerCase()]; if (!cdpType) throw new Error(`blockResources: unknown type "${t}" (known: ${Object.keys(RESOURCE_TYPES).join(", ")})`); this.blockedResourceTypes.add(cdpType); } if (opts.urls) this.blockedUrlSubstrings.push(...opts.urls); await this.applyFetchInterception(); } /** Lift resource blocking (leaves the private-network guard untouched). */ async unblockResources() { this.blockedResourceTypes.clear(); this.blockedUrlSubstrings = []; await this.applyFetchInterception(); } /** The union of Fetch patterns for whatever guards are currently active. Only * matching requests get paused, so anything unblocked keeps its exact timing. */ private fetchPatterns(): Array> { const patterns: Array> = []; if (this.blockPrivateOn) patterns.push(...PRIVATE_URL_PATTERNS); // Documents only — the allowlist gates navigation, so there is no reason to pause // (and pay the round-trip on) every image and script the page pulls. if (this.allowOrigins.length) patterns.push({ urlPattern: "*", resourceType: "Document" }); for (const t of this.blockedResourceTypes) patterns.push({ urlPattern: "*", resourceType: t }); for (const sub of this.blockedUrlSubstrings) patterns.push({ urlPattern: `*${sub}*` }); return patterns; } /** Decide the fate of one paused request against every active guard, in one * place. Commands target this.sessionId explicitly: requestPaused fires on the * MAIN session and may arrive after a useFrame() repointed activeSessionId, and * a continue/fail sent to the wrong session can't find the requestId (hang). */ private handleFetchPaused(p: any) { const url: string = p.request?.url ?? ""; const rtype: string = p.resourceType ?? ""; const requestId = p.requestId; const respond = (fn: "failRequest" | "continueRequest", extra: Record = {}) => this.cdp.send(`Fetch.${fn}`, { requestId, ...extra }, this.sessionId).catch(() => {}); // 0. Origin allowlist: where may this agent GO? Gates DOCUMENT requests only — // top-level and sub-frame. Subresources are deliberately NOT gated: a real page // pulls images, fonts and scripts from CDNs on other origins, so gating them // breaks every site and the guard gets switched off, which protects nothing. // Cross-origin READS are already stopped by the same-origin policy; what this // closes is the agent navigating to an origin and acting as the signed-in user. // NOTE: no main-frame exemption here, unlike the private-network guard below — // the top-level navigation IS the threat. if (this.allowOrigins.length && rtype === "Document" && !isOriginAllowed(url, this.allowOrigins)) { return void respond("failRequest", { errorReason: "AccessDenied" }); } // 1. Private-network guard: a PUBLIC page reaching a private host (the scan). // Allowed: the agent's own top-level nav, and a private page's own resources. if (this.blockPrivateOn && isPrivateHost(url)) { const isMainNav = rtype === "Document" && p.frameId === this.mainFrameId; if (!isMainNav && !this.topPrivate) return void respond("failRequest", { errorReason: "AccessDenied" }); } // 2. Resource blocking: by CDP resource type or URL substring. if (this.blockedResourceTypes.has(rtype) || this.blockedUrlSubstrings.some((s) => url.includes(s))) { return void respond("failRequest", { errorReason: "BlockedByClient" }); } return void respond("continueRequest"); } /** (Re)configure the shared Fetch interception for the currently-active guards: * register the single requestPaused + frameNavigated listeners once, enable * Fetch with the union of patterns, and fully tear down when nothing is active. */ private async applyFetchInterception() { const patterns = this.fetchPatterns(); if (patterns.length === 0) { this.fetchOff?.(); this.fetchOff = undefined; try { await this.cdp.send("Fetch.disable", {}, this.sessionId); } catch {} return; } if (!this.fetchOff) { const offNav = this.cdp.on( "Page.frameNavigated", (p: any) => { const f = p.frame; if (f && !f.parentId) { this.mainFrameId = f.id; this.topPrivate = isPrivateHost(f.url ?? ""); } }, this.sessionId, ); const offFetch = this.cdp.on("Fetch.requestPaused", (p: any) => this.handleFetchPaused(p), this.sessionId); this.fetchOff = () => { offNav(); offFetch(); }; } await this.cdp.send("Fetch.enable", { patterns }, this.sessionId); } /** Close this page and detach its target from the browser. Idempotent. */ async close() { if (this.closed) return; this.closed = true; this.resetRefs(); this.frameOff?.(); this.fetchOff?.(); this.frameSessions = []; if (this.targetId) { try { // Target.closeTarget closes the page/target and frees its resources. // Send to browser context (no sessionId) since we're closing the target itself. await this.cdp.send("Target.closeTarget", { targetId: this.targetId }); } catch { // Target already closed or doesn't exist; this is OK. } } // Clean up any lingering event handlers for this session this.cdp.clearHandlers(this.sessionId); } }