fix(stealth): 修复 headed 模式下 stealth 失效并统一策略

- 修复 launch 协议未透传 stealth 导致 --headed 下补丁失效的问题\n- 在 BrowserManager 引入 StealthPolicy,统一 local/CDP/provider 能力决策\n- 增加 launch 返回 stealth 状态并在 --debug 输出连接类型与能力\n- 补充 local/CDP 回归测试与 bot.sannysoft.com 自动检查脚本\n- 同步 README、CLI help、技能文档与 CDP 文档中的 stealth 能力矩阵
This commit is contained in:
leeguooooo
2026-02-24 12:18:47 +09:00
parent 2fe7394dbe
commit 8932f28926
15 changed files with 985 additions and 89 deletions
+65 -7
View File
@@ -517,7 +517,10 @@ async function handleLaunch(
browser: BrowserManager
): Promise<Response> {
await browser.launch(command);
return successResponse(command.id, { launched: true });
return successResponse(command.id, {
launched: true,
stealth: browser.getStealthStatus(command.browser ?? 'chromium'),
});
}
async function handleNavigate(
@@ -541,6 +544,30 @@ async function handleNavigate(
});
}
function bezierPoint(t: number, p0: number, p1: number, p2: number, p3: number): number {
const u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}
async function humanMouseMove(page: Page, toX: number, toY: number): Promise<void> {
const viewport = page.viewportSize();
const fromX = viewport ? Math.random() * viewport.width * 0.3 : 100;
const fromY = viewport ? Math.random() * viewport.height * 0.3 : 100;
const cp1x = fromX + (toX - fromX) * (0.2 + Math.random() * 0.3);
const cp1y = fromY + (Math.random() - 0.5) * 200;
const cp2x = fromX + (toX - fromX) * (0.5 + Math.random() * 0.3);
const cp2y = toY + (Math.random() - 0.5) * 200;
const steps = 15 + Math.floor(Math.random() * 15);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = bezierPoint(t, fromX, cp1x, cp2x, toX);
const y = bezierPoint(t, fromY, cp1y, cp2y, toY);
await page.mouse.move(x, y);
}
}
async function handleClick(command: ClickCommand, browser: BrowserManager): Promise<Response> {
// Support both refs (@e1) and regular selectors
const locator = browser.getLocator(command.selector);
@@ -572,6 +599,14 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
});
}
// Human-like: move mouse along a Bezier curve before clicking
const box = await locator.boundingBox();
if (box) {
const targetX = box.x + box.width * (0.3 + Math.random() * 0.4);
const targetY = box.y + box.height * (0.3 + Math.random() * 0.4);
await humanMouseMove(browser.getPage(), targetX, targetY);
}
await locator.click({
button: command.button,
clickCount: command.clickCount,
@@ -592,9 +627,18 @@ async function handleType(command: TypeCommand, browser: BrowserManager): Promis
await locator.fill('');
}
await locator.pressSequentially(command.text, {
delay: command.delay,
});
if (command.delay) {
// Humanized: type char-by-char with randomized delay (+-40%)
await locator.focus();
const page = browser.getPage();
for (const char of command.text) {
const jitter = command.delay * (0.6 + Math.random() * 0.8);
await page.keyboard.type(char, { delay: 0 });
await page.waitForTimeout(jitter);
}
} else {
await locator.pressSequentially(command.text, {});
}
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
@@ -870,7 +914,11 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
timeout: command.timeout,
});
} else if (command.timeout) {
await page.waitForTimeout(command.timeout);
// Random range: wait between [timeout, timeoutMax]
const min = command.timeout;
const max = command.timeoutMax ?? min;
const delay = max > min ? min + Math.random() * (max - min) : min;
await page.waitForTimeout(Math.round(delay));
} else {
// Default: wait for load state
await page.waitForLoadState('load');
@@ -1897,9 +1945,19 @@ async function handleKeyboard(
const sub = command.subaction ?? 'press';
switch (sub) {
case 'type':
await page.keyboard.type(command.text ?? '', { delay: command.delay });
case 'type': {
const text = command.text ?? '';
if (command.delay) {
for (const char of text) {
const jitter = command.delay * (0.6 + Math.random() * 0.8);
await page.keyboard.type(char, { delay: 0 });
await page.waitForTimeout(jitter);
}
} else {
await page.keyboard.type(text);
}
return successResponse(command.id, { typed: true, text: command.text });
}
case 'press':
await page.keyboard.press(command.keys ?? '');
return successResponse(command.id, { pressed: command.keys });
+72
View File
@@ -53,6 +53,78 @@ describe('BrowserManager', () => {
expect(newBrowser.getBrowser()).toBeNull();
await newBrowser.close();
});
it('should report local stealth policy capabilities', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ headless: true, stealth: true });
const status = testBrowser.getStealthStatus('chromium');
expect(status.enabled).toBe(true);
expect(status.connectionKind).toBe('local');
expect(status.capabilities).toContain('chromium-launch-args');
expect(status.capabilities).toContain('context-init-scripts');
await testBrowser.close();
});
it('should apply init-script stealth policy for CDP connections', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222, stealth: true });
expect(addInitScript).toHaveBeenCalledTimes(1);
const status = cdpBrowser.getStealthStatus();
expect(status.enabled).toBe(true);
expect(status.connectionKind).toBe('cdp');
expect(status.capabilities).toContain('context-init-scripts');
expect(status.capabilities).not.toContain('chromium-launch-args');
await cdpBrowser.close();
spy.mockRestore();
});
it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222, stealth: false });
expect(addInitScript).not.toHaveBeenCalled();
const status = cdpBrowser.getStealthStatus();
expect(status.enabled).toBe(false);
expect(status.connectionKind).toBe('cdp');
expect(status.capabilities).toEqual([]);
await cdpBrowser.close();
spy.mockRestore();
});
});
describe('stale session recovery (all pages closed)', () => {
+137 -8
View File
@@ -27,6 +27,7 @@ import {
decryptData,
ENCRYPTION_KEY_ENV,
} from './state-utils.js';
import { STEALTH_CHROMIUM_ARGS, applyStealthScripts } from './stealth.js';
/**
* Returns the default Playwright timeout in milliseconds for standard operations.
@@ -89,6 +90,30 @@ interface PageError {
timestamp: number;
}
type BrowserType = NonNullable<LaunchCommand['browser']>;
type StealthConnectionKind =
| 'local'
| 'cdp'
| 'provider-browserbase'
| 'provider-browseruse'
| 'provider-kernel';
interface StealthPolicy {
enabled: boolean;
connectionKind: StealthConnectionKind;
applyChromiumArgs: boolean;
applyInitScripts: boolean;
providerManaged: boolean;
capabilities: string[];
}
export interface StealthStatus {
enabled: boolean;
connectionKind: StealthConnectionKind;
capabilities: string[];
providerManaged: boolean;
}
/**
* Manages the Playwright browser lifecycle with multiple tabs/windows
*/
@@ -116,6 +141,8 @@ export class BrowserManager {
private lastSnapshot: string = '';
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
private stealthEnabled: boolean = false;
private stealthConnectionKind: StealthConnectionKind = 'local';
/**
* Set the persistent color scheme preference.
@@ -125,6 +152,76 @@ export class BrowserManager {
this.colorScheme = scheme;
}
/**
* Centralized stealth policy so launch mode semantics stay consistent.
* Local Chromium gets args + init scripts; CDP/providers get init scripts only.
*/
private getStealthPolicy(browserType: BrowserType = 'chromium'): StealthPolicy {
if (!this.stealthEnabled) {
return {
enabled: false,
connectionKind: this.stealthConnectionKind,
applyChromiumArgs: false,
applyInitScripts: false,
providerManaged: false,
capabilities: [],
};
}
const applyChromiumArgs = this.stealthConnectionKind === 'local' && browserType === 'chromium';
const applyInitScripts = true;
const providerManaged = this.stealthConnectionKind === 'provider-kernel';
const capabilities: string[] = [];
if (applyChromiumArgs) {
capabilities.push('chromium-launch-args');
}
if (applyInitScripts) {
capabilities.push('context-init-scripts');
}
if (providerManaged) {
capabilities.push('provider-managed-stealth');
}
return {
enabled: true,
connectionKind: this.stealthConnectionKind,
applyChromiumArgs,
applyInitScripts,
providerManaged,
capabilities,
};
}
private logStealthPolicy(phase: string, browserType: BrowserType = 'chromium'): void {
if (process.env.AGENT_BROWSER_DEBUG !== '1') return;
const policy = this.getStealthPolicy(browserType);
const capabilities = policy.capabilities.length > 0 ? policy.capabilities.join(', ') : 'none';
console.error(
`[DEBUG] Stealth ${phase}: enabled=${policy.enabled} connection=${policy.connectionKind} capabilities=${capabilities}`
);
}
getStealthStatus(browserType: BrowserType = 'chromium'): StealthStatus {
const policy = this.getStealthPolicy(browserType);
return {
enabled: policy.enabled,
connectionKind: policy.connectionKind,
capabilities: policy.capabilities,
providerManaged: policy.providerManaged,
};
}
/**
* Apply context init-script stealth patches when policy allows.
*/
private async applyStealthIfEnabled(context: BrowserContext): Promise<void> {
const policy = this.getStealthPolicy();
if (!policy.applyInitScripts) return;
await applyStealthScripts(context);
this.logStealthPolicy('init-script applied');
}
// CDP session for screencast and input injection
private cdpSession: CDPSession | null = null;
private screencastActive: boolean = false;
@@ -282,6 +379,7 @@ export class BrowserManager {
context = await this.browser.newContext({
...(this.colorScheme && { colorScheme: this.colorScheme }),
});
await this.applyStealthIfEnabled(context);
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
@@ -852,6 +950,7 @@ export class BrowserManager {
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
*/
private async connectToBrowserbase(): Promise<void> {
this.stealthConnectionKind = 'provider-browserbase';
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
@@ -889,6 +988,7 @@ export class BrowserManager {
}
const context = contexts[0];
await this.applyStealthIfEnabled(context);
const pages = context.pages();
const page = pages[0] ?? (await context.newPage());
@@ -959,6 +1059,7 @@ export class BrowserManager {
* Requires KERNEL_API_KEY environment variable.
*/
private async connectToKernel(): Promise<void> {
this.stealthConnectionKind = 'provider-kernel';
const kernelApiKey = process.env.KERNEL_API_KEY;
if (!kernelApiKey) {
throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
@@ -1026,9 +1127,11 @@ export class BrowserManager {
// Kernel browsers launch with a default context and page
if (contexts.length === 0) {
context = await browser.newContext();
await this.applyStealthIfEnabled(context);
page = await context.newPage();
} else {
context = contexts[0];
await this.applyStealthIfEnabled(context);
const pages = context.pages();
page = pages[0] ?? (await context.newPage());
}
@@ -1055,6 +1158,7 @@ export class BrowserManager {
* Requires BROWSER_USE_API_KEY environment variable.
*/
private async connectToBrowserUse(): Promise<void> {
this.stealthConnectionKind = 'provider-browseruse';
const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
if (!browserUseApiKey) {
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
@@ -1099,9 +1203,11 @@ export class BrowserManager {
if (contexts.length === 0) {
context = await browser.newContext();
await this.applyStealthIfEnabled(context);
page = await context.newPage();
} else {
context = contexts[0];
await this.applyStealthIfEnabled(context);
const pages = context.pages();
page = pages[0] ?? (await context.newPage());
}
@@ -1172,6 +1278,22 @@ export class BrowserManager {
if (options.colorScheme) {
this.colorScheme = options.colorScheme;
}
this.stealthEnabled = options.stealth ?? false;
// -p flag takes precedence over AGENT_BROWSER_PROVIDER.
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
if (cdpEndpoint || options.autoConnect) {
this.stealthConnectionKind = 'cdp';
} else if (provider === 'browserbase') {
this.stealthConnectionKind = 'provider-browserbase';
} else if (provider === 'browseruse') {
this.stealthConnectionKind = 'provider-browseruse';
} else if (provider === 'kernel') {
this.stealthConnectionKind = 'provider-kernel';
} else {
this.stealthConnectionKind = 'local';
}
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
if (cdpEndpoint) {
await this.connectViaCDP(cdpEndpoint);
@@ -1184,8 +1306,6 @@ export class BrowserManager {
}
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
// -p flag takes precedence over env var
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
if (provider === 'browserbase') {
await this.connectToBrowserbase();
return;
@@ -1214,16 +1334,18 @@ export class BrowserManager {
const launcher =
browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
// Build base args array with file access flags if enabled
// --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
// --allow-file-access: allows the browser to access local files in general
const stealthPolicy = this.getStealthPolicy(browserType);
// Build base args array with file access flags and stealth args when policy allows.
const fileAccessArgs = options.allowFileAccess
? ['--allow-file-access-from-files', '--allow-file-access']
: [];
const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : [];
const implicitArgs = [...fileAccessArgs, ...stealthArgs];
const baseArgs = options.args
? [...fileAccessArgs, ...options.args]
: fileAccessArgs.length > 0
? fileAccessArgs
? [...implicitArgs, ...options.args]
: implicitArgs.length > 0
? implicitArgs
: undefined;
// Auto-detect args that control window size and disable viewport emulation
@@ -1364,6 +1486,8 @@ export class BrowserManager {
});
}
await this.applyStealthIfEnabled(context);
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
@@ -1385,6 +1509,7 @@ export class BrowserManager {
cdpEndpoint: string | undefined,
options?: { timeout?: number }
): Promise<void> {
this.stealthConnectionKind = 'cdp';
if (!cdpEndpoint) {
throw new Error('CDP endpoint is required for CDP connection');
}
@@ -1439,6 +1564,7 @@ export class BrowserManager {
this.cdpEndpoint = cdpEndpoint;
for (const context of contexts) {
await this.applyStealthIfEnabled(context);
context.setDefaultTimeout(10000);
this.contexts.push(context);
this.setupContextTracking(context);
@@ -1696,6 +1822,7 @@ export class BrowserManager {
viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
...(this.colorScheme && { colorScheme: this.colorScheme }),
});
await this.applyStealthIfEnabled(context);
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
@@ -2435,6 +2562,8 @@ export class BrowserManager {
this.isPersistentContext = false;
this.activePageIndex = 0;
this.colorScheme = null;
this.stealthEnabled = false;
this.stealthConnectionKind = 'local';
this.refMap = {};
this.lastSnapshot = '';
this.frameCallback = null;
+13
View File
@@ -5,6 +5,19 @@ import { parseCommand } from './protocol.js';
const cmd = (obj: object) => JSON.stringify(obj);
describe('parseCommand', () => {
describe('launch', () => {
it('should parse launch command with stealth flag', () => {
const result = parseCommand(
cmd({ id: '1', action: 'launch', headless: false, stealth: true })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe('launch');
expect(result.command.stealth).toBe(true);
}
});
});
describe('navigation', () => {
it('should parse navigate command', () => {
const result = parseCommand(cmd({ id: '1', action: 'navigate', url: 'https://example.com' }));
+3
View File
@@ -49,6 +49,8 @@ const launchSchema = baseCommandSchema.extend({
provider: z.string().optional(),
ignoreHTTPSErrors: z.boolean().optional(),
allowFileAccess: z.boolean().optional(),
// Stealth toggle is part of launch semantics for local/CDP/provider modes.
stealth: z.boolean().optional(),
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
profile: z.string().optional(),
storageState: z.string().optional(),
@@ -809,6 +811,7 @@ const waitSchema = baseCommandSchema.extend({
action: z.literal('wait'),
selector: z.string().min(1).optional(),
timeout: z.number().positive().optional(),
timeoutMax: z.number().positive().optional(),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
});
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest';
import { BrowserManager } from './browser.js';
async function readWebdriverSignals(browser: BrowserManager): Promise<{
value: boolean | undefined;
inNavigator: boolean;
ownNavigator: boolean;
ownPrototype: boolean;
}> {
const page = browser.getPage();
await page.goto('about:blank');
return page.evaluate(() => {
const prototype = Object.getPrototypeOf(navigator);
return {
value: navigator.webdriver,
inNavigator: 'webdriver' in navigator,
ownNavigator: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'),
ownPrototype: Object.prototype.hasOwnProperty.call(prototype, 'webdriver'),
};
});
}
describe('Stealth mode', () => {
let browser: BrowserManager;
afterEach(async () => {
if (browser?.isLaunched()) {
await browser.close();
}
});
it('removes navigator.webdriver when stealth is enabled', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
const signals = await readWebdriverSignals(browser);
expect(signals.value).toBeUndefined();
expect(signals.inNavigator).toBe(false);
expect(signals.ownNavigator).toBe(false);
expect(signals.ownPrototype).toBe(false);
});
it('applies stealth patches to contexts created by newWindow', async () => {
browser = new BrowserManager();
await browser.launch({ headless: true, stealth: true });
await browser.newWindow();
const signals = await readWebdriverSignals(browser);
expect(signals.value).toBeUndefined();
expect(signals.inNavigator).toBe(false);
expect(signals.ownNavigator).toBe(false);
expect(signals.ownPrototype).toBe(false);
});
});
+276
View File
@@ -0,0 +1,276 @@
/**
* Stealth mode patches to prevent browser automation detection.
*
* These scripts run via addInitScript (before any page JS) and patch the
* fingerprinting surfaces that anti-bot systems use to identify Playwright /
* Puppeteer / headless Chrome.
*/
import type { BrowserContext } from 'playwright-core';
/**
* Chromium args that reduce automation fingerprint.
* Intended to be merged into the user-supplied args array at launch time.
*/
export const STEALTH_CHROMIUM_ARGS: string[] = ['--disable-blink-features=AutomationControlled'];
/**
* Apply all stealth patches to a BrowserContext.
* Must be called BEFORE any page is created / navigated.
*/
export async function applyStealthScripts(context: BrowserContext): Promise<void> {
await context.addInitScript({ content: buildStealthScript() });
}
function buildStealthScript(): string {
// Each patch is an IIFE so variable scoping is clean
return [
patchNavigatorWebdriver(),
patchChromeRuntime(),
patchNavigatorPlugins(),
patchNavigatorPermissions(),
patchWebGLVendor(),
patchCdcProperties(),
patchIframeContentWindow(),
patchNavigatorHardwareConcurrency(),
patchMediaDevices(),
patchUserAgent(),
patchPerformanceMemory(),
].join('\n');
}
// ---------------------------------------------------------------------------
// Individual patches
// ---------------------------------------------------------------------------
/**
* Remove navigator.webdriver entirely.
* Modern detection checks both value and property presence (`'webdriver' in navigator`).
*/
function patchNavigatorWebdriver(): string {
return `(function(){
const removeWebdriver = (target) => {
if (!target) return;
try { delete target.webdriver; } catch {}
};
removeWebdriver(navigator);
removeWebdriver(Object.getPrototypeOf(navigator));
removeWebdriver(Navigator.prototype);
})();`;
}
/**
* Ensure window.chrome and window.chrome.runtime exist.
* Headless Chrome (and Playwright) omit chrome.runtime which is a dead giveaway.
*/
function patchChromeRuntime(): string {
return `(function(){
if (!window.chrome) { window.chrome = {}; }
if (!window.chrome.runtime) {
window.chrome.runtime = {
connect: function(){},
sendMessage: function(){},
};
}
})();`;
}
/**
* Inject a realistic navigator.plugins array.
* Headless Chrome reports an empty PluginArray; real Chrome always has a few.
*/
function patchNavigatorPlugins(): string {
return `(function(){
const makePlugin = (name, description, filename, mimeType) => {
const mime = { type: mimeType, suffixes: '', description, enabledPlugin: null };
const plugin = { name, description, filename, length: 1, 0: mime };
mime.enabledPlugin = plugin;
return plugin;
};
const plugins = [
makePlugin('Chrome PDF Plugin', 'Portable Document Format', 'internal-pdf-viewer', 'application/x-google-chrome-pdf'),
makePlugin('Chrome PDF Viewer', '', 'mhjfbmdgcfjbbpaeojofohoefgiehjai', 'application/pdf'),
makePlugin('Native Client', '', 'internal-nacl-plugin', 'application/x-nacl'),
];
const pluginArray = Object.create(PluginArray.prototype);
plugins.forEach((p, i) => {
Object.setPrototypeOf(p, Plugin.prototype);
pluginArray[i] = p;
});
Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
pluginArray.item = (i) => plugins[i] || null;
pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
pluginArray.refresh = () => {};
pluginArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
Object.defineProperty(navigator, 'plugins', {
get: () => pluginArray,
configurable: true,
});
})();`;
}
/**
* navigator.permissions.query({name:'notifications'}) should resolve to
* 'denied' in a normal browser, but Playwright throws or returns 'prompt'.
*/
function patchNavigatorPermissions(): string {
return `(function(){
if (!navigator.permissions) return;
const origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = (params) => {
if (params.name === 'notifications') {
return Promise.resolve({ state: Notification.permission, onchange: null });
}
return origQuery(params);
};
})();`;
}
/**
* WebGL vendor/renderer: headless Chrome uses SwiftShader which is distinctive.
* Patch getParameter to return Intel GPU strings when SwiftShader is detected.
*/
function patchWebGLVendor(): string {
return `(function(){
const getCtx = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
const ctx = getCtx.call(this, type, attrs);
if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) {
const origGetParameter = ctx.getParameter.bind(ctx);
ctx.getParameter = function(param) {
const ext = ctx.getExtension('WEBGL_debug_renderer_info');
if (ext) {
if (param === ext.UNMASKED_VENDOR_WEBGL) {
const real = origGetParameter(param);
return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real;
}
if (param === ext.UNMASKED_RENDERER_WEBGL) {
const real = origGetParameter(param);
return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real;
}
}
return origGetParameter(param);
};
}
return ctx;
};
})();`;
}
/**
* Remove Playwright's injected cdc_ (Chrome DevTools) properties on document.
* Some older detection scripts look for these on the document element.
*/
function patchCdcProperties(): string {
return `(function(){
const clean = (target) => {
for (const key of Object.keys(target)) {
if (/^cdc_|^\\$cdc_/.test(key)) {
delete target[key];
}
}
};
clean(document);
if (document.documentElement) clean(document.documentElement);
})();`;
}
/**
* contentWindow on cross-origin iframes: Playwright sometimes returns null
* where real browsers return a (restricted) Window object.
*/
function patchIframeContentWindow(): string {
return `(function(){
const orig = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
if (orig && orig.get) {
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get: function() {
const w = orig.get.call(this);
if (w === null) {
return window;
}
return w;
},
configurable: true,
});
}
})();`;
}
/**
* navigator.hardwareConcurrency: headless often reports 2 (CI);
* real desktops typically have >= 4 cores.
*/
function patchNavigatorHardwareConcurrency(): string {
return `(function(){
if (navigator.hardwareConcurrency < 4) {
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => 4,
configurable: true,
});
}
})();`;
}
/**
* navigator.mediaDevices.enumerateDevices should return at least some devices
* instead of an empty array (headless default).
*/
function patchMediaDevices(): string {
return `(function(){
if (!navigator.mediaDevices) return;
const orig = navigator.mediaDevices.enumerateDevices;
if (!orig) return;
navigator.mediaDevices.enumerateDevices = async function() {
const devices = await orig.call(navigator.mediaDevices);
if (devices.length === 0) {
return [
{ deviceId: 'default', kind: 'audioinput', label: '', groupId: 'default' },
{ deviceId: 'default', kind: 'videoinput', label: '', groupId: 'default' },
{ deviceId: 'default', kind: 'audiooutput', label: '', groupId: 'default' },
];
}
return devices;
};
})();`;
}
/**
* Replace "HeadlessChrome" with "Chrome" in navigator.userAgent so
* UA-based detection is bypassed at the JavaScript level.
*/
function patchUserAgent(): string {
return `(function(){
const ua = navigator.userAgent;
if (ua.includes('HeadlessChrome')) {
const patched = ua.replace(/HeadlessChrome/g, 'Chrome');
Object.defineProperty(navigator, 'userAgent', {
get: () => patched,
configurable: true,
});
Object.defineProperty(navigator, 'appVersion', {
get: () => patched.replace('Mozilla/', ''),
configurable: true,
});
}
})();`;
}
/**
* Provide a fake performance.memory (Chrome-only, non-standard).
* Headless Chrome omits this; some detectors check for its presence.
*/
function patchPerformanceMemory(): string {
return `(function(){
if (!performance.memory) {
Object.defineProperty(performance, 'memory', {
get: () => ({
jsHeapSizeLimit: 2172649472,
totalJSHeapSize: 35839739,
usedJSHeapSize: 22592767,
}),
configurable: true,
});
}
})();`;
}