Lots of fixes + features - #707
Open
JWriter20 wants to merge 45 commits into
Open
Conversation
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>
|
@daijro |
Collaborator
Author
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. |
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>
…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>
… broke during cleanup
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
marked this pull request as ready for review
July 31, 2026 20:36
Collaborator
Author
Collaborator
|
Super cool stuff! I'm going to try and review it sometime this week. Have been super caught up with work lately. |
Collaborator
Author
Appreciate it! |
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.
Main changes:
screen.*is now spoofed per context — before this, every context created withnew_context()leaked the host monitorThank 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.28binary — same upstream version — so pre-existing faults aren't attributed here.Stealth / detection surface
screen.*is spoofed per context. Previously everynew_context()leaked the host monitor. NewScreenDimensionManagerkeys geometry byuserContextIdthroughRoverfoxStorageManager, with self-destructingsetScreenDimensions()/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.invisibleToContenton both the main and workerDebugger.select_option, date/colourfillandset_input_filesmutated the DOM and dispatchedisTrusted: falseevents in the wrong order. Now they match a real widget-driven selection event-for-event, and set the user-interacted flag:user-validselects on.windowmeans the world's own global.window/self/frames(plustop/parentat top level) resolve to the world, sowindow.x = 1andglobalThis.x = 1land in the same place.history.lengthis no longer 0.browser.sessionhistory.max_entries=0left it at 0 — impossible in a real browser, a zero-false-positive tell any page can read — and madego_back()a silent no-op. Stock default restored; the randomwindow.history.lengthpin is dropped as strictly worse.privacy.partition.network_stateback to stocktrue— the favicon-supercookie mitigation; off, the cache/connection pool/DNS/HSTS are shared across sites.LOAD_BYPASS_CACHEon everypage.route(), which putsPragma: no-cache+Cache-Control: no-cacheon all traffic.INHIBIT_CACHINGalone starves the cache without announcing it. Also restoresconnection/cookieheader order after an intercepted request is resumed.forceScopeAccessis honoured (shadowRootUnl no longer exposed #628) — was declared and read by nothing.navigator.maxTouchPointsis spoofable (Spoof navigator.maxTouchPoints from config #697).What a page can actually observe. Page-side probe, against stock Playwright Firefox as ground truth:
evaluatewindow.x = 1visible to pagewindow__fromAutomation__fromAutomationerror/unhandledrejectionsaw automation throwexpose_functionname onwindow(non-native source)The page's own view of the DOM is byte-identical to stock Firefox (
adoptedStyleSheets, computed styles,documentexpandos,windowprototype). The isolation is only visible from the automation side.Hangs, crashes, correctness
await page.mouse.move(0, 0)as the first action generated noeMouseMove, so the ack never arrived; because input dispatch is serialized process-globally, that one stuck await wedged every later input event for the life of the page.record_video_dirproduces a video (record web scrapping #93), inheadless=True,Falseand"virtual". The native capturer only has a source when headless; outside it, it either delivers no frames or segfaults, and can't start on Wayland at all. Capture now comes from the compositor viadrawSnapshot(). Also:Page.screencastFramewas missing thetimestampPlaywright's Firefox delegate multiplies with no fallback (every frameNaN, ffmpeg wrote nothing), andscreencastFrameAckrequired an id Playwright never sends (one frame, then permanent stall).headless="virtual"keeps its 1x1 root window (headless="virtual" creates Xvfb with 1x1 pixel screen, causing rendering issues #458). The root is not observable —screen.*comes from the fingerprint andclamp_screen_to_display()is skipped for virtual displays. Confirmed by recording at the default geometry.DISPLAYgate removed (it disabled the path entirely on Windows/macOS).launch_server(Specifying user_data_dir when launching camoufox as a remote websocket server. #161) rejectspersistent_context/user_data_dirrather than silently launching a throwaway profile, and closes cleanly on exit.humanizeduration types, runtime dir prepared before a read-only launch, fontconfig kept outside the read-only browser bundle (Linux: camoufox.utils._generate_fontconfig does not use XDG (platformdirs) #654),allow_addon_new_tab.Build and packaging
--includesentry is now fatal rather than silently skipped.MOZ_BUNDLED_FONTSdefaults on only for WINNT/Linux upstream, soActivateBundledFonts()was compiled out and the 287 fonts shipped underContents/Resources/fontswere never registered. A spoofed list resolved against the host's own fonts instead — measurablyhost ∩ 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..dockerignore(context 1.6GB → 988MB),ccacheinstalled so container builds aren't cold,--disable-debug-symbols(fat-LTO debug info is what OOM-kills linux/arm64).make cleanactually clobbers —git clean -fdxwas deletingmachbefore./mach clobberran.make stage-fonts—mach buildleavesdist/bin/fontswith onlyTwemojiMozilla.ttf, so anything running the objdir binary through the Python wrapper starts with no content font and fails as a confusingTargetClosedError.Testing
Playwright suite (
make tests) — A/B against the shipped binary152.0.4-beta.28, same treego_back/historyfamily (themax_entriesfix) plus the 4 new routed-header tests.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_loopfixture calledasyncio.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), andtest_page_route.pyimported 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 protocolclass+method, not the PythonapiName), and a test server answering 404/401 with no body — which Gecko renders through the plaintext viewer and never firesloadfor, hanginggoto()for the full timeout. Each was checked against stock Firefox first.Other suites
window.__testResults__, which isolation makes unreadable; verified this doesn't change what is measured. Pinsplaywright<1.61(it sendsviewport.isMobile, which this juggler's schema rejects).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.BUILD_TARGET=linux,x86_64 make dirapplies 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. Themw: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:tracing.start(snapshots=True)succeeds and writes a trace the Trace Viewer can't show a DOM for. Cause: through Xrays,document.adoptedStyleSheetsreportsisArray === truebutlength === undefinedand noSymbol.iterator, so Playwright's snapshotter dies onfor (const sheet of ... || []). Specific to WebIDLFrozenArrayattributes — every other collection is fine. Not page-visible, but silent.pageerrorno longer fires for async errors thrown frompage.evaluate(4 tests). Errors from the page's own scripts still fire correctly.window instanceof Windowisfalseinside the isolated world, andpage.evaluate(() => window)returns{}instead ofref: <Window>(1 test). This is thewindow === selfaliasing, not isolation — dropping thewindowalias restores both, at the cost ofwindow === self. As written the two are mutually exclusive.Also:
expose_functionis 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.forceScopeAccesswas read by nothing and demonstrated theCu.exportFunctiontechnique my fix reuses. I didn't take its shape (it swaps the default world for a sandbox over the page window, sopage.evaluate('window.pageSecret')returnsNone); the mechanism is entirely theirs.fonts/directory does nothing on macOS:MOZ_BUNDLED_FONTSis undefined there, soActivateBundledFonts()is compiled out and every shipped font is silently ignored. Traced it to the upstreammoz.configuredefault 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 whilefonts.jsonmixes 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.XDG_CACHE_HOMEis unset.camel_caseinlaunch_options(). Kept; dropped thepersistent_contexthalf after confirmingbrowserServerImplnever consumes it, and made those options raise instead (#161).git clean -fdxdeletingmachbefore./mach clobber. Reapplied by hand.navigator.maxTouchPointsspoofing. As-is.allow_addon_new_tab. As-is.Checklist
Service tests— not run:service-testerneeds aproxies.txtof live per-context proxies I don't have. Happy to run it if you can point me at what it expects.Compare: main...JWriter20:camoufox:main