Skip to content

Lots of fixes + features - #707

Open
JWriter20 wants to merge 45 commits into
daijro:mainfrom
JWriter20:main
Open

Lots of fixes + features#707
JWriter20 wants to merge 45 commits into
daijro:mainfrom
JWriter20:main

Conversation

@JWriter20

@JWriter20 JWriter20 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Main changes:

  1. Bringing back isolated world evaluation
  2. Debugger no longer visible in the context (looked like we always had devtools open before)
  3. Fixes
  4. selectOption event was not trusted, and didn't fire events in the same order as a real user, now it does
  5. video/screen recording automation now works in headless: false, true, and virtual
  6. screen.* is now spoofed per context — before this, every context created with new_context() leaked the host monitor
  7. Build and quality of life fixes
  8. Docker fixes

Thank you for everyone who's open prs I am integrating here! It is appreciated and sped up the process, please report if you find any new issues.

AI SUMMARY:

Fork sync: stealth, Python package, build/packaging, tests

Closes #93, #161, #425, #499, #577, #628, #631, #650, #654, #698 · Refs #196, #225, #326, #458, #706

44 commits on top of daijro/camoufox@0583c3e — 64 files, +4391/-722. Not one logical change; it's a batch, written to be reviewable one commit at a time. Roughly a third are other people's open PRs rebased (Credits). Happy to split into groups on request.

Everything below was measured on a local x86_64 Linux build of this branch and A/B'd against the shipped 152.0.4-beta.28 binary — same upstream version — so pre-existing faults aren't attributed here.


