10 Commits
Author SHA1 Message Date
f36390a19e feat(geoip): make GeoIP AIO the default source, deprecate GeoLite2 (#820)
The default GeoIP source was MaxMind GeoLite2 via sapics/ip-location-db,
whose URLs kept serving the 2026-06-17 build after that project moved to
GitHub Releases (found in #815). GeoIP AIO (daijro/geoip-all-in-one)
resolves timezones more accurately on real proxy IPs and is rebuilt weekly.

- repos.yml: AIO is the default; GeoLite2 is `deprecated: true`, with the
  Releases URLs from #815 so it still works when picked by name.
- A cache holding a deprecated source it was not explicitly given
  (`camoufox set --geoip` or the GUI) moves to the default and drops the
  old database. An explicit choice is kept, with a FutureWarning.
- needs_update() reads the database's build date instead of the file age:
  refresh once the build is over 8 days old, re-checking at most daily,
  and warn when a fresh download is over 30 days old (a frozen source).
- get_geolocation(geoip_db=...) now reads that source's own database
  rather than the active one's, and no longer makes it the active one.
- tests/test_geoip_sources.py (from #815) downloads every non-deprecated
  source and fails when its build is stale; tests.yml installs the geoip
  extra so it runs, and so gates every release.
- TypeScript twin updated to match; goldens answer in both layouts.

Co-authored-by: lp177 <57773165+lp177@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:02:52 +00:00
Jake WriterandClaude Opus 5.5 11969fa44a Prerelease on every tested merge, promote by tag, and pair each library release with its browser (#810)
* Pair each library release with the browser build it was tested with

Nothing tied a library release to a browser build: `camoufox fetch` took the
newest build in a channel, and a launch used whatever config.json marked
active, so an upgraded library could run a browser it was never tested with,
and an old library would pick up a newer, incompatible browser.

A released package now carries browser-pin.json, naming the browser release
built from the same sources. With it, and no explicit choice by the user:

- fetch installs exactly that build (no prerelease prompt: it is the build
  this release was tested with, prerelease or not);
- a launch uses exactly that build, whatever else is installed or active,
  and reports it as not installed rather than falling back to another;
- the fetcher's automatic install (TypeScript's first run) takes only it.

An explicit `camoufox set` still wins, with a one-time warning at launch;
`camoufox set --release` returns to the paired build, and `camoufox active`
says which is in use. The checked-in pin is `{}`, so development checkouts
follow their channel as before.

Also: prerelease library versions (0.5.8b1, 0.5.8-beta.1) parse as their
release; they were read as 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release a prerelease on every tested merge; promote to stable by tag

Every merge to main whose tests pass now publishes a prerelease of all
three artifacts, and pushing vX.Y.Z on a tested main commit promotes it.

- Build and Release runs after Tests on main. It builds the browser only
  when its sources changed (ci.browser_inputs.source_digest: every browser
  input, not counting the release number). Each build gets the next unused
  beta.N on a release commit beside main -- main is protected -- and is
  published as a GitHub prerelease, not a draft, with its source digest in
  the notes.
- Publish to pypi follows it: <next>bN on PyPI, then Publish to npm puts
  <next>-beta.N under the `next` dist-tag. Both are stamped with the browser
  release built from the same sources.
- A vX.Y.Z tag is refused unless the commit is on main and `All tests
  passed` succeeded on it. The paired browser prerelease then becomes the
  stable, latest release (no rebuild, so users get the tested binaries), and
  X.Y.Z goes to PyPI and npm `latest`.

The tested commit travels between workflows as an artifact: a workflow_run
is told main's head, so two quick merges would otherwise publish the second,
untested one. ci/release.py holds the planning, stamping and promotion,
unit-tested in ci/tests/test_release.py.

Also fixes two checks that failed the manual release already: vermin
targeted Python 3.8 exactly, against a package that declares ^3.10 and a
code base that needs 3.9, and check-pack compared npm and PyPI prerelease
versions as strings, although each registry spells them differently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test driver-only pull requests against the release paired with their sources

The scope step matched the release tag named by upstream.sh. With release
numbers now allocated per build, that number is a floor, not a release, so
driver-only pull requests would nearly always rebuild, or fetch a build other
than the one their sources produce. It now asks `ci.release paired` for the
release built from exactly this tree's browser sources, and fetch-browser
installs it through the same pin a released package carries.

Documents the release flow in ci/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* pythonlib: replace asyncio.to_thread so the 3.8 vermin gate passes

publish-pypi.yml checks the package with
`vermin . --eval-annotations --target=3.8 --violations camoufox/`, and
asyncio.to_thread (Python 3.9+) in _resolve_proxy_geo failed it, stopping
the 0.5.7 release. loop.run_in_executor does the same off-loop lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pair with releases cut before the digest marker, and test the pairing against the step

The scope step now asks ci.release paired, which only knew releases whose notes
carry a source digest. None does yet: v156.0.1-beta.32, the release built from
main's sources, predates the marker. So every driver-only pull request would have
rebuilt the browser, the first merge would have cut a duplicate beta.33, and the
two scope tests in ci/tests/test_ci.py -- which ran the step in a scratch repo
where ci.release did not import -- failed.

find_paired falls back to the tag upstream.sh names when that release is
published (a prerelease counts; a draft does not) and no browser source changed
since, listing the files that did when they have. browser-plan and promote use
the same lookup. paired takes --root and --releases so the tests run the
workflow's own step against a scratch repo and a fixed release list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release from one workflow, with trusted publishing

The release chain was four workflows linked by workflow_run, with the
tested commit carried between them as an artifact; a browser release
number committed beside main; pairing data in HTML comments in release
notes; a stored PyPI token; and packages rebuilt at each publish.

release.yml now does all of it with `needs`:

- On a push to main it calls tests.yml on the pushed commit (tests.yml
  loses its own push trigger), then builds the browser only when its
  sources changed, and publishes a library prerelease only when something
  a package ships changed. Docs- and CI-only merges publish nothing.
- A browser release's number lives only in its tag, which points at the
  tested main commit; `ci.release set-build` writes it into the build's
  working tree. Nothing is committed.
- Each browser release carries a manifest.json asset (source digest,
  commit), which is what a library pairs by. Builds are attested with
  actions/attest-build-provenance.
- Both packages are built once, in build-library, and the publish jobs
  upload exactly those files. PyPI and npm use trusted publishing; no
  credential is stored.
- A vX.Y.Z tag builds and checks both packages before promoting the
  paired browser and publishing.
- Every published library version is tagged (vX.Y.ZbN for a prerelease),
  which is how the next merge tells whether the library changed.
- A failed publish is retried with "Re-run failed jobs"; the retry-only
  workflow_dispatch path is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:46:41 +00:00
coffeegrind123andClaude Opus 5.5 0cb8c60bb9 Pace humanized moves on their own schedule, and write toggle prefs on every launch (#808)
* fix(juggler): pace a humanized move on its own schedule

sendTrajectoryAcked waited each point's full pause after the previous
point's ack, so every ack's round trip was added to the move: it took
its plan plus one round trip per point. On a page whose main thread is
busy 19ms in every 20, moves capped at 0.5s took 0.54-0.71s. Each point
is now due at its planned offset from the start of the move, so a late
ack delays only its own point and the move ends on its planned time.

tests/patches/humanize-pacing.py times eleven capped moves on such a
page and checks their median against the cap. It failed 3/3 before
(medians 587-607ms) and passes after (484-487ms).

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

* fix(launch): write toggle prefs on every launch, on or off

block_webrtc, block_images and disable_coop wrote their pref only when
switched on. A persistent profile keeps a user.js pref in prefs.js, so
removing the flag later left the setting in force. A real profile
launched once with block_webrtc still had media.peerconnection.enabled
false long after, and BrowserScan reported WebRTC disabled.

Each pref is now written every launch, with the stock value when its
flag is off. A caller's own firefox_user_prefs entry still wins. The
same change is made in the TypeScript launcher, with its tests.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:20:58 +00:00
Jake WriterandClaude Opus 5.5 e92caec332 CI: release npm with PyPI, split the patch guards by kind, run memory growth on every PR (#789)
* ci: split the patch guards by kind, and run memory growth on every PR

Patch guards: one 11-minute job ran all 26 guards and the Playwright
skiplist audit. Whether the patches apply is the build's check; the
guards test that what they do still works, and fall into three kinds,
now three jobs beside the skiplist audit:

  spoofing    a spoofed value still reaches the page and holds together
  automation  Playwright stays invisible to the page and never deadlocks it
  parity      what a page, or the OS, can observe matches stock Firefox

Each writes its own suite (patch_guards_<group>), so a failure names the
kind that broke. GROUPS in ci/run_patch_guards.py assigns every guard to
exactly one, and a self-test fails on a guard in none. One job id with a
matrix, so everything that needs patch-guards is unchanged.

Memory growth: ~38 minutes in one process kept it on the schedule and
out of the gate. ci.run_native --shard i/n runs every n-th collected
test, and the growth job is a 7-way matrix -- one test per runner, about
six minutes each -- on every pull request, required by the summary and
the gate. summarize.py already folds <suite>-<i>of<n> results back into
one suite, as it does for Playwright.

CONTRIBUTING.md now says why the stealth check skips on a fork pull
request: GitHub gives secrets only to branches in this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(release): publish to npm after PyPI, from the same commit

The two launchers ship at one version, but were released by two
unrelated, hand-started workflows, so npm could get a release PyPI did
not. "Publish to pypi" is now the one place a release starts:

1. it calls publish-npm.yml as a dry run -- every check, the build, the
   pack check and `npm publish --dry-run` -- so a broken npm package stops
   the release before anything is uploaded;
2. it uploads to PyPI;
3. its success triggers publish-npm.yml (workflow_run), which publishes
   the commit PyPI was released from.

publish-npm.yml stays the file that publishes, because npm's trusted
publisher is tied to its name. Started by hand it only retries the npm
half, and refuses unless PyPI already has the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): contentaccessible-parity reads its probe's marked result line

The live probe runs in a child process and the parent parsed its whole
stdout as JSON. On a machine whose cache has no addons yet, the first
Camoufox launch downloads uBlock Origin and prints its progress to
stdout first, so the parse failed ("Expecting value: line 2 column 1").
It only ever passed because another guard launched Camoufox earlier in
the same job; split into its own leg, it ran first on a fresh runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(guards): group the three guards main added since the split

addons-install-once, viewport-no-rdm and worker-config-reads landed on main
after the groups were drawn; the one-group-each self-test caught them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): gfx-probes gives a blocklisted launch a second try

The blocklist signature means gfxInfo is empty. Missing probes cause that on
every launch; a present glxtest that fails or times out on a loaded runner
causes it once in a while, which failed stock parity on this PR. Relaunch once
before failing, and print what the probes wrote to stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:42:47 +00:00
Jake WriterandClaude Opus 5.5 12a1bb4fc6 fix(fingerprints): a NewContext Linux identity is Linux in platform and oscpu too
fpgen's Linux pool now and then pairs a Linux user agent with
navigator.platform Win32 and a Windows oscpu. launch_options() corrects
that with fix_navigator_arch(); generate_context_fingerprint() never
called it, so 24 of 1,500 Linux NewContext identities (1.6%) said Win32
under a Linux UA -- and because that path reads the OS for fonts and
voices from the platform, they drew Windows fonts and voices as well.

It now applies the same fix right after the fpgen draw, in pythonlib and
in the TypeScript twin. The fix draws nothing, so the parity goldens are
unchanged.

The new tests feed the context path a real Linux draw with the Windows
platform and oscpu, and fail without the fix ('Win32' != 'Linux x86_64')
in both ports. After it: 0 of 1,500 sampled contexts mismatch.
pythonlib 412 passed; typescript 585 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:09:03 +00:00
Jake WriterandClaude Opus 5.5 8081061156 fix(juggler): never enter Responsive Design Mode, and warn about is_mobile
#798 left RDM on for isMobile, as Playwright's Juggler does. RDM matches
no real browser: Firefox for Android never runs it, and under touch
emulation it drops a mouse click's pointer events, which no device does.
Camoufox only has desktop identities, so an is_mobile context was a
desktop UA, platform, fonts and GPU with devtools' mobile mode on top.

Juggler now keeps inRDMPane off for every page, is_mobile included, and
the isMobile plumbing #798 added is gone again. viewport-no-rdm requires
is_mobile=True to keep the platform's scrollbars too.

Both launchers warn instead (warnings.yml is_mobile): on new_page() /
new_context(is_mobile=True) of a Camoufox browser, and on is_mobile
passed to launch_options() for a persistent context. has_touch,
device_scale_factor and viewport keep working without RDM.

Upstream playwright-python skips its isMobile tests on Firefox in
1.61-1.63, so no skiplist entries are needed.

On beta.31 with this Juggler in omni.ja, viewport-no-rdm passes: 12/12 px
of scrollbar with no viewport, with a viewport, and with is_mobile, and a
has_touch click fires pointerdown and pointerup. pythonlib 411 passed;
typescript 584 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:09:03 +00:00
Jake WriterandClaude Opus 5.5 8823d399b8 fix(fpgen): keep a values.dat the TypeScript launcher decompressed from the pin
CAMOUFOX_FPGEN_DATA may point the TS launcher at pythonlib's fpgen data/,
and it decompresses values.dat there. ensure_fpgen_model() refused to run
whenever values.dat existed, so sharing the directory broke every Python
launch. The pin now carries values.dat's sha256 (all three twins): a
matching values.dat is kept, any other is removed, since fpgen reads it in
preference to the verified archive. The files `fpgen decompress` leaves
still fail loudly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:09:03 +00:00
coffeegrind123 c2817c1cae fix(coherence): let macOS draw Intel Mac GPUs, checked as machines
coherence rejected "Intel(R) HD Graphics 400" and "Radeon R9 200
Series" on macOS as a Braswell Atom IGP and a desktop PC card. They are
Firefox's sanitized buckets, not devices: SanitizeRenderer
(FIREFOX_152_0_4_RELEASE) maps an Intel Mac's UHD 630 to the first and
a Radeon Pro 5300M to the second, per Firefox's own TestCiMac and
TestMacAmd. fpgen's pinned model records both from Firefox on macOS at
1.14% each. The rule kept every generated Mac off them, removed 24
real macOS presets in #779 (restored here, 9 + 15), and dropped either
GPU from a caller's Mac preset. ANGLE and llvmpipe stay rejected:
Firefox 152 has no ANGLE-on-Metal path (Bug 2046027 came later).

Intel Macs are now checked as machines instead (intel-mac-hardware),
for every non-Apple GPU on macOS:

- a core count some Intel Mac with that GPU reports. Firefox reports
  physical cores where kern.tcsm_available is set and logical ones
  otherwise, so either counts: 2-8 physical / 4-16 logical for the
  IGP, up to the 2019 Mac Pro for a discrete GPU;
- a screen that is not a notched MacBook's or the 24" iMac's. 45.5% of
  fpgen's Firefox macOS screens are one, and the draw already put an
  Intel or AMD bucket behind 4.9% of them.

The WebGL draw applies the same check, given the core count. The preset
GPU drop moves next to the WebGL draw, after the host core count and
the display clamp: before, a preset's GPU was judged against cores the
launch then replaced. The TypeScript launcher gets the same changes,
and the goldens cover the new check and the narrowed draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d6e2adcb6c)
2026-09-27 20:09:03 +00:00
coffeegrind123 7f0e52e792 Install the pinned fpgen model before fpgen is imported
pythonlib left the model to fpgen, whose first import downloads the
first release the GitHub API lists: model-4/2025, whose WebGL records
have no vendor or renderer. Every generated launch on a fresh install
failed with KeyError: 'vendor'. scripts/pin-fpgen-model.py pinned the
model for CI only, and fpgen's five-week refresh replaced even that
pin, under a stamp the script's --check still trusted.

fpgen is now imported only through fpgen_model.load_fpgen(), which
installs the release named by the pin, checks the archive and each
file against their sha256, and dates the files past fpgen's refresh.
It writes the layout and stamp the script and the TypeScript launcher
use, verifies an install by hashing it, and leaves FPGEN_MODEL_URL to
fpgen. `camoufox fetch` installs the model as well. The pin gains each
file's sha256; the package carries a copy of it, and the script now
calls the module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f043de3598)
2026-09-27 20:09:03 +00:00
Jake WriterandClaude Opus 5.5 0c6cc0a397 Official TypeScript/JavaScript launcher at parity with pythonlib, published to npm (#785)
* feat(ts): import the TypeScript launcher port from feat/captchakrakenAndJSSupport

CAPTCHA support is left out; this branch is the JS/TS driver only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): drop the CAPTCHA wiring left behind by the import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): port fpgen to TypeScript, on the same pinned model

