Skip to content

v1.58.3.0 feat: gbrowser anti-detection Layer C stealth#2047

Merged
garrytan merged 16 commits into
mainfrom
gbrowser-anti-detection
Jun 18, 2026
Merged

v1.58.3.0 feat: gbrowser anti-detection Layer C stealth#2047
garrytan merged 16 commits into
mainfrom
gbrowser-anti-detection

Conversation

@garrytan

Copy link
Copy Markdown
Owner

Summary

Ships always-on "Layer C" anti-detection stealth for GBrowser, replacing the prior webdriver-only default. (Also merges origin/main up 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.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, hw concurrency/memory) for gbrowser's Pack 1 C++ patches, emitted only when the matching GSTACK_* env is set (no-op on stock Chromium).
  • applyStealth blends Layer C (always) + a shared cdc_/Permissions cleanup shim + opt-in GSTACK_STEALTH=extended (WebGL spoof, faked plugins, mediaDevices) layered on top. --gstack-suppress-prepare-stack-trace is opt-in via GSTACK_CDP_STEALTH=on.

Launch integration (browse/src/browser-manager.ts)

  • Stealth applies on all four context-creation paths: launch, launchHeaded, handoff, and recreateContext (the useragent / viewport --scale rebuild). The shared STEALTH_LAUNCH_ARGS / STEALTH_IGNORE_DEFAULT_ARGS constants are spread into each path.

Tests

  • 80 stealth tests (static shape + real-Chromium runtime): webdriver, chrome.* shape, Notification/Permissions pairing, toString depth-3, per-install hardware, extended-mode blend, readHostProfile clamp, 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's anti-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):

  • Security: no findings. The script-injection boundary was verified safe — env values pass through Number() + isFinite + >0 clamp 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.
  • Performance: no findings. The toString proxy is an accepted O(1)-per-call tradeoff; no quadratic or sync-I/O patterns.
  • Testing (6) + Maintainability (4): all informational, all addressed — added readHostProfile clamp, toString depth-3, and chrome.csi()/loadTimes() invocation tests; dropped a dead HostProfile.platform field; corrected stale comments; documented the extended-mode coexistence limitations.
  • Adversarial (Codex) caught a HIGH the other passes missed: recreateContext() — reached by routine useragent / viewport --scale commands — 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.webdriver only") 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).

  • BROWSER.md: Rewrote the "Stealth scope" section (was "masks navigator.webdriver only") to describe always-on Layer C across all four context-creation paths: webdriver mask, window.chrome.* shape, Notification.permission/Permissions alignment, per-install hardwareConcurrency/deviceMemory, the Function.prototype.toString proxy, and the automation-global sweep. Documented GSTACK_STEALTH=extended (also 1/true), the Pack 1 --gstack-* switches, the Pack 2/B11 GSTACK_CDP_STEALTH=on--gstack-suppress-prepare-stack-trace, and ignoreDefaultArgs stripping. Corrected "What GStack Browser means" to note the stock-Chrome UA (no GStackBrowser suffix, itself a tell) and that CDP-layer detection can still trigger captchas. Added six GSTACK_* rows to the env-vars table.
  • TODOS.md: Corrected the now-stale premise on the open "Playwright CDP patches" item; the TODO stays open (the CDP-protocol layer a page-world init script can't reach is still unaddressed).

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_STEALTH values, explicit switch names, ignoreDefaultArgs, captcha over-claim).

Test plan

  • Free test suite green (browse/test + test + make-pdf/test, 0 failures)
  • Dedicated stealth suite: 80 tests passing (incl. real-Chromium runtime)
  • Cross-model adversarial review (Claude + Codex) — HIGH recreateContext finding fixed and tripwired

🤖 Generated with Claude Code

garrytan and others added 16 commits May 19, 2026 10:10
… 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>
@trunk-io

trunk-io Bot commented Jun 18, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

@github-actions

Copy link
Copy Markdown

E2E Evals: ✅ PASS

8/8 tests passed | $1.38 total cost | 12 parallel runners

Suite Result Status Cost
e2e-browse 2/2 $0.15
e2e-deploy 2/2 $0.34
e2e-qa-workflow 1/1 $0.53
llm-judge 1/1 $0.02
e2e-deploy 2/2 $0.34

12x ubicloud-standard-8 (Docker: pre-baked toolchain + deps) | wall clock ≈ slowest suite

@garrytan garrytan merged commit a861c00 into main Jun 18, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant