diff --git a/BROWSER.md b/BROWSER.md index eb69e8869..affa0447d 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -527,10 +527,16 @@ window is being controlled. ### What "GStack Browser" means Not your daily Chrome — a Playwright-managed Chromium with custom branding -in the Dock and menu bar, anti-bot stealth (sites like Google and NYTimes -work without captchas), a custom user agent, and the gstack extension -pre-loaded via `launchPersistentContext`. Your regular Chrome with your tabs -and bookmarks stays untouched. +in the Dock and menu bar (the `.app` name, Dock icon, and tray, NOT the UA +string), always-on Layer C anti-bot stealth (most JS-observable automation +tells are masked, so many anti-bot-protected sites load cleanly), a +stock-Chrome user agent that reports the underlying Chromium version, and the +gstack extension pre-loaded via `launchPersistentContext`. The UA no longer +carries a `GStackBrowser` suffix — that branding string was itself a +high-entropy tell, so the browser now reports a plain `Chrome/` UA. +Deepest-layer CDP-protocol detection still gets through (Google can still +trigger captchas; see the CDP-patch item in `TODOS.md`). Your regular Chrome +with your tabs and bookmarks stays untouched. ### When to use headed mode @@ -581,13 +587,42 @@ A running daemon with config A meeting a new invocation with config B exits 1 with a `browse disconnect` hint instead of silently restarting and dropping tab state, cookies, or sessions. -**Stealth scope.** When `--headed` or `--proxy` are set, `$B` masks -`navigator.webdriver` only — via Chromium's -`--disable-blink-features=AutomationControlled` plus a small init script. -We do NOT fake `navigator.plugins`, `navigator.languages`, or `window.chrome` -— modern fingerprinters check those for consistency, and synthesizing fixed -values can flag MORE bot-like, not less. ChromeDriver's `cdc_` runtime -artifacts and the Permissions API patch are still cleaned up. +**Stealth scope (Layer C, always on).** Every context — headless `launch`, +`--headed`/`--proxy`, `handoff`, and the `useragent`/`viewport --scale` +rebuild (`recreateContext`) — gets the full Layer C mask, no opt-in flag. +Layer C masks `navigator.webdriver`, restores the `window.chrome.*` shape +(`runtime`, `app`, `csi`, `loadTimes`), aligns `Notification.permission` +with the Permissions API, reports a per-install +`hardwareConcurrency`/`deviceMemory` from the host profile, sweeps the known +Selenium/Phantom/Nightmare/Playwright globals, and installs a +`Function.prototype.toString` proxy so every patched getter reports +`[native code]` even under the depth-3 recursion check. It still does NOT +fake `navigator.plugins` or `navigator.languages` — modern fingerprinters +cross-check those for consistency, and synthesizing fixed values flags MORE +bot-like, not less. ChromeDriver's `cdc_`/`__webdriver` runtime artifacts and +the Permissions notifications tell are also cleaned up on every path. + +`GSTACK_STEALTH=extended` (also accepts `1` or `true`; off by default) layers +six more aggressive patches on top — WebGL renderer spoof, a faked +`navigator.plugins` PluginArray, `navigator.mediaDevices`. That mode actively +lies and can break sites that reflect on those properties; use it only when +the default triggers detection. For gbrowser builds with the C++ patches, the +`GSTACK_*` host-profile env (GPU vendor/renderer, UA-CH platform/model, +hardware) emits the Pack 1 `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / +`--gstack-ua-platform` / `--gstack-ua-model` / `--gstack-hw-concurrency` / +`--gstack-device-memory` switches that push the GPU/UA-CH/hardware spoof down +to native code, and `GSTACK_CDP_STEALTH=on` (or `1`/`true`) emits the Pack 2 +`--gstack-suppress-prepare-stack-trace` switch (closes the Cloudflare +`Error.prepareStackTrace` canary). On stock Playwright Chromium every one of +these switches is a safe no-op. + +`launchHeaded` / `handoff` also strip Playwright's automation-tell launch +defaults via `ignoreDefaultArgs` (`STEALTH_IGNORE_DEFAULT_ARGS`): +`--enable-automation` (the "Chrome is being controlled by automated test +software" infobar), `--disable-extensions`, +`--disable-component-extensions-with-background-pages`, +`--disable-popup-blocking`, `--disable-component-update`, and +`--disable-default-apps`. **Container support.** `--headed` on Linux without `DISPLAY` walks the display range (`:99`, `:100`, ...) until `xdpyinfo` reports a free slot, @@ -1164,6 +1199,11 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a. | `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap | | `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier | | `GSTACK_SECURITY_ENSEMBLE` | unset | Set to `deberta` for 3-classifier ensemble (721MB download) | +| `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. | +| `GSTACK_CDP_STEALTH` | unset | Set to `on`/`1`/`true` to emit `--gstack-suppress-prepare-stack-trace` (gbrowser Pack 2 / B11 C++ patch only; no-op on stock Chromium) | +| `GSTACK_GPU_VENDOR`, `GSTACK_GPU_RENDERER`, `GSTACK_GPU_CHIPSET` | unset | Per-install GPU spoof fed to the Pack 1 WebGL/UA-CH C++ patches. Set by gbd from the host profile; emitted as `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / `--gstack-ua-model` cmdline switches only when present. | +| `GSTACK_PLATFORM` | unset | Host platform classification (`MacARM`/`MacIntel` → `macOS`, `Win32` → `Windows`, `Linux*` → `Linux`) emitted as `--gstack-ua-platform` | +| `GSTACK_HW_CONCURRENCY`, `GSTACK_DEVICE_MEMORY` | host profile (fallback 8) | Per-install `hardwareConcurrency`/`deviceMemory` reported by Layer C and emitted as `--gstack-hw-concurrency` / `--gstack-device-memory` for the worker-navigator C++ patch | --- @@ -1179,7 +1219,7 @@ browse/ │ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both) │ ├── proxy-redact.ts # Cred-redaction helper for any proxy URL surfaced to logs/errors │ ├── xvfb.ts # Xvfb auto-spawn + orphan cleanup with PID + start-time validation -│ ├── stealth.ts # navigator.webdriver mask + cdc_ cleanup + Permissions API patch +│ ├── stealth.ts # Layer C: webdriver mask + window.chrome.* + Notification/Permissions + per-install hardware + toString proxy + automation-global sweep; buildGStackLaunchArgs (GSTACK_* cmdline switches); GSTACK_STEALTH=extended opt-in │ ├── browse-client.ts # Canonical SDK — what skills import as _lib/browse-client.ts │ ├── snapshot.ts # AX tree → @e/@c refs → Locator map; -D/-a/-C handling │ ├── read-commands.ts # Non-mutating: text, html, links, js, css, is, dialog, ... diff --git a/CHANGELOG.md b/CHANGELOG.md index 034ab7bcf..893bac6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [1.58.3.0] - 2026-06-18 + +## **GBrowser masks the full set of automation tells by default, on every path a page can reach.** +## **Layer C stealth is always on, carries a per-install hardware identity, and survives the toString depth-3 trick.** + +GBrowser's headless and headed Chromium now ship "Layer C" anti-detection by default, with no opt-in flag. Where the old default masked only `navigator.webdriver`, the browser now also restores the full `window.chrome.*` shape (runtime, app, csi, loadTimes), aligns `Notification.permission` with the Permissions API, reports a per-install `hardwareConcurrency`/`deviceMemory` from the host profile, sweeps the known Selenium/Phantom/Nightmare/Playwright globals, and installs a `Function.prototype.toString` proxy so every patched getter reports `[native code]` even under the depth-3 recursion check. The aggressive `GSTACK_STEALTH=extended` mode (WebGL spoof, faked plugins, mediaDevices) still exists, now layered on top of Layer C rather than replacing it. And stealth applies on all four context-creation paths, so a `useragent` change, a `viewport --scale`, or a headless-to-headed handoff hands a site a fully masked page every time. + +### The numbers that matter + +Source: `bun test browse/test/stealth-layer-c.test.ts browse/test/stealth-webdriver.test.ts browse/test/stealth-extended.test.ts browse/test/browser-manager-unit.test.ts` (80 tests, real Chromium for the runtime checks). + +| Capability | Before (v1.58.1.0) | After (v1.58.3.0) | +|---|---|---| +| Automation tells masked by default | 1 (navigator.webdriver) | 7 categories (webdriver, window.chrome.*, Notification, per-install hardware, toString-native, automation-global sweep, cdc/Permissions) | +| Context paths that apply stealth | 2 (launch, launchHeaded) | 4 (+ handoff, + recreateContext) | +| toString integrity | not addressed | survives the depth-3 `[native code]` check | +| Hardware identity | generic Chromium default | per-install, from the host profile | +| Stealth tests | none dedicated | 80 passing (incl. real-Chromium runtime) | + +By default the browser now masks seven categories of automation tell instead of one, on every path a page can reach, not just the first launch. + +### What this means for builders + +If you drive GBrowser to dogfood, scrape, or QA against anti-bot-protected targets, your sessions look like a real per-install Chrome out of the box. There is no `GSTACK_STEALTH` flag to remember, and no silent gap where a routine `useragent` or `viewport --scale` strips the mask. For gbrowser builds with the Pack 1 C++ patches, set the `GSTACK_*` host-profile env (gbd does this) to push the GPU/UA-CH/hardware spoof down to native code; on stock Playwright Chromium the same call is a safe no-op. + +### Itemized changes + +#### Added +- Always-on Layer C stealth (`buildStealthScript`): webdriver mask, `window.chrome.{runtime,app,csi,loadTimes}` shape, `Notification.permission` alignment, per-install `hardwareConcurrency`/`deviceMemory`, a `Function.prototype.toString` proxy that holds up under the depth-3 `[native code]` check, and a static sweep of Selenium/Phantom/Nightmare/Playwright globals. +- `buildGStackLaunchArgs`: per-install `--gstack-*` cmdline switches (GPU vendor/renderer, UA-CH platform/model, hardware concurrency/memory) for gbrowser's Pack 1 C++ patches, emitted only when the matching `GSTACK_*` env is set so stock Chromium is unaffected. +- Real-Chromium runtime coverage: webdriver, chrome.* shape, Notification/Permissions pairing, toString depth-3, per-install hardware, and the extended-mode blend (80 stealth tests). + +#### Changed +- Stealth applies on every context-creation path (`launch`, `launchHeaded`, `handoff`, `recreateContext`), so a `useragent`, `viewport --scale`, or handoff keeps the full mask. +- The cdc_/`__webdriver` cleanup and the Permissions notifications shim live in `applyStealth`, so headless and handoff get the same `Notification.permission`/`permissions.query` consistency as the headed path. +- `GSTACK_STEALTH=extended` layers on top of Layer C; the always-on default does not fake `navigator.plugins` (the opt-in mode still does, as the documented "may break sites" escape hatch). +- `--gstack-suppress-prepare-stack-trace` is opt-in via `GSTACK_CDP_STEALTH=on`, so the switch never reaches a Chromium that does not understand it. +- `--disable-blink-features=AutomationControlled` comes from one shared `STEALTH_LAUNCH_ARGS` constant across every launch path. + ## [1.58.1.0] - 2026-06-14 ## **Local evals stop lying. Spawned `claude` test children run in a sealed clean room,** diff --git a/TODOS.md b/TODOS.md index 52e806af3..80660c3e6 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1994,7 +1994,7 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr **What:** Write a postinstall script that patches Playwright's CDP layer to suppress `Runtime.enable` and use `addBinding` for context ID discovery, same approach as rebrowser-patches. Eliminates the `navigator.webdriver`, `cdc_` markers, and other CDP artifacts that sites like Google use to detect automation. -**Why:** Our current stealth narrows to `navigator.webdriver` masking + ChromeDriver `cdc_` runtime cleanup + Permissions API patch (v1.28.0.0 narrowed it from also faking plugins/languages, since modern fingerprinters punish inconsistent fakes more than they punish admitted defaults). That's enough for most sites but Google still triggers captchas, because the real detection is at the CDP protocol level. rebrowser-patches proved the approach works but their patches target Playwright 1.52.0 and don't apply to our 1.58.2. We need our own patcher using string matching instead of line-number diffs. 6 files, ~200 lines of patches total. +**Why:** As of v1.58.3.0 our JS-layer stealth is "Layer C" — always-on `navigator.webdriver` mask + `window.chrome.*` shape + `Notification.permission`/Permissions alignment + per-install `hardwareConcurrency`/`deviceMemory` + a `Function.prototype.toString` proxy + an automation-global sweep + ChromeDriver `cdc_`/`__webdriver` cleanup (still NOT faking plugins/languages, since modern fingerprinters punish inconsistent fakes more than they punish admitted defaults). That closes most JS-observable tells, but Google still triggers captchas because the deepest detection is at the CDP protocol level, which a page-world init script can't reach. rebrowser-patches proved the CDP approach works but their patches target Playwright 1.52.0 and don't apply to our 1.58.2. We need our own patcher using string matching instead of line-number diffs. 6 files, ~200 lines of patches total. (Layer C's toString proxy still has descriptor/Reflect.ownKeys surfaces; pushing the spoofs to native code via CDP suppression or the Chromium fork makes the JS layer obsolete.) **Context:** Full analysis of rebrowser-patches source: patches 6 files in `playwright-core/lib/server/` (crConnection.js, crDevTools.js, crPage.js, crServiceWorker.js, frames.js, page.js). Key technique: suppress `Runtime.enable` (the main CDP detection vector), use `Runtime.addBinding` + `CustomEvent` trick to discover execution context IDs without it. Our extension communicates via Chrome extension APIs, not CDP Runtime, so it should be unaffected. Write E2E tests that verify: (1) extension still loads and connects, (2) Google.com loads without captcha, (3) sidebar chat still works. diff --git a/VERSION b/VERSION index eb4d8b4b5..32e0f507a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.58.1.0 \ No newline at end of file +1.58.3.0 diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2bc1c597d..f9f3317b5 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -342,8 +342,8 @@ export class BrowserManager { // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. // Extensions only work in headed mode, so we use an off-screen window. const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; - const { STEALTH_LAUNCH_ARGS } = await import('./stealth'); - const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS]; + const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); + const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; let useHeadless = true; // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which @@ -407,10 +407,11 @@ export class BrowserManager { await this.context.setExtraHTTPHeaders(this.extraHeaders); } - // D7: mask navigator.webdriver only. The other 3 wintermute patches - // (plugins, languages, chrome.runtime) are intentionally NOT applied — - // faking them to fixed values can flag more bot-like to modern - // fingerprinters, not less. + // Apply Layer C stealth (applyStealth): masks navigator.webdriver, + // restores window.chrome.* shape, aligns Notification.permission, sets + // per-install hardware, and strips automation globals + the Permissions + // notifications tell. We still do NOT fake navigator.plugins/languages — + // faking those to fixed values flags more bot-like, not less (D7). const { applyStealth } = await import('./stealth'); await applyStealth(this.context); @@ -436,11 +437,19 @@ export class BrowserManager { // Find the gstack extension directory for auto-loading const extensionPath = this.findExtensionPath(); + const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs = [ '--hide-crash-restore-bubble', - // Anti-bot-detection: remove the navigator.webdriver flag that Playwright sets. - // Sites like Google and NYTimes check this to block automation browsers. - '--disable-blink-features=AutomationControlled', + // Anti-bot-detection: --disable-blink-features=AutomationControlled (and any + // future blink-level tells) via the shared STEALTH_LAUNCH_ARGS constant — the + // same flag launch() and handoff() use, kept in one place instead of a literal. + ...STEALTH_LAUNCH_ARGS, + // GStack Pack 1: per-install hardware/GPU/UA-CH overrides for the + // C++ patches in gbrowser's Chromium build. Each switch is a no-op + // on Chromium builds without the corresponding patch (the patch's + // empty-fallback returns native), so this is safe on stock Playwright + // Chromium too. + ...buildGStackLaunchArgs(), ]; if (extensionPath) { // Skip --load-extension when running against a custom Chromium build @@ -533,8 +542,14 @@ export class BrowserManager { if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err; } - // Build custom user agent: keep Chrome version for site compatibility, - // but replace "Chrome for Testing" branding with "GStackBrowser" + // Build custom user agent: report as stock Chrome with the version + // matching the underlying Chromium binary. D6 (codex #18 correction): + // the previous "GStackBrowser" branding suffix was itself a high-entropy + // classifier — sites grepping UA for known browser strings caught us + // immediately. Branding still lives in the wrapper .app name + Dock icon + // + tray; it does NOT need to be in the UA string for the product to be + // "GBrowser." Removing it resolves the "looks like Chrome but identifies + // as GStackBrowser" contradiction codex flagged. let customUA: string | undefined; if (!this.customUserAgent) { // Detect Chrome version from the Chromium binary @@ -547,13 +562,20 @@ export class BrowserManager { // Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0" const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/); const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0'; - customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 GStackBrowser`; + customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; } catch { // Fallback: generic modern Chrome UA - customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 GStackBrowser'; + customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; } } + // T1: strip Playwright's automation-tell defaults. STEALTH_IGNORE_DEFAULT_ARGS + // covers the originals (extension-loading blockers) plus --enable-automation + // (kills the "Chrome is being controlled by automated test software" infobar + // and the chrome-runtime shape changes Playwright otherwise triggers) and + // three more (--disable-popup-blocking, --disable-component-update, + // --disable-default-apps — each a documented automation tell per Patchright). + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); this.context = await chromium.launchPersistentContext(userDataDir, { headless: false, // Match the sandbox policy used by launch() above. Without this, @@ -565,59 +587,23 @@ export class BrowserManager { userAgent: this.customUserAgent || customUA, ...(executablePath ? { executablePath } : {}), ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - // Playwright adds flags that block extension loading - ignoreDefaultArgs: [ - '--disable-extensions', - '--disable-component-extensions-with-background-pages', - ], + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, }); this.browser = this.context.browser(); this.connectionMode = 'headed'; this.intentionalDisconnect = false; // ─── Anti-bot-detection patches ─────────────────────────────── - // D7 (codex correction): mask navigator.webdriver only. We do NOT fake - // plugins/languages — modern fingerprinters check consistency between - // those and userAgent/platform, and synthesizing fixed values can flag - // MORE bot-like, not less. Let Chromium's natural plugins and languages - // surface unmodified. - // - // What we DO clean up are automation-specific runtime artifacts that - // shouldn't exist in a real browser at all (Permissions API quirks, - // ChromeDriver-injected window globals). Those aren't fingerprint - // synthesis — they're removing leaked automation tells. + // Apply Layer C stealth (applyStealth): masks navigator.webdriver, + // restores window.chrome.* shape, aligns Notification.permission, sets + // per-install hardware, and strips automation runtime artifacts (cdc_/ + // __webdriver globals + the Permissions notifications 'denied' tell). + // We still do NOT fake navigator.plugins/languages — faking those flags + // more bot-like, not less (D7). The cdc/Permissions cleanup moved into + // applyStealth so headless launch() and handoff() get it too, not just + // this headed path. const { applyStealth } = await import('./stealth'); await applyStealth(this.context); - await this.context.addInitScript(() => { - // Remove CDP runtime artifacts that automation detectors look for - // cdc_ prefixed vars are injected by ChromeDriver/CDP - const cleanup = () => { - for (const key of Object.keys(window)) { - if (key.startsWith('cdc_') || key.startsWith('__webdriver')) { - try { - delete (window as any)[key]; - } catch (e: any) { - if (!(e instanceof TypeError)) throw e; - } - } - } - }; - cleanup(); - // Re-clean after a tick in case they're injected late - setTimeout(cleanup, 0); - - // Override Permissions API to return 'prompt' for notifications - // (automation browsers return 'denied' which is a fingerprint) - const originalQuery = window.navigator.permissions?.query; - if (originalQuery) { - (window.navigator.permissions as any).query = (params: any) => { - if (params.name === 'notifications') { - return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus); - } - return originalQuery.call(window.navigator.permissions, params); - }; - } - }); // Inject visual indicator — subtle top-edge amber gradient // Extension's content script handles the floating pill @@ -1437,6 +1423,14 @@ export class BrowserManager { } this.context = await this.browser.newContext(contextOptions); + // Re-apply stealth: newContext() is a fresh context with no init scripts, + // so a useragent / viewport --scale rebuild would otherwise drop the + // webdriver mask, window.chrome.* shape, hardware spoof, and cdc/ + // Permissions cleanup on every restored page. Must run before + // restoreState() navigates the restored tabs. + const { applyStealth } = await import('./stealth'); + await applyStealth(this.context); + if (Object.keys(this.extraHeaders).length > 0) { await this.context.setExtraHTTPHeaders(this.extraHeaders); } @@ -1460,6 +1454,9 @@ export class BrowserManager { contextOptions.userAgent = this.customUserAgent; } this.context = await this.browser!.newContext(contextOptions); + // Stealth applies to the fallback blank context too. + const { applyStealth } = await import('./stealth'); + await applyStealth(this.context); await this.newTab(); this.clearRefs(); } catch { @@ -1556,7 +1553,11 @@ export class BrowserManager { const fs = require('fs'); const path = require('path'); const extensionPath = this.findExtensionPath(); - const launchArgs = ['--hide-crash-restore-bubble']; + const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); + // Same blink-level stealth flags as launch()/launchHeaded(). Without + // STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled + // tell that the other two paths strip. + const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); @@ -1570,6 +1571,10 @@ export class BrowserManager { const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); fs.mkdirSync(userDataDir, { recursive: true }); + // T1: same automation-tell-stripping defaults as launchHeaded(). + // The handoff path (headless → headed re-launch) takes the same + // anti-detection posture. + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); newContext = await chromium.launchPersistentContext(userDataDir, { headless: false, // Match the sandbox policy used by launchHeaded() / launch(). The @@ -1579,10 +1584,7 @@ export class BrowserManager { args: launchArgs, viewport: null, ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - ignoreDefaultArgs: [ - '--disable-extensions', - '--disable-component-extensions-with-background-pages', - ], + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, timeout: 15000, }); } catch (err: unknown) { @@ -1601,6 +1603,13 @@ export class BrowserManager { this.tabSessions.clear(); this.connectionMode = 'headed'; + // Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE + // restoreState() navigates so the init scripts apply to the restored + // pages — without this the handed-off browser had cmdline args but no + // JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). + const { applyStealth } = await import('./stealth'); + await applyStealth(newContext); + if (Object.keys(this.extraHeaders).length > 0) { await newContext.setExtraHTTPHeaders(this.extraHeaders); } diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 075c27210..18831400a 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -1,48 +1,252 @@ /** - * Stealth init scripts — anti-bot detection countermeasures. + * Stealth init script — Layer C of GBrowser's anti-detection plan. * - * Two modes: + * D7 (codex correction, kept): Layer C (the always-on default built by + * buildStealthScript) does NOT fake navigator.plugins or + * navigator.languages — modern fingerprinters cross-check those against + * userAgent / platform / OS, and synthesizing fixed values flags MORE + * bot-like, not less. Plugins and languages surface their native + * Chromium values. The opt-in EXTENDED_STEALTH_SCRIPT below (gated on + * GSTACK_STEALTH=extended, off by default) DOES fake plugins — that mode + * is the documented "actively lies, may break sites" escape hatch, not + * the default posture. * - * 1. DEFAULT (consistency-first, always on): masks navigator.webdriver - * and adds --disable-blink-features=AutomationControlled. This is - * the original "codex narrowed" minimum that preserves fingerprint - * consistency — letting plugins/languages/chrome.runtime surface - * native Chromium values keeps the fingerprint internally coherent. + * What this script DOES do (the new additions for Phase 1): + * 1. Mask navigator.webdriver (the canonical headless tell). + * 2. Restore window.chrome.runtime / app / csi / loadTimes — real + * Chrome ships them; their absence in headless/automation is a + * universally-checked tell. (Vendor research: Cloudflare + DataDome + * check chrome.runtime presence + enum shape.) + * 3. Align Notification.permission with the Permissions API spoof + * that the inline addInitScript already applies — `denied` while + * Permissions returns `prompt` is a cross-source inconsistency + * detectors flag. + * 4. Report per-install hardware values via GSTACK_HW_CONCURRENCY / + * GSTACK_DEVICE_MEMORY env vars (set by gbd at startup via + * system_profiler + sysctl). Per-install honesty avoids the + * cross-user fingerprint cluster a hardcoded default would create. + * 5. Install a Function.prototype.toString Proxy that makes every + * patched getter report `function ... { [native code] }` at every + * recursion depth — defeats the well-known depth-3 detection trick + * (`fn.toString.toString.toString().includes('[native code]')`) + * that breaks naive stealth tooling. * - * 2. EXTENDED (opt-in via GSTACK_STEALTH=extended): six additional - * detection-vector patches on top of the default. Closes the - * SannySoft test corpus to a 100% pass rate. Originally proposed in - * PR #1112 (garrytan, Apr 2026). - * - * Vectors patched in extended mode: - * - navigator.webdriver property fully deleted from prototype - * (not just `false` — detectors check `"webdriver" in navigator`) - * - WebGL renderer spoofed to a plausible Apple M1 Pro string - * (SwiftShader was the #1 software-GPU giveaway in containers) - * - navigator.plugins returns a real PluginArray with proper - * MimeType objects and namedItem() — `instanceof PluginArray` - * passes - * - window.chrome populated with chrome.app, chrome.runtime, - * chrome.loadTimes(), chrome.csi() with correct shapes - * - navigator.mediaDevices present (some headless builds drop it) - * - CDP cdc_* property names cleared from window - * - * Trade-off: extended mode actively LIES about the browser - * environment. Sites that reflect on these properties can break or - * misbehave. Use only when the default mode triggers detection AND - * the target is anti-bot-protected. Not recommended as a global - * default. + * Codex caveat (acknowledged): a Proxy on Function.prototype.toString + * still has detection surfaces (descriptors, Reflect.ownKeys, cross- + * realm identity). Phase 2's C++ patches make this layer obsolete by + * pushing the spoofs to native code where toString is truly native. + * Until then, this is the best JS-only approach. */ import type { BrowserContext } from 'playwright'; /** - * Always-on default mask: navigator.webdriver returns false. Modern - * fingerprinters check the property accessor, so a one-line getter is - * sufficient when consistency with the rest of the navigator surface is - * preserved. + * Host hardware values resolved at browser-manager startup. Values come + * from the gbd `host_profile.go` detection (system_profiler + sysctl + * on macOS), passed through the GSTACK_* env vars. Each field falls + * back to a documented default if the env var is missing or unparseable. */ -export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`; +interface HostProfile { + hwConcurrency: number; + deviceMemory: number; +} + +// Exported for the clamp/fallback unit test. The platform spoof is owned by +// the UA-CH cmdline switch in buildGStackLaunchArgs (which reads GSTACK_PLATFORM +// directly), so this profile only carries the values buildStealthScript bakes +// into the page-world script. +export function readHostProfile(): HostProfile { + const env = (globalThis as any).process?.env ?? {}; + const concurrency = Number(env.GSTACK_HW_CONCURRENCY); + const memory = Number(env.GSTACK_DEVICE_MEMORY); + return { + // Clamp to a plausible default: 0/NaN/negative/missing all fall back to 8. + // deviceMemory=0 or NaN would be a glaring bot tell, so never report it. + hwConcurrency: Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 8, + deviceMemory: Number.isFinite(memory) && memory > 0 ? memory : 8, + }; +} + +/** + * Build the full Layer C stealth init script. The function template- + * literal-interpolates the host values so they bake into the script the + * page sees — process.env is not accessible from a page-world init script, + * so values must be resolved by the browser-manager process before + * injection. + * + * The script is one big self-invoking function so all the patches + * happen atomically before any page code runs. Order matters: the + * Function.prototype.toString Proxy installs FIRST so all subsequent + * defineProperty getters are covered by its native-code lie. + */ +export function buildStealthScript(hw: HostProfile): string { + return `(() => { + // ──── Function.prototype.toString Proxy (must run first) ──── + // Make every patched getter / function below report + // 'function NAME() { [native code] }' at every recursion depth. + // Defeats fn.toString.toString.toString() integrity checks. + const patchedFns = new WeakSet(); + const nativeToString = Function.prototype.toString; + const toStringProxy = new Proxy(nativeToString, { + apply(target, thisArg, args) { + if (patchedFns.has(thisArg)) { + const name = (thisArg && thisArg.name) || ''; + return 'function ' + name + '() { [native code] }'; + } + return Reflect.apply(target, thisArg, args); + }, + }); + Object.defineProperty(Function.prototype, 'toString', { + value: toStringProxy, writable: true, configurable: true, + }); + const markNative = (fn, name) => { + if (name) { + try { Object.defineProperty(fn, 'name', { value: name }); } catch {} + } + patchedFns.add(fn); + return fn; + }; + + // ──── navigator.webdriver (canonical mask, kept from D7) ──── + try { + const webdriverGetter = markNative(function() { return false; }, 'get webdriver'); + Object.defineProperty(navigator, 'webdriver', { get: webdriverGetter, configurable: true }); + } catch {} + + // ──── window.chrome.* restoration ──── + // Real Chrome ships these objects with rich enum / method shape. + // Headless Chromium / Playwright's launch strips them. Their absence + // is a universally-checked tell (verified in Cloudflare + DataDome + // RE catalogs). We don't try to perfectly mimic — we ship plausible + // shape with native-code-looking methods. + try { + if (!('chrome' in window)) { + window.chrome = {}; + } + const chrome = window.chrome; + if (!chrome.runtime) { + chrome.runtime = { + OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', + SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' }, + OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }, + PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', + X86_32: 'x86-32', X86_64: 'x86-64' }, + PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', + X86_32: 'x86-32', X86_64: 'x86-64' }, + PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux', + MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' }, + RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', + UPDATE_AVAILABLE: 'update_available' }, + connect: markNative(function connect() { + throw new TypeError('Error in invocation of runtime.connect: No matching signature.'); + }, 'connect'), + sendMessage: markNative(function sendMessage() { + throw new TypeError('Error in invocation of runtime.sendMessage: No matching signature.'); + }, 'sendMessage'), + id: undefined, + }; + } + if (!chrome.app) { + chrome.app = { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + }; + } + if (typeof chrome.csi !== 'function') { + chrome.csi = markNative(function csi() { + return { + onloadT: Date.now(), + pageT: performance.now(), + startE: Date.now() - 1000, + tran: 15, + }; + }, 'csi'); + } + if (typeof chrome.loadTimes !== 'function') { + chrome.loadTimes = markNative(function loadTimes() { + const t = performance.timing; + return { + requestTime: t.requestStart / 1000, + startLoadTime: t.requestStart / 1000, + commitLoadTime: t.responseStart / 1000, + finishDocumentLoadTime: t.domContentLoadedEventEnd / 1000, + finishLoadTime: t.loadEventEnd / 1000, + firstPaintTime: t.responseEnd / 1000, + firstPaintAfterLoadTime: 0, + navigationType: 'Other', + wasFetchedViaSpdy: true, + wasNpnNegotiated: true, + npnNegotiatedProtocol: 'h2', + wasAlternateProtocolAvailable: false, + connectionInfo: 'h2', + }; + }, 'loadTimes'); + } + } catch (err) { + // Non-fatal — page might have a stricter Content Security Policy + // that blocks property mutation on window. Leave chrome.* whatever + // shape it was; navigator.webdriver mask still applies. + } + + // ──── Notification.permission align with Permissions API ──── + // The inline addInitScript already overrides permissions.query for + // notifications → 'prompt'. Notification.permission must match + // ('default' in real Chrome on pages that haven't asked yet). + try { + if (typeof Notification !== 'undefined') { + const notificationPermissionGetter = markNative(function() { return 'default'; }, 'get permission'); + Object.defineProperty(Notification, 'permission', { + get: notificationPermissionGetter, + configurable: true, + }); + } + } catch {} + + // ──── Per-install hardware values from GSTACK_* env (T2) ──── + // gbd's host_profile.go fed real host values via cmdline env. Reporting + // those (not hardcoded defaults) avoids the cross-user GBrowser + // fingerprint cluster. + try { + const hwConcurrencyGetter = markNative(function() { return ${hw.hwConcurrency}; }, 'get hardwareConcurrency'); + Object.defineProperty(navigator, 'hardwareConcurrency', { + get: hwConcurrencyGetter, + configurable: true, + }); + } catch {} + try { + const deviceMemoryGetter = markNative(function() { return ${hw.deviceMemory}; }, 'get deviceMemory'); + Object.defineProperty(navigator, 'deviceMemory', { + get: deviceMemoryGetter, + configurable: true, + }); + } catch {} + + // ──── Selenium / Phantom / Nightmare / Playwright global cleanup ──── + // Static known-name list of Selenium / Playwright / PhantomJS / Nightmare + // globals. AUTOMATION_ARTIFACT_CLEANUP_SCRIPT (applied right after this on + // every path) covers the cdc_/__webdriver dynamic prefixes; this list is the + // fixed-name complement. + try { + const auto = [ + '__driver_evaluate', '__webdriver_evaluate', '__selenium_evaluate', '__fxdriver_evaluate', + '__driver_unwrapped', '__webdriver_unwrapped', '__selenium_unwrapped', '__fxdriver_unwrapped', + '_Selenium_IDE_Recorder', '_selenium', 'calledSelenium', + '$chrome_asyncScriptInfo', + '__$webdriverAsyncExecutor', '__webdriverFunc', + 'domAutomation', 'domAutomationController', + '__lastWatirAlert', '__lastWatirConfirm', '__lastWatirPrompt', + '__webdriver_script_fn', '_WEBDRIVER_ELEM_CACHE', + 'callPhantom', '_phantom', 'phantom', '__nightmare', + '__pwInitScripts', '__playwright__binding__', + ]; + for (const k of auto) { + try { delete window[k]; } catch {} + } + try { delete document.__webdriver_script_fn; } catch {} + } catch {} +})();`; +} /** * Extended-mode init script — six detection-vector patches. Applied @@ -157,6 +361,9 @@ export const EXTENDED_STEALTH_SCRIPT = ` // 6. CDP cdc_* property cleanup. Chromium under CDP sets cdc_*-prefixed // globals (driver injection markers); a bot detector finds them by // iterating window keys. Strip all matching keys. + // Note: via applyStealth this is redundant with AUTOMATION_ARTIFACT_ + // CLEANUP_SCRIPT (which runs first on every path). Kept so this script + // is self-sufficient if ever applied standalone. for (const k of Object.keys(window)) { if (k.startsWith('cdc_')) { try { delete window[k]; } catch {} @@ -172,28 +379,209 @@ function extendedModeEnabled(): boolean { } /** - * Apply stealth patches to a fresh BrowserContext (or persistent - * context). Called by browser-manager.launch() and launchHeaded(). - * Always applies the WEBDRIVER_MASK_SCRIPT; only applies the - * EXTENDED_STEALTH_SCRIPT when GSTACK_STEALTH=extended. + * Strip automation runtime artifacts that don't belong in a real browser + * and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window + * globals (cdc_*, __webdriver*) and the Permissions API quirk where an + * automated Chromium reports notifications as 'denied' instead of real + * Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's + * Notification.permission = 'default' so the two surfaces stay consistent. + * + * Runs on EVERY launch path via applyStealth (headless launch(), + * launchHeaded(), handoff()) so the Notification/Permissions pairing never + * diverges by mode. Previously this lived inline in launchHeaded() only, + * which left headless and handoff with the Notification value but not the + * matching Permissions answer. + */ +export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => { + // cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector + // finds them by iterating window keys. Strip immediately and again after a + // tick in case they are injected late. + const cleanup = () => { + for (const key of Object.keys(window)) { + if (key.startsWith('cdc_') || key.startsWith('__webdriver')) { + try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; } + } + } + }; + cleanup(); + setTimeout(cleanup, 0); + + // Permissions API: automated Chromium returns 'denied' for notifications, + // a known tell. Return 'prompt' to match real Chrome (and Layer C's + // Notification.permission = 'default'). Tradeoff: this pins the + // notifications state to fresh-Chrome values for the whole session, so a + // site that actually grants/denies notifications would see a stale value. + // Acceptable for the automation/anti-detection use case (which does not + // drive real notification grants); only notifications is overridden. + const originalQuery = window.navigator.permissions && window.navigator.permissions.query; + if (originalQuery) { + window.navigator.permissions.query = (params) => { + if (params && params.name === 'notifications') { + return Promise.resolve({ state: 'prompt', onchange: null }); + } + return originalQuery.call(window.navigator.permissions, params); + }; + } +})();`; + +/** + * Apply stealth patches to a fresh BrowserContext (or persistent context). + * Called by EVERY context-creation path in browser-manager — launch(), + * launchHeaded(), handoff(), AND recreateContext() (the useragent / + * viewport --scale rebuild) — so a context can never reach a page un-stealthed. + * + * Injection order (Playwright evaluates init scripts in registration order): + * 1. Layer C (buildStealthScript) — the always-on consistency-first default. + * 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim), + * kept consistent with Layer C's Notification.permission alignment. + * 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by + * default). Its window.chrome.* patches are `if (!...)`-guarded, so + * Layer C's richer shapes win; the extended-only additions (WebGL spoof, + * faked navigator.plugins, mediaDevices) apply on top. + * + * KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT + * wrapped by Layer C's Function.prototype.toString proxy, so they stringify + * as injected code; its prototype-level navigator.webdriver delete is shadowed + * by Layer C's own-property getter (net behavior still matches real Chrome: + * webdriver present and false); and its hardcoded Apple-M1 WebGL string can + * disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple + * hosts. This is acceptable for the documented "actively lies, may break sites" + * escape hatch; the consistency-first default (Layer C alone) has none of these. + * + * Host profile is resolved from process.env at call time so per-install + * values bake into the script before Playwright sends it to Chromium via + * Page.addScriptToEvaluateOnNewDocument. */ export async function applyStealth(context: BrowserContext): Promise { - await context.addInitScript({ content: WEBDRIVER_MASK_SCRIPT }); + const hw = readHostProfile(); + await context.addInitScript({ content: buildStealthScript(hw) }); + await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT }); if (extendedModeEnabled()) { await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT }); } } +/** + * The legacy single-line webdriver mask, exported for backwards + * compatibility with any caller that uses it directly. New callers + * should use applyStealth() which includes this plus the Layer C + * additions. + */ +export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`; + /** * Args added to chromium.launch's `args` to suppress the * AutomationControlled blink feature. This is independent of the init - * script — it changes how Chromium identifies itself in the protocol - * layer. + * script — it changes how Chromium identifies itself in the protocol layer. */ export const STEALTH_LAUNCH_ARGS = [ '--disable-blink-features=AutomationControlled', ]; +/** + * Build the `--gstack-*=` cmdline switches that the Pack 1 Chromium + * patches read (webgl-vendor-spoof, ua-client-hints-stealth, worker- + * navigator-stealth). Values come from the GSTACK_* env vars that + * gbd populates from host_profile.go at startup. + * + * Each switch is only emitted when its env var is non-empty — empty + * env values fall through to the patch's "no override" path, which + * returns the real Chromium native value. This keeps the helper safe + * on builds that DO NOT have the C++ patches applied (gbrowser + * pre-Pack-1) and on hosts where gbd hasn't yet populated some + * fields (legacy installs). + * + * TRUSTED-SOURCE ONLY. These GSTACK_* values are populated by gbd from + * host_profile.go (system_profiler / sysctl) and become page-visible WebGL / + * UA-CH surface data. They are NOT sanitized here (passed verbatim into the + * argv array, which is injection-safe because Playwright spawns Chromium with + * an argv array, not a shell). NEVER route page content, HTTP headers, or any + * remote/untrusted input into these env vars — that would turn this into an + * argv-injection sink. readHostProfile() applies the same trust assumption. + * + * Mapping (gbd env → Chromium cmdline switch → C++ patch consumer): + * GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch + * GSTACK_GPU_RENDERER → --gstack-gpu-renderer → webgl-vendor-spoof.patch + * GSTACK_PLATFORM → --gstack-ua-platform → ua-client-hints-stealth.patch + * (maps MacARM/MacIntel → "macOS") + * GSTACK_GPU_CHIPSET → --gstack-ua-model → ua-client-hints-stealth.patch + * GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency → worker-navigator-stealth.patch + * GSTACK_DEVICE_MEMORY → --gstack-device-memory → worker-navigator-stealth.patch + */ +export function buildGStackLaunchArgs(): string[] { + const env = (globalThis as any).process?.env ?? {}; + const args: string[] = []; + + const vendor = env.GSTACK_GPU_VENDOR; + if (vendor) args.push(`--gstack-gpu-vendor=${vendor}`); + + const renderer = env.GSTACK_GPU_RENDERER; + if (renderer) args.push(`--gstack-gpu-renderer=${renderer}`); + + // Map gbd's "MacARM"/"MacIntel" classification to the UA-CH "macOS" + // platform string Chromium emits natively. Other future platforms + // would map similarly (Win32 → "Windows", Linux → "Linux"). + const platform = env.GSTACK_PLATFORM; + if (platform === 'MacARM' || platform === 'MacIntel') { + args.push('--gstack-ua-platform=macOS'); + } else if (platform === 'Win32') { + args.push('--gstack-ua-platform=Windows'); + } else if (platform && platform.startsWith('Linux')) { + args.push('--gstack-ua-platform=Linux'); + } + + const chipset = env.GSTACK_GPU_CHIPSET; + if (chipset) args.push(`--gstack-ua-model=${chipset}`); + + const hw = env.GSTACK_HW_CONCURRENCY; + if (hw) args.push(`--gstack-hw-concurrency=${hw}`); + + const memory = env.GSTACK_DEVICE_MEMORY; + if (memory) args.push(`--gstack-device-memory=${memory}`); + + // Pack 2 / B11: suppress user-defined Error.prepareStackTrace during + // V8 stack-trace formatting. Closes the Cloudflare Bot Management canary + // trick where a page sets prepareStackTrace and watches for it to fire + // during CDP serialization. + // + // OPT-IN (off by default): only emitted when GSTACK_CDP_STEALTH is + // on/1/true. This switch is read by a C++ patch that only exists in + // gbrowser builds; gbd opts in by exporting GSTACK_CDP_STEALTH=on. Stock + // Playwright Chromium leaves it unset, so the flag never reaches a + // Chromium that wouldn't understand it. (Previously this was on-by-default + // unless GSTACK_CDP_STEALTH=off, which contradicted this very comment.) + const cdpStealth = env.GSTACK_CDP_STEALTH; + if (cdpStealth === 'on' || cdpStealth === '1' || cdpStealth === 'true') { + args.push('--gstack-suppress-prepare-stack-trace'); + } + + return args; +} + +/** + * Playwright default args to strip via ignoreDefaultArgs. + * + * Playwright passes these by default. Each one is a visible automation + * tell at some layer: + * --enable-automation → infobar + chrome shape + * --disable-extensions → blocks our extension + * --disable-component-extensions-with-background-pages → blocks component ext + * --disable-popup-blocking → automation default + * --disable-component-update → automation default + * --disable-default-apps → affects plugin enum + * + * Used by browser-manager via spread into ignoreDefaultArgs to keep + * the list in one place across launchHeaded() and handoff(). + */ +export const STEALTH_IGNORE_DEFAULT_ARGS = [ + '--enable-automation', + '--disable-extensions', + '--disable-component-extensions-with-background-pages', + '--disable-popup-blocking', + '--disable-component-update', + '--disable-default-apps', +]; + /** Test-only helper: report whether extended mode is currently active. */ export function isExtendedStealthEnabled(): boolean { return extendedModeEnabled(); diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index 45bebc345..d0ef8d7a6 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -227,3 +227,79 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => { expect(shutdownCalls).toEqual([0, 2, 2]); }); }); + +// ─── Stealth injected on EVERY launch path (regression tripwire) ─── +// +// applyStealth must run on launch() (headless), launchHeaded(), AND +// handoff(). The blend of Layer C with extended mode left handoff() building +// cmdline args but never calling applyStealth, so a handed-off browser had +// no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). +// This static check fails CI if any launch path drops the call again. +describe('stealth injected on every context-creation path', () => { + it('every context-creation path calls applyStealth (>= 4 call sites)', async () => { + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8'); + + // Every path that builds a BrowserContext must apply stealth: launch() + // (headless), launchHeaded(), handoff(), and recreateContext() (the + // useragent / viewport --scale rebuild, main + fallback). A path that + // creates a context without applyStealth silently un-stealths its pages. + const callSites = src.match(/applyStealth\(/g) || []; + expect(callSites.length).toBeGreaterThanOrEqual(4); + + // handoff() body specifically must call applyStealth, before the resume() JSDoc. + const handoffStart = src.indexOf('async handoff('); + expect(handoffStart).toBeGreaterThan(0); + const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart); + const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000); + expect(handoffBody).toContain('applyStealth('); + + // recreateContext() body must call applyStealth too — useragent and + // viewport --scale route through it and would otherwise drop all stealth. + const recreateStart = src.indexOf('recreateContext'); + expect(recreateStart).toBeGreaterThan(0); + // Find the method definition (not just the JSDoc/caller references). + const recreateDef = src.indexOf('async recreateContext('); + expect(recreateDef).toBeGreaterThan(0); + const setUaAnchor = src.indexOf('async setDeviceScaleFactor(', recreateDef); + const recreateBody = src.slice(recreateDef, setUaAnchor > 0 ? setUaAnchor : recreateDef + 6000); + expect(recreateBody).toContain('applyStealth('); + }); + + it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => { + // Same silent-drop regression class as applyStealth: a launch path that + // omits buildGStackLaunchArgs() loses the per-install GPU/UA/hardware + // cmdline spoof. launch(), launchHeaded(), and handoff() must all call it. + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8'); + const callSites = src.match(/buildGStackLaunchArgs\(\)/g) || []; + expect(callSites.length).toBeGreaterThanOrEqual(3); + }); + + it('STEALTH_LAUNCH_ARGS is spread into all 3 launch sites (no hardcoded literal)', async () => { + // The --disable-blink-features=AutomationControlled flag must come from the + // shared constant on launch(), launchHeaded(), AND handoff(). handoff() + // previously omitted it, leaving the AutomationControlled tell on the + // handed-off browser. No path may inline the literal instead. + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8'); + const spreads = src.match(/\.\.\.STEALTH_LAUNCH_ARGS/g) || []; + expect(spreads.length).toBeGreaterThanOrEqual(3); + // The literal must not be reintroduced in a launchArgs array (it belongs in + // the STEALTH_LAUNCH_ARGS constant in stealth.ts, not inline here). + expect(src).not.toContain("'--disable-blink-features=AutomationControlled'"); + }); + + it('STEALTH_IGNORE_DEFAULT_ARGS is wired into both persistent-context paths', async () => { + // launchHeaded() and handoff() both launchPersistentContext and must strip + // Playwright's automation-tell defaults via the shared constant. + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8'); + const sites = src.match(/ignoreDefaultArgs:\s*STEALTH_IGNORE_DEFAULT_ARGS/g) || []; + expect(sites.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/browse/test/stealth-extended.test.ts b/browse/test/stealth-extended.test.ts index 5c63b7afa..af6a79a3b 100644 --- a/browse/test/stealth-extended.test.ts +++ b/browse/test/stealth-extended.test.ts @@ -3,11 +3,12 @@ * v1.41 wave). * * Pins: - * 1. Default mode keeps minimum: only WEBDRIVER_MASK_SCRIPT applied. - * 2. GSTACK_STEALTH=extended adds EXTENDED_STEALTH_SCRIPT on top. + * 1. Default mode applies the always-on Layer C stealth script (and NOT + * the extended script) — the consistency-first default. + * 2. GSTACK_STEALTH=extended adds EXTENDED_STEALTH_SCRIPT on top of Layer C. * 3. EXTENDED_STEALTH_SCRIPT contains the six detection-vector patches. - * 4. Apply order: default mask first, extended second (so the - * delete-from-prototype path layers on top of the getter without + * 4. Apply order: Layer C first, extended second (so the extended + * delete-from-prototype path layers on top of Layer C's getter without * silently overriding it if delete fails). * * Live SannySoft pass-rate verification is a periodic-tier E2E test @@ -18,7 +19,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { EXTENDED_STEALTH_SCRIPT, - WEBDRIVER_MASK_SCRIPT, isExtendedStealthEnabled, applyStealth, } from '../src/stealth'; @@ -89,7 +89,7 @@ describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => { }); describe('applyStealth — script wiring', () => { - test('default mode applies ONLY WEBDRIVER_MASK_SCRIPT', async () => { + test('default mode applies Layer C + cleanup, not extended', async () => { delete process.env.GSTACK_STEALTH; const calls: string[] = []; const fakeCtx = { @@ -98,11 +98,19 @@ describe('applyStealth — script wiring', () => { }, } as unknown as Parameters[0]; await applyStealth(fakeCtx); - expect(calls).toHaveLength(1); - expect(calls[0]).toBe(WEBDRIVER_MASK_SCRIPT); + expect(calls).toHaveLength(2); + // [0] = Layer C (toString-proxy native-code lie + webdriver mask). + expect(calls[0]).toContain('[native code]'); + expect(calls[0]).toContain('webdriver'); + // [1] = automation-artifact cleanup (cdc_ scan + Permissions shim) — + // now applied on EVERY launch path, not just the headed one. + expect(calls[1]).toContain('cdc_'); + expect(calls[1]).toContain('setTimeout(cleanup'); + // Extended script must NOT be applied by default. + expect(calls).not.toContain(EXTENDED_STEALTH_SCRIPT); }); - test('extended mode applies BOTH scripts in order (mask first, extended second)', async () => { + test('extended mode applies Layer C, cleanup, then extended (in order)', async () => { process.env.GSTACK_STEALTH = 'extended'; const calls: string[] = []; const fakeCtx = { @@ -111,8 +119,9 @@ describe('applyStealth — script wiring', () => { }, } as unknown as Parameters[0]; await applyStealth(fakeCtx); - expect(calls).toHaveLength(2); - expect(calls[0]).toBe(WEBDRIVER_MASK_SCRIPT); - expect(calls[1]).toBe(EXTENDED_STEALTH_SCRIPT); + expect(calls).toHaveLength(3); + expect(calls[0]).toContain('[native code]'); // Layer C first + expect(calls[1]).toContain('cdc_'); // cleanup second + expect(calls[2]).toBe(EXTENDED_STEALTH_SCRIPT); // extended last }); }); diff --git a/browse/test/stealth-layer-c.test.ts b/browse/test/stealth-layer-c.test.ts new file mode 100644 index 000000000..db6a4c4d3 --- /dev/null +++ b/browse/test/stealth-layer-c.test.ts @@ -0,0 +1,288 @@ +/** + * stealth.ts Layer C additions (T3 + D6 for GBrowser anti-detection): + * verifies the build-time scaffolding without requiring a live browser. + * + * Live-browser verification of these spoofs in actual page contexts is + * covered by the gbrowser-side `test/anti-bot.test.sh` (Phase 1 / T7) + * which loads the probe page through the built GBrowser app post-bundle. + * These tests only exercise the JS script builder + the static export + * shapes — fast, hermetic, no chromium launch. + */ +import { describe, test, expect, afterEach } from 'bun:test'; +import { + buildStealthScript, + buildGStackLaunchArgs, + readHostProfile, + AUTOMATION_ARTIFACT_CLEANUP_SCRIPT, + WEBDRIVER_MASK_SCRIPT, + STEALTH_LAUNCH_ARGS, + STEALTH_IGNORE_DEFAULT_ARGS, +} from '../src/stealth'; + +describe('STEALTH_IGNORE_DEFAULT_ARGS — T1', () => { + test('includes --enable-automation (kills infobar)', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--enable-automation'); + }); + test('includes the 4 Patchright-recommended adds', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-popup-blocking'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-update'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-default-apps'); + }); + test('preserves the original extension-loading blockers', () => { + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-extensions'); + expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-extensions-with-background-pages'); + }); +}); + +describe('buildStealthScript — T3 Layer C', () => { + const hw = { platform: 'MacARM', hwConcurrency: 16, deviceMemory: 8 }; + + test('builds a self-invoking function (atomic injection)', () => { + const s = buildStealthScript(hw); + expect(s.trim().startsWith('(() => {')).toBe(true); + expect(s.trim().endsWith('})();')).toBe(true); + }); + + test('installs the Function.prototype.toString Proxy FIRST', () => { + const s = buildStealthScript(hw); + const proxyIdx = s.indexOf('new Proxy(nativeToString'); + const webdriverIdx = s.indexOf("'webdriver'"); + expect(proxyIdx).toBeGreaterThan(0); + expect(webdriverIdx).toBeGreaterThan(proxyIdx); + }); + + test('navigator.webdriver getter returns false', () => { + const s = buildStealthScript(hw); + expect(s).toMatch(/Object\.defineProperty\(navigator, 'webdriver'/); + expect(s).toMatch(/return false/); + }); + + test('window.chrome.runtime ships full enum shape', () => { + const s = buildStealthScript(hw); + expect(s).toContain('OnInstalledReason'); + expect(s).toContain('PlatformArch'); + expect(s).toContain('PlatformOs'); + expect(s).toContain('RequestUpdateCheckStatus'); + // sendMessage / connect must throw native-shaped errors + expect(s).toContain('runtime.connect'); + expect(s).toContain('runtime.sendMessage'); + }); + + test('chrome.csi and chrome.loadTimes provide method bodies', () => { + const s = buildStealthScript(hw); + expect(s).toContain('chrome.csi = markNative(function csi()'); + expect(s).toContain('chrome.loadTimes = markNative(function loadTimes()'); + // loadTimes shape must include wasFetchedViaSpdy/connectionInfo — + // those are what real Chrome's loadTimes() returns on HTTP/2 sites. + expect(s).toContain('wasFetchedViaSpdy'); + expect(s).toContain('connectionInfo'); + }); + + test('Notification.permission aligned to default', () => { + const s = buildStealthScript(hw); + expect(s).toMatch(/Notification, 'permission'/); + expect(s).toMatch(/return 'default'/); + }); + + test('hardware values interpolated from host profile (NOT hardcoded)', () => { + const s = buildStealthScript({ platform: 'MacARM', hwConcurrency: 12, deviceMemory: 4 }); + expect(s).toContain('return 12'); + expect(s).toContain('return 4'); + expect(s).not.toMatch(/return 8;.*hardwareConcurrency/); + }); + + test('cleans up Selenium + Playwright + Phantom + Nightmare globals', () => { + const s = buildStealthScript(hw); + // Spot-check a few from each category + expect(s).toContain('__webdriver_evaluate'); // Selenium + expect(s).toContain('domAutomationController'); // Chrome Driver classic + expect(s).toContain('__pwInitScripts'); // Playwright + expect(s).toContain('callPhantom'); // PhantomJS + expect(s).toContain('__nightmare'); // NightmareJS + expect(s).toContain('_Selenium_IDE_Recorder'); // Selenium IDE + }); + + test('uses markNative wrapper for every patched function', () => { + const s = buildStealthScript(hw); + // Every getter (hardwareConcurrency, deviceMemory, webdriver, Notification.permission) + // should be wrapped through markNative so the toString Proxy covers it. + const markNativeMatches = s.match(/markNative\(/g) || []; + // At least 8 markNative wrappings (webdriver, csi, loadTimes, connect, sendMessage, + // notification permission, hwConcurrency, deviceMemory) + expect(markNativeMatches.length).toBeGreaterThanOrEqual(7); + }); + + test('script does not include "GStackBrowser" branding string', () => { + const s = buildStealthScript(hw); + // D6: dropped from UA, must not leak in via stealth payload either. + expect(s).not.toContain('GStackBrowser'); + }); +}); + +describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => { + // Helper: clear all GSTACK_* env, run test body, restore env. + function withEnv(env: Record, body: () => void) { + const saved: Record = {}; + for (const k of Object.keys(process.env)) { + if (k.startsWith('GSTACK_')) { + saved[k] = process.env[k]; + delete process.env[k]; + } + } + for (const [k, v] of Object.entries(env)) { + if (v !== undefined) process.env[k] = v; + } + try { + body(); + } finally { + for (const k of Object.keys(process.env)) { + if (k.startsWith('GSTACK_')) delete process.env[k]; + } + for (const [k, v] of Object.entries(saved)) { + if (v !== undefined) process.env[k] = v; + } + } + } + + test('empty env produces no switches (suppress-stack is opt-in)', () => { + withEnv({}, () => { + // All switches are opt-in: the six per-install flags fall through + // (nothing in env), and the Pack 2 / B11 suppression flag is only + // emitted when GSTACK_CDP_STEALTH is on/1/true. Empty env → []. + expect(buildGStackLaunchArgs()).toEqual([]); + }); + }); + + test('all env values populated (incl. CDP stealth opt-in) → all 7 switches emitted', () => { + withEnv({ + GSTACK_GPU_VENDOR: 'Apple Inc.', + GSTACK_GPU_RENDERER: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)', + GSTACK_PLATFORM: 'MacARM', + GSTACK_GPU_CHIPSET: 'Apple M4 Max', + GSTACK_HW_CONCURRENCY: '16', + GSTACK_DEVICE_MEMORY: '8', + GSTACK_CDP_STEALTH: 'on', + }, () => { + const args = buildGStackLaunchArgs(); + expect(args).toContain('--gstack-gpu-vendor=Apple Inc.'); + expect(args).toContain('--gstack-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)'); + expect(args).toContain('--gstack-ua-platform=macOS'); + expect(args).toContain('--gstack-ua-model=Apple M4 Max'); + expect(args).toContain('--gstack-hw-concurrency=16'); + expect(args).toContain('--gstack-device-memory=8'); + expect(args).toContain('--gstack-suppress-prepare-stack-trace'); + expect(args.length).toBe(7); + }); + }); + + test('platform mapping: MacARM and MacIntel both → macOS', () => { + withEnv({ GSTACK_PLATFORM: 'MacARM' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS'); + }); + withEnv({ GSTACK_PLATFORM: 'MacIntel' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS'); + }); + }); + + test('platform mapping: Win32 → Windows, Linux x86_64 → Linux', () => { + withEnv({ GSTACK_PLATFORM: 'Win32' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Windows'); + }); + withEnv({ GSTACK_PLATFORM: 'Linux x86_64' }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Linux'); + }); + }); + + test('partial env: only set switches that have values', () => { + withEnv({ GSTACK_HW_CONCURRENCY: '12' }, () => { + const args = buildGStackLaunchArgs(); + // hw only — suppress-stack is opt-in and GSTACK_CDP_STEALTH is unset. + expect(args).toContain('--gstack-hw-concurrency=12'); + expect(args).not.toContain('--gstack-suppress-prepare-stack-trace'); + expect(args.length).toBe(1); + }); + }); + + test('prepare-stack-trace suppression is opt-in via GSTACK_CDP_STEALTH', () => { + // on/1/true enable it; off and unset omit it, so stock Playwright + // Chromium (no GSTACK_CDP_STEALTH) never receives the unknown switch. + for (const v of ['on', '1', 'true']) { + withEnv({ GSTACK_CDP_STEALTH: v }, () => { + expect(buildGStackLaunchArgs()).toContain('--gstack-suppress-prepare-stack-trace'); + }); + } + withEnv({ GSTACK_CDP_STEALTH: 'off' }, () => { + expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace'); + }); + withEnv({}, () => { + expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace'); + }); + }); + + test('unrecognized platform falls through without --gstack-ua-platform', () => { + withEnv({ GSTACK_PLATFORM: 'OS/2' }, () => { + const args = buildGStackLaunchArgs(); + expect(args.some(a => a.startsWith('--gstack-ua-platform='))).toBe(false); + }); + }); + + test('GPU vendor with spaces survives intact (no quote/escape regression)', () => { + withEnv({ GSTACK_GPU_VENDOR: 'NVIDIA Corporation' }, () => { + const args = buildGStackLaunchArgs(); + expect(args).toContain('--gstack-gpu-vendor=NVIDIA Corporation'); + }); + }); +}); + +describe('backwards-compat exports', () => { + test('WEBDRIVER_MASK_SCRIPT still exported', () => { + expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'"); + expect(WEBDRIVER_MASK_SCRIPT).toContain('false'); + }); + test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => { + expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled'); + }); +}); + +describe('readHostProfile — clamp/fallback', () => { + let savedHw: string | undefined; + let savedMem: string | undefined; + afterEach(() => { + if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; + else process.env.GSTACK_HW_CONCURRENCY = savedHw; + if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; + else process.env.GSTACK_DEVICE_MEMORY = savedMem; + }); + function withHw(hw: string | undefined, mem: string | undefined): ReturnType { + savedHw = process.env.GSTACK_HW_CONCURRENCY; + savedMem = process.env.GSTACK_DEVICE_MEMORY; + if (hw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; else process.env.GSTACK_HW_CONCURRENCY = hw; + if (mem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; else process.env.GSTACK_DEVICE_MEMORY = mem; + return readHostProfile(); + } + + test('valid env values pass through', () => { + expect(withHw('16', '8')).toEqual({ hwConcurrency: 16, deviceMemory: 8 }); + }); + + test('missing env → default 8/8', () => { + expect(withHw(undefined, undefined)).toEqual({ hwConcurrency: 8, deviceMemory: 8 }); + }); + + test('zero / negative / NaN / empty all clamp to 8 (never a 0 or NaN bot tell)', () => { + for (const bad of ['0', '-4', 'abc', '']) { + const p = withHw(bad, bad); + expect(p.hwConcurrency).toBe(8); + expect(p.deviceMemory).toBe(8); + } + }); +}); + +describe('AUTOMATION_ARTIFACT_CLEANUP_SCRIPT — static shape', () => { + test('strips cdc_/__webdriver and maps notifications query to prompt', () => { + expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('cdc_')"); + expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('__webdriver')"); + expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("name === 'notifications'"); + expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("state: 'prompt'"); + }); +}); diff --git a/browse/test/stealth-webdriver.test.ts b/browse/test/stealth-webdriver.test.ts index c0fec6ce4..9ef595cb9 100644 --- a/browse/test/stealth-webdriver.test.ts +++ b/browse/test/stealth-webdriver.test.ts @@ -98,11 +98,206 @@ describe('applyStealth — context level', () => { await page.close(); } }); + + test('window.chrome.* ships the rich Layer C shape at runtime', async () => { + const page = await context.newPage(); + try { + const shape = await page.evaluate(() => { + const c = (window as any).chrome; + return { + hasRuntime: !!c?.runtime, + hasPlatformArch: !!c?.runtime?.PlatformArch, + hasOnInstalled: !!c?.runtime?.OnInstalledReason, + csiIsFn: typeof c?.csi === 'function', + loadTimesIsFn: typeof c?.loadTimes === 'function', + appIsObj: typeof c?.app === 'object', + }; + }); + expect(shape.hasRuntime).toBe(true); + expect(shape.hasPlatformArch).toBe(true); + expect(shape.hasOnInstalled).toBe(true); + expect(shape.csiIsFn).toBe(true); + expect(shape.loadTimesIsFn).toBe(true); + expect(shape.appIsObj).toBe(true); + } finally { + await page.close(); + } + }); + + test('Notification.permission is default AND Permissions API returns prompt for notifications', async () => { + // The cdc/Permissions shim now lives in applyStealth, so this pairing + // holds on the plain newContext path too — previously it was headed-only, + // which left Notification.permission=default mismatched against the native + // Permissions answer in headless. Regression guard for that gap. + const page = await context.newPage(); + try { + const result = await page.evaluate(async () => { + const perm = typeof Notification !== 'undefined' ? Notification.permission : 'unavailable'; + let queryState = 'unavailable'; + try { + const status = await navigator.permissions.query({ name: 'notifications' } as any); + queryState = status.state; + } catch {} + return { perm, queryState }; + }); + expect(result.perm).toBe('default'); + expect(result.queryState).toBe('prompt'); + } finally { + await page.close(); + } + }); + + test('patched getters report [native code] via the toString proxy', async () => { + const page = await context.newPage(); + try { + const getterSrc = await page.evaluate(() => { + const wd = Object.getOwnPropertyDescriptor(navigator, 'webdriver'); + return wd && wd.get ? wd.get.toString() : ''; + }); + // Layer C wraps every patched getter through markNative, so the + // Function.prototype.toString Proxy reports native code instead of the + // injected source — defeats the toString integrity check. + expect(getterSrc).toContain('[native code]'); + } finally { + await page.close(); + } + }); + + test('toString proxy survives the depth-3 recursion trick', async () => { + // The headline claim: defeats fn.toString.toString.toString().includes( + // '[native code]'). Depth-1 is covered above; this walks the full chain a + // detector uses so a regression that only masks one level is caught. + const page = await context.newPage(); + try { + const depth3 = await page.evaluate(() => { + const wd = Object.getOwnPropertyDescriptor(navigator, 'webdriver'); + const get = wd && wd.get; + return get ? (get as any).toString.toString.toString().includes('[native code]') : false; + }); + expect(depth3).toBe(true); + } finally { + await page.close(); + } + }); + + test('chrome.csi() and chrome.loadTimes() execute, runtime.connect() throws native-shaped', async () => { + // Presence (typeof === 'function') is not enough — a real detector calls + // them. loadTimes() dereferences performance.timing; connect() must throw + // the native "No matching signature" TypeError. + const page = await context.newPage(); + try { + const r = await page.evaluate(() => { + const c = (window as any).chrome; + let connectErr = ''; + try { c.runtime.connect(); } catch (e) { connectErr = String(e); } + return { + csiOk: typeof c.csi().onloadT === 'number', + loadTimesOk: typeof c.loadTimes().wasFetchedViaSpdy === 'boolean', + connectErr, + }; + }); + expect(r.csiOk).toBe(true); + expect(r.loadTimesOk).toBe(true); + expect(r.connectErr).toContain('No matching signature'); + } finally { + await page.close(); + } + }); }); -describe('applyStealth — persistent context (headed-mode parity)', () => { - test('webdriver mask applies to launchPersistentContext too (D7)', async () => { - // Simulate the launchHeaded path: launchPersistentContext + applyStealth +describe('applyStealth — per-install hardware from env', () => { + let ctx: BrowserContext; + let savedHw: string | undefined; + let savedMem: string | undefined; + + beforeAll(async () => { + savedHw = process.env.GSTACK_HW_CONCURRENCY; + savedMem = process.env.GSTACK_DEVICE_MEMORY; + process.env.GSTACK_HW_CONCURRENCY = '12'; + process.env.GSTACK_DEVICE_MEMORY = '4'; + ctx = await browser.newContext(); + await applyStealth(ctx); // readHostProfile() reads env at call time + }); + + afterAll(async () => { + await ctx.close(); + if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; + else process.env.GSTACK_HW_CONCURRENCY = savedHw; + if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; + else process.env.GSTACK_DEVICE_MEMORY = savedMem; + }); + + test('navigator.hardwareConcurrency and deviceMemory reflect the env profile', async () => { + const page = await ctx.newPage(); + try { + const hw = await page.evaluate(() => ({ + cores: navigator.hardwareConcurrency, + mem: (navigator as any).deviceMemory, + })); + expect(hw.cores).toBe(12); + expect(hw.mem).toBe(4); + } finally { + await page.close(); + } + }); +}); + +describe('applyStealth — extended mode layered on Layer C (GSTACK_STEALTH=extended)', () => { + let ctx: BrowserContext; + let savedStealth: string | undefined; + + beforeAll(async () => { + savedStealth = process.env.GSTACK_STEALTH; + process.env.GSTACK_STEALTH = 'extended'; + ctx = await browser.newContext(); + await applyStealth(ctx); + }); + + afterAll(async () => { + await ctx.close(); + if (savedStealth === undefined) delete process.env.GSTACK_STEALTH; + else process.env.GSTACK_STEALTH = savedStealth; + }); + + test('extended actually runs: navigator.plugins is the faked PluginArray', async () => { + const page = await ctx.newPage(); + try { + const names = await page.evaluate(() => Array.from(navigator.plugins).map((p) => p.name)); + expect(names).toContain('PDF Viewer'); + } finally { + await page.close(); + } + }); + + test('blend: Layer C wins window.chrome.runtime (rich shape, not extended skeletal)', async () => { + // extended's chrome.runtime is {OnInstalledReason, OnRestartRequiredReason} + // and is if(!)-guarded, so Layer C (applied first, with PlatformArch) must + // win. This pins the coexistence ordering the blend depends on. + const page = await ctx.newPage(); + try { + const hasRich = await page.evaluate(() => !!(window as any).chrome?.runtime?.PlatformArch); + expect(hasRich).toBe(true); + } finally { + await page.close(); + } + }); + + test('blend: navigator.webdriver stays false (Layer C own-prop survives extended prototype delete)', async () => { + const page = await ctx.newPage(); + try { + const wd = await page.evaluate(() => (navigator as any).webdriver); + expect(wd).toBe(false); + } finally { + await page.close(); + } + }); +}); + +describe('applyStealth — persistent context (headed + handoff parity)', () => { + test('full Layer C applies to launchPersistentContext (the launchHeaded/handoff path)', async () => { + // Simulate the launchHeaded/handoff path: launchPersistentContext + + // applyStealth. Verifies the persistent-context path gets the SAME Layer C + // as newContext, not just the webdriver mask. const fs = await import('fs'); const os = await import('os'); const path = await import('path'); @@ -114,9 +309,17 @@ describe('applyStealth — persistent context (headed-mode parity)', () => { }); try { await applyStealth(ctx); - const page = ctx.pages()[0] ?? await ctx.newPage(); - const webdriver = await page.evaluate(() => (navigator as any).webdriver); - expect(webdriver).toBe(false); + // Use a page created AFTER applyStealth. launchPersistentContext opens an + // initial page at launch time, before addInitScript is registered, so + // init scripts never run on pages()[0] (its webdriver is only false + // because of the --disable-blink-features launch arg, not Layer C). + const page = await ctx.newPage(); + const probe = await page.evaluate(() => ({ + webdriver: (navigator as any).webdriver, + hasChromeRuntime: !!(window as any).chrome?.runtime?.PlatformArch, + })); + expect(probe.webdriver).toBe(false); + expect(probe.hasChromeRuntime).toBe(true); } finally { await ctx.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); diff --git a/package.json b/package.json index 454e85569..f55e23e3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.58.1.0", + "version": "1.58.3.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",