fpgen is not on npm. The port reads scripts/data/fpgen-model.json and checks
its sha256 with TLS on, never fpgen's own first-release download. Everything
that does not depend on the random draw is identical to Python (network,
value lookups, trace probabilities, conditions, errors); the draws are held to
Python's distributions by chi-square tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): identity layer at parity with pythonlib

A bit-exact port of CPython's random.Random, numpy's PCG64 choice and orjson's
serialisation, so identity_salt/identity_seed and every seeded draw (fonts,
voices, media devices, WebGL, noise seeds) come out identical to Python for the
same identity. coherence.py, presets and screen/window fixes are ported, and
golden fixtures recorded from pythonlib hold all of it to exact equality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): launcher at parity with pythonlib's launch_options

launch_options() now produces pythonlib's output byte for byte (CAMOU_CONFIG,
CAMOU_PREFS_N, prefs, env, fontconfig, warnings) over 89 recorded scenarios.
Ports core pinning, geolocation, locales, fontprobe, the async API, and the
pkgman/multiversion integrity checks. An opt-in e2e suite launches a real
build through both launchers and compares what a page sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: test the TypeScript package, and publish it to npm like pypi

- ci/run_typescript.py writes the `typescript` gate (typecheck, lint, vitest
  with the pythonlib golden tests) and, with --browser, `typescript_browser`
  (the e2e suite against the browser under test). Both are required by the gate.
