From 57cb7447e0e28533b010331fe54a88c5adee2531 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 21 Jun 2026 03:54:40 +0200 Subject: [PATCH] feat(coding-agent): enhanced browser stealth and automation capabilities - Overhauled stealth spoofing mechanisms for WebGL, screen dimensions, Web workers, and iframe contexts using prototype-aware injection. - Centralized function string representation patching to improve mimicry of native browser behavior across global objects. - Patched puppeteer-core to remove detectable evaluation markers and implement lazy, pull-style execution context management. - Enabled support for capturing LLM request JSON dumps and adjusted launcher flags to improve organic request patterns. --- bun.lock | 1 + package.json | 3 +- packages/coding-agent/CHANGELOG.md | 7 + .../coding-agent/src/tools/browser/launch.ts | 138 +++- .../tools/puppeteer/00_stealth_tampering.txt | 51 +- .../tools/puppeteer/01_stealth_activity.txt | 98 ++- .../tools/puppeteer/02_stealth_hairline.txt | 68 +- .../src/tools/puppeteer/03_stealth_botd.txt | 24 +- .../src/tools/puppeteer/04_stealth_iframe.txt | 229 +++++-- .../src/tools/puppeteer/05_stealth_webgl.txt | 290 ++++++-- .../src/tools/puppeteer/06_stealth_screen.txt | 318 +++++++-- .../src/tools/puppeteer/07_stealth_fonts.txt | 32 +- .../src/tools/puppeteer/08_stealth_audio.txt | 52 +- .../src/tools/puppeteer/09_stealth_locale.txt | 83 +-- .../tools/puppeteer/10_stealth_plugins.txt | 20 +- .../tools/puppeteer/11_stealth_hardware.txt | 63 +- .../src/tools/puppeteer/12_stealth_codecs.txt | 38 +- .../src/tools/puppeteer/13_stealth_worker.txt | 247 +++++-- patches/puppeteer-core@25.1.0.patch | 648 ++++++++++++++++++ 19 files changed, 1951 insertions(+), 459 deletions(-) create mode 100644 patches/puppeteer-core@25.1.0.patch diff --git a/bun.lock b/bun.lock index db55dcd27..a3ff12a75 100644 --- a/bun.lock +++ b/bun.lock @@ -324,6 +324,7 @@ }, "patchedDependencies": { "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch", + "puppeteer-core@25.1.0": "patches/puppeteer-core@25.1.0.patch", }, "catalog": { "@agentclientprotocol/sdk": "0.25.0", diff --git a/package.json b/package.json index 7328e2c16..cf438c9e9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "packageManager": "bun@1.3.14", "patchedDependencies": { - "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch" + "@ark/schema@0.56.0": "patches/@ark%2Fschema@0.56.0.patch", + "puppeteer-core@25.1.0": "patches/puppeteer-core@25.1.0.patch" }, "workspaces": { "packages": [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d4f40294e..eb239f134 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,10 +1,17 @@ # Changelog ## [Unreleased] + ### Added - Added LLM request JSON export functionality to `/dump` +### Changed + +- Improved browser stealth by suppressing common automation flags and patching property descriptors +- Enhanced stealth for `WebGL`, `Worker`, `IFrame`, `Screen`, and `Audio` APIs to evade detection +- Updated `toString` patching to register native function sources for improved fingerprint protection + ### Removed - Removed `/debug dump-next-request` command diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 044bed2ff..d9c9bb846 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -29,10 +29,23 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1 * connection dropped, etc.). */ export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000; +// Automation-tell launch flags that puppeteer-core adds by default. We suppress +// them via `ignoreDefaultArgs` (the supported escape hatch) to mirror xxxx's +// chromiumSwitches patch. `--enable-automation` is the loudest: it sets +// navigator.webdriver=true and shows the "controlled by automated software" infobar. +// `ignoreDefaultArgs` does exact-string matching, so each entry must be a flag that +// puppeteer emits verbatim. The default `--disable-features=...` string can't be +// matched this way; it is neutralized in the puppeteer-core patch (ChromeLauncher). const STEALTH_IGNORE_DEFAULT_ARGS = [ + "--enable-automation", "--disable-extensions", "--disable-default-apps", "--disable-component-extensions-with-background-pages", + "--disable-popup-blocking", + "--disable-client-side-phishing-detection", + "--allow-pre-commit-input", + "--disable-ipc-flooding-protection", + "--metrics-recording-only", ]; const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; @@ -309,9 +322,11 @@ export interface UserAgentOverride { userAgentMetadata: { brands: Array<{ brand: string; version: string }>; fullVersion: string; + fullVersionList: Array<{ brand: string; version: string }>; platform: string; platformVersion: string; architecture: string; + bitness: string; model: string; mobile: boolean; }; @@ -371,6 +386,26 @@ function patchSourceUrl(page: Page): void { }; } +async function resolveMacOsProductVersion(): Promise { + if (os.platform() !== "darwin") return ""; + try { + const plist = await Bun.file("/System/Library/CoreServices/SystemVersion.plist").text(); + return plist.match(/ProductVersion<\/key>\s*([^<]+)<\/string>/)?.[1] ?? ""; + } catch { + return ""; + } +} + +function resolveHostArchitecture(): string { + if (os.arch() === "arm64") return "arm"; + if (os.arch().includes("64")) return "x86"; + return ""; +} + +function resolveHostBitness(): string { + return os.arch().includes("64") ? "64" : ""; +} + async function resolveUserAgentOverride(page: Page): Promise { const rawUserAgent = await page.browser().userAgent(); let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/"); @@ -379,32 +414,24 @@ async function resolveUserAgentOverride(page: Page): Promise } const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/); - const fallbackVersionMatch = uaVersionMatch ?? (await page.browser().version()).match(/\/([\d|.]+)/); - const uaVersion = fallbackVersionMatch?.[1] ?? "0"; - const majorVersion = Number.parseInt(uaVersion.split(".")[0] ?? "0", 10) || 0; + const browserVersionMatch = (await page.browser().version()).match(/\/([\d|.]+)/); + const legacyVersion = uaVersionMatch?.[1] ?? browserVersionMatch?.[1] ?? "0"; + const fullVersion = browserVersionMatch?.[1] ?? legacyVersion; + const majorVersion = Number.parseInt(legacyVersion.split(".")[0] ?? "0", 10) || 0; const isAndroid = userAgent.includes("Android"); - const platform = userAgent.includes("Mac OS X") - ? "MacIntel" - : isAndroid - ? "Android" - : userAgent.includes("Linux") - ? "Linux" - : "Win32"; - const platformFull = userAgent.includes("Mac OS X") - ? "Mac OS X" - : isAndroid - ? "Android" - : userAgent.includes("Linux") - ? "Linux" - : "Windows"; - const platformVersion = userAgent.includes("Mac OS X ") - ? (userAgent.match(/Mac OS X ([^)]+)/)?.[1] ?? "") + const isMac = userAgent.includes("Mac OS X"); + const isWindows = userAgent.includes("Windows"); + const platform = isMac ? "MacIntel" : isAndroid ? "Android" : userAgent.includes("Linux") ? "Linux" : "Win32"; + const platformFull = isMac ? "macOS" : isAndroid ? "Android" : userAgent.includes("Linux") ? "Linux" : "Windows"; + const platformVersion = isMac + ? await resolveMacOsProductVersion() : userAgent.includes("Android ") ? (userAgent.match(/Android ([^;]+)/)?.[1] ?? "") - : userAgent.includes("Windows ") - ? (userAgent.match(/Windows .*?([\d|.]+);?/)?.[1] ?? "") + : isWindows + ? (userAgent.match(/Windows NT ([\d.]+)/)?.[1] ?? "") : ""; - const architecture = isAndroid ? "" : "x86"; + const architecture = isAndroid ? "" : resolveHostArchitecture(); + const bitness = isAndroid ? "" : resolveHostBitness(); const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : ""; const brandOrders = [ @@ -422,6 +449,10 @@ async function resolveUserAgentOverride(page: Page): Promise brands[order[0]!] = { brand: greaseyBrand, version: "99" }; brands[order[1]!] = { brand: "Chromium", version: String(majorVersion) }; brands[order[2]!] = { brand: "Google Chrome", version: String(majorVersion) }; + const fullVersionList = brands.map(({ brand }) => ({ + brand, + version: brand === greaseyBrand ? "99.0.0.0" : fullVersion, + })); return { userAgent, @@ -429,10 +460,12 @@ async function resolveUserAgentOverride(page: Page): Promise acceptLanguage: STEALTH_ACCEPT_LANGUAGE, userAgentMetadata: { brands, - fullVersion: uaVersion, + fullVersion, + fullVersionList, platform: platformFull, platformVersion, architecture, + bitness, model, mobile: isAndroid, }, @@ -578,15 +611,29 @@ function buildStealthInjectionScript(scripts: readonly string[] = STEALTH_PATCH_ .join(";\n"); return `(() => { - // Native function cache - captured before any tampering - const iframe = document.createElement("iframe"); - iframe.style.display = "none"; + const Page_Function_toString = Function.prototype.toString; + const Page_FunctionToStringDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, "toString"); + const Page_Proxy = Proxy; + const Page_WeakMap = WeakMap; + const Page_WeakMap_get = Page_WeakMap.prototype.get; + const Page_WeakMap_set = Page_WeakMap.prototype.set; + // Native function cache - captured before any tampering. + // A same-origin iframe yields natives uncontaminated by page-level + // tampering, but at document-start (when this preload runs) there is + // no documentElement to attach it to. In that case the page itself + // hasn't executed yet, so window's own natives are still pristine — + // fall back to window instead of bailing, otherwise none of the + // fingerprint patches below would ever run. + let iframe = null; const container = document.head ?? document.documentElement; - if (!container) return; - container.appendChild(iframe); + if (container) { + iframe = document.createElement("iframe"); + iframe.style.display = "none"; + container.appendChild(iframe); + if (!iframe.contentWindow) iframe = null; + } try { - const nativeWindow = iframe.contentWindow; - if (!nativeWindow) return; + const nativeWindow = iframe ? iframe.contentWindow : window; // Cache pristine native functions const Function_toString = nativeWindow.Function.prototype.toString; @@ -626,9 +673,38 @@ function buildStealthInjectionScript(scripts: readonly string[] = STEALTH_PATCH_ const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat; const Date_constructor = nativeWindow.Date; + const nativeFunctionSources = new Page_WeakMap(); + const makeNativeString = (name) => "function " + (name || "") + "() { [native code] }"; + const registerNativeSource = (fn, source) => { + if (typeof fn === "function") Reflect_apply(Page_WeakMap_set, nativeFunctionSources, [fn, source]); + return fn; + }; + const patchToString = (fn, name) => registerNativeSource(fn, makeNativeString(name)); + if (${scripts.length > 0 ? "true" : "false"}) { + const functionToStringProxy = new Page_Proxy(Page_Function_toString, { + apply(target, thisArg, args) { + const source = Reflect_apply(Page_WeakMap_get, nativeFunctionSources, [thisArg]); + if (source) return source; + return Reflect_apply(target, thisArg, args || []); + }, + get(target, key, receiver) { + return Reflect_get(target, key, receiver); + }, + }); + registerNativeSource(functionToStringProxy, makeNativeString("toString")); + Object_defineProperty(Function.prototype, "toString", { + ...(Page_FunctionToStringDescriptor || { + writable: true, + configurable: true, + enumerable: false, + }), + value: functionToStringProxy, + }); + } + ${joint} } finally { - if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + if (iframe && iframe.parentNode) iframe.parentNode.removeChild(iframe); }})();`; } diff --git a/packages/coding-agent/src/tools/puppeteer/00_stealth_tampering.txt b/packages/coding-agent/src/tools/puppeteer/00_stealth_tampering.txt index bdc87d9c5..02c558dcf 100644 --- a/packages/coding-agent/src/tools/puppeteer/00_stealth_tampering.txt +++ b/packages/coding-agent/src/tools/puppeteer/00_stealth_tampering.txt @@ -1,8 +1,5 @@ -// Helper to generate native code string -const makeNativeString = (name) => - "function " + (name || "") + "() { [native code] }"; - -// Patch toString for common fingerprinted functions +// Register native-looking source for common fingerprinted functions without +// adding own toString properties. const patchedFns = [ [window.alert, "alert"], [window.prompt, "prompt"], @@ -20,44 +17,28 @@ const patchedFns = [ ]; for (const [fn, name] of patchedFns) { - if (typeof fn === "function") { - const nativeStr = makeNativeString(name); - Object_defineProperty(fn, "toString", { - value: function toString() { return nativeStr; }, - writable: false, - configurable: true, - enumerable: false, - }); - } + patchToString(fn, name); } -// Patch Object.getOwnPropertyDescriptor to return native-looking descriptors -Object.getOwnPropertyDescriptor = function (obj, prop) { - const descriptor = Object_getOwnPropertyDescriptor.call(this, obj, prop); +// Patch Object.getOwnPropertyDescriptor to return native-looking accessor +// source through the shared Function.prototype.toString registry. +const patchedGetOwnPropertyDescriptor = function getOwnPropertyDescriptor(obj, prop) { + const descriptor = Reflect_apply(Object_getOwnPropertyDescriptor, this, [obj, prop]); if (!descriptor) return descriptor; - // Make patched descriptors look native if (descriptor.get && typeof descriptor.get === "function") { - const getStr = makeNativeString("get " + String(prop)); - Object_defineProperty(descriptor.get, "toString", { - value: function toString() { return getStr; }, - writable: false, - configurable: true, - enumerable: false, - }); + patchToString(descriptor.get, "get " + String(prop)); } if (descriptor.set && typeof descriptor.set === "function") { - const setStr = makeNativeString("set " + String(prop)); - Object_defineProperty(descriptor.set, "toString", { - value: function toString() { return setStr; }, - writable: false, - configurable: true, - enumerable: false, - }); + patchToString(descriptor.set, "set " + String(prop)); } return descriptor; }; - -// Cleanup -document.head.removeChild(iframe); +patchToString(patchedGetOwnPropertyDescriptor, "getOwnPropertyDescriptor"); +Object_defineProperty(Object, "getOwnPropertyDescriptor", { + value: patchedGetOwnPropertyDescriptor, + writable: true, + configurable: true, + enumerable: false, +}); diff --git a/packages/coding-agent/src/tools/puppeteer/01_stealth_activity.txt b/packages/coding-agent/src/tools/puppeteer/01_stealth_activity.txt index 8306db9b6..ae000d40e 100644 --- a/packages/coding-agent/src/tools/puppeteer/01_stealth_activity.txt +++ b/packages/coding-agent/src/tools/puppeteer/01_stealth_activity.txt @@ -1,20 +1,80 @@ -const scheduleActivity = () => { - const delay = 3000 + Math_random() * 4000; - Window_setTimeout(() => { - Object_defineProperty(document, "hidden", { get: () => false }); - Object_defineProperty(document, "visibilityState", { - get: () => "visible", +{ + const visibilityDescriptors = Object_getOwnPropertyDescriptors({ + get hidden() { + return false; + }, + get visibilityState() { + return "visible"; + }, + get webkitHidden() { + return false; + }, + get webkitVisibilityState() { + return "visible"; + }, + }); + for (const key of Object_keys(visibilityDescriptors)) { + const descriptor = visibilityDescriptors[key]; + if (descriptor && typeof descriptor.get === "function") { + patchToString(descriptor.get, "get " + key); + } + } + const focusDescriptor = Object_getOwnPropertyDescriptor( + { + hasFocus() { + return document.visibilityState === "visible"; + }, + }, + "hasFocus", + ); + if (focusDescriptor && typeof focusDescriptor.value === "function") { + patchToString(focusDescriptor.value, "hasFocus"); + } + + const clearOwnSlot = (name) => { + const ownDescriptor = Object_getOwnPropertyDescriptor(document, name); + if (!ownDescriptor) return true; + if (ownDescriptor.configurable !== true) return false; + return Reflect_deleteProperty(document, name); + }; + + const inheritedSlot = (name, expectedKind) => { + let proto = Object_getPrototypeOf(document); + while (proto) { + const descriptor = Object_getOwnPropertyDescriptor(proto, name); + if (descriptor && typeof descriptor[expectedKind] === "function") { + return [proto, descriptor]; + } + proto = Object_getPrototypeOf(proto); + } + }; + + const defineAccessor = (name) => { + if (!clearOwnSlot(name)) return; + const slot = inheritedSlot(name, "get"); + if (!slot || slot[1].configurable !== true) return; + + Object_defineProperty(slot[0], name, { + get: visibilityDescriptors[name].get, + enumerable: slot[1].enumerable, + configurable: true, }); - Object_defineProperty(document, "webkitVisibilityState", { - get: () => "visible", - }); - document.dispatchEvent(new Window_Event("visibilitychange")); - if (Math_random() < 0.4) window.dispatchEvent(new Window_Event("focus")); - document.hasFocus = () => true; - if (Math_random() < 0.3) window.dispatchEvent(new Window_Event("scroll")); - if (navigator.wakeLock) - navigator.wakeLock.request("screen").catch(() => {}); - scheduleActivity(); - }, delay); -}; -scheduleActivity(); + }; + + defineAccessor("hidden"); + defineAccessor("visibilityState"); + defineAccessor("webkitHidden"); + defineAccessor("webkitVisibilityState"); + + if (clearOwnSlot("hasFocus")) { + const slot = inheritedSlot("hasFocus", "value"); + if (slot && slot[1].configurable === true) { + Object_defineProperty(slot[0], "hasFocus", { + value: focusDescriptor.value, + writable: slot[1].writable === true, + enumerable: slot[1].enumerable, + configurable: true, + }); + } + } +} diff --git a/packages/coding-agent/src/tools/puppeteer/02_stealth_hairline.txt b/packages/coding-agent/src/tools/puppeteer/02_stealth_hairline.txt index 37ce845ea..ab7301c2d 100644 --- a/packages/coding-agent/src/tools/puppeteer/02_stealth_hairline.txt +++ b/packages/coding-agent/src/tools/puppeteer/02_stealth_hairline.txt @@ -1,11 +1,57 @@ -const elementDescriptor = Object_getOwnPropertyDescriptor( - HTMLElement.prototype, - "offsetHeight", -); -Object_defineProperty(HTMLDivElement.prototype, "offsetHeight", { - ...elementDescriptor, - get: function () { - if (this.id === "modernizr") return 1; - return Reflect_apply(elementDescriptor.get, this, []); - }, -}); +{ + const htmlElementOffsetHeightDescriptor = + typeof HTMLElement === "undefined" + ? undefined + : Object_getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + const htmlDivElementOffsetHeightDescriptor = + typeof HTMLDivElement === "undefined" + ? undefined + : Object_getOwnPropertyDescriptor(HTMLDivElement.prototype, "offsetHeight"); + const offsetHeightDescriptor = + htmlDivElementOffsetHeightDescriptor || htmlElementOffsetHeightDescriptor; + const offsetHeightPrototype = htmlDivElementOffsetHeightDescriptor + ? HTMLDivElement.prototype + : htmlElementOffsetHeightDescriptor + ? HTMLElement.prototype + : undefined; + const elementIdDescriptor = + typeof Element === "undefined" + ? undefined + : Object_getOwnPropertyDescriptor(Element.prototype, "id"); + + if ( + typeof HTMLDivElement !== "undefined" && + offsetHeightPrototype && + offsetHeightDescriptor && + typeof offsetHeightDescriptor.get === "function" && + offsetHeightDescriptor.configurable && + elementIdDescriptor && + typeof elementIdDescriptor.get === "function" + ) { + const offsetHeightGetter = new Window_Proxy(offsetHeightDescriptor.get, { + apply(target, thisArg, args) { + const height = Reflect_apply(target, thisArg, args); + + if ( + height === 0 && + Object_getPrototypeOf(thisArg) === HTMLDivElement.prototype && + Reflect_apply(elementIdDescriptor.get, thisArg, []) === "modernizr" + ) { + return 1; + } + + return height; + }, + }); + + patchToString(offsetHeightGetter, "get offsetHeight"); + + Object_defineProperty( + offsetHeightPrototype, + "offsetHeight", + Object_assign({}, offsetHeightDescriptor, { + get: offsetHeightGetter, + }), + ); + } +} diff --git a/packages/coding-agent/src/tools/puppeteer/03_stealth_botd.txt b/packages/coding-agent/src/tools/puppeteer/03_stealth_botd.txt index 4a322ffda..49b520e03 100644 --- a/packages/coding-agent/src/tools/puppeteer/03_stealth_botd.txt +++ b/packages/coding-agent/src/tools/puppeteer/03_stealth_botd.txt @@ -1,15 +1,3 @@ -const makeNativeString = (name) => "function " + (name || "") + "() { [native code] }"; -const patchToString = (fn, name) => { - if (typeof fn !== "function") return; - Object_defineProperty(fn, "toString", { - value: function toString() { - return makeNativeString(name); - }, - writable: false, - configurable: true, - enumerable: false, - }); -}; // Ensure navigator.webdriver behaves like real Chrome if (navigator.webdriver !== false && navigator.webdriver !== undefined) { @@ -356,13 +344,19 @@ if (window.chrome && !("runtime" in window.chrome) && isSecureOrigin) { // Suppress Permission.query for automation-controlled if (navigator.permissions?.query) { if (isSecureOrigin && "Notification" in window) { + const notificationPermissionGetter = Object_getOwnPropertyDescriptor({ + get permission() { + return "default"; + }, + }, "permission").get; + patchToString(notificationPermissionGetter, "get permission"); Object_defineProperty(Notification, "permission", { - get: () => "default", + get: notificationPermissionGetter, configurable: true, }); } else if (!isSecureOrigin) { const originalQuery = navigator.permissions.query; - navigator.permissions.query = function (parameters) { + const patchedPermissionsQuery = function query(parameters) { if (parameters?.name === "notifications") { const status = { state: "denied", onchange: null }; if (typeof PermissionStatus !== "undefined") { @@ -372,6 +366,8 @@ if (navigator.permissions?.query) { } return originalQuery.call(this, parameters); }; + patchToString(patchedPermissionsQuery, "query"); + navigator.permissions.query = patchedPermissionsQuery; } } diff --git a/packages/coding-agent/src/tools/puppeteer/04_stealth_iframe.txt b/packages/coding-agent/src/tools/puppeteer/04_stealth_iframe.txt index 8d6264699..fd9994712 100644 --- a/packages/coding-agent/src/tools/puppeteer/04_stealth_iframe.txt +++ b/packages/coding-agent/src/tools/puppeteer/04_stealth_iframe.txt @@ -1,81 +1,174 @@ -const addContentWindowProxy = (iframe) => { - const contentWindowProxy = { - get(target, key) { - if (key === "self") return this; - if (key === "frameElement") return iframe; - if (key === "0") return undefined; - return Reflect_get(target, key); - }, - }; +const iframeWindowProxies = new WeakMap(); - if (!iframe.contentWindow) { - const proxy = new Window_Proxy(window, contentWindowProxy); - Object_defineProperty(iframe, "contentWindow", { - get() { - return proxy; - }, - set(newValue) { - return newValue; - }, - enumerable: true, - configurable: false, - }); +const isFrameIndexKey = (key) => { + if (typeof key !== "string" || key === "") return false; + const number = +key; + return number >= 0 && number < 4294967295 && Math_floor(number) === number && `${number}` === key; +}; + +const getPropertyDescriptor = (object, key) => { + let current = object; + while (current) { + const descriptor = Object_getOwnPropertyDescriptor(current, key); + if (descriptor) return descriptor; + current = Object_getPrototypeOf(current); } }; -const handleIframeCreation = (target, thisArg, args) => { - const iframe = Reflect_apply(target, thisArg, args); - const originalIframe = iframe; - const originalSrcdoc = originalIframe.srcdoc; +const descriptorWithValue = (target, key, value, writable) => { + const descriptor = Reflect_getOwnPropertyDescriptor(target, key); + if (descriptor && descriptor.configurable === false) return descriptor; + const next = descriptor ? Object_assign({}, descriptor) : { configurable: true, enumerable: true }; + Reflect_deleteProperty(next, "get"); + Reflect_deleteProperty(next, "set"); + next.value = value; + if (!Reflect_has(next, "writable")) next.writable = writable; + return next; +}; +const iframeContentWindowDescriptor = + typeof HTMLIFrameElement === "undefined" + ? undefined + : getPropertyDescriptor(HTMLIFrameElement.prototype, "contentWindow"); +const iframeSrcdocDescriptor = + typeof HTMLIFrameElement === "undefined" + ? undefined + : getPropertyDescriptor(HTMLIFrameElement.prototype, "srcdoc"); - Object_defineProperty(iframe, "srcdoc", { - configurable: true, - get() { - return originalSrcdoc; - }, - set(newValue) { - addContentWindowProxy(this); - Object_defineProperty(iframe, "srcdoc", { - configurable: false, - writable: false, - value: originalSrcdoc, - }); - originalIframe.srcdoc = newValue; - }, - }); - - return iframe; +const getNativeContentWindow = (iframe) => { + if (iframeContentWindowDescriptor && iframeContentWindowDescriptor.get) { + return Reflect_apply(iframeContentWindowDescriptor.get, iframe, []); + } + return undefined; }; -const addIframeCreationSniffer = () => { - const originalCreateElement = document.createElement; - const handler = { - apply(target, thisArg, args) { - const isIframe = args && args.length && `${args[0]}`.toLowerCase() === "iframe"; - if (!isIframe) { - return Reflect_apply(target, thisArg, args); - } - return handleIframeCreation(target, thisArg, args); - }, +const addContentWindowProxy = (iframe) => { + let state = Reflect_apply(Page_WeakMap_get, iframeWindowProxies, [iframe]); + if (state) return state.proxy; + + state = { proxy: undefined, target: undefined, wasConnected: false, setProxyTarget: undefined }; + + const isDiscarded = () => { + if (iframe.isConnected) state.wasConnected = true; + return state.wasConnected && !iframe.isConnected; + }; + + const currentFrameElement = () => { + if (iframe.isConnected) { + state.wasConnected = true; + return iframe; + } + return state.wasConnected ? null : iframe; + }; + + const contentWindowProxy = { get(target, key) { + if (key === "self" || key === "window" || key === "frames" || key === "globalThis") return state.proxy; + if (key === "frameElement") return currentFrameElement(); + if (key === "closed" && isDiscarded()) return true; + if (isFrameIndexKey(key)) return undefined; + if (key === "length") return 0; return Reflect_get(target, key); }, + getOwnPropertyDescriptor(target, key) { + if (key === "self" || key === "window" || key === "frames" || key === "globalThis") { + return descriptorWithValue(target, key, state.proxy, true); + } + if (key === "frameElement") { + return descriptorWithValue(target, key, currentFrameElement(), false); + } + if (key === "closed" && isDiscarded()) { + return descriptorWithValue(target, key, true, false); + } + if (isFrameIndexKey(key)) return undefined; + if (key === "length") return descriptorWithValue(target, key, 0, false); + return Reflect_getOwnPropertyDescriptor(target, key); + }, + has(target, key) { + if (key === "self" || key === "window" || key === "frames" || key === "globalThis" || key === "frameElement") return true; + if (isFrameIndexKey(key)) return false; + return Reflect_has(target, key); + }, + ownKeys(target) { + const keys = Reflect_ownKeys(target); + const filtered = []; + for (let index = 0; index < keys.length; index++) { + if (!isFrameIndexKey(keys[index])) filtered.push(keys[index]); + } + return filtered; + }, }; - const proxied = new Window_Proxy(originalCreateElement, handler); - Object_defineProperty(document, "createElement", { - value: proxied, - writable: true, - configurable: true, - }); - Object_defineProperty(document.createElement, "toString", { - value: Function_toString.bind(originalCreateElement), - writable: false, - configurable: true, - enumerable: false, - }); -}; + const setProxyTarget = (target) => { + if (state.target === target && state.proxy) return state.proxy; + state.target = target; + state.proxy = new Window_Proxy(target, contentWindowProxy); + return state.proxy; + }; + state.setProxyTarget = setProxyTarget; + const initialContentWindow = getNativeContentWindow(iframe); + if (initialContentWindow) setProxyTarget(initialContentWindow); + if (iframe.isConnected) state.wasConnected = true; + Reflect_apply(Page_WeakMap_set, iframeWindowProxies, [iframe, state]); -try { - addIframeCreationSniffer(); -} catch {} + + return state.proxy; +}; +if ( + iframeContentWindowDescriptor && + iframeContentWindowDescriptor.get && + iframeContentWindowDescriptor.configurable !== false +) { + const contentWindowAccessors = { + get contentWindow() { + const state = Reflect_apply(Page_WeakMap_get, iframeWindowProxies, [this]); + if (!state) return getNativeContentWindow(this); + if (this.isConnected) state.wasConnected = true; + if (state.wasConnected && !this.isConnected) return null; + const nativeContentWindow = getNativeContentWindow(this); + if (!nativeContentWindow) return nativeContentWindow; + return state.setProxyTarget(nativeContentWindow); + }, + }; + const contentWindowGetter = Object_getOwnPropertyDescriptor(contentWindowAccessors, "contentWindow").get; + patchToString(contentWindowGetter, "get contentWindow"); + Object_defineProperty(HTMLIFrameElement.prototype, "contentWindow", { + get: contentWindowGetter, + set: iframeContentWindowDescriptor.set, + enumerable: iframeContentWindowDescriptor.enumerable, + configurable: iframeContentWindowDescriptor.configurable, + }); +} + + +if ( + iframeSrcdocDescriptor && + iframeSrcdocDescriptor.configurable !== false +) { + const srcdocAccessors = { + get srcdoc() { + if (iframeSrcdocDescriptor.get) { + return Reflect_apply(iframeSrcdocDescriptor.get, this, []); + } + const value = this.getAttribute("srcdoc"); + return value === null ? "" : value; + }, + set srcdoc(newValue) { + addContentWindowProxy(this); + if (iframeSrcdocDescriptor.set) { + return Reflect_apply(iframeSrcdocDescriptor.set, this, [newValue]); + } + return this.setAttribute("srcdoc", newValue); + }, + }; + const srcdocDescriptor = Object_getOwnPropertyDescriptor(srcdocAccessors, "srcdoc"); + const srcdocGetter = srcdocDescriptor.get; + const srcdocSetter = srcdocDescriptor.set; + patchToString(srcdocGetter, "get srcdoc"); + patchToString(srcdocSetter, "set srcdoc"); + Object_defineProperty(HTMLIFrameElement.prototype, "srcdoc", { + get: srcdocGetter, + set: srcdocSetter, + enumerable: iframeSrcdocDescriptor.enumerable, + configurable: iframeSrcdocDescriptor.configurable, + }); +} diff --git a/packages/coding-agent/src/tools/puppeteer/05_stealth_webgl.txt b/packages/coding-agent/src/tools/puppeteer/05_stealth_webgl.txt index 73f731464..ee94c3198 100644 --- a/packages/coding-agent/src/tools/puppeteer/05_stealth_webgl.txt +++ b/packages/coding-agent/src/tools/puppeteer/05_stealth_webgl.txt @@ -1,75 +1,233 @@ -const vendors = ["Intel Inc.", "NVIDIA Corporation", "AMD"]; -const renderers = [ - "Intel(R) Iris(TM) Plus Graphics 640", - "Intel(R) HD Graphics 630", - "NVIDIA GeForce GTX 1050 Ti", - "NVIDIA GeForce GTX 1060", - "NVIDIA GeForce RTX 3060", - "AMD Radeon RX 580", - "AMD Radeon Pro 560", -]; -const vendor = vendors[Math_floor(Math_random() * vendors.length)]; -const renderer = renderers[Math_floor(Math_random() * renderers.length)]; +const webglPlatformSource = + String(navigator.userAgentData?.platform || navigator.platform || "") + + " " + + String(navigator.userAgent || ""); +const webglPlatformText = webglPlatformSource.toLowerCase(); +const webglPlatformKind = webglPlatformText.includes("android") + ? "android" + : webglPlatformText.includes("iphone") || + webglPlatformText.includes("ipad") || + webglPlatformText.includes("ipod") + ? "ios" + : webglPlatformText.includes("mac") + ? "mac" + : webglPlatformText.includes("win") + ? "windows" + : webglPlatformText.includes("cros") + ? "cros" + : "linux"; -const getParameterProxyHandler = { - apply(target, thisArg, args) { - const param = args[0]; - // VENDOR = 0x1F00 - if (param === 0x1F00) return vendor; - // RENDERER = 0x1F01 - if (param === 0x1F01) return renderer; - // UNMASKED_VENDOR_WEBGL = 0x9245 - if (param === 0x9245) return vendor; - // UNMASKED_RENDERER_WEBGL = 0x9246 - if (param === 0x9246) return renderer; - return Reflect_apply(target, thisArg, args); - }, +const webglFallbackProfiles = { + android: { + vendor: "Qualcomm", + renderer: "Adreno (TM) 640", + }, + ios: { + vendor: "Apple Inc.", + renderer: "Apple GPU", + }, + mac: { + vendor: "Google Inc. (Intel Inc.)", + renderer: "ANGLE (Intel Inc., Intel(R) Iris(TM) Plus Graphics 640 OpenGL Engine, OpenGL 4.1)", + }, + windows: { + vendor: "Google Inc. (Intel)", + renderer: "ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)", + }, + cros: { + vendor: "Google Inc. (Intel)", + renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)", + }, + linux: { + vendor: "Google Inc. (Intel)", + renderer: "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)", + }, +}; +const webglFallbackProfile = webglFallbackProfiles[webglPlatformKind] || webglFallbackProfiles.linux; +const webglContextProfiles = new WeakMap(); +const webglIsObjectKey = (value) => + (typeof value === "object" && value !== null) || typeof value === "function"; + +const webglLooksSoftware = (value) => { + const text = String(value || "").toLowerCase(); + return ( + text.includes("swiftshader") || + text.includes("llvmpipe") || + text.includes("lavapipe") || + text.includes("software") || + text.includes("mesa offscreen") || + text.includes("google inc. (google)") + ); }; -// Hook WebGL contexts -const hookWebGL = (proto) => { - const originalGetParameter = proto.getParameter; - Object_defineProperty(proto, "getParameter", { - value: new Window_Proxy(originalGetParameter, getParameterProxyHandler), - writable: true, - configurable: true, - enumerable: true, - }); +const webglMatchesPlatform = (renderer) => { + const text = String(renderer || "").toLowerCase(); + if (webglPlatformKind === "windows") { + return !text.includes("apple") && !text.includes("mesa") && !text.includes("opengl engine"); + } + if (webglPlatformKind === "mac") { + return !text.includes("direct3d") && !text.includes("d3d") && !text.includes("mesa"); + } + if (webglPlatformKind === "android") { + return ( + text.includes("adreno") || + text.includes("mali") || + text.includes("powervr") || + text.includes("qualcomm") + ); + } + if (webglPlatformKind === "ios") { + return text.includes("apple"); + } + return !text.includes("direct3d") && !text.includes("d3d") && !text.includes("apple"); +}; + +const webglGetContextProfile = (target, thisArg) => { + if (!webglIsObjectKey(thisArg)) return webglFallbackProfile; + const cached = Reflect_apply(Page_WeakMap_get, webglContextProfiles, [thisArg]); + if (cached) return cached; + + let nativeVendor; + let nativeRenderer; + try { + nativeVendor = Reflect_apply(target, thisArg, [0x9245]); + nativeRenderer = Reflect_apply(target, thisArg, [0x9246]); + } catch {} + + const nativeProfile = + typeof nativeVendor === "string" && + typeof nativeRenderer === "string" && + nativeVendor && + nativeRenderer && + !webglLooksSoftware(nativeVendor) && + !webglLooksSoftware(nativeRenderer) && + webglMatchesPlatform(nativeRenderer) + ? { vendor: nativeVendor, renderer: nativeRenderer } + : webglFallbackProfile; + + Reflect_apply(Page_WeakMap_set, webglContextProfiles, [thisArg, nativeProfile]); + return nativeProfile; +}; + +const webglGetParameterHandler = { + apply(target, thisArg, args) { + const nativeValue = Reflect_apply(target, thisArg, args); + const param = args[0]; + if (param === 0x1f00 && typeof nativeValue === "string") return "WebKit"; + if (param === 0x1f01 && typeof nativeValue === "string") return "WebKit WebGL"; + if ((param === 0x9245 || param === 0x9246) && typeof nativeValue === "string") { + const profile = webglGetContextProfile(target, thisArg); + return param === 0x9245 ? profile.vendor : profile.renderer; + } + return nativeValue; + }, +}; + +const webglFloatPrecisionTypes = { + 0x8df0: true, + 0x8df1: true, + 0x8df2: true, +}; + +const webglClonePrecisionFormat = (result, values) => { + const ownKeys = Reflect_ownKeys(result); + let hasRangeMin = false; + let hasRangeMax = false; + let hasPrecision = false; + for (let index = 0; index < ownKeys.length; index += 1) { + if (ownKeys[index] === "rangeMin") hasRangeMin = true; + if (ownKeys[index] === "rangeMax") hasRangeMax = true; + if (ownKeys[index] === "precision") hasPrecision = true; + } + if (!hasRangeMin || !hasRangeMax || !hasPrecision) return result; + + const clone = Object_create(Object_getPrototypeOf(result)); + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + const descriptor = Object_getOwnPropertyDescriptor(result, key); + if (!descriptor) return result; + if (key === "rangeMin" || key === "rangeMax" || key === "precision") { + if (!("value" in descriptor)) return result; + descriptor.value = values[key]; + } + try { + Object_defineProperty(clone, key, descriptor); + } catch { + return result; + } + } + return clone; +}; + +const webglGetShaderPrecisionFormatHandler = { + apply(target, thisArg, args) { + const result = Reflect_apply(target, thisArg, args); + const precisionType = args[1]; + if ( + !result || + webglPlatformKind === "android" || + webglPlatformKind === "ios" || + !webglFloatPrecisionTypes[precisionType] + ) { + return result; + } + + const rangeMin = result.rangeMin; + const rangeMax = result.rangeMax; + const precision = result.precision; + if ( + typeof rangeMin !== "number" || + typeof rangeMax !== "number" || + typeof precision !== "number" + ) { + return result; + } + + const values = { + rangeMin: Math_max(rangeMin, 127), + rangeMax: Math_max(rangeMax, 127), + precision: Math_max(precision, 23), + }; + if ( + values.rangeMin === rangeMin && + values.rangeMax === rangeMax && + values.precision === precision + ) { + return result; + } + return webglClonePrecisionFormat(result, values); + }, +}; + +const webglInstallMethodProxy = (proto, name, handler) => { + if (!proto) return; + const descriptor = Object_getOwnPropertyDescriptor(proto, name); + if (!descriptor || typeof descriptor.value !== "function") return; + const proxy = new Window_Proxy(descriptor.value, handler); + patchToString(proxy, name); + try { + Object_defineProperty(proto, name, { + value: proxy, + writable: descriptor.writable, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + }); + } catch {} }; if (window.WebGLRenderingContext) { - hookWebGL(WebGLRenderingContext.prototype); + webglInstallMethodProxy(WebGLRenderingContext.prototype, "getParameter", webglGetParameterHandler); + webglInstallMethodProxy( + WebGLRenderingContext.prototype, + "getShaderPrecisionFormat", + webglGetShaderPrecisionFormatHandler, + ); } if (window.WebGL2RenderingContext) { - hookWebGL(WebGL2RenderingContext.prototype); -} - -// Also mask getShaderPrecisionFormat for software rendering detection -const precisionMask = (proto) => { - const original = proto.getShaderPrecisionFormat; - if (!original) return; - Object_defineProperty(proto, "getShaderPrecisionFormat", { - value: function (shaderType, precisionType) { - const result = original.call(this, shaderType, precisionType); - if (result) { - // Hardware typically has higher precision than SwiftShader defaults - return { - precision: Math_max(result.precision, 23), - rangeMin: Math_min(result.rangeMin, 127), - rangeMax: Math_max(result.rangeMax, 127), - }; - } - return result; - }, - writable: true, - configurable: true, - enumerable: true, - }); -}; - -if (window.WebGLRenderingContext) { - precisionMask(WebGLRenderingContext.prototype); -} -if (window.WebGL2RenderingContext) { - precisionMask(WebGL2RenderingContext.prototype); + webglInstallMethodProxy(WebGL2RenderingContext.prototype, "getParameter", webglGetParameterHandler); + webglInstallMethodProxy( + WebGL2RenderingContext.prototype, + "getShaderPrecisionFormat", + webglGetShaderPrecisionFormatHandler, + ); } diff --git a/packages/coding-agent/src/tools/puppeteer/06_stealth_screen.txt b/packages/coding-agent/src/tools/puppeteer/06_stealth_screen.txt index 1b920f672..6cac051ea 100644 --- a/packages/coding-agent/src/tools/puppeteer/06_stealth_screen.txt +++ b/packages/coding-agent/src/tools/puppeteer/06_stealth_screen.txt @@ -1,72 +1,260 @@ -// Generate consistent "real" screen dimensions based on viewport -const width = window.innerWidth; -const height = window.innerHeight; -const availWidth = width; -const availHeight = Math_max(height - 40, 0); // Account for taskbar -const colorDepth = 24; -const pixelDepth = 24; -const devicePixelRatio = window.devicePixelRatio && window.devicePixelRatio > 1 ? window.devicePixelRatio : 1.25; +;(() => { + if (typeof Window_Proxy !== "function" || typeof Reflect_apply !== "function") return; -const defineScreenProp = (prop, value) => { - try { - Object_defineProperty(window.Screen?.prototype ?? window.screen, prop, { - get: () => value, - configurable: true, - enumerable: true, - }); - } catch {} -}; + const screenObject = window.screen; + if (!screenObject) return; -// Override screen properties -for (const [prop, descriptor] of Object_entries({ - width, - height, - availWidth, - availHeight, - availLeft: 0, - availTop: 0, - colorDepth, - pixelDepth, -})) { - defineScreenProp(prop, descriptor); -} + const isFiniteNumber = (value) => + typeof value === "number" && + value === value && + value !== Infinity && + value !== -Infinity; -// Ensure outer dimensions match screen for consistency -const chromeFrameHeight = 85; -Object_defineProperty(window, "outerWidth", { - get: () => window.innerWidth, - configurable: true, - enumerable: true, -}); -Object_defineProperty(window, "outerHeight", { - get: () => window.innerHeight + chromeFrameHeight, - configurable: true, - enumerable: true, -}); - -if (window.visualViewport) { - const defineVvpProp = (prop, value) => { + const readValue = (object, prop, fallback) => { try { - Object_defineProperty(window.visualViewport, prop, { - get: () => value, - configurable: true, - enumerable: true, - }); - } catch {} + const value = object[prop]; + return value === undefined ? fallback : value; + } catch { + return fallback; + } }; - defineVvpProp("width", width); - defineVvpProp("height", height); - defineVvpProp("scale", 1); - defineVvpProp("offsetLeft", 0); - defineVvpProp("offsetTop", 0); - defineVvpProp("pageLeft", 0); - defineVvpProp("pageTop", 0); -} + const finiteNumber = (value, fallback) => + isFiniteNumber(value) ? value : fallback; -// Consistent devicePixelRatio -Object_defineProperty(window, "devicePixelRatio", { - get: () => devicePixelRatio, - configurable: true, - enumerable: true, -}); + const positiveNumber = (value, fallback) => + isFiniteNumber(value) && value > 0 ? value : fallback; + + const integerNumber = (value, fallback) => + isFiniteNumber(value) ? Math_floor(value) : fallback; + + const positiveInteger = (value, fallback) => + isFiniteNumber(value) && value > 0 ? Math_floor(value) : fallback; + + const findDescriptorOwner = (object, prop) => { + let owner = object; + while (owner) { + let descriptor; + try { + descriptor = Object_getOwnPropertyDescriptor(owner, prop); + } catch { + return undefined; + } + if (descriptor) return [owner, descriptor]; + try { + owner = Object_getPrototypeOf(owner); + } catch { + return undefined; + } + } + return undefined; + }; + + const patchGetter = (object, prop, getValue) => { + const found = findDescriptorOwner(object, prop); + if (!found) return false; + + const owner = found[0]; + const descriptor = found[1]; + if (descriptor.configurable !== true || typeof descriptor.get !== "function") { + return false; + } + + const originalGet = descriptor.get; + const patchedGet = new Window_Proxy(originalGet, { + apply(target, thisArg, args) { + const nativeValue = Reflect_apply(target, thisArg, args); + return getValue(nativeValue, thisArg); + }, + }); + patchToString(patchedGet, "get " + prop); + try { + Object_defineProperty(owner, prop, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + get: patchedGet, + set: descriptor.set, + }); + return true; + } catch { + return false; + } + }; + + const patchStableNumber = (object, prop, value) => { + const current = readValue(object, prop, undefined); + if (current === value) return; + patchGetter(object, prop, () => value); + }; + + const initialInnerWidth = positiveInteger(readValue(window, "innerWidth", 0), 0); + const initialInnerHeight = positiveInteger(readValue(window, "innerHeight", 0), 0); + const initialOuterWidth = positiveInteger( + readValue(window, "outerWidth", Math_max(initialInnerWidth, 1)), + Math_max(initialInnerWidth, 1), + ); + const initialOuterHeight = positiveInteger( + readValue(window, "outerHeight", Math_max(initialInnerHeight, 1)), + Math_max(initialInnerHeight, 1), + ); + // Real Chrome reserves vertical space for the tab strip + URL bar, so + // outerHeight is always taller than innerHeight (~88px on a stock window). + // Headless reports outerHeight === innerHeight, which is a well-known tell, so + // synthesize a realistic chrome height when the window has no visible chrome. + const browserChromeHeight = 88; + const targetOuterHeight = + initialOuterHeight > initialInnerHeight + ? initialOuterHeight + : initialInnerHeight + browserChromeHeight; + const requiredWidth = Math_max(initialInnerWidth, initialOuterWidth, 1); + const requiredHeight = Math_max(initialInnerHeight, initialOuterHeight, targetOuterHeight, 1); + + const screenWidth = Math_max( + positiveInteger(readValue(screenObject, "width", requiredWidth), requiredWidth), + requiredWidth, + ); + const screenHeight = Math_max( + positiveInteger(readValue(screenObject, "height", requiredHeight), requiredHeight), + requiredHeight, + ); + + let screenAvailWidth = positiveInteger( + readValue(screenObject, "availWidth", screenWidth), + screenWidth, + ); + if (screenAvailWidth < requiredWidth || screenAvailWidth > screenWidth) { + screenAvailWidth = screenWidth; + } + + let screenAvailHeight = positiveInteger( + readValue(screenObject, "availHeight", screenHeight), + screenHeight, + ); + if (screenAvailHeight < requiredHeight || screenAvailHeight > screenHeight) { + screenAvailHeight = screenHeight; + } + + const screenAvailLeft = integerNumber(readValue(screenObject, "availLeft", 0), 0); + const screenAvailTop = integerNumber(readValue(screenObject, "availTop", 0), 0); + const screenColorDepth = positiveInteger(readValue(screenObject, "colorDepth", 24), 24); + const screenPixelDepth = positiveInteger( + readValue(screenObject, "pixelDepth", screenColorDepth), + screenColorDepth, + ); + + for (const entry of [ + ["width", screenWidth], + ["height", screenHeight], + ["availWidth", screenAvailWidth], + ["availHeight", screenAvailHeight], + ["availLeft", screenAvailLeft], + ["availTop", screenAvailTop], + ["colorDepth", screenColorDepth], + ["pixelDepth", screenPixelDepth], + ]) { + patchStableNumber(screenObject, entry[0], entry[1]); + } + + const outerWidthNeedsPatch = + !isFiniteNumber(readValue(window, "outerWidth", undefined)) || + readValue(window, "outerWidth", 0) <= 0 || + Math_floor(readValue(window, "outerWidth", 0)) < initialInnerWidth; + if (outerWidthNeedsPatch) { + patchGetter(window, "outerWidth", (nativeValue) => + Math_max( + positiveInteger(nativeValue, initialOuterWidth), + positiveInteger(readValue(window, "innerWidth", requiredWidth), requiredWidth), + ), + ); + } + + const outerHeightNeedsPatch = + !isFiniteNumber(readValue(window, "outerHeight", undefined)) || + readValue(window, "outerHeight", 0) <= 0 || + Math_floor(readValue(window, "outerHeight", 0)) <= initialInnerHeight; + if (outerHeightNeedsPatch) { + patchGetter(window, "outerHeight", (nativeValue) => + Math_max( + positiveInteger(nativeValue, initialOuterHeight), + positiveInteger(readValue(window, "innerHeight", requiredHeight), requiredHeight) + + browserChromeHeight, + ), + ); + } + + const initialDevicePixelRatio = positiveNumber( + readValue(window, "devicePixelRatio", 1), + 1, + ); + if (readValue(window, "devicePixelRatio", undefined) !== initialDevicePixelRatio) { + patchGetter(window, "devicePixelRatio", () => initialDevicePixelRatio); + } + + const visualViewportObject = readValue(window, "visualViewport", null); + if (visualViewportObject) { + const initialScale = positiveNumber(readValue(visualViewportObject, "scale", 1), 1); + if (readValue(visualViewportObject, "scale", undefined) !== initialScale) { + patchGetter(visualViewportObject, "scale", (nativeValue) => + positiveNumber(nativeValue, initialScale), + ); + } + + const getViewportScale = () => + positiveNumber(readValue(visualViewportObject, "scale", initialScale), initialScale); + + const getViewportWidthFallback = () => + positiveNumber(readValue(window, "innerWidth", requiredWidth), requiredWidth) / + getViewportScale(); + + const getViewportHeightFallback = () => + positiveNumber(readValue(window, "innerHeight", requiredHeight), requiredHeight) / + getViewportScale(); + + if (positiveNumber(readValue(visualViewportObject, "width", 0), 0) <= 0) { + patchGetter(visualViewportObject, "width", (nativeValue) => + positiveNumber(nativeValue, getViewportWidthFallback()), + ); + } + + if (positiveNumber(readValue(visualViewportObject, "height", 0), 0) <= 0) { + patchGetter(visualViewportObject, "height", (nativeValue) => + positiveNumber(nativeValue, getViewportHeightFallback()), + ); + } + + const getViewportOffsetLeft = () => + finiteNumber(readValue(visualViewportObject, "offsetLeft", 0), 0); + const getViewportOffsetTop = () => + finiteNumber(readValue(visualViewportObject, "offsetTop", 0), 0); + + if (!isFiniteNumber(readValue(visualViewportObject, "offsetLeft", undefined))) { + patchGetter(visualViewportObject, "offsetLeft", (nativeValue) => + finiteNumber(nativeValue, 0), + ); + } + + if (!isFiniteNumber(readValue(visualViewportObject, "offsetTop", undefined))) { + patchGetter(visualViewportObject, "offsetTop", (nativeValue) => + finiteNumber(nativeValue, 0), + ); + } + + if (!isFiniteNumber(readValue(visualViewportObject, "pageLeft", undefined))) { + patchGetter(visualViewportObject, "pageLeft", (nativeValue) => + finiteNumber( + nativeValue, + finiteNumber(readValue(window, "scrollX", 0), 0) + getViewportOffsetLeft(), + ), + ); + } + + if (!isFiniteNumber(readValue(visualViewportObject, "pageTop", undefined))) { + patchGetter(visualViewportObject, "pageTop", (nativeValue) => + finiteNumber( + nativeValue, + finiteNumber(readValue(window, "scrollY", 0), 0) + getViewportOffsetTop(), + ), + ); + } + } +})(); diff --git a/packages/coding-agent/src/tools/puppeteer/07_stealth_fonts.txt b/packages/coding-agent/src/tools/puppeteer/07_stealth_fonts.txt index 237912763..49ee55a6d 100644 --- a/packages/coding-agent/src/tools/puppeteer/07_stealth_fonts.txt +++ b/packages/coding-agent/src/tools/puppeteer/07_stealth_fonts.txt @@ -47,7 +47,6 @@ const commonFonts = [ "Wingdings 3", "Apple Color Emoji", "Apple SD Gothic Neo", - "Helvetica Neue", "Hoefler Text", "Menlo", "Monaco", @@ -56,32 +55,31 @@ const commonFonts = [ "SF Pro Text", ]; -// Override queryLocalFonts if present (Local Font Access API) if ("queryLocalFonts" in window) { + const queryLocalFonts = async function queryLocalFonts() { + return commonFonts.map((family) => ({ + family, + fullName: family, + postscriptName: family.replace(/\s+/g, ""), + style: "Regular", + blob: () => Promise_resolve(new Window_Blob([])), + })); + }; + patchToString(queryLocalFonts, "queryLocalFonts"); Object_defineProperty(window, "queryLocalFonts", { - value: async () => { - return commonFonts.map((family) => ({ - family, - fullName: family, - postscriptName: family.replace(/\s+/g, ""), - style: "Regular", - blob: () => Promise_resolve(new Window_Blob([])), - })); - }, + value: queryLocalFonts, writable: true, configurable: true, enumerable: true, }); } -// Hide fonts-unique tracking via canvas const originalGetContext = HTMLCanvasElement.prototype.getContext; -HTMLCanvasElement.prototype.getContext = function (type, options) { +const patchedGetContext = function getContext(type, options) { const ctx = originalGetContext.call(this, type, options); if (ctx && type === "2d") { const originalFillText = ctx.fillText; - ctx.fillText = function (text, x, y, maxWidth) { - // Add tiny imperceptible noise to text rendering + const patchedFillText = function fillText(text, x, y, maxWidth) { const noiseX = (Math_random() - 0.5) * 0.02; const noiseY = (Math_random() - 0.5) * 0.02; return originalFillText.call( @@ -92,6 +90,10 @@ HTMLCanvasElement.prototype.getContext = function (type, options) { maxWidth, ); }; + patchToString(patchedFillText, "fillText"); + ctx.fillText = patchedFillText; } return ctx; }; +patchToString(patchedGetContext, "getContext"); +HTMLCanvasElement.prototype.getContext = patchedGetContext; \ No newline at end of file diff --git a/packages/coding-agent/src/tools/puppeteer/08_stealth_audio.txt b/packages/coding-agent/src/tools/puppeteer/08_stealth_audio.txt index c2f120656..b4f7755a0 100644 --- a/packages/coding-agent/src/tools/puppeteer/08_stealth_audio.txt +++ b/packages/coding-agent/src/tools/puppeteer/08_stealth_audio.txt @@ -1,32 +1,45 @@ -// Spoof AudioContext latency values to look like real hardware -const spoofLatency = (proto) => { - Object_defineProperty(proto, "baseLatency", { - get: () => 0.005, // ~5ms typical for real hardware - configurable: true, - enumerable: true, - }); - Object_defineProperty(proto, "outputLatency", { - get: () => 0.01, // ~10ms typical +// Spoof AudioContext latency values to look like real hardware. +const audioLatencyAccessors = { + get baseLatency() { + return 0.005; + }, + get outputLatency() { + return 0.01; + }, + get sampleRate() { + return 48000; + }, +}; + +const defineAudioGetter = (proto, name) => { + const descriptor = Object_getOwnPropertyDescriptor(audioLatencyAccessors, name); + if (!descriptor || !descriptor.get) return; + patchToString(descriptor.get, "get " + name); + Object_defineProperty(proto, name, { + get: descriptor.get, configurable: true, enumerable: true, }); }; +const spoofLatency = (proto) => { + defineAudioGetter(proto, "baseLatency"); + defineAudioGetter(proto, "outputLatency"); +}; + if (window.AudioContext) { spoofLatency(AudioContext.prototype); } + if (window.OfflineAudioContext) { - // For offline context, add subtle randomness to prevent deterministic fingerprints const OriginalOfflineAudioContext = window.OfflineAudioContext; - window.OfflineAudioContext = class extends OriginalOfflineAudioContext { + const PatchedOfflineAudioContext = class OfflineAudioContext extends OriginalOfflineAudioContext { constructor(numberOfChannels, length, sampleRate) { super(numberOfChannels, length, sampleRate); - // Hook startRendering to add noise const originalStartRendering = this.startRendering.bind(this); - this.startRendering = async () => { + const patchedStartRendering = async function startRendering() { const buffer = await originalStartRendering(); - // Add imperceptible noise to prevent deterministic hash for (let c = 0; c < buffer.numberOfChannels; c++) { const channel = buffer.getChannelData(c); for (let i = 0; i < channel.length; i++) { @@ -37,15 +50,14 @@ if (window.OfflineAudioContext) { } return buffer; }; + patchToString(patchedStartRendering, "startRendering"); + this.startRendering = patchedStartRendering; } }; + patchToString(PatchedOfflineAudioContext, "OfflineAudioContext"); + window.OfflineAudioContext = PatchedOfflineAudioContext; } -// Also spoof sampleRate consistency if (window.AudioContext) { - Object_defineProperty(AudioContext.prototype, "sampleRate", { - get: () => 48000, // Common hardware rate - configurable: true, - enumerable: true, - }); + defineAudioGetter(AudioContext.prototype, "sampleRate"); } diff --git a/packages/coding-agent/src/tools/puppeteer/09_stealth_locale.txt b/packages/coding-agent/src/tools/puppeteer/09_stealth_locale.txt index 81b00947b..8be61947c 100644 --- a/packages/coding-agent/src/tools/puppeteer/09_stealth_locale.txt +++ b/packages/coding-agent/src/tools/puppeteer/09_stealth_locale.txt @@ -1,46 +1,51 @@ -// Define a consistent locale profile const locale = "en-US"; -const languages = ["en-US", "en"]; -const timezone = "America/New_York"; +const languages = [locale, "en"]; -// Override navigator language properties -Object_defineProperty(navigator, "language", { - get: () => locale, - configurable: true, - enumerable: true, -}); -Object_defineProperty(navigator, "languages", { - get: () => [...languages], - configurable: true, - enumerable: true, -}); - -// Override Intl.DateTimeFormat for timezone consistency -const OriginalDateTimeFormat = Intl_DateTimeFormat; -Intl.DateTimeFormat = class extends OriginalDateTimeFormat { - constructor(locales, options) { - const mergedOptions = { ...options, timeZone: timezone }; - super(locales, mergedOptions); +const sameLanguages = (value) => { + if (!value || value.length !== languages.length) return false; + for (let index = 0; index < languages.length; index += 1) { + if (value[index] !== languages[index]) return false; } - resolvedOptions() { - const options = super.resolvedOptions(); - options.timeZone = timezone; - return options; + return true; +}; + +const navigatorProto = Object_getPrototypeOf(navigator); +const navigatorAccessors = { + get language() { + return locale; + }, + get languages() { + return [locale, "en"]; + }, +}; + +const defineNavigatorAccessor = (name) => { + const owner = navigatorProto || navigator; + const descriptor = + (navigatorProto && Object_getOwnPropertyDescriptor(navigatorProto, name)) || + Object_getOwnPropertyDescriptor(navigator, name); + if (descriptor && descriptor.configurable === false) return; + + const accessor = Object_getOwnPropertyDescriptor(navigatorAccessors, name); + if (!accessor || !accessor.get) return; + patchToString(accessor.get, "get " + name); + + Object_defineProperty(owner, name, { + get: accessor.get, + configurable: descriptor ? descriptor.configurable : true, + enumerable: descriptor ? descriptor.enumerable : true, + }); + + const ownDescriptor = Object_getOwnPropertyDescriptor(navigator, name); + if (owner !== navigator && ownDescriptor && ownDescriptor.configurable !== false) { + Reflect_deleteProperty(navigator, name); } }; -// Ensure Date timezone is consistent -const originalDateConstructor = Date_constructor; -const originalToString = originalDateConstructor.prototype.toString; -const originalToTimeString = originalDateConstructor.prototype.toTimeString; +if (navigator.language !== locale) { + defineNavigatorAccessor("language"); +} -Date.prototype.toString = function () { - return originalToString - .call(this) - .replace(/\(.*\)$/, "(Eastern Standard Time)"); -}; -Date.prototype.toTimeString = function () { - return originalToTimeString - .call(this) - .replace(/\(.*\)$/, "(Eastern Standard Time)"); -}; +if (!sameLanguages(navigator.languages)) { + defineNavigatorAccessor("languages"); +} diff --git a/packages/coding-agent/src/tools/puppeteer/10_stealth_plugins.txt b/packages/coding-agent/src/tools/puppeteer/10_stealth_plugins.txt index 33773e040..6ce93afa9 100644 --- a/packages/coding-agent/src/tools/puppeteer/10_stealth_plugins.txt +++ b/packages/coding-agent/src/tools/puppeteer/10_stealth_plugins.txt @@ -54,8 +54,8 @@ const defineProp = (obj, prop, value) => configurable: true, }); -const generateFunctionMocks = (proto, itemMainProp, dataArray) => ({ - item: new Window_Proxy(proto.item, { +const generateFunctionMocks = (proto, itemMainProp, dataArray) => { + const item = new Window_Proxy(proto.item, { apply(target, ctx, args) { if (!args.length) { throw new TypeError( @@ -65,8 +65,8 @@ const generateFunctionMocks = (proto, itemMainProp, dataArray) => ({ const isInteger = args[0] && Number.isInteger(Number(args[0])); return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null; }, - }), - namedItem: new Window_Proxy(proto.namedItem, { + }); + const namedItem = new Window_Proxy(proto.namedItem, { apply(target, ctx, args) { if (!args.length) { throw new TypeError( @@ -75,15 +75,19 @@ const generateFunctionMocks = (proto, itemMainProp, dataArray) => ({ } return dataArray.find(item => item[itemMainProp] === args[0]) || null; }, - }), - refresh: proto.refresh + }); + patchToString(item, "item"); + patchToString(namedItem, "namedItem"); + const refresh = proto.refresh ? new Window_Proxy(proto.refresh, { apply() { return undefined; }, }) - : undefined, -}); + : undefined; + patchToString(refresh, "refresh"); + return { item, namedItem, refresh }; +}; const generateMagicArray = (dataArray, proto, itemProto, itemMainProp) => { const makeItem = (data) => { diff --git a/packages/coding-agent/src/tools/puppeteer/11_stealth_hardware.txt b/packages/coding-agent/src/tools/puppeteer/11_stealth_hardware.txt index 078a9a5e1..7da8754a2 100644 --- a/packages/coding-agent/src/tools/puppeteer/11_stealth_hardware.txt +++ b/packages/coding-agent/src/tools/puppeteer/11_stealth_hardware.txt @@ -1,8 +1,59 @@ -const navigatorProto = Object_getPrototypeOf(navigator); -if (navigatorProto && "hardwareConcurrency" in navigatorProto) { - Object_defineProperty(navigatorProto, "hardwareConcurrency", { - get: () => 4, - configurable: true, - enumerable: true, +const hardwareConcurrencyName = "hardwareConcurrency"; +let hardwareConcurrencyProto = Object_getPrototypeOf(navigator); +let hardwareConcurrencyOwner; +let hardwareConcurrencyDescriptor; + +while (hardwareConcurrencyProto && !hardwareConcurrencyDescriptor) { + hardwareConcurrencyDescriptor = Object_getOwnPropertyDescriptor( + hardwareConcurrencyProto, + hardwareConcurrencyName, + ); + if (hardwareConcurrencyDescriptor) { + hardwareConcurrencyOwner = hardwareConcurrencyProto; + } else { + hardwareConcurrencyProto = Object_getPrototypeOf(hardwareConcurrencyProto); + } +} + +const hardwareConcurrencyValue = 8; +let shouldPatchHardwareConcurrency = false; + +if ( + hardwareConcurrencyOwner && + hardwareConcurrencyDescriptor && + hardwareConcurrencyDescriptor.configurable && + typeof hardwareConcurrencyDescriptor.get === "function" +) { + shouldPatchHardwareConcurrency = true; + try { + shouldPatchHardwareConcurrency = + Reflect_apply(hardwareConcurrencyDescriptor.get, navigator, []) !== + hardwareConcurrencyValue; + } catch { + shouldPatchHardwareConcurrency = false; + } +} + +if (shouldPatchHardwareConcurrency) { + const hardwareConcurrencyAccessors = { + get hardwareConcurrency() { + Reflect_apply(hardwareConcurrencyDescriptor.get, this, []); + return hardwareConcurrencyValue; + }, + }; + const getHardwareConcurrency = Object_getOwnPropertyDescriptor( + hardwareConcurrencyAccessors, + hardwareConcurrencyName, + ).get; + + if (typeof patchToString === "function") { + patchToString(getHardwareConcurrency, "get hardwareConcurrency"); + } + + Object_defineProperty(hardwareConcurrencyOwner, hardwareConcurrencyName, { + get: getHardwareConcurrency, + set: hardwareConcurrencyDescriptor.set, + enumerable: hardwareConcurrencyDescriptor.enumerable, + configurable: hardwareConcurrencyDescriptor.configurable, }); } diff --git a/packages/coding-agent/src/tools/puppeteer/12_stealth_codecs.txt b/packages/coding-agent/src/tools/puppeteer/12_stealth_codecs.txt index 806f69084..563db3c08 100644 --- a/packages/coding-agent/src/tools/puppeteer/12_stealth_codecs.txt +++ b/packages/coding-agent/src/tools/puppeteer/12_stealth_codecs.txt @@ -15,25 +15,27 @@ const parseInput = (arg) => { }; const originalCanPlayType = HTMLMediaElement.prototype.canPlayType; +const proxiedCanPlayType = new Window_Proxy(originalCanPlayType, { + apply(target, ctx, args) { + if (!args || !args.length) { + return Reflect_apply(target, ctx, args); + } + const { mime, codecs } = parseInput(args[0]); + if (mime === "video/mp4" && codecs.includes("avc1.42E01E")) { + return "probably"; + } + if (mime === "audio/x-m4a" && !codecs.length) { + return "maybe"; + } + if (mime === "audio/aac" && !codecs.length) { + return "probably"; + } + return Reflect_apply(target, ctx, args); + }, +}); +patchToString(proxiedCanPlayType, "canPlayType"); Object_defineProperty(HTMLMediaElement.prototype, "canPlayType", { - value: new Window_Proxy(originalCanPlayType, { - apply(target, ctx, args) { - if (!args || !args.length) { - return Reflect_apply(target, ctx, args); - } - const { mime, codecs } = parseInput(args[0]); - if (mime === "video/mp4" && codecs.includes("avc1.42E01E")) { - return "probably"; - } - if (mime === "audio/x-m4a" && !codecs.length) { - return "maybe"; - } - if (mime === "audio/aac" && !codecs.length) { - return "probably"; - } - return Reflect_apply(target, ctx, args); - }, - }), + value: proxiedCanPlayType, writable: true, configurable: true, enumerable: true, diff --git a/packages/coding-agent/src/tools/puppeteer/13_stealth_worker.txt b/packages/coding-agent/src/tools/puppeteer/13_stealth_worker.txt index a1321e751..212c4b885 100644 --- a/packages/coding-agent/src/tools/puppeteer/13_stealth_worker.txt +++ b/packages/coding-agent/src/tools/puppeteer/13_stealth_worker.txt @@ -1,52 +1,214 @@ const patchWorkerConstructor = (name, OriginalWorker) => { if (typeof OriginalWorker !== "function") return; - const buildWrappedUrl = (scriptURL, options) => { - const ua = navigator.userAgent; - const platform = navigator.platform; - const uaData = navigator.userAgentData && typeof navigator.userAgentData.toJSON === "function" - ? navigator.userAgentData.toJSON() - : navigator.userAgentData; + const windowDescriptor = Object_getOwnPropertyDescriptor(window, name); + if (windowDescriptor && windowDescriptor.configurable === false) return; - const preludeLines = [ - "try {", - `const ua = ${JSON.stringify(ua)};`, - `const platform = ${JSON.stringify(platform)};`, - "Object_defineProperty(self.navigator, 'userAgent', { get: () => ua, configurable: true });", - "Object_defineProperty(self.navigator, 'platform', { get: () => platform, configurable: true });", - ]; + const NativeURL = window.URL; + const URL_createObjectURL = NativeURL && NativeURL.createObjectURL; + const URL_revokeObjectURL = NativeURL && NativeURL.revokeObjectURL; + if ( + typeof NativeURL !== "function" || + typeof URL_createObjectURL !== "function" || + typeof URL_revokeObjectURL !== "function" + ) { + return; + } - if (uaData) { - preludeLines.push(`const uaData = ${JSON.stringify(uaData)};`); - preludeLines.push( - "Object_defineProperty(self.navigator, 'userAgentData', { get: () => uaData, configurable: true });", - ); + const sharedWorkerUrls = name === "SharedWorker" ? new Map() : undefined; + + const revokeUrl = (url) => { + try { + Reflect_apply(URL_revokeObjectURL, NativeURL, [url]); + } catch {} + }; + + const scheduleWorkerUrlRevoke = (worker, url) => { + let revoked = false; + const revokeOnce = () => { + if (revoked) return; + revoked = true; + revokeUrl(url); + }; + try { + if (typeof worker.addEventListener === "function") { + Reflect_apply(worker.addEventListener, worker, ["error", revokeOnce, { once: true }]); + } + } catch {} + Window_setTimeout(revokeOnce, 1000); + }; + + if (sharedWorkerUrls) { + try { + window.addEventListener("pagehide", (event) => { + if (event && event.persisted) return; + for (const url of sharedWorkerUrls.values()) { + revokeUrl(url); + } + sharedWorkerUrls.clear(); + }); + } catch {} + } + + const canWrapArguments = (args) => { + if (!args || args.length !== 1) { + return name === "SharedWorker" && args && args.length === 2 && typeof args[1] === "string"; + } + return true; + }; + + const resolveWorkerUrl = (scriptURL) => { + const baseUrl = document.baseURI || window.location.href; + const scriptUrlString = + typeof scriptURL === "string" + ? scriptURL + : scriptURL instanceof NativeURL + ? scriptURL.href + : undefined; + if (scriptUrlString === undefined) return undefined; + let absoluteUrl; + try { + absoluteUrl = new NativeURL(scriptUrlString, baseUrl); + } catch { + return undefined; + } + if (absoluteUrl.origin !== window.location.origin) return undefined; + if (absoluteUrl.protocol !== "http:" && absoluteUrl.protocol !== "https:") return undefined; + if (absoluteUrl.username || absoluteUrl.password) return undefined; + return absoluteUrl.href; + }; + + const buildWorkerPrelude = () => { + const values = []; + const addNavigatorString = (prop) => { + try { + const value = navigator[prop]; + if (typeof value === "string") values.push([prop, value]); + } catch {} + }; + addNavigatorString("userAgent"); + addNavigatorString("platform"); + if (!values.length) return ""; + + return `(() => { + try { + const values = ${JSON.stringify(values)}; + const nav = self.navigator; + if (!nav) return; + const defineProperty = Object.defineProperty; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const getPrototypeOf = Object.getPrototypeOf; + const Reflect_apply = Reflect.apply; + const nativeFunctionToString = Function.prototype.toString; + const nativeSources = new WeakMap(); + const WeakMap_get = WeakMap.prototype.get; + const WeakMap_has = WeakMap.prototype.has; + const WeakMap_set = WeakMap.prototype.set; + const rememberNative = (fn, source) => { + if (typeof fn === "function") Reflect_apply(WeakMap_set, nativeSources, [fn, source]); + return fn; + }; + const findDescriptor = (prop) => { + let owner = nav; + while (owner) { + const descriptor = getOwnPropertyDescriptor(owner, prop); + if (descriptor) return [owner, descriptor]; + owner = getPrototypeOf(owner); + } + return [getPrototypeOf(nav) || nav, undefined]; + }; + let patched = false; + for (let i = 0; i < values.length; i += 1) { + const prop = values[i][0]; + const value = values[i][1]; + try { + if (nav[prop] === value) continue; + } catch (_) {} + const found = findDescriptor(prop); + const owner = found[0]; + const descriptor = found[1]; + if (!owner || (descriptor && descriptor.configurable === false)) continue; + const getterDescriptor = getOwnPropertyDescriptor({ get [prop]() { return value; } }, prop); + const getter = getterDescriptor && getterDescriptor.get; + if (typeof getter !== "function") continue; + rememberNative(getter, "function get " + prop + "() { [native code] }"); + defineProperty(owner, prop, { + get: getter, + enumerable: descriptor ? descriptor.enumerable : true, + configurable: true, + }); + patched = true; + } + if (!patched) return; + const toStringDescriptor = getOwnPropertyDescriptor(Function.prototype, "toString"); + if (toStringDescriptor && toStringDescriptor.configurable === false) return; + const functionToString = new Proxy(nativeFunctionToString, { + apply(target, thisArg, args) { + if (Reflect_apply(WeakMap_has, nativeSources, [thisArg])) return Reflect_apply(WeakMap_get, nativeSources, [thisArg]); + return Reflect_apply(target, thisArg, args); + }, + }); + rememberNative(functionToString, "function toString() { [native code] }"); + defineProperty(Function.prototype, "toString", { + value: functionToString, + writable: toStringDescriptor ? toStringDescriptor.writable : true, + configurable: toStringDescriptor ? toStringDescriptor.configurable : true, + enumerable: toStringDescriptor ? toStringDescriptor.enumerable : false, + }); + } catch (_) {} +})();`; + }; + + const buildWrappedUrl = (scriptURL) => { + const originalUrl = resolveWorkerUrl(scriptURL); + if (!originalUrl) return undefined; + if (sharedWorkerUrls) { + const cachedUrl = sharedWorkerUrls.get(originalUrl); + if (cachedUrl) return { url: cachedUrl, cacheKey: originalUrl }; } - preludeLines.push("} catch (e) {};"); - const prelude = preludeLines.join("\n"); - const importLine = options?.type === "module" - ? `import ${JSON.stringify(String(scriptURL))};` - : `importScripts(${JSON.stringify(String(scriptURL))});`; - const blob = new Window_Blob([prelude, "\n", importLine], { type: "application/javascript" }); - const url = URL.createObjectURL(blob); - return url; + const prelude = buildWorkerPrelude(); + if (!prelude) return undefined; + + try { + const blob = new Window_Blob( + [prelude, "\n", `importScripts(${JSON.stringify(originalUrl)});`], + { type: "application/javascript" }, + ); + const url = Reflect_apply(URL_createObjectURL, NativeURL, [blob]); + if (sharedWorkerUrls) sharedWorkerUrls.set(originalUrl, url); + return { url, cacheKey: sharedWorkerUrls ? originalUrl : undefined }; + } catch { + return undefined; + } }; const handler = { - construct(target, args) { + construct(target, args, newTarget) { + if (!canWrapArguments(args)) { + return Reflect_construct(target, args || [], newTarget); + } + + const wrapped = buildWrappedUrl(args[0]); + if (!wrapped) { + return Reflect_construct(target, args || [], newTarget); + } + + const wrappedArgs = [wrapped.url]; + for (let i = 1; i < args.length; i += 1) { + wrappedArgs[i] = args[i]; + } + try { - const scriptURL = args?.[0]; - const options = args?.[1]; - if (!scriptURL) { - return new target(...(args || [])); + const worker = Reflect_construct(target, wrappedArgs, newTarget); + if (!sharedWorkerUrls) { + scheduleWorkerUrlRevoke(worker, wrapped.url); } - const wrappedUrl = buildWrappedUrl(scriptURL, options); - const worker = new target(wrappedUrl, options); - URL.revokeObjectURL(wrappedUrl); return worker; } catch { - return new target(...(args || [])); + if (wrapped.cacheKey && sharedWorkerUrls) sharedWorkerUrls.delete(wrapped.cacheKey); + revokeUrl(wrapped.url); + return Reflect_construct(target, args || [], newTarget); } }, apply(target, thisArg, args) { @@ -55,16 +217,15 @@ const patchWorkerConstructor = (name, OriginalWorker) => { }; const proxied = new Window_Proxy(OriginalWorker, handler); + patchToString(proxied, name); Object_defineProperty(window, name, { value: proxied, - writable: true, - configurable: true, - }); - Object_defineProperty(window[name], "toString", { - value: Function_toString.bind(OriginalWorker), - writable: false, - configurable: true, - enumerable: false, + writable: + windowDescriptor && "writable" in windowDescriptor + ? windowDescriptor.writable + : true, + configurable: windowDescriptor ? windowDescriptor.configurable : true, + enumerable: windowDescriptor ? windowDescriptor.enumerable : false, }); }; diff --git a/patches/puppeteer-core@25.1.0.patch b/patches/puppeteer-core@25.1.0.patch new file mode 100644 index 000000000..63282adfc --- /dev/null +++ b/patches/puppeteer-core@25.1.0.patch @@ -0,0 +1,648 @@ +diff --git a/lib/puppeteer/cdp/ExecutionContext.js b/lib/puppeteer/cdp/ExecutionContext.js +index d2cfb72b63070527185c8d2e3b9bf7b19e91baa4..439821d543413ea620833ebe94908fe44e70b836 100644 +--- a/lib/puppeteer/cdp/ExecutionContext.js ++++ b/lib/puppeteer/cdp/ExecutionContext.js +@@ -326,14 +326,19 @@ export class ExecutionContext extends EventEmitter { + return await this.#evaluate(false, pageFunction, ...args); + } + async #evaluate(returnByValue, pageFunction, ...args) { +- const sourceUrlComment = getSourceUrlComment(getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? +- PuppeteerURL.INTERNAL_URL); ++ // xxx-stealth: never append the synthetic ++ // `//# sourceURL=__puppeteer_evaluation_script__` marker. That constant string ++ // leaks automation through V8 error stacks / debugger script listings. Any ++ // genuine user-supplied sourceURL already in the source is preserved untouched. ++ void getSourceUrlComment; ++ void PuppeteerURL; ++ const sourceUrlComment = ''; + if (isString(pageFunction)) { + const contextId = this.#id; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression +- : `${expression}\n${sourceUrlComment}\n`; ++ : expression; + const { exceptionDetails, result: remoteObject } = await this.#client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, +@@ -352,9 +357,8 @@ export class ExecutionContext extends EventEmitter { + return this.#world.createCdpHandle(remoteObject); + } + const functionDeclaration = stringifyFunction(pageFunction); +- const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test(functionDeclaration) +- ? functionDeclaration +- : `${functionDeclaration}\n${sourceUrlComment}\n`; ++ void sourceUrlComment; ++ const functionDeclarationWithSourceUrl = functionDeclaration; + let callFunctionOnPromise; + try { + callFunctionOnPromise = this.#client.send('Runtime.callFunctionOn', { +diff --git a/lib/puppeteer/cdp/FrameManager.js b/lib/puppeteer/cdp/FrameManager.js +index 23afcd3def20ff2f578bf14692de1c86365e95c8..26925d30746cb83d0322ea8b4e93993ff4a28a8e 100644 +--- a/lib/puppeteer/cdp/FrameManager.js ++++ b/lib/puppeteer/cdp/FrameManager.js +@@ -13,6 +13,7 @@ import { disposeSymbol } from '../util/disposable.js'; + import { isErrorLike } from '../util/ErrorLike.js'; + import { CdpIssue } from './CdpIssue.js'; + import { CdpPreloadScript } from './CdpPreloadScript.js'; ++import { CDP_BINDING_PREFIX } from './utils.js'; + import { isTargetClosedError } from './Connection.js'; + import { CdpDeviceRequestPromptManager } from './DeviceRequestPrompt.js'; + import { ExecutionContext } from './ExecutionContext.js'; +@@ -44,6 +45,10 @@ export class FrameManager extends EventEmitter { + * frameNavigated event usually contains the latest information. + */ + #frameNavigatedReceived = new Set(); ++ // xxx-stealth: coalesce concurrent world re-acquisitions per frame so the ++ // frameNavigated/init/load triggers don't stomp each other's contexts. ++ #acquireQueued = new Set(); ++ #acquirePromises = new Map(); + #deviceRequestPromptManagerMap = new WeakMap(); + #frameTreeHandled; + get timeoutSettings() { +@@ -181,9 +186,18 @@ export class FrameManager extends EventEmitter { + this.#frameTreeHandled?.resolve(); + }), + client.send('Page.setLifecycleEventsEnabled', { enabled: true }), +- client.send('Runtime.enable').then(() => { +- return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME); +- }), ++ // xxx-stealth: do NOT send Runtime.enable. It is the single ++ // most-detected automation tell (Brotector/CreepJS/Cloudflare probe ++ // it). Execution contexts are instead acquired pull-style in ++ // #acquireWorlds (main world via Runtime.evaluate globalThis idOnly, ++ // utility world via the Page.createIsolatedWorld response) and fed ++ // into the existing push pipeline via #onExecutionContextCreated. ++ // The utility-world preload sentinel is kept so world-scoped init ++ // scripts still attach on navigation. ++ client.send('Page.addScriptToEvaluateOnNewDocument', { ++ source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`, ++ worldName: UTILITY_WORLD_NAME, ++ }).catch(debugError), + ...(frame + ? Array.from(this.#scriptsToEvaluateOnNewDocument.values()) + : []).map(script => { +@@ -336,6 +350,7 @@ export class FrameManager extends EventEmitter { + return; + } + frame = new CdpFrame(this, frameId, parentFrameId, session); ++ this.#installContextProviders(frame); + this._frameTree.addFrame(frame); + this.emit(FrameManagerEvent.FrameAttached, frame); + } +@@ -366,6 +381,177 @@ export class FrameManager extends EventEmitter { + frame._navigated(framePayload); + this.emit(FrameManagerEvent.FrameNavigated, frame); + frame.emit(FrameEvent.FrameNavigated, navigationType); ++ // xxx-stealth: install lazy context providers and invalidate the ++ // pre-navigation contexts. With Runtime.enable off there is no ++ // executionContextDestroyed event, so dispose synchronously here; this makes ++ // IsolatedWorld.#context undefined so the next evaluate pulls a fresh context ++ // via its provider (resolved after the navigation has settled) instead of ++ // using a dead one. We intentionally do NOT proactively acquire — proactive ++ // contexts captured mid-navigation go stale silently. Resolution is lazy. ++ this.#installContextProviders(frame); ++ for (const world of this.#frameWorlds(frame)) { ++ world?.context?.[disposeSymbol](); ++ } ++ } ++ // xxx-stealth: the main + utility worlds. worlds is keyed by Symbols, so ++ // Object.values misses them — enumerate the known world symbols explicitly. ++ #frameWorlds(frame) { ++ return [frame.worlds[MAIN_WORLD], frame.worlds[PUPPETEER_WORLD]]; ++ } ++ // xxx-stealth: point each of the frame's worlds at the coalesced acquirer ++ // so IsolatedWorld can pull its context on demand. ++ #installContextProviders(frame) { ++ for (const world of this.#frameWorlds(frame)) { ++ world?.setContextProvider?.(() => this.#acquireWorlds(frame)); ++ } ++ } ++ // xxx-stealth: coalescing acquirer. Returns a promise that resolves when ++ // the current (or freshly started) acquisition for this frame completes, so a ++ // lazy provider can await it. Concurrent callers share the in-flight promise ++ // rather than racing — concurrent acquires resolve different transient contexts ++ // and blank each other. ++ #acquireWorlds(frame) { ++ const id = frame._id; ++ const existing = this.#acquirePromises.get(id); ++ if (existing) { ++ this.#acquireQueued.add(id); ++ return existing; ++ } ++ const promise = this.#doAcquireWorlds(frame).finally(() => { ++ this.#acquirePromises.delete(id); ++ if (this.#acquireQueued.delete(id) && this.frame(id)) { ++ void this.#acquireWorlds(frame); ++ } ++ }); ++ this.#acquirePromises.set(id, promise); ++ return promise; ++ } ++ // xxx-stealth: true when `frame` is the top frame of its CDP session ++ // (the page main frame, or an OOP iframe root). Only such frames can resolve ++ // their main world via a context-less Runtime.evaluate, because that targets ++ // the session's default context. Same-process sub-frames share the parent's ++ // session, so a context-less evaluate would resolve the WRONG frame — we skip ++ // proactive main-world acquisition for them rather than mis-register. ++ #frameIsTopOfSession(frame) { ++ const parentId = frame._parentId; ++ if (!parentId) { ++ return true; ++ } ++ const parent = this.frame(parentId); ++ return !parent || parent.client !== frame.client; ++ } ++ // xxx-stealth: pull-acquire a frame's main + utility execution contexts ++ // without Runtime.enable, then feed them into the normal push pipeline. ++ async #doAcquireWorlds(frame) { ++ const session = frame.client; ++ // xxx-stealth: never pre-dispose here. IsolatedWorld.setContext ++ // already disposes the previous context when a fresh one is installed, and ++ // a transiently-failed resolve (common while a navigation is mid-flight) ++ // must leave the last good context intact rather than blank the world. ++ // Stale invalidation on navigation is handled once in #onFrameNavigated. ++ try { ++ // Utility (PUPPETEER) world: Page.createIsolatedWorld returns the new ++ // context id directly — works for any frameId on the session. ++ const iso = await session ++ .send('Page.createIsolatedWorld', { ++ frameId: frame._id, ++ worldName: UTILITY_WORLD_NAME, ++ grantUniveralAccess: true, ++ }) ++ .catch(debugError); ++ const utilityId = iso && typeof iso.executionContextId === 'number' ? iso.executionContextId : undefined; ++ if (utilityId !== undefined) { ++ this.#onExecutionContextCreated({ ++ id: utilityId, ++ name: UTILITY_WORLD_NAME, ++ origin: '', ++ auxData: { frameId: frame._id, isDefault: false }, ++ }, session); ++ } ++ // Main world: resolve this frame's main execution context id. ++ const id = await this.#resolveMainContextId(session, frame, utilityId); ++ if (id !== undefined) { ++ this.#onExecutionContextCreated({ ++ id, ++ name: '', ++ origin: '', ++ auxData: { frameId: frame._id, isDefault: true }, ++ }, session); ++ // xxx-stealth: re-install exposed-function bindings into the ++ // freshly acquired main world. Normally the binding wrapper is ++ // (re)installed when Chrome fires executionContextCreated; with that ++ // event silenced we must re-add the native binding for this context ++ // id and re-run the wrapper init source ourselves on every navigation. ++ for (const binding of this.#bindings) { ++ void session ++ .send('Runtime.addBinding', { ++ name: CDP_BINDING_PREFIX + binding.name, ++ executionContextId: id, ++ }) ++ .catch(() => { }); ++ void session ++ .send('Runtime.evaluate', { ++ expression: binding.initSource, ++ contextId: id, ++ }) ++ .catch(() => { }); ++ } ++ } ++ } ++ catch (error) { ++ debugError(error); ++ } ++ } ++ // xxx-stealth: resolve a frame's MAIN-world execution context id without ++ // Runtime.enable. For the top frame of a session a context-less ++ // `Runtime.evaluate globalThis` resolves the session default (cheap, 1 RTT). For ++ // same-process sub-frames that would resolve the PARENT, so instead we take the ++ // frame's document node (via the utility world we just created) and DOM.resolveNode ++ // it with no executionContextId — CDP resolves it in the owning frame's main world, ++ // whose objectId encodes the main context id. The objectId format is ++ // `..`. ++ async #resolveMainContextId(session, frame, utilityId) { ++ const parse = (objectId) => { ++ if (typeof objectId !== 'string') { ++ return undefined; ++ } ++ const id = Number.parseInt(objectId.split('.')[1] ?? '', 10); ++ return Number.isNaN(id) ? undefined : id; ++ }; ++ if (this.#frameIsTopOfSession(frame)) { ++ const globalThis = await session ++ .send('Runtime.evaluate', { ++ expression: 'globalThis', ++ serializationOptions: { serialization: 'idOnly' }, ++ }) ++ .catch(debugError); ++ return parse(globalThis?.result?.objectId); ++ } ++ if (utilityId === undefined) { ++ return undefined; ++ } ++ const utilDoc = await session ++ .send('Runtime.evaluate', { ++ expression: 'document', ++ contextId: utilityId, ++ serializationOptions: { serialization: 'idOnly' }, ++ }) ++ .catch(debugError); ++ const utilDocObjectId = utilDoc?.result?.objectId; ++ if (typeof utilDocObjectId !== 'string') { ++ return undefined; ++ } ++ const described = await session ++ .send('DOM.describeNode', { objectId: utilDocObjectId }) ++ .catch(debugError); ++ const backendNodeId = described?.node?.backendNodeId; ++ if (typeof backendNodeId !== 'number') { ++ return undefined; ++ } ++ const mainNode = await session ++ .send('DOM.resolveNode', { backendNodeId }) ++ .catch(debugError); ++ return parse(mainNode?.object?.objectId); + } + async #createIsolatedWorld(session, name) { + const key = `${session.id()}:${name}`; +diff --git a/lib/puppeteer/cdp/IsolatedWorld.js b/lib/puppeteer/cdp/IsolatedWorld.js +index b0619734a9eeb884f3ac4ffff39b540226aaf818..0ef7ce4616a27ff9aabd0747937b593f28bb36de 100644 +--- a/lib/puppeteer/cdp/IsolatedWorld.js ++++ b/lib/puppeteer/cdp/IsolatedWorld.js +@@ -21,6 +21,13 @@ export class IsolatedWorld extends Realm { + #worldId; + #origin; + #frameOrWorker; ++ // xxx-stealth: lazy context provider. With Runtime.enable disabled there ++ // are no executionContextCreated events to push contexts, so the world resolves ++ // its context on demand the first time it is needed after a navigation. ++ #contextProvider; ++ setContextProvider(provider) { ++ this.#contextProvider = provider; ++ } + constructor(frameOrWorker, timeoutSettings, worldId) { + super(timeoutSettings); + this.#frameOrWorker = frameOrWorker; +@@ -72,6 +79,19 @@ export class IsolatedWorld extends Realm { + * Waits for the next context to be set on the isolated world. + */ + async #waitForExecutionContext() { ++ // xxx-stealth: pull the context on demand before falling back to ++ // waiting for a (never-arriving) push event. ++ if (this.#contextProvider && !this.#context && !this.disposed) { ++ try { ++ await this.#contextProvider(); ++ } ++ catch { ++ // fall through to the event wait below ++ } ++ if (this.#context) { ++ return this.#context; ++ } ++ } + const error = new Error('Execution context was destroyed'); + const result = await firstValueFrom(fromEmitterEvent(this.#emitter, 'context').pipe(raceWith(fromEmitterEvent(this.#emitter, 'disposed').pipe(map(() => { + // The message has to match the CDP message expected by the WaitTask class. +diff --git a/lib/puppeteer/cdp/WebWorker.js b/lib/puppeteer/cdp/WebWorker.js +index e3ee6673245e92faa53a028867e7dbb3a1aed089..4fcc9785077ca868fd0fa892b4ec907cd807ed96 100644 +--- a/lib/puppeteer/cdp/WebWorker.js ++++ b/lib/puppeteer/cdp/WebWorker.js +@@ -27,9 +27,21 @@ export class CdpWebWorker extends WebWorker { + this.#targetType = targetType; + this.#world = new IsolatedWorld(this, new TimeoutSettings(), MAIN_WORLD); + this.#emitter = new EventEmitter(); +- this.#client.once('Runtime.executionContextCreated', async (event) => { +- this.#world.setContext(new ExecutionContext(client, event.context, this.#world)); +- }); ++ // xxx-stealth: acquire the worker's execution context via an idOnly ++ // globalThis evaluate instead of Runtime.enable + executionContextCreated. ++ void this.#client ++ .send('Runtime.evaluate', { ++ expression: 'globalThis', ++ serializationOptions: { serialization: 'idOnly' }, ++ }) ++ .then((res) => { ++ const objectId = res?.result?.objectId; ++ const id = typeof objectId === 'string' ? Number.parseInt(objectId.split('.')[1] ?? '', 10) : NaN; ++ if (!Number.isNaN(id)) { ++ this.#world.setContext(new ExecutionContext(client, { id }, this.#world)); ++ } ++ }) ++ .catch(debugError); + this.#world.emitter.on('consoleapicalled', async (event) => { + try { + const values = event.args.map(arg => { +@@ -61,7 +73,6 @@ export class CdpWebWorker extends WebWorker { + }); + // This might fail if the target is closed before we receive all execution contexts. + networkManager?.addClient(this.#client).catch(debugError); +- this.#client.send('Runtime.enable').catch(debugError); + } + mainRealm() { + return this.#world; +diff --git a/lib/puppeteer/node/ChromeLauncher.js b/lib/puppeteer/node/ChromeLauncher.js +index 94db7a76f81ecdcff59a620c1fd5b65957e22e68..fdd8de60c97b7c3037c0d7676fabf150e190751c 100644 +--- a/lib/puppeteer/node/ChromeLauncher.js ++++ b/lib/puppeteer/node/ChromeLauncher.js +@@ -126,22 +126,12 @@ export class ChromeLauncher extends BrowserLauncher { + ].filter(feature => { + return feature !== ''; + }); +- // Merge default disabled features with user-provided ones, if any. ++ // xxx-stealth: drop puppeteer's default --disable-features list. That ++ // list (Translate, AcceptCHFrame, MediaRouter, ...) is a non-default flag ++ // fingerprint vs a real user-launched Chrome. Only honor user-supplied ++ // disabled features so the assembled --disable-features looks organic. ++ void turnOnExperimentalFeaturesForTesting; + const disabledFeatures = [ +- 'Translate', +- // AcceptCHFrame disabled because of crbug.com/1348106. +- 'AcceptCHFrame', +- 'MediaRouter', +- 'OptimizationHints', +- 'WebUIReloadButton', +- ...(turnOnExperimentalFeaturesForTesting +- ? [] +- : [ +- // https://crbug.com/1492053 +- 'ProcessPerSiteUpToMainFrameThreshold', +- // https://github.com/puppeteer/puppeteer/issues/10715 +- 'IsolateSandboxedIframes', +- ]), + ...userDisabledFeatures, + ] + .filter(feature => { +diff --git a/lib/puppeteer/api/Frame.js b/lib/puppeteer/api/Frame.js +--- a/lib/puppeteer/api/Frame.js ++++ b/lib/puppeteer/api/Frame.js +@@ -119,6 +119,31 @@ + export const throwIfDetached = throwIfDisposed(frame => { + return `Attempted to use detached Frame '${frame._id}'.`; + }); ++const MAIN_WORLD_DIRECTIVE = /^\s*(?:(?:\/\/!world=main(?=$|\s))|(?:\/\*!world=main\s*\*\/))/; ++const shouldEvaluateInMainWorld = (pageFunction) => { ++ if (typeof pageFunction !== 'function' && typeof pageFunction !== 'string') { ++ return false; ++ } ++ let source; ++ try { ++ source = ++ typeof pageFunction === 'string' ++ ? pageFunction ++ : Function.prototype.toString.call(pageFunction); ++ } ++ catch { ++ return false; ++ } ++ if (MAIN_WORLD_DIRECTIVE.test(source)) { ++ return true; ++ } ++ if (typeof pageFunction !== 'function') { ++ return false; ++ } ++ const arrowIndex = source.indexOf('=>'); ++ const bodyStart = source.indexOf('{', arrowIndex >= 0 ? arrowIndex : 0); ++ return bodyStart >= 0 && MAIN_WORLD_DIRECTIVE.test(source.slice(bodyStart + 1)); ++}; + /** + * Represents a DOM frame. + * +@@ -277,12 +302,21 @@ + super(); + } + #_document; ++ #_mainDocument; + /** + * @internal + */ +- #document() { ++ #document(mainWorld = false) { ++ if (mainWorld) { ++ if (!this.#_mainDocument) { ++ this.#_mainDocument = this.mainRealm().evaluateHandle(() => { ++ return document; ++ }); ++ } ++ return this.#_mainDocument; ++ } + if (!this.#_document) { +- this.#_document = this.mainRealm().evaluateHandle(() => { ++ this.#_document = this.isolatedRealm().evaluateHandle(() => { + return document; + }); + } +@@ -295,6 +329,7 @@ + */ + clearDocumentHandle() { + this.#_document = undefined; ++ this.#_mainDocument = undefined; + } + /** + * @returns The frame element associated with this frame (if any). +@@ -345,8 +380,9 @@ + * See {@link Page.evaluateHandle} for details. + */ + async evaluateHandle(pageFunction, ...args) { ++ const realm = shouldEvaluateInMainWorld(pageFunction) ? this.mainRealm() : this.isolatedRealm(); + pageFunction = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, pageFunction); +- return await this.mainRealm().evaluateHandle(pageFunction, ...args); ++ return await realm.evaluateHandle(pageFunction, ...args); + } + /** + * Behaves identically to {@link Page.evaluate} except it's run within +@@ -355,8 +391,9 @@ + * See {@link Page.evaluate} for details. + */ + async evaluate(pageFunction, ...args) { ++ const realm = shouldEvaluateInMainWorld(pageFunction) ? this.mainRealm() : this.isolatedRealm(); + pageFunction = withSourcePuppeteerURLIfNone(this.evaluate.name, pageFunction); +- return await this.mainRealm().evaluate(pageFunction, ...args); ++ return await realm.evaluate(pageFunction, ...args); + } + /** + * @internal +@@ -458,9 +495,10 @@ + * @returns A promise to the result of the function. + */ + async $eval(selector, pageFunction, ...args) { ++ const mainWorld = shouldEvaluateInMainWorld(pageFunction); + pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction); + // eslint-disable-next-line @puppeteer/use-using -- This is cached. +- const document = await this.#document(); ++ const document = await this.#document(mainWorld); + return await document.$eval(selector, pageFunction, ...args); + } + /** +@@ -498,9 +536,10 @@ + * @returns A promise to the result of the function. + */ + async $$eval(selector, pageFunction, ...args) { ++ const mainWorld = shouldEvaluateInMainWorld(pageFunction); + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + // eslint-disable-next-line @puppeteer/use-using -- This is cached. +- const document = await this.#document(); ++ const document = await this.#document(mainWorld); + return await document.$$eval(selector, pageFunction, ...args); + } + /** +@@ -577,7 +616,8 @@ + * @returns the promise which resolve when the `pageFunction` returns a truthy value. + */ + async waitForFunction(pageFunction, options = {}, ...args) { +- return await this.mainRealm().waitForFunction(pageFunction, options, ...args); ++ const realm = shouldEvaluateInMainWorld(pageFunction) ? this.mainRealm() : this.isolatedRealm(); ++ return await realm.waitForFunction(pageFunction, options, ...args); + } + /** + * The full HTML contents of the frame, including the DOCTYPE. +diff --git a/lib/puppeteer/cdp/Frame.js b/lib/puppeteer/cdp/Frame.js +--- a/lib/puppeteer/cdp/Frame.js ++++ b/lib/puppeteer/cdp/Frame.js +@@ -276,7 +276,7 @@ + this.#client.send('Runtime.addBinding', { + name: CDP_BINDING_PREFIX + binding.name, + }), +- this.evaluate(binding.initSource).catch(debugError), ++ this.mainRealm().evaluate(binding.initSource).catch(debugError), + ]); + } + async removeExposedFunctionBinding(binding) { +@@ -289,7 +289,7 @@ + this.#client.send('Runtime.removeBinding', { + name: CDP_BINDING_PREFIX + binding.name, + }), +- this.evaluate(name => { ++ this.mainRealm().evaluate(name => { + // Removes the dangling Puppeteer binding wrapper. + // @ts-expect-error: In a different context. + globalThis[name] = undefined; +diff --git a/lib/puppeteer/api/ElementHandle.js b/lib/puppeteer/api/ElementHandle.js +--- a/lib/puppeteer/api/ElementHandle.js ++++ b/lib/puppeteer/api/ElementHandle.js +@@ -102,6 +102,31 @@ + import { _isElementHandle } from './ElementHandleSymbol.js'; + import { JSHandle } from './JSHandle.js'; + import { NodeLocator } from './locators/locators.js'; ++const MAIN_WORLD_DIRECTIVE = /^\s*(?:(?:\/\/!world=main(?=$|\s))|(?:\/\*!world=main\s*\*\/))/; ++const shouldEvaluateInMainWorld = (pageFunction) => { ++ if (typeof pageFunction !== 'function' && typeof pageFunction !== 'string') { ++ return false; ++ } ++ let source; ++ try { ++ source = ++ typeof pageFunction === 'string' ++ ? pageFunction ++ : Function.prototype.toString.call(pageFunction); ++ } ++ catch { ++ return false; ++ } ++ if (MAIN_WORLD_DIRECTIVE.test(source)) { ++ return true; ++ } ++ if (typeof pageFunction !== 'function') { ++ return false; ++ } ++ const arrowIndex = source.indexOf('=>'); ++ const bodyStart = source.indexOf('{', arrowIndex >= 0 ? arrowIndex : 0); ++ return bodyStart >= 0 && MAIN_WORLD_DIRECTIVE.test(source.slice(bodyStart + 1)); ++}; + /** + * A given method will have it's `this` replaced with an isolated version of + * `this` when decorated with this decorator. +@@ -299,6 +324,7 @@ + * trying to adopt it multiple times + */ + isolatedHandle = __runInitializers(this, _instanceExtraInitializers); ++ mainHandle; + /** + * @internal + */ +@@ -335,19 +361,37 @@ + async getProperties() { + return await this.handle.getProperties(); + } ++ async #handleForPageFunction(pageFunction) { ++ const realm = shouldEvaluateInMainWorld(pageFunction) ? this.frame.mainRealm() : this.frame.isolatedRealm(); ++ if (this.realm === realm) { ++ return this; ++ } ++ if (realm === this.frame.isolatedRealm()) { ++ if (!this.isolatedHandle) { ++ this.isolatedHandle = await realm.adoptHandle(this); ++ } ++ return this.isolatedHandle; ++ } ++ if (!this.mainHandle) { ++ this.mainHandle = await realm.adoptHandle(this); ++ } ++ return this.mainHandle; ++ } + /** + * @internal + */ + async evaluate(pageFunction, ...args) { ++ const handle = await this.#handleForPageFunction(pageFunction); + pageFunction = withSourcePuppeteerURLIfNone(this.evaluate.name, pageFunction); +- return await this.handle.evaluate(pageFunction, ...args); ++ return await handle.handle.evaluate(pageFunction, ...args); + } + /** + * @internal + */ + async evaluateHandle(pageFunction, ...args) { ++ const handle = await this.#handleForPageFunction(pageFunction); + pageFunction = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, pageFunction); +- return await this.handle.evaluateHandle(pageFunction, ...args); ++ return await handle.handle.evaluateHandle(pageFunction, ...args); + } + /** + * @internal +@@ -371,7 +415,7 @@ + * @internal + */ + async dispose() { +- await Promise.all([this.handle.dispose(), this.isolatedHandle?.dispose()]); ++ await Promise.all([this.handle.dispose(), this.isolatedHandle?.dispose(), this.mainHandle?.dispose()]); + } + /** + * @internal +diff --git a/lib/puppeteer/api/ElementHandle.js b/lib/puppeteer/api/ElementHandle.js +--- a/lib/puppeteer/api/ElementHandle.js ++++ b/lib/puppeteer/api/ElementHandle.js +@@ -599,15 +599,27 @@ + async $$eval(selector, pageFunction, ...args) { + const env_2 = { stack: [], error: void 0, hasError: false }; + try { ++ const mainWorld = shouldEvaluateInMainWorld(pageFunction); + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); + const results = await this.$$(selector); +- const elements = __addDisposableResource(env_2, await this.evaluateHandle((_, ...elements) => { ++ const realm = mainWorld ? this.frame.mainRealm() : this.frame.isolatedRealm(); ++ const adoptedResults = []; ++ const handlesToDispose = []; ++ for (const result of results) { ++ handlesToDispose.push(result); ++ const adopted = result.realm === realm ? result : await realm.adoptHandle(result); ++ adoptedResults.push(adopted); ++ if (adopted !== result) { ++ handlesToDispose.push(adopted); ++ } ++ } ++ const elements = __addDisposableResource(env_2, await realm.evaluateHandle((...elements) => { + return elements; +- }, ...results), false); ++ }, ...adoptedResults), false); + const [result] = await Promise.all([ + elements.evaluate(pageFunction, ...args), +- ...results.map(results => { +- return results.dispose(); ++ ...handlesToDispose.map(result => { ++ return result.dispose(); + }), + ]); + return result;