Stealth / detection surface

  • screen.* is spoofed per context. Previously every new_context() leaked the host monitor. New ScreenDimensionManager keys geometry by userContextId through RoverfoxStorageManager, with self-destructing setScreenDimensions() / setScreenColorDepth() WebIDL setters. Verified: 3 contexts hold 3 independent values, stable as later contexts are created.
  • page.evaluate() runs in an isolated world again. World '' is a sandbox in its own compartment, so nothing the automation evaluates is reachable by page script, and page-installed traps don't see it. See Known limitations for the cost.
  • The automation debugger is invisible to content. Juggler attaching was equivalent to having DevTools open (async stack capture, throw-site capture, deoptimized asm.js/wasm). invisibleToContent on both the main and worker Debugger.
  • Automation actions are no longer separable from user input. select_option, date/colour fill and set_input_files mutated the DOM and dispatched isTrusted: false events in the wrong order. Now they match a real widget-driven selection event-for-event, and set the user-interacted flag :user-valid selects on.
  • window means the world's own global. window/self/frames (plus top/parent at top level) resolve to the world, so window.x = 1 and globalThis.x = 1 land in the same place.
  • history.length is no longer 0. browser.sessionhistory.max_entries=0 left it at 0 — impossible in a real browser, a zero-false-positive tell any page can read — and made go_back() a silent no-op. Stock default restored; the random window.history.length pin is dropped as strictly worse.
  • privacy.partition.network_state back to stock true — the favicon-supercookie mitigation; off, the cache/connection pool/DNS/HSTS are shared across sites.
  • Routed requests stop looking like forced reloads. Playwright set LOAD_BYPASS_CACHE on every page.route(), which puts Pragma: no-cache + Cache-Control: no-cache on all traffic. INHIBIT_CACHING alone starves the cache without announcing it. Also restores connection/cookie header order after an intercepted request is resumed.
  • forceScopeAccess is honoured (shadowRootUnl no longer exposed #628) — was declared and read by nothing.
  • navigator.maxTouchPoints is spoofable (Spoof navigator.maxTouchPoints from config #697).

What a page can actually observe. Page-side probe, against stock Playwright Firefox as ground truth:

measurable from page script stock FF upstream Camoufox this branch
page's getter on a window global fired by evaluate yes yes no
automation's window.x = 1 visible to page yes yes no
new own-properties on page window __fromAutomation __fromAutomation none
page's error / unhandledrejection saw automation throw yes yes no
expose_function name on window (non-native source) yes yes no

The page's own view of the DOM is byte-identical to stock Firefox (adoptedStyleSheets, computed styles, document expandos, window prototype). The isolation is only visible from the automation side.


Hangs, crashes, correctness

Build and packaging

  • Windows packages ship the MSVC CRT again (camoufox.exe fails to launch on Windows: SxS manifest declares mozglue as win32 assembly dependency #650) — version-globbed, and a missing --includes entry is now fatal rather than silently skipped.
  • macOS bundled fonts (Docs: fonts can't be added via the bundled fonts/ directory on macOS or Windows hosts #706, @wnxd) — MOZ_BUNDLED_FONTS defaults on only for WINNT/Linux upstream, so ActivateBundledFonts() was compiled out and the 287 fonts shipped under Contents/Resources/fonts were never registered. A spoofed list resolved against the host's own fonts instead — measurably host ∩ claimed, 13 of 60 claimed families, and those 13 exactly the macOS-native ones, which contradicts a Windows UA rather than merely weakening it. The pref side was already on; only the build flag was missing. With the full Windows family list whitelisted and a warm-up pass (bundled fonts load lazily — the first measurement is what triggers loading), this build measures 107/107.
  • Docker (Docker linux/arm64 build OOM-killed during link (+ missing ccache, no .dockerignore) #698) — .dockerignore (context 1.6GB → 988MB), ccache installed so container builds aren't cold, --disable-debug-symbols (fat-LTO debug info is what OOM-kills linux/arm64).
  • make clean actually clobbersgit clean -fdx was deleting mach before ./mach clobber ran.
  • make stage-fontsmach build leaves dist/bin/fonts with only TwemojiMozilla.ttf, so anything running the objdir binary through the Python wrapper starts with no content font and fails as a confusing TargetClosedError.

Testing

Playwright suite (make tests) — A/B against the shipped binary

failed passed skipped
this branch 63 1033 107
shipped 152.0.4-beta.28, same tree 14 1082 107
  • 10 upstream failures are fixed here — the go_back/history family (the max_entries fix) plus the 4 new routed-header tests.
  • 4 fail on both — pre-existing, untouched.
  • 59 are branch-only. 53 are tests reaching for page-script globals through page.evaluate(), which cannot work now that evaluation is isolated — the intended cost, not a defect. The remaining 6 are listed below.

Two harness defects had to be fixed first, both of which destroyed coverage silently: the session event_loop fixture called asyncio.get_event_loop() with no running loop (raises on 3.14 — a full run reported 1151 errors and 0 passes, which reads like a catastrophic browser failure), and test_page_route.py imported a Playwright helper that no longer exists, dropping all 42 route tests — exactly the ones covering the header changes here. Collection goes 1182 + 1 error → 1225, on both 3.12 and 3.14.

Remaining triage: removed Playwright APIs (Page.accessibility, expose_binding(handle=True), the private glob helper), assertion drift (traces now record protocol class+method, not the Python apiName), and a test server answering 404/401 with no body — which Gecko renders through the plaintext viewer and never fires load for, hanging goto() for the full timeout. Each was checked against stock Firefox first.

Other suites

  • build-tester — 1039/1048, grade A, 8/8 profiles, vs 1027/1048 for the shipped binary through the identical harness. All 12 gained checks are in the per-context phase. Checks now report through a DOM node rather than window.__testResults__, which isolation makes unreadable; verified this doesn't change what is measured. Pins playwright<1.61 (it sends viewport.isMobile, which this juggler's schema rejects).
  • tests/patches — 9/9. Five new: isolated-evaluate, main-world-eval (if(){} statement in js breaks main_world_eval #631), force-scope-access (shadowRootUnl no longer exposed #628), trusted-events, noop-mousemove-deadlock. The last fails on the shipped binary and passes here.
  • pythonlib unit tests — 108 passed.
  • Patch applicationBUILD_TARGET=linux,x86_64 make dir applies all 52 patches, exit 0, zero rejected hunks, from a clean tree.

Known limitations

The isolated world is the trade-off this PR is built around: page.evaluate() can no longer see page-script state. The mw: prefix (main_world_eval=True) is the escape hatch. Three further consequences are worth a decision before merge, and I'd rather flag them than have a reviewer find them:

  1. Tracing records zero DOM snapshots. tracing.start(snapshots=True) succeeds and writes a trace the Trace Viewer can't show a DOM for. Cause: through Xrays, document.adoptedStyleSheets reports isArray === true but length === undefined and no Symbol.iterator, so Playwright's snapshotter dies on for (const sheet of ... || []). Specific to WebIDL FrozenArray attributes — every other collection is fine. Not page-visible, but silent.
  2. pageerror no longer fires for async errors thrown from page.evaluate (4 tests). Errors from the page's own scripts still fire correctly.
  3. window instanceof Window is false inside the isolated world, and page.evaluate(() => window) returns {} instead of ref: <Window> (1 test). This is the window === self aliasing, not isolation — dropping the window alias restores both, at the cost of window === self. As written the two are mutually exclusive.

Also: expose_function is no longer visible to page script. That removes a classic detection vector, but it does break Playwright's documented behaviour for anyone using it as a page→automation callback.


Credits

A good chunk of this PR is other people's work, rebased onto current main. Where I changed the shape of a contribution I've said why; the disagreements are about approach, not about whether the bug was real — in every case they found it first.

contribution
@pratyush618 Largest single contribution, 7 commits. Headful window geometry (#674, #694fixes #499, #425) and the routed-request header fingerprint (#675). Both taken as-is.
@Cloudymap1e #681 env isolation, #682 humanize types, #683 read-only launch dir, #684 server shutdown. Plus #685 — diagnosed that forceScopeAccess was read by nothing and demonstrated the Cu.exportFunction technique my fix reuses. I didn't take its shape (it swaps the default world for a sandbox over the page window, so page.evaluate('window.pageSecret') returns None); the mechanism is entirely theirs.
@neuregex #693 MSVC CRT packaging (#650), incl. making a missing include fatal. #692 main-world regression test (#631). Both as-is.
@wnxd #706 — found that the bundled fonts/ directory does nothing on macOS: MOZ_BUNDLED_FONTS is undefined there, so ActivateBundledFonts() is compiled out and every shipped font is silently ignored. Traced it to the upstream moz.configure default and confirmed the option itself has no platform restriction. Authored the fix, taken as-is. Also corrected my read of the localized-family-name behaviour: the whitelist is keyed on canonical en-US names while fonts.json mixes canonical and localized forms, and loading is lazy — with the full list and a warm-up pass it is 107/107, with no missing alias registration.
@zhemaituk #678 XDG-aware fontconfig cache. Kept; only the base directory moved (#654), byte-identical when XDG_CACHE_HOME is unset.
@pierorolando1 #398 camel_case in launch_options(). Kept; dropped the persistent_context half after confirming browserServerImpl never consumes it, and made those options raise instead (#161).
@msly #520 spotted git clean -fdx deleting mach before ./mach clobber. Reapplied by hand.
@nxbdev #697 navigator.maxTouchPoints spoofing. As-is.
@MaestroOfAutomation #243 allow_addon_new_tab. As-is.
@regseb #355 corrected a referenced filename in the docs.
@daijro For Camoufox, and for the C++-level design this leans on — several findings above were only verifiable because spoofing lives below JS, so an isolated-world probe and a page-script probe read identically.

Checklist

  • Linked a related issue
  • Single logical change — no, deliberately. Fork sync of 44 commits, each self-contained and separately reviewable.
  • Testing instructions with desired result
  • Service tests — not run: service-tester needs a proxies.txt of live per-context proxies I don't have. Happy to run it if you can point me at what it expects.
  • Build test — 1039/1048, grade A, 8/8 profiles (shipped beta.28 scores 1027/1048 through the same harness)

Compare: main...JWriter20:camoufox:main

JWriter20 and others added 18 commits July 24, 2026 20:15
A mousemove whose destination rounds to the pixel the pointer is already
on generates no eMouseMove in the widget, so the
juggler-mouse-event-hit-renderer notification never fires and the
sendEvents() call awaits an ack that never arrives. Because input dispatch
is serialized on activateAndRun()'s process-global promise chain
(TargetRegistry.js), that one stuck await wedges EVERY later input event
for the life of the page — the page goes permanently unresponsive at 0%
CPU with nothing in flight.

Same activation-chain failure family as daijro#225, but a distinct trigger: not
an out-of-viewport / exact-edge coordinate (those are already guarded),
but a move with no movement at all.

It is reachable as the FIRST mouse action of a session. The pointer starts
at (0,0) (this._lastTrackedPos), so any first move that rounds to the
origin is a no-op:

    page.mouse.move(0, 0)      // exact origin
    page.mouse.move(0.4, 0.4)  // rounds to (0, 0)

A humanized driver whose cursor model initialises to 0,0 and whose first
move is a short hop near the corner hits this every run. It manifests
through Playwright's sync client (the async client dedupes a move to the
current position and never sends it).

Fix: in the mousemove branch, if the rounded destination equals the
rounded current position, record the position and return without
dispatching. A no-op move has nothing to send.

Verified by warm-rebuilding the beta.28 omni.ja (juggler is JS, no C++
recompile) and A/B/C testing: stock and a pristine no-edit repack both
wedge on `move(0,0)`; only this one-line guard makes it survive, and a
genuine move afterwards still fires. tests/patches/noop-mousemove-deadlock.py
fails on stock and passes with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aijro#654)

PR daijro#678 made the fontconfig cache XDG-aware, but `get_path('fontconfig')`
resolves inside the versioned browser install directory
(.../browsers/official/<version>-<hash>/fontconfig/), which already holds the
bundled linux/ macos/ windows/ trees and is read-only in the common
"bake the browser into the image as root, run as non-root" deployment.

Use INSTALL_DIR / 'fontconfig' instead: still XDG-aware, but outside the
bundle. This is byte-identical to the pre-daijro#678 path when XDG_CACHE_HOME is
unset, so existing caches are reused and no migration is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_screen_cons() bounds the generated fingerprint to the monitor, but
BrowserForge honours a Screen constraint only when its pool has a match:
FingerprintGenerator.partial_csp catches the filtering failure and deletes the
constraint unless strict=True. So a 1366x768 laptop routinely gets a 2560x1440
fingerprint with window.outerWidth 1920, and browser-init resizes the real
chrome window to it -- rendering past the edge of the monitor.

Re-apply the bound after generation instead of trusting BrowserForge with it,
and pull screenX/screenY back inside the shrunken screen.

Headful only. headless has no window to overflow, and headless='virtual' runs a
1x1 Xvfb whose "monitor" would otherwise shrink the fingerprint to 1x1.

Fixes daijro#499
headless='virtual' reaches launch_options as headless=False with
virtual_display set (async_api rewrites it), so the headful gate fired and
clamped the fingerprint to Xvfb's 1x1 stub. fix_screen_no_taskbar then drove
availHeight to -39 and validate_config rejected the launch outright.
screeninfo makes the process per-monitor DPI aware, so it reports physical
pixels, while Firefox lays windows out in CSS pixels. At 150% Windows scaling a
1920x1080 panel is 1280x720 CSS px, so bounding the fingerprint by the physical
size lets the window open 1.5x larger than the screen.

Refs daijro#425
get_screen_cons() was gated on DISPLAY being set, which only ever happens on
Linux, so headful runs on Windows and macOS generated fingerprints with no
monitor bound at all.

Fixes daijro#425
PR daijro#398 added `persistent_context` / `user_data_dir` to `launch_options()` and
emitted `_user_data_dir` in the result, on the assumption that Playwright's
`browserServerImpl` consumes it. It does not.

`launchServer()` spreads its options into `BrowserType.launch()`, which passes
`undefined` as the userDataDir and never reads `options._userDataDir` (only
`browser._userDataDirForTest` is ever assigned, after the fact). Verified
against the bundled playwright-core 1.53.1: launching a server with
`user_data_dir=/tmp/...` starts cleanly and leaves the directory empty.

Serving a persistent context is not merely unimplemented, it is outside
Playwright's server model: `launchPersistentContext` returns a BrowserContext
while `PlaywrightServer` only accepts a `preLaunchedBrowser`.

So keep daijro#398's genuinely-correct `camel_case` fix -- it lets any underscore-
prefixed private option reach the driver -- and drop the two options that would
otherwise be accepted, validated, and silently ignored. `launch_server()` now
fails loudly and points at the in-process API instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`VirtualDisplay.kill()` reaps the Xvfb child and then clears `self.proc`, so
asserting `vd.proc.poll() is not None` afterwards raises AttributeError on
None. Two tests failed this way on main, unrelated to any of the merged PRs.

Assert `proc is None or proc.poll() is not None` -- reaped-and-cleared is the
success path, and a surviving handle must still report an exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playwright pairs every setRequestInterception with setCacheDisabled, and
LOAD_BYPASS_CACHE makes necko attach Pragma: no-cache and Cache-Control:
no-cache to every request. Firefox only sends those for a forced reload.
INHIBIT_CACHING alone keeps responses out of the cache without saying so.
Resuming an intercepted request rebuilds the channel, and the header copy
skips connection and cookie so the new channel re-appends them after the
sec-fetch-* block. No real Firefox emits that order.
… partitioning

Two one-line pref changes in settings/camoufox.cfg.

1. browser.sessionhistory.max_entries: 0 -> 50  (F1, daijro#326, daijro#196)

   Setting this to 0 was grouped under "turn off bfcache", but it is not what
   disables bfcache -- fission.bfcacheInParent=false (line 487) and
   max_total_viewers=0 do that. What max_entries=0 does is leave the session
   history with no entries at all, so window.history.length reports 0.

   The HTML spec guarantees a browsing context always retains at least its
   current entry, so history.length >= 1 in every real browser. 0 is therefore a
   zero-false-positive automation tell that any page script can read with no
   timing and no heuristics. It also makes page.go_back() a silent no-op.

   Measured on the shipped 152.0.4-beta.28 binary, three navigations:

     entries=0,  viewers=0   history.length=0  go_back: no-op   bfcache: unused
     entries=50, viewers=0   history.length=3  go_back: works   bfcache: unused
     entries=50, viewers=-1  history.length=3  go_back: works   bfcache: unused

   So 50 (Firefox's stock default) restores correct history semantics and
   working back-navigation without bfcache ever serving a page. This is also
   why the existing `enable_cache=True` workaround for daijro#196 works: CACHE_PREFS
   in utils.py already resets max_entries to 10.

2. privacy.partition.network_state: false -> true  (daijro#577)

   This is Firefox's stock default and the documented mitigation for the
   favicon-cache supercookie; disabling it also un-partitions the HTTP cache,
   connection pool, DNS cache and HSTS store.

   Being straight about the evidence: on FF152 I could NOT demonstrate an open
   channel. With a working same-site control (cached: 1 fetch), a shared
   third-party subresource loaded from two different top-level sites produced 2
   fetches and 2 distinct TCP sockets with the pref BOTH false and true -- the
   HTTP cache and the connection pool are already partitioned either way.

   So this is a stock-default/consistency change and defense-in-depth for the
   channels the pref still governs (DNS, HSTS, TLS session resumption, favicon
   cache) that I have no probe for -- not a demonstrated leak fix. It measured
   as behaviour-neutral in both directions, so the performance risk that
   presumably motivated disabling it looks negligible on FF152. Worth
   re-checking against the build-tester score.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KittisakPh

Copy link
Copy Markdown

@daijro
Thx god

@JWriter20

Copy link
Copy Markdown
Collaborator Author

@daijro Thx god

Still gonna probably be a day or two before this is ready, just did a quick look over it, then going to have AI do a full review as well.

neuregex and others added 10 commits July 30, 2026 14:32
Camoufox evaluates page scripts through its own main-world path, which has to
wrap a bare statement (if/let/block) so it still yields a value. daijro#631 reported
that any script containing an if statement failed with "Execution context was
destroyed" on 0.4.11; that symptom is gone on current builds.

Adds a regression test covering expressions and statements across both
main_world_eval modes, so a Juggler rebase cannot silently reintroduce it.

Verified green against v152.0.4-beta.25 (pythonlib 0.5.4).
…ld (daijro#628)

`forceScopeAccess` was declared in settings/properties.json and
settings/camoucfg.jvv, validated on the way in, and then read by nothing --
grep found no consumer anywhere in additions/juggler/. So
`element.shadowRootUnl`, which patches/shadow-root-bypass.patch adds to
Element.webidl gated on Func="Document::IsCallerChromeOrAddon", was `undefined`
from page.evaluate() no matter what the flag was set to. Reproduced on
152.0.4-beta.28.

The gate tests the caller, not the world the property is defined in. So export
a getter whose body stays in FrameTree.js's system-principal scope and install
it on the page's own Element.prototype. The default execution context is left
alone: page.evaluate() still runs against the real page window.

Deliberately NOT taking PR daijro#685's shape. It unlocks the same binding by swapping
the default main world for a Cu.Sandbox over the page window, and with Xray
vision that hides page expandos -- its own tests assert
`page.evaluate('window.pageSecret') is None` and
`element.pageMarker is None`. Enabling a shadow-DOM flag should not silently
stop page.evaluate() from seeing page state; that is a much worse failure than
the two caveats below, and it is undocumented in the PR.

The trade-offs of keeping evaluation in the main world, both documented at the
call site: the property is visible to the page while the flag is on (so it can
be fingerprinted -- hence opt-in and off by default), and a page that defines
its own `shadowRootUnl` on an element shadows the accessor. A page can only do
either if it already knows the property exists.

Credit to @Cloudymap1e (daijro#685) for the Cu.exportFunction technique this reuses.

Adds tests/patches/force-scope-access.py, which pins both halves: the binding
works with the flag on, is absent with it off, and main-world evaluation sees
page globals, page expandos and element handles identically in both modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

package-windows pulls VCRUNTIME140/VCRUNTIME140_1/MSVCP140 out of the mozbuild
Visual Studio tree through a shell glob that pins one redist version
(14.38.33135) and one toolset (VC143). Windows builds cross-compile on Linux
and get their toolchain from mozbootstrap, so a different version there leaves
the glob unexpanded -- and add_includes_to_package() skipped anything that did
not exist, with no warning.

The package then ships without the CRT. camoufox.exe imports those DLLs, so on
any machine without the Visual C++ Redistributable installed the process dies
immediately and Playwright surfaces only "spawn UNKNOWN".

- glob the redist and toolset versions instead of pinning them
- treat a missing --includes entry as fatal, so an unexpanded glob fails the
  build instead of silently shipping a broken package
…' error

`git clean -fdx` removes the untracked mach script, so the following
`./mach clobber` in the same command failed with
"/bin/sh: 1: ./mach: not found" and `make clean` never actually clobbered the
object directory. Run clobber first, then clean.

Also fall back to re-extracting the source when the tree has no .git at all,
instead of failing in `make revert`.

Reapplied by hand from PR daijro#520 (the original commit conflicted). The PR's
scripts/patch.py half is dropped: that reset line is already correct on main
(`git reset --hard unpatched && ./mach clobber && git clean -fdx`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. assets/base.mozconfig: add --disable-debug-symbols.
   --disable-debug was already set, but that only strips DEBUG code paths; it
   does not drop -g. So the gkrust fat-LTO staticlib and the libxul link both
   carried full debug info, which is what puts linux/arm64 container builds into
   the OOM killer. Nothing in scripts/package.py reads symbol files, so this is
   pure cost for a release artifact.

2. Dockerfile: install ccache.
   base.mozconfig enables it only `if command -v ccache >/dev/null`, and the apt
   list never included it -- so the conditional was always false in-image and
   every container build was a cold build, despite /root/.mozbuild being a
   VOLUME.

3. Add .dockerignore.
   `COPY . /app` was shipping .git and any locally extracted camoufox-*/ tree
   into the build context. Measured with a scratch `COPY . /app`: context
   transfer drops from 1.6GB to 988MB (.git alone is 624MB), and a developer
   with an extracted Firefox tree was previously sending tens of GB.

   The remaining 931MB is bundle/fonts, which scripts/package.py genuinely
   reads, so it stays. Verified that nothing in Makefile, multibuild.py,
   copy-additions.sh, patch.py or package.py references the excluded paths
   (tests/, build-tester/, service-tester/, docs/, example/).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…daijro#458, daijro#93)

Two hardcoded Xvfb arguments, both verified against a live Xvfb with xdpyinfo.

daijro#458 -- `-screen 0 1x1x24`. A 1x1 root window is not a plausible desktop: it
breaks anything that measures the screen, and it is the reason
clamp_screen_to_display() has to special-case virtual displays (a generated
fingerprint would otherwise be clamped to 1x1). Default to 1920x1080x24;
the framebuffer cost is ~8MB. Overridable per-run with
CAMOUFOX_VIRTUAL_DISPLAY_SIZE="1920x1080[x24]", which is validated and rejects
malformed values rather than passing them to Xvfb.

daijro#93 -- `-extension COMPOSITE`. Offscreen rendering needs Composite, which is
what Playwright's video recording uses, so disabling it silently broke
record_video_dir under headless="virtual". A real X server has the extension,
so enabling it is also the more faithful default. Set
CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=0 to restore the old behaviour.

Verified with xdpyinfo against real Xvfb instances:
  default            -> dimensions 1920x1080, Composite present
  screen="800x600x24", composite=False -> dimensions 800x600, Composite absent
  CAMOUFOX_VIRTUAL_DISPLAY_SIZE=2560x1440 -> resolves to 2560x1440x24
  CAMOUFOX_VIRTUAL_DISPLAY_SIZE=bogus     -> VirtualDisplayNotSupported

xvfb_args becomes a property so the two settings can vary per instance; the
existing VirtualDisplay(debug=...) call sites are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every script in tests/patches/ launches through AsyncCamoufox/Camoufox, which
sets FONTCONFIG_FILE from the installed bundle and expects the packaged fonts/
tree. Pointed at an unpackaged `make build` output, font init fails
("[GFX1]: no fonts - init: 1 fonts: 74"), pending idle-startup work
(BuiltInThemes.ensureBuiltInThemes, XPIProvider cleanupTemporaryAddons,
SessionSaver) turns into a quit-application shutdown blocker, and Playwright
force-kills the browser after its graceful-close timeout. That surfaces as a
TargetClosedError on a later new_page(), which reads like a browser crash and is
easy to misattribute to whatever change is under test.

Measured for the same commit, 6 pages per run:
  shipped beta.28 (packaged)  0/5 failed
  this build UNPACKAGED       5/5 failed
  this build PACKAGED         0/5 failed

`make tests` is unaffected -- tests/conftest.py launches via plain Playwright
rather than the camoufox wrapper, so it works against obj-*/dist/bin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Video recording was completely broken: record_video_dir left an empty
directory. Reproduced identically on the shipped 152.0.4-beta.28, so this is
long-standing, not a regression from this branch.

Two independent defects in the screencast path, both from juggler having
drifted behind Playwright's Firefox delegate:

1. Protocol.js required `screencastId` on Page.screencastFrameAck, but
   Playwright acks with no parameters at all. Every ack was rejected by the
   dispatcher, and because the client sends it via sendMayFail it never
   noticed. nsScreencastService allows kMaxFramesInFlight = 1, so an unacked
   frame stalls capture permanently -- exactly one frame was ever emitted.

2. The Page.screencastFrame event carried no `timestamp`. Playwright's Firefox
   delegate does `event.timestamp * 1e3` with no fallback (its WebKit delegate
   guards with `?? Date.now()`), so every frame reached VideoRecorder with a
   NaN wall time and nothing was written to ffmpeg's stdin. ffmpeg then died
   with "Error opening output file" and no .webm appeared.

Fixing only (1) restores frame flow (1 -> 75 frames) but still yields no file;
both are required.

Verified on the packaged Linux build by decoding the result rather than
checking that a file exists: 640x480, 3.96s, 99 frames, with the animated test
content present in the decoded PNGs (hundreds of distinct colours per frame).

Video under headless="virtual" is still broken -- it records 24 pure-white
frames -- and is left open as daijro#93.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JWriter20 and others added 11 commits July 30, 2026 14:32
…recording

9654452 enabled Xvfb's Composite extension on the theory that daijro#93 (no video
under headless="virtual") was caused by disabling it. Measurement disproves it:

  composite off + record_video_dir -> valid .webm, 24 pure-white frames
  composite ON  + record_video_dir -> browser dies with SIGSEGV, no video
  composite ON  + no recording     -> fine

So compositing does not fix daijro#93, and defaulting it on turns a blank recording
into a crash for anyone recording under a virtual display. The segfault
reproduces on the shipped 152.0.4-beta.28 too, so it is a pre-existing fault in
the screencast capture path rather than something this branch introduced -- but
that is exactly why it should not be reached by default.

Kept as an opt-in (CAMOUFOX_VIRTUAL_DISPLAY_COMPOSITE=1) for hosts with real
GL, where it may behave differently. The real-screen-size half of 9654452 is
unaffected and stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two harness defects, both of which silently destroyed coverage rather than
reporting anything useful:

* conftest's session `event_loop` fixture called asyncio.get_event_loop() with
  no running loop. That is deprecated on 3.12 and raises on 3.14, and because
  it is a fixture every async test errored at setup: a full run reported 1151
  errors and 0 passes in 10s, which reads like a catastrophic browser failure
  and is not. Allocate and install a fresh loop instead.

* async/test_page_route.py imported playwright._impl._glob.glob_to_regex, which
  Playwright renamed to glob_to_regex_pattern (now returning the pattern string
  rather than a compiled regex). A module-level ImportError dropped all 43
  route tests from collection -- the tests covering exactly the code path the
  routed-header fix touches. Shim it, keeping the old helper's semantics.

Collection goes from 1182 tests + 1 error to 1225 tests, and the suite runs
under both 3.12 and 3.14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o#93)

After the screencastFrameAck/timestamp fix, recording worked headless but still
produced nothing usable anywhere else: `headless="virtual"` and plain headful
both emitted a valid .webm containing 24 pure-white frames -- Playwright's
filler for a screencast that never delivered a frame.

nsScreencastService only has a working source when the browser is headless
(HeadlessWindowCapturer). Outside headless, CreateWindowCapturer falls through
to libwebrtc's X11 window capturer, which fails three different ways:

  * no XComposite -> startVideoRecording() succeeds and then never delivers a
    frame. This is Camoufox's own Xvfb configuration, which passes
    `-extension COMPOSITE`;
  * XComposite enabled -> the browser segfaults during capture (reproduced on
    the shipped 152.0.4-beta.28 as well, so it is not specific to this branch);
  * Wayland -> nsWindow::GetNativeData(NS_NATIVE_WINDOW_WEBRTC_DEVICE_ID) is
    documented as unhandled and returns null, so the service throws
    NS_ERROR_FAILURE ("Failed to get native window id") and no capture starts.

Capture from the compositor instead when not headless, via
WindowGlobalParent.drawSnapshot() -- the same call Page.screenshot already
uses, which is why screenshots have always worked in every mode. It renders
page content directly and does not care about the windowing system.

The tick is ack-driven, mirroring nsScreencastService's kMaxFramesInFlight = 1,
so a slow consumer throttles capture rather than queueing JPEGs. Headless keeps
the native C++ capturer, which is cheaper and already correct.

Measured on the packaged Linux build, 3s recording of an animated page, frames
decoded to PNG and inspected rather than trusting file existence:

                        before                  after
  headless              100 frames, real        unchanged, real
  headless="virtual"    24 frames, all white    100 frames, real
  headful (Xvfb, X11)   24 frames, all white    99 frames, real
  headful (Wayland env) no capture at all       99 frames, real

tests/async/test_video.py passes 5/5 both headless and headful. Enabling
Composite no longer crashes either, since X11 window capture is now unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Triaged all 76 failures from the full run. None was a Camoufox browser bug --
every one was the vendored harness disagreeing with the Playwright it runs
against, or asserting a response no real server sends. Each was checked against
stock Firefox before being written off.

Harness bugs that hid real coverage:
* conftest's RemoteServer wrote snake_case launch options into a JSON consumed
  by the Node driver, which needs camelCase, so `executable_path` was dropped
  and launch-server fell back to a Firefox that isn't installed. It printed no
  endpoint and all 16 connect tests died on an empty ws_endpoint with a
  nonsense "Port should be >= 0 and < 65536. Received type string ('')".
  Also drop None-valued options: a null `channel` aborts the driver outright.
  16 failed -> 17 passed.
* tests/server.py answered 404/401 with a bare status line -- no Content-Type,
  no body. Gecko renders that through the plaintext viewer and then never
  fires `load`, leaving readyState at "interactive" forever, so page.goto()
  (which waits for `load`) hung for the full timeout. That single defect
  accounted for 17 of the 76 failures across five files, and cost 30s each.
  Confirmed on stock Firefox too, so it is Gecko behaviour, not ours -- real
  servers always send a body. clearcookies alone: 5 failed in 152s -> 7 passed
  in 2s.

Removed APIs (gone from every Playwright the package supports, <1.61):
* test_accessibility.py in full -- Page.accessibility no longer exists.
* the two expose_binding(handle=True) tests -- the parameter is gone.
* test_glob_to_regex plus its import shim -- it pinned the old `?`/`[]` glob
  wildcards, which upstream deliberately made literals. It only ever exercised
  Playwright's private helper, never Camoufox.

Assertion drift, updated to what the current Playwright actually does:
* expect(...) failures raise AssertionError, not playwright.Error.
* editability is undefined for a <button>; use a readonly input.
* timeout wording: 'Expect "x" with timeout Nms', 'Timeout Nms exceeded'.
* traces no longer carry the Python-level `apiName`; action events record
  protocol-level class+method ("Frame.goto"). Reading the old key raised
  KeyError. 5 failed -> 11 passed.
* APIRequestContext `params` are appended to an existing query rather than
  replacing it -- assert the request is built correctly instead.
* test_network asserted "Firefox" in the UA. The bare binary advertises
  "Camoufox/<version>"; the Python package rewrites it to "Firefox/<version>"
  (verified on the wire and in navigator.userAgent). This suite drives the
  bare binary, so assert what this layer can promise.

Left failing on purpose, each reproduced identically on stock Firefox:
  test_page_clock::test_should_pause (clock resumes 1-5ms late: 1002 here,
  1005 stock), test_page_add_locator_handler::test_should_wait_for_hidden_by_default_2,
  test_navigation's empty-url popup readyState, and
  test_frame_goto_should_continue_after_client_redirect -- that last one is a
  genuine race in the networkidle accounting (a subframe's navigationCommitted
  can land after its subresource requests, and Playwright clears inflight
  bookkeeping on commit), flaky in both: 3/10 wrong here, 1/10 on stock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attaching Juggler's Debugger marks the realm a debuggee, and SpiderMonkey
changes content-visible behaviour for debuggee realms: promise return values
are treated as implicitly used, throw sites capture stacks unconditionally,
and async stack capture switches on. A page can therefore tell that something
is attached, and the shape it sees is the one DevTools produces -- an
automated browser that looks like it has DevTools open all the time.

Firefox already has the concept this needs (invisibleToContent) but only
consults it in a few places, so the patch threads it through: a realm is
"debuggee visible to content" only when it has at least one debugger that is
*not* invisibleToContent, and the content-visible switches consult that
instead of isDebuggee().

Juggler then sets invisibleToContent on both debuggers it creates -- the
content one in Runtime.js and the per-worker one in WorkerMain.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d hatch

Playwright routes page.evaluate() to the execution context juggler names '',
and upstream puts that context on the page window itself. Everything the
automation evaluates is then reachable by page script: a detection script can
hook Function.prototype.toString, window.eval or Object.defineProperty and
watch the automation work. That is the leak this fork exists to avoid, and it
regressed silently in 03c1230 ("migrate Juggler modules from JSM to ESM"),
which replaced the juggler sources with upstream's -- a two-line change,
invisible in a diff full of module-format churn, and no test noticed, because
page.evaluate() keeps working either way. It just stops being hidden.

FrameTree.js gives the '' world a Cu.Sandbox over the page window instead, so
the automation runs in its own compartment. tests/patches/isolated-evaluate.py
pins the property so it cannot regress the same way twice.

The cost is that Xray vision hides the page's own JS state, so
page.evaluate('window.pageVar') reads undefined. Runtime.js therefore carries a
`mw:` escape hatch: a standalone re-implementation of Playwright's
utilityScript.evaluate compiled inside the page's real global, gated on the
`allowMainWorld` config key and off by default. It mirrors the wire format
exactly, since the client would otherwise misread a returned object such as
{a: 1} as a serialized array. Handles are refused rather than silently
mistranslated.

forceScopeAccess now selects a system-principal sandbox for that world rather
than installing an accessor on the page's Element.prototype (daijro#628), so the flag
no longer advertises itself to anything that probes for shadowRootUnl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inside an isolated world, `window` resolved through sandboxPrototype to the
page's window, while top-level declarations and exported bindings landed on the
sandbox global. The two were different objects, so `window.x = 1` and
`globalThis.x = 1` wrote to different places and only the latter read back.

Playwright calls its own bindings as `globalThis[name]` and survives, but
anything reaching for `window[name]` does not: expose_function() as documented,
and Playwright's clock, which installs stubs in one and looks them up in the
other. Aliasing the two repairs 21 tests (the whole page_clock block, locator
focus/blur, expose_binding cycles).

Nothing is lost -- the sandbox still inherits every real window property
through its prototype, so window.document, window.location, getComputedStyle,
matchMedia and addEventListener all resolve and invoke correctly. It is also
the more faithful shape: `window === globalThis` holds in a real page and did
not hold here.

One deviation remains, visible only to the automation: `window ===
document.defaultView` is false inside the world, and Playwright's serializer no
longer labels the world's global "ref: <Window>". Neither is reachable from
page script -- verified that a page-side probe still sees window identical to
document.defaultView, globalThis, top, self and frames, that no sandbox-named
property appears on window, and that writes through window, document
defaultView, top, ownerDocument.defaultView and indirect eval all stay
invisible to the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…user input

Most Playwright actions already reach the page through the widget layer, so
they are real by construction. The ones that cannot -- selecting an <option>,
filling a date or colour input, setting files on a file input -- are performed
by mutating the DOM and dispatching the events the widget code would have sent.
Those come out isTrusted: false, and they also skip the user-interacted flag
that :user-valid selects on, so a page can separate every one of them from a
real user's.

Event::Init now treats a caller in one of juggler's automation worlds as
trusted. The check is narrow on purpose: an ExpandedPrincipal whose allowlist
is exactly the target document's principal, which excludes WebExtension content
scripts (expanded principals that also list the extension). It tests the
scripted caller rather than the current realm, because the sandbox reaches
Event through an Xray and by that point the wrapper has entered the page's
realm.

A mouse-driven <select> commit also dispatches events on the chosen <option>
from the parent process; Playwright mutates the DOM instead, so those were
simply missing. nsINode.cpp now synthesises them in the shape
SelectChild.sys.mjs produces.

PageAgent's file-picker events were marked cancelable and composed, which the
real picker is not -- corrected to match DispatchEvents() in
HTMLInputElement.cpp.

tests/patches/trusted-events.py measures against a genuine widget-driven
selection rather than a hard-coded expectation: it focuses a <select>, presses
ArrowDown to go through HTMLSelectElement::UserFinishedInteracting, and
requires select_option's events to match event for event, target for target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mach build` leaves dist/bin/fonts holding only TwemojiMozilla.ttf -- the font
bundles and fontconfig are staged by scripts/package.py, so they exist only in
packaged builds. Anything launching the objdir binary through the Python
wrapper therefore starts a browser with no usable content font, because the
wrapper sets FONTCONFIG_FILE to a file that is not there.

It fails confusingly: the browser chrome still has system fonts, so the only
symptom is tofu boxes in page content, and through AsyncCamoufox it surfaces as
a TargetClosedError with no indication of the cause. That cost real time while
writing the tests/patches scripts, hence the note in their docstrings.

`make stage-fonts` copies them in. Idempotent, and a no-op when nothing is
built yet. Not needed by `make run` or `make tests`, which launch the binary
directly and fall back to the system fontconfig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checks ran as a <script> in the served page and handed their results back
through window.__testResults__. That stopped working the moment page.evaluate()
moved into an isolated world: the runner reads those globals via evaluate() and
wait_for_function(), which no longer see anything the page itself wrote. Every
profile failed before a single check executed -- per-context ones with
'NoneType' object has no attribute 'get', global ones with a 120s
wait_for_function timeout.

Running the bundle as an init script puts it in the same world the runner reads
from. Verified first that this does not change what is measured: platform,
oscpu, userAgent, hardwareConcurrency, language, screen, devicePixelRatio,
timezone, WebGL vendor/renderer and fonts.check all read identically from the
isolated world and from page script, because the spoofing is at the C++ level
and Xrays show the same values.

It is also the better shape for an antibot tester -- the checks are now
invisible to the page under test. Init scripts run at document-start, hence the
readiness wait the inline version did not need.

Result on a local x86_64 Linux build: 1023/1048 checks, grade A across all 8
profiles. The remaining failures are all screen.width/height on per-context
profiles, where the runner clamps its viewport request to 1920x1080 while the
preset expects a larger screen.

Also pin playwright<1.61: it sends viewport.isMobile in
Browser.setDefaultViewport, which this juggler's protocol schema rejects, and
requirements.txt was unpinned. Matches the cap in pythonlib/pyproject.toml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JWriter20 and others added 5 commits July 30, 2026 15:50
Reverts the default half of 4b20b77. That commit raised Xvfb's root window
from 1x1x24 to 1920x1080x24 for daijro#458, on the reasoning that a 1x1 root
"breaks anything that measures the screen".

That reasoning does not hold here:

- screen.* never comes from the root window. It comes from the generated
  fingerprint, applied per context in the browser, and
  clamp_screen_to_display() is skipped outright for virtual displays (the
  `not virtual_display` guard in utils.py), so a 1x1 root cannot clamp a
  generated screen down to 1x1.
- daijro#458's actual symptom -- blank/dark screenshots -- does not reproduce on
  152.0.4-beta.28. Measured at both geometries on the same build, same page:

    Xvfb 1x1x24        493 distinct colours, 57.5% dominant  -> renders
    Xvfb 1920x1080x24  493 distinct colours, 55.8% dominant  -> renders

  Identical. Firefox composites offscreen, so the root window size does not
  gate rendering. A full-page screenshot of example.com under 1x1x24 is
  pixel-correct.

1x1x24 is Camoufox's long-standing default and has run that way for years.
CAMOUFOX_VIRTUAL_DISPLAY_SIZE is kept as an escape hatch for anyone who does
want a real framebuffer, and still validates its input.

The Composite half of 4b20b77 was already reverted separately in 75d09a3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
camoufox ships 287 font files under Contents/Resources/fonts on macOS
(Makefile passes `--fonts windows linux` to package.py) and already sets
`defaultPref("gfx.bundled-fonts.activate", 1)` in settings/camoufox.cfg,
but none of them are ever loaded: the macOS build does not define
MOZ_BUNDLED_FONTS, so CoreTextFontList::ActivateBundledFonts() is
compiled out. That function is the only caller of ActivateFontsFromDir(),
which is what registers <GRE>/fonts with CoreText via
CTFontManagerRegisterFontURLs().

Upstream defaults the option to `target.os == "WINNT" or target.kernel ==
"Linux"` (toolkit/moz.configure), so Linux and Windows enable it
implicitly and macOS silently does not. The configure option itself is
gated only on `project == "browser"`, which camoufox is; there is no
platform restriction.

Effect on a macOS host: a spoofed font list currently resolves against
the host's own fonts, so the measurable set is `host fonts INTERSECT
claimed list` -- 13 of 60 claimed families measurable in local testing,
and those 13 are exactly the macOS-native ones. Font metrics are a
cross-checked fingerprinting surface (a UA claiming Windows alongside
macOS font metrics is a contradiction detectors look for), so this
undercuts the spoofing rather than merely reducing it.

Refs daijro#706

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JWriter20
JWriter20 marked this pull request as ready for review July 31, 2026 20:36
@JWriter20

JWriter20 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

@daijro @icepaq Looks good to me

@JWriter20
JWriter20 requested review from daijro and icepaq July 31, 2026 20:36
@icepaq

icepaq commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Super cool stuff! I'm going to try and review it sometime this week. Have been super caught up with work lately.

@JWriter20

Copy link
Copy Markdown
Collaborator Author

Super cool stuff! I'm going to try and review it sometime this week. Have been super caught up with work lately.

Appreciate it!

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.

Window rendering defect. Flickering effect on right and bottom margins.