- publish-npm.yml mirrors publish-pypi.yml: workflow_dispatch, checks, build,
  scripts/check-pack.mjs (version == pythonlib, every data file shipped, the
  tarball installs and imports), then publish via npm trusted publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): e2e that holds on any browser, NewContext, and the package on every PR

- Parity (TS == Python on the same binary) stays strict everywhere; whether the
  browser honours the config is asserted only on a binary whose properties.json
  knows every key the launcher sets, and otherwise skips naming the missing keys.
  A driver-only pull request is tested against the published release, which
  lags the launcher (beta.30 predates #779), so this is what makes the suite
  meaningful there instead of red on skew it cannot fix.
- New: NewContext in a real browser -- a per-context identity that differs from
  the launch identity and from a sibling context, and equals Python's.
- python_probe.py keeps stdout for its JSON (pythonlib prints "Skipping unknown
  patch" there), and a non-JSON reply now fails fast instead of hanging 240 s.
- The typescript gate builds the package and runs scripts/check-pack.mjs, so a
  packaging mistake fails the pull request that makes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): commit the launch fixtures, and fetch the browser from its real directory

- The root .gitignore ignores every path named `launch` (local build output),
  which silently dropped typescript/tests/fixtures/launch/ -- the
  launch_options() goldens -- from the branch. Re-included in
  typescript/.gitignore.
- fetch-browser read camoufox-bin from `camoufox path`, the cache ROOT, but
  multiversion installs each build under browsers/<channel>/<version>/, so the
  job has failed on every driver-only pull request since #772. It now resolves
  the active build as the launcher does (pkgman.camoufox_path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(ts): give the typescript job pythonlib, so the cross-language checks run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): fpgen model install is safe across processes

Processes installing into an empty cache at once each downloaded the model,
and one's install deleted the values.dat another had just decompressed, which
then failed its next lookup with ENOENT. Seen with vitest's parallel files on a
cold cache; a worker pool on a fresh machine would hit it too.

- ensureModel() installs under a cross-process lock (an atomic mkdir, stale
  after 10 min) and re-checks what is installed once it holds it.
- values.dat is only removed when the model is actually being replaced.
- The model keeps values.dat open, instead of reopening it on every lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: make local runs and CI see the same test suite

Three ways the suite passed here and not on the runner, each fixed at its cause:

- The root .gitignore's bare `launch` rule (for the Go launcher binary) ignored
  every path segment named launch, so tests/fixtures/launch/ never reached git.
  Anchored to /launch; and ci/run_typescript.py now fails when any file under
  typescript/{src,tests,scripts} is git-ignored, which would have caught it on
  the machine that wrote the fixtures.
- A missing prerequisite (fpgen model, pythonlib venv, fontTools, Xvfb, a font
  directory) skipped its tests, and a skip reads as green. tests/prereq.ts now
  fails them under CI unless the job names the gap in
  CAMOUFOX_TEST_ALLOW_MISSING. The typescript job installs all of them. The
  font-name check read one developer's local browser bundle; it now reads
  /usr/share/fonts (or CAMOUFOX_TEST_FONT_DIR), and CI installs a .ttc set.
- The fpgen install race surfaced only on a cold cache, by accident. It now has
  deterministic tests: a same-model reinstall keeps values.dat (verified to fail
  on the old code), the lock admits one holder and releases on error, and a
  stale lock is reclaimed.

Also: the browser gate runs only the e2e file, and the e2e probe and the
virtual-display test time-box each await, so a hang names its step instead of
reporting a bare 240 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): hold headless="virtual" to Python's, not to headless

On the runner (no media hardware), published beta.31 never settles
enumerateDevices() in a headful window while headless answers -- the named
timeout in the probe caught it. That is a browser property, so like the other
page-vs-config checks it moves to a test that runs on a binary current with
the launcher; the virtual-display test now requires the same page as Python's
headless="virtual" on the same binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): test on the published release only when it matches this tree

A pull request that did not touch the browser was always tested against the
published release. The patch guards and suites come from the checkout, so once
a browser change was merged but not yet released (#779, on top of beta.31),
every driver-only pull request ran #779's guards against a browser without
#779 -- eight guards failed on #785, which changes no browser source.

resolve now also compares the tree's browser sources with the tag the release
was cut from (v<version>-<release> from upstream.sh), and builds when they
differ or the tag does not exist. Building restores the base branch's cached
browser when its compiled half matches -- main's #779 build, here -- so the
extra cost is a cache restore, not a compile. Self-tests run the workflow's own
scope step in a scratch repo for the four cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): say when CI's browser cannot present the configured locale

