import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $which, getPuppeteerDir, logger, removeWithRetries } from "@oh-my-pi/pi-utils"; import type * as BrowsersNs from "@puppeteer/browsers"; import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core"; import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" }; import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" }; import stealthHairlineScript from "../puppeteer/02_stealth_hairline.txt" with { type: "text" }; import stealthBotdScript from "../puppeteer/03_stealth_botd.txt" with { type: "text" }; import stealthIframeScript from "../puppeteer/04_stealth_iframe.txt" with { type: "text" }; import stealthWebglScript from "../puppeteer/05_stealth_webgl.txt" with { type: "text" }; import stealthScreenScript from "../puppeteer/06_stealth_screen.txt" with { type: "text" }; import stealthFontsScript from "../puppeteer/07_stealth_fonts.txt" with { type: "text" }; import stealthAudioScript from "../puppeteer/08_stealth_audio.txt" with { type: "text" }; import stealthLocaleScript from "../puppeteer/09_stealth_locale.txt" with { type: "text" }; import stealthPluginsScript from "../puppeteer/10_stealth_plugins.txt" with { type: "text" }; import stealthHardwareScript from "../puppeteer/11_stealth_hardware.txt" with { type: "text" }; import stealthCodecsScript from "../puppeteer/12_stealth_codecs.txt" with { type: "text" }; import stealthWorkerScript from "../puppeteer/13_stealth_worker.txt" with { type: "text" }; import { ToolError } from "../tool-errors"; export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 }; /** * Per-CDP-message timeout applied to every puppeteer launch/connect. Set above * `TOOL_TIMEOUTS.browser.max` (30s) so the agent-side wall-clock is the canonical * limit; this constant only catches genuinely stuck CDP sockets (renderer wedged, * connection dropped, etc.). */ export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000; const ENABLE_AUTOMATION_FLAG = "--enable-automation"; // 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 normally sets // navigator.webdriver=true and shows the "controlled by automated software" infobar. // Edge is the launch-stability exception: it can exit before CDP opens when this // default flag is stripped, so Edge keeps Puppeteer's flag while our explicit // `--disable-blink-features=AutomationControlled` launch arg still handles // navigator.webdriver. // `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_FLAG, "--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", ]; function isMicrosoftEdgeExecutable(executablePath: string | undefined): boolean { if (!executablePath) return false; const normalizedPath = executablePath.replaceAll("\\", "/").toLowerCase(); const executableName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1); return ( executableName === "msedge.exe" || executableName === "microsoft edge" || executableName.startsWith("microsoft-edge") ); } function stealthIgnoreDefaultArgs(executablePath: string | undefined): string[] { if (!isMicrosoftEdgeExecutable(executablePath)) return [...STEALTH_IGNORE_DEFAULT_ARGS]; return STEALTH_IGNORE_DEFAULT_ARGS.filter(arg => arg !== ENABLE_AUTOMATION_FLAG); } const STEALTH_ACCEPT_LANGUAGE = "en-US,en"; const USER_AGENT_TARGET_TIMEOUT_MS = 5_000; const USER_AGENT_TARGET_TYPES = new Set(["page", "webview", "background_page"]); const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__"; /** * Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke * on malformed package.json files in the user's project tree. * * Dynamic import is required because puppeteer-core probes the cwd at module * load time; we must `process.chdir` to a safe scratch dir before loading and * restore cwd afterwards. A static import would run at module-init time before * cwd is safe. */ let puppeteerModule: typeof Puppeteer | undefined; export async function loadPuppeteer(): Promise { if (puppeteerModule) return puppeteerModule; const prev = process.cwd(); const safeDir = getPuppeteerDir(); await Bun.write(path.join(safeDir, "package.json"), "{}"); try { process.chdir(safeDir); puppeteerModule = (await import("puppeteer-core")).default; return puppeteerModule; } finally { process.chdir(prev); } } let puppeteerModuleWorker: typeof Puppeteer | undefined; export async function loadPuppeteerInWorker(safeDir: string): Promise { if (puppeteerModuleWorker) return puppeteerModuleWorker; const orig = process.cwd; Object.defineProperty(process, "cwd", { value: () => safeDir, configurable: true }); try { puppeteerModuleWorker = (await import("puppeteer-core")).default; return puppeteerModuleWorker; } finally { Object.defineProperty(process, "cwd", { value: orig, configurable: true }); } } let browsersModule: typeof BrowsersNs | undefined; async function loadBrowsers(): Promise { if (!browsersModule) { browsersModule = await import("@puppeteer/browsers"); } return browsersModule; } /** * Resolve the Chromium executable puppeteer will launch, honoring * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily * downloading Chromium otherwise. The browser is cached under * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform * detection fails (puppeteer default resolution takes over). Exported so * real-browser tests can probe launchability and skip on hosts missing * Chrome's system libraries. */ let chromiumExecutablePromise: Promise | undefined; export async function ensureChromiumExecutable(): Promise { const envPath = process.env.PUPPETEER_EXECUTABLE_PATH; if (envPath) return envPath; const sysChrome = resolveSystemChromium(); if (sysChrome) return sysChrome; if (chromiumExecutablePromise) return chromiumExecutablePromise; chromiumExecutablePromise = (async () => { const browsers = await loadBrowsers(); const platform = browsers.detectBrowserPlatform(); if (!platform) { logger.warn("Could not detect browser platform; relying on puppeteer default resolution"); return undefined; } const cacheDir = getPuppeteerDir(); const { PUPPETEER_REVISIONS } = await import("puppeteer-core/internal/revisions.js"); const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, PUPPETEER_REVISIONS.chrome); const executablePath = browsers.computeExecutablePath({ browser: browsers.Browser.CHROME, buildId, cacheDir, platform, }); if (fs.existsSync(executablePath)) return executablePath; logger.warn("Downloading Chromium for puppeteer (first browser use)", { buildId, platform, cacheDir, }); let lastReportedPercent = -1; await browsers.install({ browser: browsers.Browser.CHROME, buildId, cacheDir, platform, downloadProgressCallback: (downloaded, total) => { if (total <= 0) return; const pct = Math.floor((downloaded / total) * 100); if (pct >= lastReportedPercent + 10 || downloaded === total) { lastReportedPercent = pct; logger.debug( `Chromium download: ${pct}% (${Math.round(downloaded / 1_000_000)} / ${Math.round(total / 1_000_000)} MB)`, ); } }, }); return executablePath; })().catch(err => { chromiumExecutablePromise = undefined; throw new ToolError( `Failed to install Chromium for puppeteer: ${(err as Error).message}. ` + "Set PUPPETEER_EXECUTABLE_PATH to use an existing Chrome/Chromium binary, or install one manually.", ); }); return chromiumExecutablePromise; } let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found function isExecutableFile(p: string): boolean { try { const st = fs.statSync(p); return st.isFile(); } catch { return false; } } /** Flatpak application id published by the Ungoogled Chromium project. */ const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium"; function systemChromiumCandidates( platform: NodeJS.Platform = process.platform, home = os.homedir(), which: (name: string) => string | null | undefined = $which, ): string[] { const candidates: string[] = []; switch (platform) { case "darwin": { for (const root of ["/Applications", path.join(home, "Applications")]) { candidates.push( path.join(root, "Google Chrome.app/Contents/MacOS/Google Chrome"), path.join(root, "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"), path.join(root, "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev"), path.join(root, "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"), path.join(root, "Chromium.app/Contents/MacOS/Chromium"), path.join(root, "Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), ); } break; } case "linux": { const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]; for (const name of names) { const found = which(name); if (found) candidates.push(found); } candidates.push( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", "/var/lib/flatpak/exports/bin/com.google.Chrome", "/var/lib/flatpak/exports/bin/org.chromium.Chromium", ); let onNixos = false; try { onNixos = fs.existsSync("/etc/NIXOS"); } catch {} if (onNixos) { candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium"); } for (const name of ["ungoogled-chromium", "ungoogled-chromium-browser"]) { const found = which(name); if (found) candidates.push(found); } candidates.push( // Ungoogled Chromium. Distro and AUR packages that keep the plain // `chromium` name are already covered above; these are the paths // unique to it, including the system and per-user Flatpak shims. "/usr/bin/ungoogled-chromium", "/usr/bin/ungoogled-chromium-browser", `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`, path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID), ); break; } case "win32": { const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData\\Local"); candidates.push( path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"), path.join(programFiles, "Chromium\\Application\\chrome.exe"), path.join(localAppData, "Chromium\\Application\\chrome.exe"), path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"), path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"), ); break; } } return candidates; } function resolveSystemChromium(): string | undefined { if (resolvedChromium !== undefined) return resolvedChromium ?? undefined; const seen = new Set(); for (const candidate of systemChromiumCandidates()) { if (!candidate || seen.has(candidate)) continue; seen.add(candidate); if (isExecutableFile(candidate)) { resolvedChromium = candidate; logger.debug("Using system Chrome/Chromium", { path: candidate }); return candidate; } } resolvedChromium = null; return undefined; } /** Options shared by headless Chromium consumers. */ export interface LaunchHeadlessOptions { headless: boolean; viewport?: { width: number; height: number; deviceScaleFactor?: number }; /** Additional Chromium arguments merged with the centralized launch defaults. */ args?: readonly string[]; /** Additional exact Puppeteer default arguments to suppress. */ ignoreDefaultArgs?: readonly string[]; } /** Result of a headless Chromium launch. */ export interface LaunchHeadlessResult { browser: Browser; /** * OMP-owned temporary Chromium profile directory to remove after the browser * process tree exits, or `undefined` when the caller supplied its own * `--user-data-dir` (which OMP must not delete). */ userDataDir?: string; } /** * Base Chromium argv shared by process-local puppeteer launches and the * broker-owned shared browser: sandbox/stealth flags, window size, and * PUPPETEER_PROXY* env-derived proxy flags. */ export function buildHeadlessLaunchArgs(viewport: { width: number; height: number }): string[] { const launchArgs = [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-blink-features=AutomationControlled", `--window-size=${viewport.width},${viewport.height}`, ]; const proxy = process.env.PUPPETEER_PROXY; if (proxy) { launchArgs.push(`--proxy-server=${proxy}`); // Chrome (since v72) bypasses proxies for localhost by default. When PUPPETEER_PROXY_BYPASS_LOOPBACK // is true, add <-loopback> so traffic to localhost reaches the proxy (e.g. for mitmdump/auth capture). const bypassLoopback = process.env.PUPPETEER_PROXY_BYPASS_LOOPBACK?.toLowerCase(); if (bypassLoopback === "true" || bypassLoopback === "1" || bypassLoopback === "yes" || bypassLoopback === "on") { launchArgs.push("--proxy-bypass-list=<-loopback>"); } } const ignoreCert = process.env.PUPPETEER_PROXY_IGNORE_CERT_ERRORS?.toLowerCase(); if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") { launchArgs.push("--ignore-certificate-errors"); } return launchArgs; } export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise { const vp = opts.viewport ?? DEFAULT_VIEWPORT; const initialViewport = { width: vp.width, height: vp.height, deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor, }; const puppeteer = await loadPuppeteer(); const launchArgs = buildHeadlessLaunchArgs(initialViewport); for (const arg of opts.args ?? []) { if (!launchArgs.includes(arg)) launchArgs.push(arg); } // Own the Chromium profile directory instead of letting puppeteer-core create // (and delete) a temporary one. Passing `--user-data-dir` makes puppeteer // treat the profile as non-temporary, so `ChromeLauncher.cleanUserDataDir` // becomes a no-op and can no longer reject its eager process-exit hook with an // unhandled EBUSY when Chromium still holds the profile lock on Windows // (issue #7058). `removeUserDataDir` cleans it up on our terms instead. let userDataDir: string | undefined; if (!launchArgs.some(arg => arg.startsWith("--user-data-dir"))) { userDataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-chrome-profile-")); launchArgs.push(`--user-data-dir=${userDataDir}`); } try { const executablePath = await ensureChromiumExecutable(); const browser = await puppeteer.launch({ headless: opts.headless, defaultViewport: opts.headless ? initialViewport : null, executablePath, args: launchArgs, ignoreDefaultArgs: [ ...new Set([...stealthIgnoreDefaultArgs(executablePath), ...(opts.ignoreDefaultArgs ?? [])]), ], protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS, }); return { browser, userDataDir }; } catch (error) { if (userDataDir) await removeUserDataDir(userDataDir); throw error; } } /** Fully resolved executable and argv for a broker-spawned shared Chromium. */ export interface SharedBrowserLaunchSpec { executablePath: string; args: string[]; } /** * Resolve the executable and complete argv for a shared Chromium the daemon * broker spawns directly (no puppeteer inside the broker). Mirrors * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the * stealth-suppressed set — plus `--remote-debugging-port=0` so every client * attaches over CDP. Returns null when no Chromium executable resolves; * callers fall back to a process-local launch. */ export async function resolveSharedBrowserLaunchSpec(opts: { headless: boolean; userDataDir: string; viewport?: { width: number; height: number }; }): Promise { const executablePath = await ensureChromiumExecutable(); if (!executablePath) return null; const puppeteer = await loadPuppeteer(); const vp = opts.viewport ?? DEFAULT_VIEWPORT; const ignored = new Set(stealthIgnoreDefaultArgs(executablePath)); const defaults = await puppeteer.defaultArgs({ headless: opts.headless, args: buildHeadlessLaunchArgs(vp), userDataDir: opts.userDataDir, }); return { executablePath, args: [...defaults.filter(arg => !ignored.has(arg)), "--remote-debugging-port=0"], }; } /** * Remove an OMP-owned headless Chromium profile directory, tolerating the brief * window on Windows in which Chromium (or an orphaned browser subprocess) still * holds the profile lock. The shared temp remover centralizes retry handling * for EBUSY/EPERM/ENOTEMPTY; if the directory is still busy afterwards we warn * and leave it for a later cleanup pass rather than throwing — a shutdown cleanup * failure must never crash the process (issue #7058). */ export async function removeUserDataDir(dir: string): Promise { try { await removeWithRetries(dir); } catch (error) { logger.warn("Left Chromium profile directory in place after cleanup failure", { dir, error: error instanceof Error ? error.message : String(error), }); } } export async function applyViewport( page: Page, viewport?: { width: number; height: number; deviceScaleFactor?: number }, ): Promise { if (!viewport) { await page.setViewport(DEFAULT_VIEWPORT); return; } await page.setViewport({ width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor, }); } // ===================================================================== // Stealth patches // ===================================================================== interface PuppeteerCdpClient { send: (method: string, params?: Record) => Promise; } export interface UserAgentOverride { userAgent: string; platform: string; acceptLanguage: string; 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; }; } function resolvePageClient(page: Page): PuppeteerCdpClient | null { const pageWithClient = page as Page & { _client?: (() => PuppeteerCdpClient) | PuppeteerCdpClient; }; if (!pageWithClient._client) return null; return typeof pageWithClient._client === "function" ? pageWithClient._client() : pageWithClient._client; } const patchedClients = new WeakSet(); function patchSourceUrl(page: Page): void { const client = resolvePageClient(page); if (!client) return; const clientKey = client as object; if (patchedClients.has(clientKey)) return; patchedClients.add(clientKey); const originalSend = client.send.bind(client); client.send = async (method: string, params?: Record) => { const next = async (payload?: Record) => { try { return await originalSend(method, payload); } catch (error) { if ( error instanceof Error && error.message.includes( "Protocol error (Network.getResponseBody): No resource with given identifier found", ) ) { return undefined; } throw error; } }; if (!method || !params) { return next(params); } const key = method === "Runtime.evaluate" ? "expression" : method === "Runtime.callFunctionOn" ? "functionDeclaration" : null; if (!key) { return next(params); } const value = params[key]; if (typeof value !== "string" || !value.includes(PUPPETEER_SOURCE_URL_SUFFIX)) { return next(params); } const patchedParams = { ...params, [key]: value.replace(PUPPETEER_SOURCE_URL_SUFFIX, "") }; return next(patchedParams); }; } 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/"); if (userAgent.includes("Linux") && !userAgent.includes("Android")) { userAgent = userAgent.replace(/\(([^)]+)\)/, "(Windows NT 10.0; Win64; x64)"); } const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/); 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 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] ?? "") : isWindows ? (userAgent.match(/Windows NT ([\d.]+)/)?.[1] ?? "") : ""; const architecture = isAndroid ? "" : resolveHostArchitecture(); const bitness = isAndroid ? "" : resolveHostBitness(); const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : ""; const brandOrders = [ [0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0], ]; const order = brandOrders[majorVersion % brandOrders.length] ?? brandOrders[0]!; const escapedChars = [" ", " ", ";"]; const greaseyBrand = `${escapedChars[order[0]!]}Not${escapedChars[order[1]!]}A${escapedChars[order[2]!]}Brand`; const brands: { brand: string; version: string }[] = []; 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, platform, acceptLanguage: STEALTH_ACCEPT_LANGUAGE, userAgentMetadata: { brands, fullVersion, fullVersionList, platform: platformFull, platformVersion, architecture, bitness, model, mobile: isAndroid, }, }; } function wrapSession(session: CDPSession): PuppeteerCdpClient { return { send: async (method, params) => session.send(method as never, params as never), }; } async function sendUserAgentOverride(client: PuppeteerCdpClient, override: UserAgentOverride): Promise { try { await client.send("Network.enable"); } catch {} try { await client.send("Network.setUserAgentOverride", override as unknown as Record); } catch (error) { logger.debug("Failed to apply Network user agent override", { error: error instanceof Error ? error.message : String(error), }); } try { await client.send("Emulation.setUserAgentOverride", override as unknown as Record); } catch (error) { logger.debug("Failed to apply Emulation user agent override", { error: error instanceof Error ? error.message : String(error), }); } } export interface UserAgentSession { override: UserAgentOverride; browserSession: CDPSession | null; } /** Configure UA override on the browser + auto-attach to new targets. */ async function configureUserAgentTargets( browser: Browser, state: { browserSession: CDPSession | null; override: UserAgentOverride }, targetTimeoutMs = USER_AGENT_TARGET_TIMEOUT_MS, ): Promise { if (!state.browserSession) { state.browserSession = await browser.target().createCDPSession(); await state.browserSession.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }); state.browserSession.on( "Target.attachedToTarget", async (event: { sessionId: string; targetInfo?: { type?: string } }) => { if (!targetInfoSupportsUserAgentOverride(event.targetInfo)) return; const connection = state.browserSession?.connection(); const session = connection?.session(event.sessionId); if (!session) return; await withSoftTimeout( sendUserAgentOverride(wrapSession(session), state.override), targetTimeoutMs, "new target user-agent override", ); }, ); } const targets = browser.targets().filter(targetSupportsUserAgentOverride); await Promise.all( targets.map(async target => { await withSoftTimeout( applyTargetUserAgentOverride(target, state.override), targetTimeoutMs, "target user-agent override", ); }), ); } function targetSupportsUserAgentOverride(target: Target): boolean { return targetInfoSupportsUserAgentOverride({ type: target.type() }); } function targetInfoSupportsUserAgentOverride(targetInfo: { type?: string } | undefined): boolean { return Boolean(targetInfo?.type && USER_AGENT_TARGET_TYPES.has(targetInfo.type)); } async function applyTargetUserAgentOverride(target: Target, override: UserAgentOverride): Promise { const session = await target.createCDPSession(); try { await sendUserAgentOverride(wrapSession(session), override); } finally { await session.detach().catch(() => undefined); } } async function withSoftTimeout(promise: Promise, timeoutMs: number, label: string): Promise { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise(resolve => { timeout = setTimeout(() => { logger.debug(`Timed out applying ${label}`); resolve(undefined); }, timeoutMs); }); try { return await Promise.race([ promise.catch(error => { logger.debug(`Failed to apply ${label}`, { error: error instanceof Error ? error.message : String(error) }); return undefined; }), timeoutPromise, ]); } finally { if (timeout) clearTimeout(timeout); } } const STEALTH_PATCH_SCRIPTS = [ stealthTamperingScript, stealthActivityScript, stealthHairlineScript, stealthBotdScript, stealthIframeScript, stealthWebglScript, stealthScreenScript, stealthFontsScript, stealthAudioScript, stealthLocaleScript, stealthPluginsScript, stealthHardwareScript, stealthCodecsScript, stealthWorkerScript, ]; function buildStealthInjectionScript(scripts: readonly string[] = STEALTH_PATCH_SCRIPTS): string { const joint = scripts .map( script => ` try { ${script}; } catch (e) {} `, ) .join(";\n"); return `(() => { 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) { iframe = document.createElement("iframe"); iframe.style.display = "none"; container.appendChild(iframe); if (!iframe.contentWindow) iframe = null; } try { const nativeWindow = iframe ? iframe.contentWindow : window; // Cache pristine native functions const Function_toString = nativeWindow.Function.prototype.toString; const Object_getOwnPropertyDescriptor = nativeWindow.Object.getOwnPropertyDescriptor; const Object_getOwnPropertyDescriptors = nativeWindow.Object.getOwnPropertyDescriptors; const Object_getPrototypeOf = nativeWindow.Object.getPrototypeOf; const Object_defineProperty = nativeWindow.Object.defineProperty; const Object_getOwnPropertyDescriptorOriginal = nativeWindow.Object.getOwnPropertyDescriptor; const Object_create = nativeWindow.Object.create; const Object_keys = nativeWindow.Object.keys; const Object_getOwnPropertyNames = nativeWindow.Object.getOwnPropertyNames; const Object_entries = nativeWindow.Object.entries; const Object_setPrototypeOf = nativeWindow.Object.setPrototypeOf; const Object_assign = nativeWindow.Object.assign; const Window_setTimeout = nativeWindow.setTimeout; const Math_random = nativeWindow.Math.random; const Math_floor = nativeWindow.Math.floor; const Math_max = nativeWindow.Math.max; const Math_min = nativeWindow.Math.min; const Window_Event = nativeWindow.Event; const Promise_resolve = nativeWindow.Promise.resolve.bind(nativeWindow.Promise); const Window_Blob = nativeWindow.Blob; const Window_Proxy = nativeWindow.Proxy; const Reflect_get = nativeWindow.Reflect.get; const Reflect_set = nativeWindow.Reflect.set; const Reflect_apply = nativeWindow.Reflect.apply; const Reflect_construct = nativeWindow.Reflect.construct; const Reflect_defineProperty = nativeWindow.Reflect.defineProperty; const Reflect_deleteProperty = nativeWindow.Reflect.deleteProperty; const Reflect_getOwnPropertyDescriptor = nativeWindow.Reflect.getOwnPropertyDescriptor; const Reflect_getPrototypeOf = nativeWindow.Reflect.getPrototypeOf; const Reflect_has = nativeWindow.Reflect.has; const Reflect_isExtensible = nativeWindow.Reflect.isExtensible; const Reflect_ownKeys = nativeWindow.Reflect.ownKeys; const Reflect_preventExtensions = nativeWindow.Reflect.preventExtensions; const Reflect_setPrototypeOf = nativeWindow.Reflect.setPrototypeOf; 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 && iframe.parentNode) iframe.parentNode.removeChild(iframe); }})();`; } async function injectStealthScripts(page: Page): Promise { await page.evaluateOnNewDocument(buildStealthInjectionScript()); } /** Builds the browser-page stealth bootstrap source for regression tests. */ export function buildStealthInjectionScriptForTest(scripts: readonly string[] = STEALTH_PATCH_SCRIPTS): string { return buildStealthInjectionScript(scripts); } /** Apply stealth patches + UA override to a headless page. Idempotent within a tab. */ export async function applyStealthPatches( browser: Browser, page: Page, state: { browserSession: CDPSession | null; override: UserAgentOverride | null }, ): Promise { patchSourceUrl(page); if (!state.override) { state.override = await resolveUserAgentOverride(page); } const client = resolvePageClient(page); if (client) { await sendUserAgentOverride(client, state.override); } const targetState = { browserSession: state.browserSession, override: state.override }; await configureUserAgentTargets(browser, targetState); state.browserSession = targetState.browserSession; await injectStealthScripts(page); } /** Exposes executable candidates for detection tests. */ export function systemChromiumCandidatesForTest( platform: NodeJS.Platform = process.platform, home?: string, which?: (name: string) => string | undefined, ): string[] { return systemChromiumCandidates(platform, home, which); } export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] { return stealthIgnoreDefaultArgs(executablePath); } export function targetSupportsUserAgentOverrideForTest(target: Target): boolean { return targetSupportsUserAgentOverride(target); } export async function configureUserAgentTargetsForTest( browser: Browser, state: { browserSession: CDPSession | null; override: UserAgentOverride }, targetTimeoutMs?: number, ): Promise { await configureUserAgentTargets(browser, state, targetTimeoutMs); }