v1.58.3.0 feat: gbrowser anti-detection Layer C stealth#2047
Merged
Conversation
… toString Proxy (gbrowser T1+T3+D6)
Three additions stacked into the existing applyStealth() init script
to close the visible automation tells that today push GBrowser users
into Google's /sorry/index captcha and similar:
T1 — Strip Playwright's automation default args:
--enable-automation (kills "Chrome is being
controlled" infobar)
--disable-popup-blocking, --disable-component-update,
--disable-default-apps (Patchright's list — each
is a documented tell)
Now centralized in STEALTH_IGNORE_DEFAULT_ARGS export, used by BOTH
launchHeaded() and handoff() (the headless → headed re-launch path).
D6 — Drop "GStackBrowser" UA branding suffix:
Real Chrome's UA ends `Safari/537.36`, not `Safari/537.36 GStackBrowser`.
The branded suffix was a high-entropy classifier for any vendor that
grep'd UA for known automation/test-browser strings. Branding still
lives in the wrapper .app name + Dock icon + tray — does not need
to leak via the UA string for the product to be "GBrowser." Resolves
the "looks like Chrome but identifies as GStackBrowser" contradiction
codex review #18 flagged.
T3 — Layer C init-script additions in stealth.ts:
1. Function.prototype.toString Proxy (must run first). Wraps every
patched getter / function in a WeakSet so they report
`function NAME() { [native code] }` at every recursion depth,
defeating the depth-3+ integrity check
(fn.toString.toString.toString().includes('[native code]')).
2. window.chrome.runtime / chrome.app / chrome.csi / chrome.loadTimes
restoration with full enum shape (OnInstalledReason, PlatformArch,
PlatformOs, etc.) + method bodies. Real Chrome ships these; their
absence is universally checked. Vendor research (gbrowser plan
deep-dive on Cloudflare + DataDome) confirmed both vendors probe
this shape directly.
3. Notification.permission aligned to 'default'. The existing inline
addInitScript already spoofs permissions.query({name:'notifications'})
to return 'prompt' — Notification.permission being 'denied' while
Permissions returns 'prompt' is a cross-source inconsistency that
detectors flag specifically.
4. Per-install hardware values via GSTACK_HW_CONCURRENCY /
GSTACK_DEVICE_MEMORY env vars (set by gbd's host_profile.go from
system_profiler + sysctl). Reporting real host values within the
Chrome shape avoids the cross-user GBrowser fingerprint cluster
that hardcoded defaults would create. Codex review #10 flagged
hardcoding as creating contradictions across Apple Silicon / Intel
/ UA-CH architecture.
5. Selenium 25-global cleanup + PhantomJS + NightmareJS + Watir +
Playwright (__pwInitScripts, __playwright__binding__) static-name
deletion. The inline block continues to handle the dynamic
cdc_/__webdriver/__selenium/__driver prefixes.
D7 (codex correction) kept: still do NOT fake navigator.plugins or
navigator.languages. Synthesizing those triggers MORE consistency
flags from modern fingerprinters than letting Chromium surface them
natively.
Test coverage:
- 15 new tests in stealth-layer-c.test.ts covering: launch-flag
exports, script structure, toString-Proxy installs first, every
spoof present, hardware values interpolated from input (not
hardcoded), Selenium global cleanup spot-check, no GStackBrowser
leak in stealth payload, backwards-compat exports preserved.
- All 8 existing stealth-webdriver tests still pass.
- All 2 existing browser-manager-unit tests still pass.
For GBrowser specifically: this is the gstack-side half of Phase 1 / T1
+ T3 + D6 in the anti-detection plan. The gbrowser repo's submodule
pointer bump will land alongside this.
…gbrowser
New stealth.ts export that turns the GSTACK_* env vars (already populated
by gbrowser's gbd from host_profile.go) into the --gstack-* cmdline
switches the Pack 1 Chromium patches read at WebGL getParameter,
NavigatorUA::userAgentData, NavigatorConcurrentHardware::hardwareConcurrency,
and NavigatorDeviceMemory::deviceMemory time.
Wired into all three launchArgs sites: launch() (headless), launchHeaded()
(real product path), and handoff() (headless → headed re-launch).
Mapping:
GSTACK_GPU_VENDOR → --gstack-gpu-vendor
GSTACK_GPU_RENDERER → --gstack-gpu-renderer
GSTACK_PLATFORM → --gstack-ua-platform (with mapping:
MacARM/MacIntel → macOS, Win32 → Windows,
Linux x86_64 → Linux)
GSTACK_GPU_CHIPSET → --gstack-ua-model
GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency
GSTACK_DEVICE_MEMORY → --gstack-device-memory
Each switch is emitted only when its env var is non-empty — empty
values fall through to the patch's "no override" path, which returns
the real Chromium native value. Safe to ship on Chromium builds
without the Pack 1 patches applied (zero behavior change).
The patches themselves live in the gbrowser repo at chromium/patches/
{webgl-vendor-spoof,ua-client-hints-stealth,worker-navigator-stealth}.patch.
Both halves (gstack arg construction + gbrowser C++ patches) must
land + Chromium rebuild before the spoof reaches the WebGL/UA-CH/
hardware accessors. Currently dormant until then.
Tests (browse/test/stealth-layer-c.test.ts):
7 new buildGStackLaunchArgs cases — empty env, all-populated, partial,
platform mapping (MacARM/MacIntel/Win32/Linux), unrecognized platform
fallthrough, vendor-with-spaces escape-safety.
All 32 stealth/browser-manager tests pass.
For GBrowser specifically: gstack-side half of the Pack 1 flag plumbing.
gbrowser repo will bump the submodule pointer to this commit, then re-run
bun run test/anti-bot/evidence-run.ts to verify creepjs's "33% headless"
score drops after Pack 1 + Chromium rebuild.
Pack 2 / B11 flag plumbing for the new error-preparestacktrace-stealth.patch in gbrowser/chromium/patches/. Always emit --gstack-suppress-prepare-stack-trace unless the caller explicitly sets GSTACK_CDP_STEALTH=off in the environment. Off by default in patch behavior (no-op without the C++ patch), so this is safe on stock Playwright Chromium too. Closes the Cloudflare canary trick where a page sets Error.prepareStackTrace and watches for it to fire during CDP serialization of a logged Error object. Tests: All 33 stealth/browser-manager tests pass. New cases: - GSTACK_CDP_STEALTH=off disables suppression - empty env still emits the always-on flag (count=1) - all-populated env now emits 7 flags (was 6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors v1.40.0.1 from main lineage (PR #1617). Cherry-picked onto gbrowser-anti-detection so the GBrowser submodule can consume the fix without waiting for main to merge. Playwright auto-adds --no-sandbox whenever chromiumSandbox !== true (playwright-core/lib/server/chromium/chromium.js:291-292). The headless chromium.launch() site set the option; the two headed sites (launchHeaded() and handoff()) did not. Every headed launch on macOS and Linux showed Chromium's yellow "unsupported command-line flag: --no-sandbox" infobar. shouldEnableChromiumSandbox() centralizes the Win32 / CI / CONTAINER / root heuristic that previously lived only in the headless path's explicit --no-sandbox push at :225. All three launch sites now use the helper, and six unit tests pin the policy across darwin, linux, win32, CI, CONTAINER, and root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ervisor respawn
Three browser.on('disconnected') handlers in browse/src/browser-manager.ts
(launch, launchHeaded, handoff) each exited with a non-zero code on every
disconnect, regardless of cause. Process supervisors that consume our exit
code (gbrowser's gbd HealthMonitor in cmd/gbd/health.go) treated user
Cmd+Q identical to a Chromium crash and respawned with exponential
backoff, so the visible browser kept reappearing after the user closed it.
Add resolveDisconnectCause(browser) that reads the underlying ChildProcess
exitCode + signalCode (waiting up to 1s for the exit event if the
disconnected event fired first). Exit code 0 + no signal = clean user
quit; anything else = crash, signal-kill, or OOM.
Wire the resolver into all three disconnect handlers:
- launch() (headless): clean → exit 0, crash → exit 1 (was always 1)
- launchHeaded() (headed): clean → exit 0, crash → exit 2 (was always 2)
onDisconnect() cleanup callback still runs in both cases.
- handoff() (re-launch): same as launch() via the helper.
Preserve the per-path crash codes (1 vs 2) so any supervisor that
differentiated headed vs headless crashes keeps working.
Seven new unit tests in browse-manager-unit.test.ts cover the resolver
across already-exited, signal-killed (SIGSEGV / SIGKILL), async exits,
and null-browser inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the branch up to date with main (v1.40.0.2 -> v1.58.1.0). Conflict resolutions: - VERSION: take main's 1.58.1.0 (branch re-bumps at /ship time). - CHANGELOG.md: keep main's full history; slot the branch's unique v1.40.0.2 entry into descending-order position (no content lost). - browse/src/browser-manager.ts: keep main's GSTACK_CHROMIUM_NO_SANDBOX override and onDisconnect(exitCode) signature; branch's buildGStackLaunchArgs / STEALTH_IGNORE_DEFAULT_ARGS wiring preserved. - browse/test/browser-manager-unit.test.ts: keep main's override + exit-code propagation tests alongside the branch's Cmd+Q cause-resolver tests. - browse/src/stealth.ts: blend the two stealth designs. Layer C (buildStealthScript) is the always-on consistency-first default; main's GSTACK_STEALTH=extended (EXTENDED_STEALTH_SCRIPT) remains an opt-in layer applied on top. Both public APIs and both test suites (stealth-layer-c + stealth-extended) preserved; the two applyStealth wiring assertions updated to reflect the Layer C default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tifact cleanup handoff() built cmdline args but never called applyStealth, so a handed-off browser had no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). And the cdc_/Permissions cleanup shim lived inline in launchHeaded() only, so headless launch() reported Notification.permission='default' without the matching permissions.query='prompt' answer — the exact cross-source inconsistency the shim exists to prevent. Move the cleanup into AUTOMATION_ARTIFACT_CLEANUP_SCRIPT inside applyStealth so all three launch paths (launch, launchHeaded, handoff) get identical stealth, and call applyStealth(newContext) in handoff() before restoreState() navigates. A static tripwire in browser-manager-unit.test.ts fails CI if any launch path drops the applyStealth call again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…efault-on
buildGStackLaunchArgs() pushed the flag unless GSTACK_CDP_STEALTH=off, i.e.
on-by-default — contradicting its own comment ("off by default, only for
gbrowser builds"). The switch is read by a C++ patch that only exists in
gbrowser; on stock Playwright Chromium it is an unknown switch.
Flip to opt-in: emit only when GSTACK_CDP_STEALTH is on/1/true. gbd opts in by
exporting GSTACK_CDP_STEALTH=on; stock installs leave it unset so the flag
never reaches a Chromium that wouldn't understand it. Comment now matches code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The file-level stealth.ts docstring claimed "we DON'T fake navigator.plugins" while the same file now ships EXTENDED_STEALTH_SCRIPT, which does fake plugins when GSTACK_STEALTH=extended. Clarify that Layer C (the always-on default) doesn't fake plugins and the opt-in extended mode does, as the documented "actively lies, may break sites" escape hatch. Also fix the launch()/launchHeaded() comments that said "mask navigator.webdriver only" — applyStealth (Layer C) also restores window.chrome.*, aligns Notification.permission, and sets per-install hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stealth tests were all static string-shape assertions; nothing executed the script in a real page. Add real-Chromium runtime checks via applyStealth + page.evaluate: - Layer C runtime: window.chrome.* rich shape, Notification.permission='default' paired with permissions.query notifications='prompt' (guards the shim now running on every path), and patched getters reporting [native code]. - Per-install hardware: navigator.hardwareConcurrency/deviceMemory reflect the GSTACK_* env profile. - Extended-mode blend: navigator.plugins is faked when GSTACK_STEALTH=extended, Layer C still wins window.chrome.runtime, and navigator.webdriver stays false (own-prop getter survives extended's prototype delete). - Persistent-context (launchHeaded/handoff) parity now uses a page created AFTER applyStealth — the old test checked pages()[0], which predates the init script, so webdriver was false only via the launch arg, not Layer C. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…NCH_ARGS handoff() built its launch args from only ['--hide-crash-restore-bubble', ...buildGStackLaunchArgs()], omitting STEALTH_LAUNCH_ARGS — so a handed-off browser kept the --disable-blink-features=AutomationControlled tell that launch() and launchHeaded() strip. launchHeaded() also hardcoded the flag as a literal. Both now spread the shared constant, so the AutomationControlled flag lives in one place across all three launch paths. Tripwires: STEALTH_LAUNCH_ARGS spread into >= 3 sites (no inline literal) and STEALTH_IGNORE_DEFAULT_ARGS wired into both persistent-context paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HostProfile.platform was set by readHostProfile but never read by buildStealthScript — the platform spoof is owned by the UA-CH cmdline switch in buildGStackLaunchArgs (which reads GSTACK_PLATFORM directly). Remove the dead field. Export readHostProfile and AUTOMATION_ARTIFACT_CLEANUP_SCRIPT so their clamp/shape invariants can be unit-tested. Correct the stale "25 Selenium globals" count comment and note the extended cdc_ scan is redundant-but-retained for standalone use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… calls
Pre-landing review coverage gaps:
- readHostProfile clamps 0/negative/NaN/missing env to 8 (a deviceMemory=0 or
NaN would be a glaring bot tell) — now asserted.
- toString proxy survives the depth-3 recursion trick
(fn.toString.toString.toString().includes('[native code]')), the headline
claim that was only tested at depth-1.
- chrome.csi() and chrome.loadTimes() are invoked (not just typeof-checked) and
runtime.connect() throws the native-shaped "No matching signature" error.
- AUTOMATION_ARTIFACT_CLEANUP_SCRIPT static shape (cdc_/__webdriver strip +
notifications->prompt) as a hermetic backup for the live-Chromium pairing test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lth path) useragent and viewport --scale route through recreateContext(), which rebuilds the BrowserContext via newContext() — a fresh context with no init scripts. It never called applyStealth, so a routine useragent/viewport-scale command silently dropped webdriver masking, window.chrome.* shape, hardware spoof, and the cdc/Permissions cleanup on every restored page. Caught by the cross-model adversarial review (Codex) after the Claude pass and eng review missed it. Both the main and fallback paths now call applyStealth before any page is created. The launch-path tripwire is raised to >= 4 sites and now asserts the recreateContext() body specifically, so the regression class can't recur. Also documents the load-bearing trust assumption on buildGStackLaunchArgs / readHostProfile (GSTACK_* must be gbd-sourced, never page/remote data — the injection-safety argument depends on it) and the notifications-permission spoof tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BROWSER.md "Stealth scope" still described the default as navigator.webdriver masking only; Layer C is now the always-on default across all four context-creation paths. Update the stealth-scope prose, the "What GStack Browser means" blurb (stock-Chrome UA, no GStackBrowser suffix, captchas can still get through at the CDP layer), the stealth.ts source-map line, and the env-vars table (GSTACK_STEALTH, GSTACK_CDP_STEALTH, GSTACK_GPU_*, GSTACK_PLATFORM, GSTACK_HW_CONCURRENCY/GSTACK_DEVICE_MEMORY + the explicit --gstack-* switches and ignoreDefaultArgs stripping). Correct the stale "narrows to navigator.webdriver masking only" premise on the open CDP-patch TODO (the TODO itself stays open — the CDP-protocol layer is still unaddressed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
E2E Evals: ✅ PASS8/8 tests passed | $1.38 total cost | 12 parallel runners
12x ubicloud-standard-8 (Docker: pre-baked toolchain + deps) | wall clock ≈ slowest suite |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships always-on "Layer C" anti-detection stealth for GBrowser, replacing the prior webdriver-only default. (Also merges
origin/mainup to v1.58.1.0; the VERSION/CHANGELOG/docs commits are bookkeeping.)Stealth engine (
browse/src/stealth.ts)buildStealthScript: always-on Layer C init script — webdriver mask,window.chrome.{runtime,app,csi,loadTimes}shape,Notification.permissionalignment, per-installhardwareConcurrency/deviceMemory, aFunction.prototype.toStringproxy 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, hw concurrency/memory) for gbrowser's Pack 1 C++ patches, emitted only when the matchingGSTACK_*env is set (no-op on stock Chromium).applyStealthblends Layer C (always) + a sharedcdc_/Permissions cleanup shim + opt-inGSTACK_STEALTH=extended(WebGL spoof, faked plugins, mediaDevices) layered on top.--gstack-suppress-prepare-stack-traceis opt-in viaGSTACK_CDP_STEALTH=on.Launch integration (
browse/src/browser-manager.ts)launch,launchHeaded,handoff, andrecreateContext(theuseragent/viewport --scalerebuild). The sharedSTEALTH_LAUNCH_ARGS/STEALTH_IGNORE_DEFAULT_ARGSconstants are spread into each path.Tests
readHostProfileclamp, plus static tripwires asserting every context path applies stealth.Test Coverage
AI-assessed coverage: 89% (gate: 80% target — PASS). Dedicated stealth suite: 80 tests passing (real Chromium for the runtime checks). Full free suite green (browse/test + test + make-pdf/test, 0 failures).
4 documented gaps, none blocking: 2 are live-only (the
--gstack-*C++ patch consumers, verified out-of-repo in gbrowser'santi-bot.test.sh; in-repo only asserts the switches are built), 1 is a defensive CSP catch branch, 1 is the inline UA-string assembly. Everything in the JS stealth path has runtime coverage.Pre-Landing Review
Specialist army (testing, maintainability, security, performance) + cross-model adversarial (Claude + Codex):
Number()+isFinite+>0clamp before page-world interpolation (only bare numeric literals survive), and cmdline values reach Chromium via an argv array (no shell). A "trusted-source only" comment now documents the load-bearing assumption.toStringproxy is an accepted O(1)-per-call tradeoff; no quadratic or sync-I/O patterns.readHostProfileclamp, toString depth-3, andchrome.csi()/loadTimes()invocation tests; dropped a deadHostProfile.platformfield; corrected stale comments; documented the extended-mode coexistence limitations.recreateContext()— reached by routineuseragent/viewport --scalecommands — rebuilt the context without re-applying stealth, silently un-masking every restored page. Fixed in both the main and fallback paths, and the launch-path tripwire now asserts all four context-creation paths apply stealth so the regression class can't recur.Design Review
No frontend files changed — design review skipped.
Eval Results
No prompt-related files changed — evals skipped.
Scope Drift
Scope Check: CLEAN. Stated intent (anti-detection stealth) matches the delivered diff; no creep, no missing requirements.
Plan Completion
No plan file detected for this branch.
Verification Results
No localhost dev server (this is a browser library). Live anti-bot verification lives in gbrowser's out-of-repo
anti-bot.test.sh; runtime behavior is covered by the real-Chromium tests in this PR.TODOS
No TODO items completed in this PR. The "Anti-bot stealth: Playwright CDP patches" TODO had a stale premise ("masks
navigator.webdriveronly") that was corrected to reflect Layer C; the CDP-protocol-layer work itself remains open.Documentation
Synced browser anti-detection docs to the shipped "Layer C" stealth (v1.58.3.0).
navigator.webdriveronly") to describe always-on Layer C across all four context-creation paths: webdriver mask,window.chrome.*shape,Notification.permission/Permissions alignment, per-installhardwareConcurrency/deviceMemory, theFunction.prototype.toStringproxy, and the automation-global sweep. DocumentedGSTACK_STEALTH=extended(also1/true), the Pack 1--gstack-*switches, the Pack 2/B11GSTACK_CDP_STEALTH=on→--gstack-suppress-prepare-stack-trace, andignoreDefaultArgsstripping. Corrected "What GStack Browser means" to note the stock-Chrome UA (noGStackBrowsersuffix, itself a tell) and that CDP-layer detection can still trigger captchas. Added sixGSTACK_*rows to the env-vars table.CHANGELOG v1.58.3.0 was already complete and left untouched (clobber protection). A Codex documentation pass ran and fixed 6 doc-vs-code gaps in BROWSER.md/TODOS.md (UA, Pack label, accepted
GSTACK_STEALTHvalues, explicit switch names,ignoreDefaultArgs, captcha over-claim).Test plan
recreateContextfinding fixed and tripwired🤖 Generated with Claude Code