With #785 finally tested on a browser current with the tree (main's cached
#779 build), every TS-vs-Python parity check passed and the page-vs-config
check failed: a de-DE/fr-FR identity presented en-US. Python presents the same
on that binary. CI tests the build job's unpackaged dist/bin, whose
res/multilocale.txt lists en-US only -- scripts/package.py injects the
langpacks, and CI never packages. So no CI suite had ever run a non-English
locale on a browser that has one.

The e2e locale assertions now run when the binary under test packages the
configured locale (read from res/multilocale.txt, loose or in omni.ja), and
otherwise go through prerequisite("packaged-locales"), which fails in CI
unless the job names the gap. The typescript (browser) job names it, with the
reason; the rest of the page-vs-config check stays strict. On a packaged #779
build all of it, locale included, passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): give the headful e2e page focus before probing it

enumerateDevices() intermittently never settled in the headless="virtual"
test on CI (passed one run, timed out the next, same build). Firefox defers
device enumeration until the document has focus -- LEAKS row 57 recorded the
same for a background tab -- and headless mode fakes focus while a headful
window on a bare Xvfb, with no window manager, only sometimes receives it. A
user's window has focus, so both launchers' virtual-display probes now bring
the page to the front first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: remove build tooling nothing uses

- The developer UI (scripts/developer.py, `make edits`). It depended on
  easygui, which no requirements file declares, and every action it offered is
  a Makefile target: patch, unpatch, workspace, revert, diff. Its two helpers in
  scripts/_mixin.py (is_bootstrap_patch, patch) had no other callers.
- legacy/, the Go launcher deprecated in 2024-11. Nothing built or shipped it.
  Its Makefile targets and scripts/run-pw.py go with it, and so does Go from
  every dependency list and workflow.
- jsonvv/ and settings/camoucfg.jvv. Nothing read the .jvv schema: config is
  validated against settings/properties.json, and the two had already drifted.
  The jsonvv package stays on PyPI.
- Scripts with no caller: bootstrap.py, moztree, setup-wasi-linux.sh,
  package-helper.sh, install-local-build.sh, mozfetch.sh (copied into lw/ but
  never packaged), examples/.
- The pre-ESM Juggler copies JugglerFrameParent.jsm and JugglerFrameChild.jsm,
  and hidden-scrollbars.css. Juggler loads the .sys.mjs actors and deliberately
  no stylesheet, but jar.mn still packaged all three.
