From a32d7c4eaec2270e87b742e209fc5c6af9954aaf Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Mon, 6 Apr 2026 03:11:12 +0200 Subject: [PATCH] feat: add page.stealth_evaluate() for undetectable JS execution (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract CDP isolated world classes from humanize layer into standalone stealth_eval module. Attach page.stealth_evaluate(expression) to every page automatically — runs JS in a CDP isolated world with clean Error.stack traces and full variable isolation from main world JS. --- README.md | 27 ++++ cloakbrowser/browser.py | 20 +++ cloakbrowser/human/__init__.py | 154 ++----------------- cloakbrowser/stealth_eval.py | 271 +++++++++++++++++++++++++++++++++ js/README.md | 18 +++ js/src/human/index.ts | 110 ++----------- js/src/playwright.ts | 12 ++ js/src/stealth-eval.ts | 161 ++++++++++++++++++++ js/src/types.ts | 7 + js/tests/launch.test.ts | 97 +++++++++++- js/tests/stealth.test.ts | 24 +-- 11 files changed, 639 insertions(+), 262 deletions(-) create mode 100644 cloakbrowser/stealth_eval.py create mode 100644 js/src/stealth-eval.ts diff --git a/README.md b/README.md index 131a4a4..99d1630 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,33 @@ Access the original un-patched Playwright page at `page._original` if you need r > Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage. +## Stealth Evaluate + +`page.stealth_evaluate(expression)` runs JavaScript in a CDP isolated world instead of Playwright's main-world `evaluate()`. This produces clean `Error.stack` traces and full variable isolation from page JS — useful when a site's anti-bot scripts inspect execution context. + +```python +browser = launch() +page = browser.new_page() +page.goto("https://example.com") + +# Stealth — clean stack trace, invisible to page JS +title = page.stealth_evaluate("document.title") +rect = page.stealth_evaluate("document.querySelector('#btn').getBoundingClientRect().toJSON()") + +# Regular evaluate — unchanged, use for DOM writes +page.evaluate("document.body.style.display = 'none'") +``` + +```javascript +const browser = await launch(); +const page = await browser.newPage(); +await page.goto('https://example.com'); + +const title = await page.stealthEvaluate('document.title'); +``` + +Always available on every page — no flag needed. Returns JSON-serializable values only. The isolated world context auto-recreates after navigation. + ## Configuration | Env Variable | Default | Description | diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 1c82a7a..2ad9a4c 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -138,6 +138,10 @@ def launch( cfg = resolve_config(human_preset, human_config) patch_browser(browser, cfg) + # Stealth evaluate — always attached + from .stealth_eval import patch_browser_stealth_eval + patch_browser_stealth_eval(browser, is_async=False) + return browser @@ -227,6 +231,10 @@ async def launch_async( # noqa: C901 cfg = resolve_config(human_preset, human_config) patch_browser_async(browser, cfg) + # Stealth evaluate — always attached + from .stealth_eval import patch_browser_stealth_eval + patch_browser_stealth_eval(browser, is_async=True) + return browser @@ -344,6 +352,10 @@ def launch_persistent_context( cfg = resolve_config(human_preset, human_config) patch_context(context, cfg) + # Stealth evaluate — always attached + from .stealth_eval import patch_context_stealth_eval + patch_context_stealth_eval(context, is_async=False) + return context @@ -463,6 +475,10 @@ async def launch_persistent_context_async( cfg = resolve_config(human_preset, human_config) patch_context_async(context, cfg) + # Stealth evaluate — always attached + from .stealth_eval import patch_context_stealth_eval + patch_context_stealth_eval(context, is_async=True) + return context @@ -556,6 +572,10 @@ def launch_context( cfg = resolve_config(human_preset, human_config) patch_context(context, cfg) + # Stealth evaluate — always attached + from .stealth_eval import patch_context_stealth_eval + patch_context_stealth_eval(context, is_async=False) + return context diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py index 4c0ef63..90223b4 100644 --- a/cloakbrowser/human/__init__.py +++ b/cloakbrowser/human/__init__.py @@ -26,6 +26,7 @@ from .scroll import scroll_to_element from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle from .keyboard_async import AsyncRawKeyboard, async_human_type from .scroll_async import async_scroll_to_element +from ..stealth_eval import _SyncIsolatedWorld, _AsyncIsolatedWorld _SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a" @@ -44,142 +45,9 @@ logger = logging.getLogger("cloakbrowser.human") # CDP Isolated World — stealth DOM evaluation # ============================================================================ -class _SyncIsolatedWorld: - """Manages a CDP isolated execution context for DOM reads (sync). - Produces clean Error.stack traces (no 'eval at evaluate :302:') - and is invisible to querySelector monkey-patches in the main world. - Context ID is invalidated on navigation and auto-recreated on next call. - """ - - __slots__ = ("_page", "_cdp", "_context_id") - - def __init__(self, page: Any): - self._page = page - self._cdp: Any = None - self._context_id: Optional[int] = None - - def _ensure_cdp(self) -> Any: - if self._cdp is None: - self._cdp = self._page.context.new_cdp_session(self._page) - return self._cdp - - def _create_world(self) -> int: - cdp = self._ensure_cdp() - tree = cdp.send("Page.getFrameTree") - frame_id = tree["frameTree"]["frame"]["id"] - result = cdp.send("Page.createIsolatedWorld", { - "frameId": frame_id, - "worldName": "", - "grantUniveralAccess": True, - }) - self._context_id = result["executionContextId"] - return self._context_id - - def evaluate(self, expression: str) -> Any: - """Evaluate JS in isolated world. Auto-recreates on stale context.""" - if self._context_id is None: - self._create_world() - - for attempt in range(2): - try: - result = self._cdp.send("Runtime.evaluate", { - "expression": expression, - "contextId": self._context_id, - "returnByValue": True, - }) - if "exceptionDetails" in result: - if attempt == 0: - self._create_world() - continue - return None - return result.get("result", {}).get("value") - except Exception: - if attempt == 0: - self._context_id = None - try: - self._create_world() - except Exception: - return None - continue - return None - return None - - def invalidate(self) -> None: - """Mark context as stale — call after navigation.""" - self._context_id = None - - def get_cdp_session(self) -> Any: - """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" - return self._ensure_cdp() - - -class _AsyncIsolatedWorld: - """Manages a CDP isolated execution context for DOM reads (async). - - Same as _SyncIsolatedWorld but uses await for all CDP calls. - """ - - __slots__ = ("_page", "_cdp", "_context_id") - - def __init__(self, page: Any): - self._page = page - self._cdp: Any = None - self._context_id: Optional[int] = None - - async def _ensure_cdp(self) -> Any: - if self._cdp is None: - self._cdp = await self._page.context.new_cdp_session(self._page) - return self._cdp - - async def _create_world(self) -> int: - cdp = await self._ensure_cdp() - tree = await cdp.send("Page.getFrameTree") - frame_id = tree["frameTree"]["frame"]["id"] - result = await cdp.send("Page.createIsolatedWorld", { - "frameId": frame_id, - "worldName": "", - "grantUniveralAccess": True, - }) - self._context_id = result["executionContextId"] - return self._context_id - - async def evaluate(self, expression: str) -> Any: - """Evaluate JS in isolated world. Auto-recreates on stale context.""" - if self._context_id is None: - await self._create_world() - - for attempt in range(2): - try: - result = await self._cdp.send("Runtime.evaluate", { - "expression": expression, - "contextId": self._context_id, - "returnByValue": True, - }) - if "exceptionDetails" in result: - if attempt == 0: - await self._create_world() - continue - return None - return result.get("result", {}).get("value") - except Exception: - if attempt == 0: - self._context_id = None - try: - await self._create_world() - except Exception: - return None - continue - return None - return None - - def invalidate(self) -> None: - """Mark context as stale — call after navigation.""" - self._context_id = None - - async def get_cdp_session(self) -> Any: - """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" - return await self._ensure_cdp() +# _SyncIsolatedWorld and _AsyncIsolatedWorld are defined in +# cloakbrowser.stealth_eval and imported at the top of this file. # ============================================================================ @@ -739,10 +607,12 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: page._original = originals page._human_cfg = cfg - # --- Stealth infrastructure --- + # --- Stealth infrastructure (reuse if already attached by stealth_eval) --- try: - stealth = _SyncIsolatedWorld(page) - page._stealth_world = stealth + stealth = getattr(page, '_stealth_world', None) + if not isinstance(stealth, _SyncIsolatedWorld): + stealth = _SyncIsolatedWorld(page) + page._stealth_world = stealth cdp_session = stealth.get_cdp_session() except Exception: stealth = None @@ -1104,9 +974,11 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: page._original = originals page._human_cfg = cfg - # --- Stealth infrastructure (lazy-initialized, async) --- - stealth = _AsyncIsolatedWorld(page) - page._stealth_world = stealth + # --- Stealth infrastructure (reuse if already attached by stealth_eval) --- + stealth = getattr(page, '_stealth_world', None) + if not isinstance(stealth, _AsyncIsolatedWorld): + stealth = _AsyncIsolatedWorld(page) + page._stealth_world = stealth cdp_session_holder: list[Any] = [None] # mutable container for closure async def _ensure_cdp() -> Any: diff --git a/cloakbrowser/stealth_eval.py b/cloakbrowser/stealth_eval.py new file mode 100644 index 0000000..b8f572a --- /dev/null +++ b/cloakbrowser/stealth_eval.py @@ -0,0 +1,271 @@ +"""Stealth evaluate — run JS in a CDP isolated world. + +Provides page.stealth_evaluate(expression) on every page returned by +cloakbrowser launch functions. Produces clean Error.stack traces (no +``eval at evaluate :302:`` leak) and full variable isolation from main +world JS. Context auto-recreates after navigation. + +The same isolated world instances are reused by the humanize layer +(human/__init__.py) for stealth DOM queries. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +logger = logging.getLogger("cloakbrowser.stealth_eval") + + +# ============================================================================ +# Isolated world classes +# ============================================================================ + +class _SyncIsolatedWorld: + """CDP isolated execution context for DOM reads (sync). + + Produces clean Error.stack traces and is invisible to + querySelector monkey-patches in the main world. + Context ID is invalidated on navigation and auto-recreated. + """ + + __slots__ = ("_page", "_cdp", "_context_id") + + def __init__(self, page: Any): + self._page = page + self._cdp: Any = None + self._context_id: Optional[int] = None + + def _ensure_cdp(self) -> Any: + if self._cdp is None: + self._cdp = self._page.context.new_cdp_session(self._page) + return self._cdp + + def _create_world(self) -> int: + cdp = self._ensure_cdp() + tree = cdp.send("Page.getFrameTree") + frame_id = tree["frameTree"]["frame"]["id"] + result = cdp.send("Page.createIsolatedWorld", { + "frameId": frame_id, + "worldName": "", + "grantUniveralAccess": True, + }) + self._context_id = result["executionContextId"] + return self._context_id + + def evaluate(self, expression: str) -> Any: + """Evaluate JS in isolated world. Auto-recreates on stale context.""" + if self._context_id is None: + try: + self._create_world() + except Exception: + logger.debug("stealth_evaluate: failed to create isolated world") + return None + + for attempt in range(2): + try: + result = self._cdp.send("Runtime.evaluate", { + "expression": expression, + "contextId": self._context_id, + "returnByValue": True, + }) + if "exceptionDetails" in result: + if attempt == 0: + self._create_world() + continue + logger.debug("stealth_evaluate: JS exception: %s", + result["exceptionDetails"].get("text", "unknown")) + return None + return result.get("result", {}).get("value") + except Exception: + if attempt == 0: + self._context_id = None + try: + self._create_world() + except Exception: + logger.debug("stealth_evaluate: failed to recreate isolated world") + return None + continue + logger.debug("stealth_evaluate: CDP evaluate failed after retry") + return None + return None + + def invalidate(self) -> None: + """Mark context as stale — call after navigation.""" + self._context_id = None + + def get_cdp_session(self) -> Any: + """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" + return self._ensure_cdp() + + +class _AsyncIsolatedWorld: + """CDP isolated execution context for DOM reads (async). + + Same as _SyncIsolatedWorld but uses await for all CDP calls. + """ + + __slots__ = ("_page", "_cdp", "_context_id") + + def __init__(self, page: Any): + self._page = page + self._cdp: Any = None + self._context_id: Optional[int] = None + + async def _ensure_cdp(self) -> Any: + if self._cdp is None: + self._cdp = await self._page.context.new_cdp_session(self._page) + return self._cdp + + async def _create_world(self) -> int: + cdp = await self._ensure_cdp() + tree = await cdp.send("Page.getFrameTree") + frame_id = tree["frameTree"]["frame"]["id"] + result = await cdp.send("Page.createIsolatedWorld", { + "frameId": frame_id, + "worldName": "", + "grantUniveralAccess": True, + }) + self._context_id = result["executionContextId"] + return self._context_id + + async def evaluate(self, expression: str) -> Any: + """Evaluate JS in isolated world. Auto-recreates on stale context.""" + if self._context_id is None: + try: + await self._create_world() + except Exception: + logger.debug("stealth_evaluate: failed to create isolated world") + return None + + for attempt in range(2): + try: + result = await self._cdp.send("Runtime.evaluate", { + "expression": expression, + "contextId": self._context_id, + "returnByValue": True, + }) + if "exceptionDetails" in result: + if attempt == 0: + await self._create_world() + continue + logger.debug("stealth_evaluate: JS exception: %s", + result["exceptionDetails"].get("text", "unknown")) + return None + return result.get("result", {}).get("value") + except Exception: + if attempt == 0: + self._context_id = None + try: + await self._create_world() + except Exception: + logger.debug("stealth_evaluate: failed to recreate isolated world") + return None + continue + logger.debug("stealth_evaluate: CDP evaluate failed after retry") + return None + return None + + def invalidate(self) -> None: + """Mark context as stale — call after navigation.""" + self._context_id = None + + async def get_cdp_session(self) -> Any: + """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" + return await self._ensure_cdp() + + +# ============================================================================ +# Page / context / browser patching +# ============================================================================ + +def _patch_page_sync(page: Any) -> None: + """Attach page.stealth_evaluate() using a sync isolated world.""" + if hasattr(page, "stealth_evaluate"): + return + existing = getattr(page, "_stealth_world", None) + if isinstance(existing, _SyncIsolatedWorld): + world = existing + else: + world = _SyncIsolatedWorld(page) + page._stealth_world = world + page.stealth_evaluate = world.evaluate + + +def _patch_page_async(page: Any) -> None: + """Attach page.stealth_evaluate() using an async isolated world.""" + if hasattr(page, "stealth_evaluate"): + return + existing = getattr(page, "_stealth_world", None) + if isinstance(existing, _AsyncIsolatedWorld): + world = existing + else: + world = _AsyncIsolatedWorld(page) + page._stealth_world = world + page.stealth_evaluate = world.evaluate + + +def patch_context_stealth_eval(context: Any, *, is_async: bool = False) -> None: + """Patch existing pages + hook new_page() for stealth_evaluate.""" + if getattr(context, "_stealth_eval_patched", False): + return + context._stealth_eval_patched = True + patch_fn = _patch_page_async if is_async else _patch_page_sync + + for p in context.pages: + patch_fn(p) + + orig_new_page = context.new_page + + if is_async: + async def _patched_new_page(*args: Any, **kwargs: Any) -> Any: + page = await orig_new_page(*args, **kwargs) + patch_fn(page) + return page + else: + def _patched_new_page(*args: Any, **kwargs: Any) -> Any: + page = orig_new_page(*args, **kwargs) + patch_fn(page) + return page + + context.new_page = _patched_new_page + context.on("page", lambda p: patch_fn(p)) + + +def patch_browser_stealth_eval(browser: Any, *, is_async: bool = False) -> None: + """Patch browser factory methods for stealth_evaluate.""" + patch_fn = _patch_page_async if is_async else _patch_page_sync + + # Hook new_context() + orig_new_context = browser.new_context + + if is_async: + async def _patched_new_context(*args: Any, **kwargs: Any) -> Any: + ctx = await orig_new_context(*args, **kwargs) + patch_context_stealth_eval(ctx, is_async=True) + return ctx + else: + def _patched_new_context(*args: Any, **kwargs: Any) -> Any: + ctx = orig_new_context(*args, **kwargs) + patch_context_stealth_eval(ctx, is_async=False) + return ctx + + browser.new_context = _patched_new_context + + # Hook new_page() + orig_new_page = browser.new_page + + if is_async: + async def _patched_new_page(*args: Any, **kwargs: Any) -> Any: + page = await orig_new_page(*args, **kwargs) + patch_context_stealth_eval(page.context, is_async=True) + patch_fn(page) + return page + else: + def _patched_new_page(*args: Any, **kwargs: Any) -> Any: + page = orig_new_page(*args, **kwargs) + patch_context_stealth_eval(page.context, is_async=False) + patch_fn(page) + return page + + browser.new_page = _patched_new_page diff --git a/js/README.md b/js/README.md index b410f98..b5e12ae 100644 --- a/js/README.md +++ b/js/README.md @@ -177,6 +177,24 @@ if (newVersion) console.log(`Updated to ${newVersion}`); | TLS fingerprint | Mismatch | **Identical to Chrome** | | | | **Tested against 30+ detection sites** | +## Stealth Evaluate + +`page.stealthEvaluate(expression)` runs JavaScript in a CDP isolated world instead of Playwright's main-world `evaluate()`. This produces clean `Error.stack` traces and full variable isolation from page JS. + +```typescript +const browser = await launch(); +const page = await browser.newPage(); +await page.goto('https://example.com'); + +// Stealth — clean stack trace, invisible to page JS +const title = await page.stealthEvaluate('document.title'); + +// Regular evaluate — unchanged, use for DOM writes +await page.evaluate(() => { document.body.style.display = 'none'; }); +``` + +Always available on every page — no flag needed. Returns JSON-serializable values only. The isolated world context auto-recreates after navigation. + ## Configuration | Env Variable | Default | Description | diff --git a/js/src/human/index.ts b/js/src/human/index.ts index a01728f..2fda42f 100644 --- a/js/src/human/index.ts +++ b/js/src/human/index.ts @@ -19,6 +19,7 @@ import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js' import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; import { humanType } from './keyboard.js'; import { scrollToElement } from './scroll.js'; +import { StealthEval } from '../stealth-eval.js'; export { HumanConfig, resolveConfig } from './config.js'; export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; @@ -29,102 +30,7 @@ export { scrollToElement } from './scroll.js'; const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; -// ============================================================================ -// CDP Isolated World — stealth DOM evaluation -// ============================================================================ - -/** - * Manages a CDP isolated execution context for DOM reads. - * Produces clean Error.stack traces (no 'eval at evaluate :302:') - * and is invisible to querySelector monkey-patches in the main world. - * - * Context ID is invalidated on navigation and auto-recreated on next call. - */ -class StealthEval { - private cdp: CDPSession | null = null; - private contextId: number | null = null; - private page: Page; - - constructor(page: Page) { - this.page = page; - } - - private async ensureCdp(): Promise { - if (!this.cdp) { - this.cdp = await this.page.context().newCDPSession(this.page); - } - return this.cdp; - } - - private async createWorld(): Promise { - const cdp = await this.ensureCdp(); - const tree = await cdp.send('Page.getFrameTree'); - const frameId = tree.frameTree.frame.id; - const result = await cdp.send('Page.createIsolatedWorld', { - frameId, - worldName: '', - grantUniveralAccess: true, - }); - const ctxId = result.executionContextId; - this.contextId = ctxId; - return ctxId; - } - - /** - * Evaluate a JS expression in the isolated world. - * Auto-recreates the world if the context was invalidated (navigation). - * Returns the result value, or undefined on failure. - */ - async evaluate(expression: string): Promise { - if (this.contextId === null) { - await this.createWorld(); - } - - for (let attempt = 0; attempt < 2; attempt++) { - try { - const cdp = await this.ensureCdp(); - const result = await cdp.send('Runtime.evaluate', { - expression, - contextId: this.contextId!, - returnByValue: true, - }); - - if (result.exceptionDetails) { - // Context was likely invalidated by navigation - if (attempt === 0) { - await this.createWorld(); - continue; - } - return undefined; - } - - return result.result?.value; - } catch { - if (attempt === 0) { - this.contextId = null; - try { - await this.createWorld(); - } catch { - return undefined; - } - continue; - } - return undefined; - } - } - return undefined; - } - - /** Mark context as stale — call after navigation. */ - invalidate(): void { - this.contextId = null; - } - - /** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */ - async getCdpSession(): Promise { - return this.ensureCdp(); - } -} +// StealthEval is defined in stealth-eval.ts and imported at the top of this file. // ============================================================================ @@ -163,7 +69,8 @@ async function isInputElement( || el.getAttribute('contenteditable') === 'true'; })() `); - return !!result; + if (result !== undefined && result !== null) return !!result; + // undefined/null = CDP failed, fall through to page.evaluate } catch { // Fall through to page.evaluate } @@ -197,7 +104,8 @@ async function isSelectorFocused( return el === document.activeElement; })() `); - return !!result; + if (result !== undefined && result !== null) return !!result; + // undefined/null = CDP failed, fall through to page.evaluate } catch { // Fall through to page.evaluate } @@ -246,9 +154,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { (page as any)._original = originals; (page as any)._humanCfg = cfg; - // --- Stealth infrastructure --- - const stealth = new StealthEval(page); - (page as any)._stealth = stealth; + // --- Stealth infrastructure (reuse if already attached by stealth-eval) --- + const stealth = (page as any)._stealthWorld ?? new StealthEval(page); + (page as any)._stealthWorld = stealth; // CDP session for shift symbol typing (lazy-initialized, reuses stealth's session) let cdpSession: CDPSession | null = null; diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 232810f..1aef168 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -67,6 +67,10 @@ export async function launch(options: LaunchOptions = {}): Promise { patchBrowser(browser, cfg); } + // Stealth evaluate — always attached + const { patchBrowser: patchStealthEval } = await import('./stealth-eval.js'); + patchStealthEval(browser); + return browser; } @@ -132,6 +136,10 @@ export async function launchContext( patchContext(context, cfg); } + // Stealth evaluate — always attached + const { patchContext: patchStealthEvalCtx } = await import('./stealth-eval.js'); + patchStealthEvalCtx(context); + return context; } @@ -197,6 +205,10 @@ export async function launchPersistentContext( patchContext(context, cfg); } + // Stealth evaluate — always attached + const { patchContext: patchStealthEvalCtx2 } = await import('./stealth-eval.js'); + patchStealthEvalCtx2(context); + return context; } diff --git a/js/src/stealth-eval.ts b/js/src/stealth-eval.ts new file mode 100644 index 0000000..83b07d4 --- /dev/null +++ b/js/src/stealth-eval.ts @@ -0,0 +1,161 @@ +/** + * Stealth evaluate — run JS in a CDP isolated world. + * + * Provides page.stealthEvaluate(expression) on every page returned by + * cloakbrowser launch functions. Produces clean Error.stack traces (no + * `eval at evaluate :302:` leak) and full variable isolation from main + * world JS. Context auto-recreates after navigation. + * + * The same StealthEval instances are reused by the humanize layer + * (human/index.ts) for stealth DOM queries. + */ + +import type { Browser, BrowserContext, Page, CDPSession } from 'playwright-core'; + + +// ============================================================================ +// Isolated world class +// ============================================================================ + +/** + * Manages a CDP isolated execution context for DOM reads. + * Produces clean Error.stack traces (no 'eval at evaluate :302:') + * and is invisible to querySelector monkey-patches in the main world. + * + * Context ID is invalidated on navigation and auto-recreated on next call. + */ +export class StealthEval { + private cdp: CDPSession | null = null; + private contextId: number | null = null; + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + private async ensureCdp(): Promise { + if (!this.cdp) { + this.cdp = await this.page.context().newCDPSession(this.page); + } + return this.cdp; + } + + private async createWorld(): Promise { + const cdp = await this.ensureCdp(); + const tree = await cdp.send('Page.getFrameTree'); + const frameId = tree.frameTree.frame.id; + const result = await cdp.send('Page.createIsolatedWorld', { + frameId, + worldName: '', + grantUniveralAccess: true, + }); + const ctxId = result.executionContextId; + this.contextId = ctxId; + return ctxId; + } + + /** + * Evaluate a JS expression in the isolated world. + * Auto-recreates the world if the context was invalidated (navigation). + * Returns the result value, or undefined on failure. + */ + async evaluate(expression: string): Promise { + if (this.contextId === null) { + try { + await this.createWorld(); + } catch { + return undefined; + } + } + + for (let attempt = 0; attempt < 2; attempt++) { + try { + const cdp = await this.ensureCdp(); + const result = await cdp.send('Runtime.evaluate', { + expression, + contextId: this.contextId!, + returnByValue: true, + }); + + if (result.exceptionDetails) { + if (attempt === 0) { + await this.createWorld(); + continue; + } + return undefined; + } + + return result.result?.value; + } catch { + if (attempt === 0) { + this.contextId = null; + try { + await this.createWorld(); + } catch { + return undefined; + } + continue; + } + return undefined; + } + } + return undefined; + } + + /** Mark context as stale — call after navigation. */ + invalidate(): void { + this.contextId = null; + } + + /** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */ + async getCdpSession(): Promise { + return this.ensureCdp(); + } +} + + +// ============================================================================ +// Page / context / browser patching +// ============================================================================ + +function patchPage(page: Page): void { + if ((page as any).stealthEvaluate) return; + const existing = (page as any)._stealthWorld; + const stealth = existing instanceof StealthEval ? existing : new StealthEval(page); + (page as any)._stealthWorld = stealth; + (page as any).stealthEvaluate = stealth.evaluate.bind(stealth); +} + +export function patchContext(context: BrowserContext): void { + if ((context as any)._stealthEvalPatched) return; + (context as any)._stealthEvalPatched = true; + for (const p of context.pages()) { + patchPage(p); + } + + const origNewPage = context.newPage.bind(context); + context.newPage = async (...args: Parameters) => { + const page = await origNewPage(...args); + patchPage(page); + return page; + }; + + context.on('page', (page: Page) => patchPage(page)); +} + +export function patchBrowser(browser: Browser): void { + const origNewContext = browser.newContext.bind(browser); + browser.newContext = async (...args: Parameters) => { + const ctx = await origNewContext(...args); + patchContext(ctx); + return ctx; + }; + + const origNewPage = browser.newPage.bind(browser); + browser.newPage = async (...args: Parameters) => { + const page = await origNewPage(...args); + patchContext(page.context()); + patchPage(page); + return page; + }; +} diff --git a/js/src/types.ts b/js/src/types.ts index 6ca60af..b64034d 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -2,6 +2,13 @@ * Shared types for cloakbrowser launch wrappers. */ +declare module 'playwright-core' { + interface Page { + /** Evaluate JS in a CDP isolated world — clean stack traces, invisible to main-world monkey-patches. */ + stealthEvaluate(expression: string): Promise; + } +} + export interface LaunchOptions { /** Run in headless mode (default: true). */ headless?: boolean; diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index d9f4d25..859f4a1 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -46,14 +46,17 @@ describe("launchContext (unit)", () => { let mockContext: any; let mockBrowser: any; let mockChromium: any; + let origNewContext: any; const origEnv = process.env.CLOAKBROWSER_BINARY_PATH; beforeEach(() => { process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome"; const origClose = vi.fn(); - mockContext = { close: origClose, _origClose: origClose }; + mockContext = { close: origClose, _origClose: origClose, newPage: vi.fn(), on: vi.fn(), pages: vi.fn().mockReturnValue([]) }; + origNewContext = vi.fn().mockResolvedValue(mockContext); mockBrowser = { - newContext: vi.fn().mockResolvedValue(mockContext), + newContext: origNewContext, + newPage: vi.fn(), close: vi.fn(), }; mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) }; @@ -75,7 +78,7 @@ describe("launchContext (unit)", () => { const { launchContext } = await import("../src/playwright.js"); await launchContext(); - const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; + const ctxArgs = origNewContext.mock.calls[0][0]; expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT); }); @@ -84,7 +87,7 @@ describe("launchContext (unit)", () => { const custom = { width: 1280, height: 720 }; await launchContext({ viewport: custom }); - const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; + const ctxArgs = origNewContext.mock.calls[0][0]; expect(ctxArgs.viewport).toEqual(custom); }); @@ -92,7 +95,7 @@ describe("launchContext (unit)", () => { const { launchContext } = await import("../src/playwright.js"); await launchContext({ userAgent: "Custom/1.0" }); - const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; + const ctxArgs = origNewContext.mock.calls[0][0]; expect(ctxArgs.userAgent).toBe("Custom/1.0"); }); @@ -108,7 +111,7 @@ describe("launchContext (unit)", () => { expect(hasTimezoneFlag).toBe(true); // NOT in newContext() — no CDP emulation - const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; + const ctxArgs = origNewContext.mock.calls[0][0]; expect(ctxArgs.timezoneId).toBeUndefined(); }); @@ -116,7 +119,7 @@ describe("launchContext (unit)", () => { const { launchContext } = await import("../src/playwright.js"); await launchContext({ colorScheme: "dark" }); - const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; + const ctxArgs = origNewContext.mock.calls[0][0]; expect(ctxArgs.colorScheme).toBe("dark"); }); @@ -132,6 +135,84 @@ describe("launchContext (unit)", () => { }); }); +// --------------------------------------------------------------------------- +// stealth_evaluate patching unit tests +// --------------------------------------------------------------------------- + +describe("stealthEvaluate patching (unit)", () => { + const origEnv = process.env.CLOAKBROWSER_BINARY_PATH; + let mockPage: any; + let mockContext: any; + let mockBrowser: any; + + beforeEach(() => { + process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome"; + + // Page mock with context() returning the implicit context + mockPage = { + context: vi.fn(), + }; + // Implicit context created by browser.newPage() + mockContext = { + pages: vi.fn().mockReturnValue([]), + newPage: vi.fn(), + on: vi.fn(), + }; + mockPage.context.mockReturnValue(mockContext); + + mockBrowser = { + newContext: vi.fn().mockResolvedValue(mockContext), + newPage: vi.fn().mockResolvedValue(mockPage), + close: vi.fn(), + }; + const mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) }; + vi.doMock("playwright-core", () => ({ chromium: mockChromium })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + if (origEnv) { + process.env.CLOAKBROWSER_BINARY_PATH = origEnv; + } else { + delete process.env.CLOAKBROWSER_BINARY_PATH; + } + }); + + it("page.stealthEvaluate exists after launch + browser.newPage", async () => { + const { launch } = await import("../src/playwright.js"); + const browser = await launch({ headless: true }); + const page = await browser.newPage(); + + expect(typeof (page as any).stealthEvaluate).toBe("function"); + }); + + it("implicit context from browser.newPage is patched for future pages", async () => { + const { launch } = await import("../src/playwright.js"); + const browser = await launch({ headless: true }); + await browser.newPage(); + + // The 'page' event listener should be registered on the implicit context + expect(mockContext.on).toHaveBeenCalledWith("page", expect.any(Function)); + // The context should be marked as patched + expect((mockContext as any)._stealthEvalPatched).toBe(true); + }); + + it("context from browser.newContext patches pages with stealthEvaluate", async () => { + const { launch } = await import("../src/playwright.js"); + const browser = await launch({ headless: true }); + + const mockPage2: any = { context: vi.fn().mockReturnValue(mockContext) }; + mockContext.newPage.mockResolvedValue(mockPage2); + mockContext.pages.mockReturnValue([]); + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + + expect(typeof (page as any).stealthEvaluate).toBe("function"); + }); +}); + describe("launchPersistentContext (unit)", () => { let mockContext: any; let mockChromium: any; @@ -139,7 +220,7 @@ describe("launchPersistentContext (unit)", () => { beforeEach(() => { process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome"; - mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) }; + mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]), newPage: vi.fn(), on: vi.fn() }; mockChromium = { launchPersistentContext: vi.fn().mockResolvedValue(mockContext), }; diff --git a/js/tests/stealth.test.ts b/js/tests/stealth.test.ts index 7b2c60e..24e3171 100644 --- a/js/tests/stealth.test.ts +++ b/js/tests/stealth.test.ts @@ -470,7 +470,7 @@ describe("humanType mixed text with CDP", () => { // patchPage stealth wiring // ========================================================================= describe("patchPage stealth infrastructure", () => { - it("page._stealth is a StealthEval instance after patching", async () => { + it("page._stealthWorld is a StealthEval instance after patching", async () => { const { patchPage } = await import("../src/human/index.js"); const page = buildMockPage(); @@ -478,10 +478,10 @@ describe("patchPage stealth infrastructure", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - expect((page as any)._stealth).toBeDefined(); - expect(typeof (page as any)._stealth.evaluate).toBe("function"); - expect(typeof (page as any)._stealth.invalidate).toBe("function"); - expect(typeof (page as any)._stealth.getCdpSession).toBe("function"); + expect((page as any)._stealthWorld).toBeDefined(); + expect(typeof (page as any)._stealthWorld.evaluate).toBe("function"); + expect(typeof (page as any)._stealthWorld.invalidate).toBe("function"); + expect(typeof (page as any)._stealthWorld.getCdpSession).toBe("function"); }); it("page._original and page._humanCfg are set", async () => { @@ -504,7 +504,7 @@ describe("patchPage stealth infrastructure", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; const invalidateSpy = vi.spyOn(stealth, "invalidate"); await page.goto("https://example.com"); @@ -540,7 +540,7 @@ describe("StealthEval lifecycle", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; expect(() => stealth.invalidate()).not.toThrow(); }); @@ -552,7 +552,7 @@ describe("StealthEval lifecycle", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; const session = await stealth.getCdpSession(); expect(session).toBeDefined(); expect(typeof session.send).toBe("function"); @@ -587,7 +587,7 @@ describe("StealthEval lifecycle", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; const result = await stealth.evaluate("1 + 1"); expect(result).toBe(true); }); @@ -626,7 +626,7 @@ describe("StealthEval lifecycle", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; const result = await stealth.evaluate("test"); expect(result).toBe("recovered"); }); @@ -660,7 +660,7 @@ describe("StealthEval lifecycle", () => { const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); - const stealth = (page as any)._stealth; + const stealth = (page as any)._stealthWorld; const result = await stealth.evaluate("broken"); expect(result).toBeUndefined(); }); @@ -975,7 +975,7 @@ describeIfSlow("stealth browser: navigation invalidation", () => { const browser = await launch({ headless: true, args: ['--humanize'] }); const page = await browser.newPage(); - expect((page as any)._stealth).toBeDefined(); + expect((page as any)._stealthWorld).toBeDefined(); await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); await sleep(1000);