- patches/librewolf/*.opt, which list_patches() never picks up; the roverfox
  second pass in patch.py, whose directory no longer exists; the unread
  --no-settings-pane option.
- The CAMOUFOX_PASSWD secret passed to `make fetch` and closedsrc_rev in
  upstream.sh, which nothing reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove dead helpers and a stale dependency

None of these had a caller:
- pkgman: is_supported_path, extract_zip, cleanup and set_version, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion.get_cached_repo_names, CONSTRAINTS.as_range,
  fingerprints._load_os_voices, utils._clean_locals, and unused imports.

Also:
- The "Apify Fingerprints" row in `camoufox version`, which has read "?"
  since fpgen replaced BrowserForge.
- lxml is no longer a dependency; nothing imports it.
- The geoip extra now names maxminddb, the module geolocation.py actually
  imports, rather than getting it transitively through geoip2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: show the cursor paths humanize=True actually produces

The README's cursor video showed the Bezier generator Camoufox replaced
with Cursory's recorded trajectories. scripts/cursor-demo.py drives a real
build with humanize=True and records every mousemove event the page
receives. It writes assets/humanize-cursor.svg, an animated replay at the
recorded speed, so what the figure shows is what a site sees.

The script cannot change the binary, so ci/browser_inputs.py lists it as
non-native and editing it does not invalidate the cached browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop naming BrowserForge in user-facing text

fpgen replaced BrowserForge, but two LeakWarnings, the NonFirefoxFingerprint
message and the fingerprint_preset docstring still named it. One warning also
linked to a README anchor that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: one AGENTS.md for every agent, a roadmap, and docs that match the code

- AGENTS.md holds the engineering rules for any coding agent, plus the
  repo map, build, patch and test commands that CLAUDE.md used to carry.
  CLAUDE.md now only imports it, so there is one set of rules.
  ci/tribal-rules.yml is the record of settled decisions it points to.
- ROADMAP.md lists planned work, each item linked to its issue.
- README:
  - fpgen and the coherence check replace BrowserForge;
  - the patch workflow uses the make targets instead of the removed
    developer UI;
  - letter-spacing noise is described as off by default, as it is.
- docs/:
  - beta-testing-ff146.md removed;
  - patch-upgrading-guide rewritten around the make targets;
  - per-context-patches without the canvas patch that no longer exists,
    and with measured preset counts;
  - playwright-maintenance without the JSM wrapper that does not exist;
  - smaller fixes in MEDIA-DEVICES, input-dispatch and FONTS.
- ci/README: every job, and the real shard, skiplist and entry-point lists.
- pythonlib, tester and patch-dependency READMEs corrected against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pythonlib): handle headless='virtual' in launch_server

launch_server() is documented to take the same arguments as Camoufox(),
but passed headless='virtual' straight to launch_options(), so the server
launched with no Xvfb display. Start a VirtualDisplay the way Camoufox()
does, launch headful on it, and kill it when the server process exits or
the launch fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): remove fontprobe, which nothing called

fontprobe listed the fonts installed on the host, for a `camoufox fonts`
command that was never added. It has nothing to do with the font bundle
Camoufox serves to pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(license): the Python launcher is MIT; the browser stays MPL-2.0

The Python package has always been published to PyPI as MIT (#727), but
pythonlib/ shipped no licence file, and the repo's LICENSE is the browser's
MPL-2.0. MPL is copyleft per file. It covers the modified Firefox sources, not
a separate launcher that drives the browser over Playwright. So
pythonlib/LICENSE now carries the MIT text its metadata already declares, and
a Licensing section in the README says which part is which.

Closes #727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): fingerprint_preset=False no longer turns presets on

launch_options checked `fingerprint_preset is not None`, so passing False
drew a random bundled preset, the opposite of what was asked. It now uses a
truthiness check, and a test proves that None and False never draw a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: bring the release workflow in line with the tests build job

build.yml had drifted from tests.yml. It ran actions at v1/v2 on a
retired Node runtime, prepared the source tree with bare make calls
that fail the whole release on one dropped connection, and built with
a different Python than every pull request is tested with.

- Pin every action by commit SHA, at the major versions tests.yml uses
  (checkout v4, setup-python v5, upload/download-artifact v4, the same
  remove-unwanted-software SHA), and action-gh-release v2. The release
  job holds contents: write, so it should not follow a movable tag.
- Prepare the tree with `python3 -m ci.run_prepare`, as the tests build
  job does. BUILD_TARGET is set from the matrix so `make dir` writes the
  right mozconfig and Rust targets; multibuild.py then finds _READY and
  builds without re-patching. mach's toolchain bootstrap ignores the
  mozconfig, so running it after `dir` bootstraps the same toolchains.
- Build with Python 3.12, the version the tests build job compiles with.
- Default the workflow to no permissions; the build job gets
  contents: read and the release job keeps contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): NewContext looks up a proxy's exit IP through the right URL, or fails

NewContext derives the context's WebRTC IP and timezone from the proxy's exit
IP. That lookup had two defects, and both left the context showing the
host's values while its traffic went through the proxy:

- It built its own proxy URL with urlparse, which reads a scheme-less server
  such as "1.2.3.4:8080" (a form Playwright accepts) as scheme "1.2.3.4" with
  no host. urllib could not use a SOCKS proxy at all.
- Any failure was swallowed, and the context opened without the values.

The URL is now built with Proxy.as_string(), which the geoip launch path
already uses (scheme-less means http). The lookup goes through requests,
which handles SOCKS, and a failed lookup raises InvalidIP, naming the two
options that skip it. The tests cover scheme-less, http and socks5 servers
with credentials, both failure modes, and the case where no lookup is needed,
for NewContext and AsyncNewContext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stop generating a canvas seed, and drop config keys nothing reads

The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): remove dead helpers

None of these had a caller:
- pkgman: isSupportedPath, extractZip, cleanup and setVersion, left over
  from the single-directory install. cleanup() would have deleted every
  installed browser version.
- multiversion getCachedRepoNames and getCachedVersions, CONSTRAINTS.asRange,
  removeMmdb (Python keeps its twins for the GUI) and pycompat pySorted.
- The "Apify Fingerprints" row in `camoufox version`, which read "?".

utils.ts now calls noiseSeedsFromIdentity instead of repeating its two
formulas inline, so the tested function is the one that runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): remove fontprobe, which nothing called

fontprobe.ts listed the fonts installed on the host, for a `camoufox fonts`
command neither launcher has. It has nothing to do with the font bundle
Camoufox serves to pages. Its parity test goes with it, and so do the CI
prerequisites only that test needed: fonttools and the extra font packages.
(The Python twin is removed in #787.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): license the launcher MIT, with third-party notices

The TypeScript launcher is a port of pythonlib, which has always been
published to PyPI as MIT (#727); the MPL-2.0 of the browser covers the
modified Firefox sources, not a launcher that drives it over Playwright.

THIRD_PARTY_NOTICES.md ships in the npm package with the notices for the
code the port translates: fpgen (Apache-2.0), CPython's random (the MT19937
BSD notice and the PSF licence), and NumPy's SeedSequence and PCG64 (BSD-3
and MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document the TypeScript package outside typescript/

The README, CONTRIBUTING, ci/README and the issue templates did not mention
the npm package or its two CI gates. ci/README also still said driver-only
pull requests never build. Since the scope step started comparing browser
sources against the release tag, they build whenever the release is behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): repair devicePixelRatio the same way on every launch

The DPR repair snaps an off-grid ratio to the nearest real scaling step and
keeps the first of two equally near steps. The steps were frozenset
literals, and a frozenset literal iterates in one order when the module is
compiled from source and another when it is loaded back from a .pyc. So a
midpoint such as 1.125 became 1.25 on the first launch after an install and
1 on every launch after it: the same pinned identity presented two different
devicePixelRatio values.

The steps are now ascending tuples, so a tie always goes to the lower step.
The test runs the repair in two fresh interpreters that share a bytecode
cache, compiling in the first and loading in the second. It failed before
this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): mirror #787's pythonlib fixes

The TypeScript side of the behaviour #787 changes in pythonlib, so the port
stays at parity:
- fingerprint_preset=false no longer draws a preset.
- NewContext builds the proxy URL with ProxyHelper.asString() (scheme-less
  means http), looks up the exit IP through impit, and throws InvalidIP when
  the lookup fails instead of opening the context with the host's values.
- No canvas seed is generated or sent (#721). noiseSeedsFromIdentity becomes
  audioSeedFromIdentity, and fpgen's constant navigator fields are no longer
  mapped.
- The devicePixelRatio steps are ascending, so a tie goes to the lower step.
- The two LeakWarning texts that named BrowserForge.
- The README's note that Python's launch_server() ignored headless='virtual'
  is gone, because it no longer does.

The golden fixtures are regenerated from #787's pythonlib. The generator now
masks the fontconfig file name the way the test already did. The name hashes
content that embeds the checkout path, so every regeneration from a different
checkout used to rewrite 76 fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: remove the glyph-spacing seed from the browser and the launcher

anti-font-fingerprinting.patch added a seeded amount to every glyph advance,
so that text widths differed per context. No real machine produces those
widths: the same font on the same OS measures the same everywhere. So the
noise was itself a fingerprint, measured in #779 at +1 px per ~100 glyphs
plus fractional deltas on every measureText. #779 defaulted the seed to 0
and kept it as an opt-in, but an opt-in whose only effect is to become
detectable is not worth carrying. Removed:

- The browser side:
  - FontSpacingSeedManager and window.setFontSpacingSeed;
  - the HarfBuzz hook;
  - the plumbing that existed only to carry the context id down to the
    shaper: the userContextId on gfxTextRun, gfxShapedWord and the word-cache
    key, and the extra MakeTextRun argument in nsTextFrame, nsFontMetrics,
    MathML and canvas.
  The font group keeps its userContextId, which font-list-spoofing.patch
  uses to apply the per-context font list. Text is now shaped exactly as
  stock Firefox shapes it.
- The fonts:spacing_seed key. The launcher had been sending 0 on every
  launch, plus a setFontSpacingSeed(0) call in every context's init script.
- tests/patches/config-overrides.py, which tested only the spacing override.
  A pythonlib test now covers config_overrides with another key.

timezone-spoofing, webrtc-ip-spoofing and window-setter-seal change only in
context lines and the setter seal list. Every patch applies cleanly to a
fresh tree, and the result builds. The settled decision is recorded as
no-glyph-spacing-noise, with an automated check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: stock animations and speech by default; drop config keys that freeze live values

Three behaviours a page could detect, changed in one breaking release:

- **Animations run on stock timing.** no-css-animations.patch finished every
  finite animation at once by default, and any page could read it:
  `el.animate(frames, 1000).effect.getComputedTiming().duration` was 0, and a
  500ms transition reported 0. Measured on v152.0.4-beta.31. The speedup is
  now an opt-in, `instantAnimations: True`, which raises a LeakWarning.
  disableInstantAnimations is gone.
- **speak() on a spoofed voice works like a real voice.** It fired `error`
  after 3ms unless voices:fakeCompletion was set, and then start and end in
  the same tick. It now starts and ends after the text's duration at ~150
  words per minute. Both voices:fakeCompletion keys are gone, and so is a
  debug line printed to stderr on every call.
- **Keys removed:**
  - battery:* and window.scrollMinX/Y: Firefox keeps getBattery() and
    scrollMin* chrome-only, so no page could read them.
  - window.scrollMaxX/Y, screen.pageXOffset/pageYOffset,
    window.history.length and document.body.client*: each pinned a live value
    to a constant, so scrolling, navigating or re-laying out never changed it.
    fpgen.yml mapped pageYOffset, so about 15% of identities froze
    window.scrollY at a non-zero value.
  - The body keys' role as an undocumented alias for window.innerWidth/Height
    in browser-init and in the launcher.
  - MaskConfig::GetInt32Rect, which only the body keys used.

New guards, both of which fail on v152.0.4-beta.31:
tests/patches/animation-timing.py and tests/patches/spoofed-voice-speaks.py.
The decisions are recorded as animations-run-on-stock-timing and
spoofed-voices-speak. Every patch applies cleanly to a fresh tree, and the
result builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python)!: remove dead public API and make `list all --path` work

Breaking changes:

- Remove the exceptions UnknownProperty, InvalidDebugPort and
  MissingDebugPort. Nothing in the package raises them, so code catching
  them was catching nothing.
- Remove the legacy `allow_webgl` keyword of launch_options(). Use
  `block_webgl=True`. The keyword now reaches Playwright as an unknown
  launch option and fails there instead of being silently consumed.

`camoufox list all --path` accepted the flag and ignored it. It now prints
the install path beside each installed build, as `camoufox list --path`
already does for the installed tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python)!: drop data the package never draws from

voices.json shipped in the wheel, but no code in the package reads it: the
voice draw uses voice-manifests.json and voice-uris.json. Its only readers
are the TypeScript port's golden-fixture generator and data-sync script
(typescript/scripts/golden/identity_golden.py,
typescript/scripts/sync-identity-data.py), which live on another branch and
will need a new source; the last copy is at
676fb3f:pythonlib/camoufox/voices.json. docs/per-context-patches.md
described it as runtime data and now describes the files that are.

webgl_data.db held two rows with zero weight on every OS ("Intel(R) HD
Graphics 400, or similar" from "Intel Inc." and "Radeon R9 200 Series, or
similar" from "ATI Technologies Inc."), left behind when their impossible
macOS weights were zeroed. No draw can reach them. They are deleted with
secure_delete so their blobs do not linger in free pages; the file is not
vacuumed, so the other pages are unchanged. A new test requires every row
to be drawable on at least one OS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): warn whenever an identity falls back to a substitute value

Several draws swallowed their failure and used something else, so an
identity could ship with values the rest of it was not drawn to match and
nobody would hear about it:

- from_preset(): a failed font or voice draw used the preset's recorded
  list, or nothing, on any exception.
- generate_context_fingerprint(): a failed font, voice or WebGL draw was
  `except Exception: pass`, leaving the browser's launch-time values.
- _load_font_groups() / _load_font_bases(): an unreadable file became {},
  i.e. no font additions or no OS-version base.
- launch_options(): a failed font draw used every font in fonts.json, a
  failed voice draw used no voices, and a preset GPU missing from
  webgl_data.db was silently swapped for a drawn one (36 of the 397
  bundled presets).

Each site now catches only the errors its data can raise (OSError and
ValueError for an unreadable or corrupt file, KeyError for a manifest with
no entry for the OS, sqlite3.Error for the WebGL database) and emits a
FallbackWarning. The text names what failed and what the identity uses
instead, then gives a block to paste into an issue (camoufox, browser, OS
and Python versions, the error, and the identity's user agent or GPU),
asking the user to report it on GitHub. It shares LeakWarning's
caller-frame attribution and its template lives in warnings.yml.

The broad excepts had also been hiding a broken fixture:
test_launch_environment's font and voice stubs did not accept `seed`, so
every draw there raised and was swallowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): give NewContext identities the browser's Firefox version

NewContext() and AsyncNewContext() passed ff_version=None through to
generate_context_fingerprint(), so a context's user agent kept the version
fpgen drew (e.g. Firefox/146) while the browser underneath was 152. They
now default ff_version to the major version of Playwright's
Browser.version, which Juggler reports from MOZ_APP_VERSION_DISPLAY, so the
UA always names the browser the page is actually talking to. An explicit
ff_version still wins.

The docstrings said each context gets "its own real fingerprint preset";
the default has been an fpgen draw, with a preset only when one is passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): send an IPv6 WebRTC address to setWebRTCIPv6

The per-context init script passed every webrtc_ip, IPv6 included, to
window.setWebRTCIPv4(), and never called setWebRTCIPv6(). An IPv6 address
(given directly, or resolved as a proxy's exit IP) was stored as the
context's IPv4 value and the IPv6 slot stayed empty. The script now picks
the setter by address family, and an address that is neither raises
InvalidIP instead of being passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): stop pinning the page's scroll offset from fpgen

fpgen.yml mapped the drawn window.pageYOffset (e.g. 528) to
screen.pageYOffset, and the browser returns that value from scrollY on
every read, so a page saw one scroll position forever whatever the user
did. Real scroll offsets are live page state, not part of a device's
fingerprint, so neither offset is mapped any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(python): stop checking a config key that no longer exists

warn_manual_config() looked for navigator.languages, which was removed
from settings/properties.json; validate_config() rejects it before the
check could matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): close the WebGL database connection on every path

sample_webgl raised its not-found and wrong-OS errors before reaching
conn.close(), leaking a sqlite connection each time a preset named a GPU the
database does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(data): drop the 23 presets whose GPU has no WebGL data

A preset records only its GPU's name. The WebGL parameters, extensions and
shader precision behind it have to come from somewhere, and for these 23
nothing Camoufox has describes the GPU: fpgen has never seen Firefox report it
on that OS. So each launch paired the name with another device's parameters,
a mismatch any WebGL fingerprinter can see. They were:

- Windows on ARM (Adreno 650);
- Direct3D 10-level GPUs (vs_4_0/vs_4_1);
- "Generic Renderer";
- 945GM and GTX 480 on macOS;
- nouveau/Mesa buckets on Linux;
- one Linux preset pairing NVIDIA's proprietary vendor string with the
  nouveau renderer name.

scripts/clean-fingerprint-data.py now applies the rule, via a shared
fingerprints.firefox_gpus(), and test_shipped_data asserts it. 374 presets
remain, and every OS keeps its presets. ROADMAP.md lists capturing WebGL data
for these GPUs, which would bring them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): stop generating the glyph-spacing seed

Mirrors 676fb3f: the browser no longer has glyph-spacing noise, so the
launcher sends no fonts:spacing_seed and the per-context init script no
longer calls setFontSpacingSeed. config_overrides is now tested with
audio:seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): warn on instantAnimations; stop treating body keys as window size

Mirrors the launcher half of fb21b2e: instantAnimations raises the
instant_animations LeakWarning (warnings.yml copied from pythonlib), the
document.body.client* keys no longer count as window dimensions, and fpgen's
pageYOffset is no longer mapped, so no identity freezes window.scrollY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts)!: remove dead public API and make `list all --path` work

Mirrors 4616aa5: drop the UnknownProperty, InvalidDebugPort and
MissingDebugPort exceptions (nothing raises them) and the legacy allow_webgl
option (use block_webgl; allow_webgl now passes through to Playwright like
any unknown option). `camoufox list all --path` prints each installed
build's path, as `list --path` already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts)!: drop voices.json, which nothing draws from

Mirrors 9a42b6a. The voice draw reads voice-manifests.json and
voice-uris.json; voices.json was only read by the golden generator and the
data-sync script. The voice-URI golden now hashes the URI of every entry in
voice-manifests.json, the list the draw actually picks from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): warn whenever an identity falls back to a substitute value

Mirrors 165e68f for the font and voice draws. Each fallback site in
fromPreset(), generateContextFingerprint(), loadFontGroups(),
loadFontBases() and launchOptions() now catches only the errors its data can
raise and emits a FallbackWarning naming what failed, what the identity uses
instead, and a block to paste into an issue (camoufox, browser, OS and Node
versions, the error, the identity). The message is warnings.yml's
`fallback` template, shared with pythonlib.

Python's except clauses name builtin classes JavaScript lacks, so pycompat
gains OSError, ValueError and KeyError twins and isPyError(): a Node system
error counts as an OSError and JSON.parse's SyntaxError as a ValueError, as
json.JSONDecodeError is. The voice draw now throws ValueError for a malformed
entry and KeyError when the manifest has no macOS entry, as Python does.

The WebGL fallback sites are left for the change that replaces the TS WebGL
source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): give NewContext identities the browser's Firefox version

Mirrors 46e5c8d: without an explicit ff_version, NewContext() kept the
Firefox version fpgen drew, so a context's UA could name 146 on a 152
browser. It now defaults to the major version of Browser.version(). The
option docs now say the default identity is an fpgen draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): send an IPv6 WebRTC address to setWebRTCIPv6

Mirrors 0bbd152: the per-context init script passed every WebRTC IP to
setWebRTCIPv4(), IPv6 included. It now picks the setter by address family
and raises InvalidIP for an address that is neither. The init-script golden
gains an IPv6 case.

Also ports 7b43112's regression test: a drawn pageXOffset/pageYOffset is
not carried into the config (the mapping went in fb21b2e's mirror).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): stop checking a config key that no longer exists

Mirrors 480789a: warnManualConfig() looked for navigator.languages, which
settings/properties.json no longer has; validateConfig() rejects it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ts): sync the pruned presets and the properties.json fixture

Copies fingerprint-presets*.json from pythonlib (40edebb dropped the 23
presets whose GPU has no WebGL data) and refreshes the launch fixture's copy
of settings/properties.json, which lost the keys removed in 676fb3f and
fb21b2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(python): draw every identity's WebGL from fpgen

WebGL vendor, renderer, context attributes, extensions, parameters and
shader precisions, for WebGL1 and WebGL2, now come from fpgen's recorded
Firefox devices instead of webgl_data.db, which is deleted with the
camoufox/webgl/ package.

camoufox/webgl.py:
- webgl_for_gpu() traces `webgl` given Firefox, the OS and the GPU, then
  `webgl2` given the chosen `webgl` too, and draws each with one seeded
  random.Random. The GPU and the webgl value are pinned by their fpgen
  lookup index: a dict condition is flattened into leaves that overwrite
  each other, so only the renderer applied and Linux "Mesa" and "AMD"
  Radeon HD 3200 devices came back mixed.
- sample_webgl_for_screen() draws the GPU of a generated identity from
  fpgen's per-OS weights, filtering out software rasterisers, GPUs the OS
  cannot report, discrete GPUs behind a netbook screen and the
  resistFingerprinting "Mozilla" mask before the weighted choice, so there
  is no rejection loop. An empty pool raises.
- The draft/host-dependent extension filter moves over unchanged.

A preset's GPU and a caller's webgl_config pair are looked up as given;
a pair fpgen has never seen from Firefox on that OS raises instead of
falling back to another GPU. generate_context_fingerprint no longer
falls back to the host GPU when the draw fails.

For 10 of the 15 (GPU, OS) pairs the two sources share, one of fpgen's
records converts to exactly the database row on every value the browser
reads. The other five rows (Linux R9 200 and Radeon HD 3200, macOS
Intel HD, and two software rasterisers) are devices fpgen does not carry;
those GPUs now present fpgen's recorded devices instead. The Linux
GTX 980 row is kept as a test fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say where WebGL comes from now that the database is gone

The per-context guide, the fpgen.yml header and coherence's comments still
named webgl_data.db and sample_webgl(). They now point at camoufox/webgl.py
and fpgen. The guide also claimed presets carry WebGL parameters; they
record only the vendor and renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): regenerate the golden fixtures for the mirrored pythonlib changes

Regenerates identity_golden.py and launch_golden.py output from this tree's
pythonlib. launch_golden.py drops fonts:spacing_seed and
document.body.clientWidth from its inputs, adds an instantAnimations
scenario, and masks a FallbackWarning's report block in both launchers, since
it names the host and the runtime.

Two launch scenarios still differ: preset_windows_unknown_gpu and
config_webgl_unknown_pair expect the FallbackWarning pythonlib now raises when
a preset's GPU is missing from the WebGL data. That site belongs to the
change replacing the TS WebGL source; the rest of each scenario matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ts): draw every identity's WebGL from fpgen

Mirrors 0d859b3. src/webgl.ts is the twin of camoufox/webgl.py:
webglForGpu() traces fpgen's webgl node given Firefox, the OS and the GPU,
then webgl2 given the chosen webgl too; sampleWebglForScreen() first draws
the GPU from fpgen's per-OS weights, filtered (no software rasteriser, no
resistFingerprinting mask, a GPU the OS can report, no discrete GPU behind a
netbook screen) before one weighted choice. Every draw is PyRandom.choices on
one seeded instance, in Python's order. The GPU and webgl value are pinned by
their fpgen lookup index, found from the value's stored JSON, which
TraceResult now carries: re-serialising a parsed value would spell 2**64
differently from orjson.

A preset's GPU and a caller's webgl_config are looked up as given and raise
when fpgen has never seen them, instead of falling back to another GPU;
generateContextFingerprint no longer swallows a failed draw. Removed with
the old source: webgl/sample.ts, the numpy default_rng port
(webgl/nprandom.ts), data-files/webgl_data.json and its export in
sync-identity-data.py. The NumPy notice now covers the pairwise sum in
locales.ts, the one NumPy port left.

launchOptions now throws the pycompat ValueError where Python raises
ValueError. Tests port test_webgl.py and the shipped-data check that every
preset GPU has WebGL data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): pin the fpgen WebGL draws against Python bit for bit

identity_golden.py now records camoufox.webgl's output as sha256 of the
exact orjson bytes (key order, int versus float and all): 1272 screen draws
over every OS and seven screens (60 seeds each, plus four large seeds),
webgl_for_gpu for every GPU fpgen records and every bundled preset GPU, the
unknown-GPU and unknown-OS errors, and to_config's extension filter. The
numpy golden keeps only np.sum, now checked against locales.ts. The renderer
list the coherence golden walks comes from fpgen's traces.

launch_golden.py takes its WebGL pairs from firefox_gpus(), and the
unknown-GPU preset input is a GPU nobody records. The launch goldens are
regenerated; the preset_windows_unknown_gpu and config_webgl_unknown_pair
scenarios now expect Python's ValueError. The golden test no longer maps
ValueError to Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(patches): a host's missing speech daemon no longer errors spoofed speech

On a Linux host where speech-dispatcher cannot start, Firefox broadcasts
synth-voices-error, and SpeechSynthesis answers it by firing `error` on every
queued utterance. So a spoofed Windows voice errored about 11ms into speak()
on any host without the daemon: the CI runners, and most servers. It passed
only where the daemon runs.

While Camoufox manages the voice list, the registry no longer forwards a host
backend's error. The spoofed voices do not depend on the host's engine, and a
Windows or macOS identity never raises one. The guard now makes the daemon
unreachable itself, so it tests this case on every machine; on the previous
build it fails every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: stop skipping the two click tests that stock animation timing fixed

test_wait_for_stable_position and test_timeout_waiting_for_stable_position
were skipped with humanized travel time as the reason. The real cause was
instant animations. Every finite animation finished at once, so the button
Playwright waits on to stop moving never moved, and the click landed where
upstream does not expect. With animations on stock timing both pass, and the
skiplist audit flagged them as no longer failing. The entries go, and the
counts in ci/README.md drop from 14 to 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(build-tester): accept 18 and 22 cores, as real hardware reports

plausibleHWC's list of common core counts lacked 18 and 22 -- Intel Meteor
Lake laptops (Core Ultra 5 125H, Core Ultra 7 155H), and 22 is in 8 recorded
presets. build-tester draws random presets, so a run that picked one of the two
Linux presets reporting 22 failed: about one run in eleven, on any pull
request. A CI self-test now fails if the list rejects any core count pythonlib
can present (the presets and PLAUSIBLE_CORE_COUNTS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): judge the query-cost probes on a median, not one sample

stock-parity-probes timed each getter once. On a shared runner one GC pause or
CPU-steal spike decided the verdict: navigator.hardwareConcurrency took 77 ms
against a 50 ms allowance on the same restored build that passed the run
before. Each pair is now timed five times, interleaved, and compared by median.
The regressions these catch (a sync IPC per read, ~240 ms over the loop) cost
extra on every read, so they move the median; verified by giving the getter a
constant ~4 us of extra work per read -- 86 ms median, FAIL -- while the
healthy build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): compare the whole fingerprint when two launches must differ

test_two_browsers_get_different_fingerprints compared seven coarse values:
UA, platform, screen size, core count, timezone and language. CI pins the
timezone and language, and real machines share the rest: two draws of a
common Mac (Firefox 152, MacIntel, 2560x1440, 8 cores) matched, and the test
failed on a correct browser.

It now reads the whole fingerprint a site computes, from a script in the page:
- navigator values, screen and window geometry, device pixel ratio, timezone;
- WebGL vendor, renderer, limits and extensions;
- installed fonts, measured by width against the generic fallbacks;
- voices, media-device counts, and an OfflineAudioContext hash.
The page is served from an https URL Playwright fulfils locally, because
mediaDevices exists only in a secure context. The page computes the result
itself because the isolated world may not read audio sample data. The test
then requires the fingerprints to differ, and the audio hash to differ on its
own, since its noise is seeded per identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update README to remove warning, camoufox is now actively maintained

Camoufox will now be actively maintained and improved for the foreseeable future

* ci(ts): hold the golden tests to live pythonlib, not a snapshot of it

The typescript job ran the golden tests against the fixtures committed in
typescript/tests/fixtures/. A pythonlib change that typescript/ did not
mirror left those fixtures untouched, so the tests kept passing -- the
opposite of what the job's comment promised.

`ci.run_typescript --regenerate-golden` now rewrites the fixtures from the
checkout's pythonlib before vitest runs, and CI passes it. The job runs
Python 3.14 because pySum() reproduces sum() as 3.14 computes it; on 3.12
one crafted mixed int/float case differs in its last bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(ts): do not redraw fpgen's stats fixture on every run

stats.json is thousands of random fpgen draws that the TS tests compare
statistically. It changes with the pinned model, not with pythonlib, and
redrawing it took eight of the typescript job's eleven minutes on a
runner. The deterministic fpgen fixtures are still regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): measure crash growth from a warmed-up parent

test_the_parent_stays_flat_across_content_crashes took its baseline right
after launch. The parent's first context costs it 150-200 MB with no
crash at all, so the warm-up counted as crash growth: 330-370 MB of the
400 MB allowance locally, and 469 MB on a CI runner, failing a PR that
changes nothing in the browser. The baseline now follows one clean
context; each crash still has to stay within the same allowance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ts): read pythonlib's data files instead of copying them

typescript/src/data-files/ held byte-identical copies of eleven pythonlib
data files (23k lines), kept in step by a sync script and a test that
failed when a copy drifted. The TS launcher now reads them from
pythonlib/camoufox/ when it runs from the repo, and `pnpm build` copies
them into dist/data-files/ for the npm tarball, so what users install is
unchanged. DATA_FILES in src/paths.ts is the one list; check-pack.mjs
checks each is in the tarball.

The essential-font lists were the one large table both ports hard-coded
(~170 lines of Python, ~770 of TS). They move to
pythonlib/camoufox/essential-fonts.json, which fingerprints.py and
fingerprints.ts both read; gen-fonts-json.py --print-bases writes it and
verify-fonts.py checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): record the golden fixtures from pythonlib on every run

The goldens were pythonlib's output committed as 14k lines of fixtures,
most of it the same input fingerprint pasted into ~80 launch scenarios,
and CI already rewrote them from pythonlib before each run. They are now
recorded by a vitest globalSetup (tests/golden-setup.ts) from the repo's
.venv or $CAMOUFOX_PYTHON, in about 6 seconds, and git-ignored. A
pythonlib change that typescript/ does not mirror fails `pnpm test`
locally as well as in CI, and ci.run_typescript no longer needs
--regenerate-golden.

Committed inputs stay: launch/inputs.json, the bundle stubs, the addon,
e2e/probe.js, and fpgen/stats.json (random draws tested statistically,
which change with the pinned model, not with pythonlib, and take minutes
to redraw). The one Python-version-sensitive case, sum() over mixed ints
and floats, skips with a named prerequisite when the goldens come from
Python < 3.14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(npm): give the publish job the pythonlib its tests record goldens from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): NewContext hands Playwright the identity's user agent, DPR and timezone

generateContextFingerprint already returns Playwright's JS option names;
NewContext re-cased them, and camelCase() lowercases first, so userAgent,
deviceScaleFactor and timezoneId became keys Playwright silently drops.
navigator.userAgent was still spoofed at the C++ level, so the page probe
matched Python's, but the HTTP User-Agent, the DPR and the timezone did
not. The options now pass through as generated.

NewContext also awaits ensureModel(): a browser from connect() or a
custom executable never went through launchOptions(), which fetches the
fpgen model, so an fpgen draw threw ModelNotInstalled where Python works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): a failed browser download rejects instead of killing the process

The download's file stream had no 'error' listener, so a failed write
(disk full) was an uncaught 'error' event: Node exited before
installVersioned()'s catch could remove the partial install and its temp
directory, and the caller had nothing to catch. finished() now listens
from the moment the stream is created, and webdl() surfaces an errored
stream instead of writing into it.

webdl() also waits for 'drain': it ignored write()'s return value, so on
a slow disk the whole archive queued in memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): concurrent launches no longer read or inherit each other's CPU pin

playwright-core spawns the browser from this process, so pin_cpu_cores
narrows this process's own mask while a launch's browser starts. Python
pins a separate driver and never sees its own mask change. Here, a
second launch started in that window:

- read the pinned mask as the host's cores (pinnedCoreCount, and the
  identity's hardwareConcurrency via availableParallelism()), and
- if it did not pin, spawned its browser without the lock, inheriting the
  first launch's pin while reporting more cores.

The host's core count is now read once, before this process first pins
itself (cpu_affinity.hostCoreCount), and unpinned launches, launchServer
included, take the pin lock once any launch in the process has pinned.
With pin_cpu_cores off (the default) nothing waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ts): an Xvfb that fails to start throws CannotExecuteXvfb

A spawn that fails after spawn() returns (EACCES, ENOENT) is an 'error'
event on the child process, which had no listener: Node treated it as
uncaught and exited instead of get() throwing. The child now always has
a listener, readDisplayNumber() rejects with CannotExecuteXvfb on it, and
the display pipe keeps an error listener after the read settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scripts): gen-fonts-json refuses to write an incomplete essential-fonts.json

An OS with no bases in the manifest was skipped, and the file written
without its key; fingerprints.py and fingerprints.ts read every OS's list
at import, so `import camoufox` then failed with a KeyError. The script
now exits and keeps the existing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(release): pythonlib and the npm package to 0.5.7

@camoufox/camoufox 0.5.6 went to npm before the review fixes above, so
they ship as 0.5.7; the two launchers are versioned in lockstep, and
main already carries pythonlib changes from #787 that 0.5.6 on PyPI
does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): NewContext's HTTP User-Agent must match navigator.userAgent

The e2e parity check read only navigator.userAgent, which the browser
spoofs itself, so a context that dropped Playwright's userAgent option
still matched Python. The probe server now records the request's
User-Agent header. Against the NewContext before the fix, a context
whose navigator said Windows sent the launch identity's Linux UA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): draw the NewContext option-name test's preset from the v150 bundle

getRandomPreset() without a Firefox version draws from the older bundle,
where some Windows presets carry no devicePixelRatio, so the test failed
on some draws in CI. Every v150 preset has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ts): wait for the headful e2e page to have focus, not one bringToFront()

On a bare Xvfb, with no window manager, a single bringToFront() before
goto() sometimes left the window unfocused, and Firefox holds
enumerateDevices() until the document has focus, so the virtual-display
probe timed out on some runs. Both launchers' probes now navigate first,
then bring the page to the front until document.hasFocus() is true, and
fail with that reason if it never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:28:12 +00:00