Commit Graph
177 Commits
Author SHA1 Message Date
Jake WriterandClaude Opus 5 52d6746a4a ci: a repo-wide test pipeline, and the one check that gates merge on it (#772)
* ci: a repo-wide test pipeline, and the suites Camoufox was missing

Nothing checked a pull request before this. `build.yml` runs on tags and takes
about forty minutes, and `lint.yml` ran a single static script, so a change
could reach main having had no browser suite run against it at all.

This adds one pipeline, driven identically from a pull request, a push to main,
and -- through `workflow_call` -- any caller that needs to test a specific
browser version, so there is exactly one definition of "the tests pass".

  resolve ──┬─ static ────────── tribal rules, skiplist, self-tests
            ├─ pythonlib ─────── the package's own tests
            └─ build ──┬─ playwright upstream × 6 shards   (conformance)
                       ├─ playwright vendored              (regression)
                       ├─ native ───────────── leaks, contexts
                       ├─ patch guards ─────── one per spoofing patch
                       ├─ build-tester ─────── 8 fingerprint profiles
                       └─ sundial ──────────── stealth grade (off, see below)
                                      │
                                   summary ──► one comment on the PR

Two Playwright suites, because they answer different questions. `tests/` is a
frozen ~v1.55-era fork carrying roughly 1800 lines of Camoufox adaptations, so
every test in it has a known prior outcome: that is the regression check. The
upstream suite is fetched fresh at the tag `ci/versions.py` resolves and runs
unmodified, which is the conformance check -- `ci/pw_camoufox_plugin.py` adapts
the environment around it rather than editing it, hooking BrowserType at the
_impl layer so upstream can refactor its fixtures freely.

`native-tests/` covers what neither can ask about: that resource cost does not
scale with launch count (the shape an FD or socket leak actually has), that two
contexts in one browser get different fingerprints while two pages in one
context get the same one (get this wrong and per-context injection silently
degrades to process-global, which passes every single-context test there is),
and that decisions already made stay made -- `ci/tribal-rules.yml` lists them
with the issue or PR that settled each.

Cost is tiered so a two-second lint failure never reaches a build, and a
driver-only pull request never builds at all: it fetches the published release
and tests against the build users are actually running, a minute instead of
seventy. Merges gate on one required check, `All tests passed`, so the
branch-protection list does not need editing every time a suite is added or
resharded; `ci/branch-protection.json` holds the settings so they are reviewable
rather than lore.

**The stealth check ships disabled** (`ci/sundial.yml: enabled: false`). It
drives a private detection suite, and the deployment it talks to predates that
suite's score mode; an older one ignores `?score=1` and posts the entire report
-- every vector's id, name, brief, source and value -- to whatever collector
asked. Receiving that on a public runner and discarding it afterwards is not the
same guarantee as never being sent it, so while the flag is false the job is not
scheduled, no credential enters a runner, and `run_sundial.py` refuses a hand-run
too. When it is enabled, `redact()` publishes a grade and counts against a
runtime whitelist and refuses anything that is not already aggregated.

Also included: the fixes these suites exposed on a clean runner -- build-tester
hashing canvas pixels rather than a prefix of the data URL, the virtdisplay
cleanup when Xvfb has already died, a juggler sandbox released on frame destroy
rather than only on navigation, and the pythonlib geometry and version-floor
corrections. `lint.yml` is removed because the static job absorbed its one check.

Verified locally: ci/tests 68 passed, tribal rules 24 passed, pythonlib 209
passed, input-dispatch clean, `ci.versions` resolves 152.0.4/beta.31 against
playwright v1.61.0, and `ci.summarize` folds a run to "all suites passed".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* ci: make result files survive the trip from job to summary

The first full run failed, and the summary could not say why: five suites came
back "required, but produced no result", including two whose jobs had passed.
Three separate plumbing bugs, none of them in a test.

**Hidden files.** `actions/upload-artifact@v4` excludes dotfiles unless told
otherwise, and every result we write lives under `.ci-work`. The jobs whose
`path:` was a list containing a glob uploaded nothing at all -- the Playwright
suites and the leak suite each wrote their evidence and then had it silently
dropped:

    evidence -> .../.ci-work/results/playwright_vendored.json (fail, 1203 tests)
    ##[warning]No files were found with the provided path: .ci-work/results/
    .ci-work/junit-*.xml. No artifacts will be uploaded.

**Common root.** Where a list did upload, the second entry moved
upload-artifact's common root from `.ci-work/results/` up to `.ci-work/`, so the
JSON arrived at `results/build_tester.json` instead of the artifact root. The
summary merges every `results-*` into one directory and `load_all()` globs a
single level, so the file was there and invisible. build_tester passed and was
reported missing.

Every `results-*` artifact now uploads exactly `.ci-work/results/`, with
diagnostics (junit XML, the build-tester graded tree) split into their own
`diagnostics-*` artifacts that the summary's `results-*` pattern ignores.
`include-hidden-files: true` everywhere that touches `.ci-work`.

**A required name nothing writes.** `static` was in the required list, but it is
a job, not a suite -- no runner writes a result by that name, so summarize
reported it missing on every run including a wholly green one. The suites that
job runs are the pipeline self-tests, which write no result, and native_rules,
which is required by name. The job is already covered: the gate fails on any job
that is not success.

Three guards, each verified by reintroducing the bug it catches:

  - results-* artifacts upload exactly one path, so nothing nests
  - anything touching .ci-work sets include-hidden-files
  - every required name is one some runner can actually write

This changes no test. The real failures the first run found -- 6 in the vendored
suite, plus upstream shards 1 and 5 and the leak suite -- were masked by the
above and should now be reported rather than swallowed.

ci/tests 71 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* test: two failures that were the tests' fault, not the browser's

**The leak check waited on the wrong set of processes.**
`test_a_single_launch_leaves_nothing` failed with Gecko's GPU probe still alive:

    1 process(es) this test started are still alive: glxtest(2887, now ppid=1)

`settle()` polled `children(recursive=True)`, but `survivors()` judges the
sampled PID set -- deliberately, so that a process reparented to init cannot
hide a leak. Those two sets differ exactly when a process outlives its parent:
it stops being our child, `settle()` sees nothing left and returns at once, and
anything still winding down is reported as leaked. `glxtest` does this on every
launch; it is spawned by Gecko, its parent exits first, and it needs a moment.

So settle on the set the assertion actually uses. This is a grace period, not an
exemption -- a process that is still there when the timeout expires fails the
test exactly as before, and no name is special-cased.

**Playwright renamed a protocol method the tracing tests spelled out.**
`Page.waitForEventInfo` is `Page.__waitInfo__` in newer versions, so two tracing
assertions failed on a name, not on behaviour. The suite is pinned to a range
(`playwright<1.63`), not a version, so hard-coding either spelling is wrong.
Normalised in `get_trace_actions()`, next to the comment about the last time
Playwright moved this data -- the tests care which actions ran and in what
order, not what Playwright calls them this month.

Neither of these was Camoufox misbehaving.

Still failing, and genuinely about the browser or by design -- triaged next:
navigation popup load state, locator handler visibility, clock pause off by 1ms,
websocket close reason, and the three upstream ones (request headers, worker
locale, screencast viewport) which all look like deliberate spoofing divergence
and probably belong in the skiplist with a stated reason.

ci/tests 71 passed, tribal rules 24 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix: the failures the new pipeline found, and the flake that hid them

Eleven gates were red on PR #9. Each one is now either a fixed defect or an
entry that says why the test cannot apply here -- nothing is silenced.

One real browser bug, found by the conformance suite:

  The compositor-backed screencast added in fa8a935 reported the *scaled frame*
  size as the viewport. Playwright's Firefox delegate maps deviceWidth/Height
  straight onto the client-visible viewportWidth/viewportHeight, and every other
  backend fills them from the page's viewport -- the native path right below it
  sends pageWidth/pageHeight, clamped to the viewport and never scaled. So a
  client asking for a 500x400 frame of a 1000x400 page was told the viewport was
  500x200, and asking for a frame *larger* than the page reported a viewport
  larger than the page. Confirmed against the built binary, fixed, and confirmed
  again: 11 screencast and video tests pass, where the requested size no longer
  moves the reported viewport at all.

Four stale expectations in the vendored suite. All four pass in upstream's
v1.61 suite against this same binary, which is what identified them as the
suite's problem rather than the browser's:

  websocket    Firefox no longer collapses a refused handshake to
               CLOSE_ABNORMAL; it reports the HTTP status, like the other
               engines. The handler also set a settled future twice, which
               surfaced as a suite ERROR rather than a failure.
  navigation   Firefox now reports a window.open('') popup as "complete". The
               old assertion also mis-parsed -- the conditional bound to the
               whole assert, so the non-Firefox arm compared nothing.
  locator      expect().to_be_visible() kept re-arming the locator handler the
               click was still waiting to see finish, so the check meant to
               observe the interstitial kept it alive. One-shot is_visible().
  page_clock   resume() before reading the clock added the real second spent in
               wait_for_timeout back, landing at 1001 -- one millisecond out,
               every time.

Two upstream tests that encode a stock-Firefox quirk this fork does not
reproduce, now in the skiplist with the reason:

  A worker inheriting the context locale -- upstream expects en-US from a
  ru-RU context, citing playwright#38919, because stock Firefox applies the
  locale to the page and not to its workers. Camoufox sets it below that layer,
  so matching upstream would mean reintroducing a main-thread/worker
  disagreement that anything looking in both places gets for free.

  "Firefox" in the User-Agent -- the bare binary advertises its own build
  token; the Python package replaces it when it injects a fingerprint. The
  vendored suite already asserts what this layer can promise.

And three pieces of the harness that were reporting badly:

  A profile that asked for llvmpipe is no longer graded as headless. camoufox's
  own preset pool ships "llvmpipe, or similar", so when that preset is drawn,
  reporting it is the WebGL spoof working -- and grading it a failure made this
  gate fail at random depending on which presets the run happened to draw. The
  check still fails on a software renderer the profile did not ask for, which
  is the case it exists for.

  junit ids put a test's class in the path (test_page_clock/TestWhileRunning.py
  ::test_should_pause), naming a directory that does not exist -- so the id
  could not be fed back to pytest and no skiplist entry could match it.

  A `test:` skiplist entry was compared for exact equality against a node id
  that always ends in [firefox], so every such entry was a silent no-op: the
  test went on running and failing while the list read as handled.

Finally, `mach bootstrap` pulls toolchains from Taskcluster, and a connection
reset there failed the whole pull request (run 34673115086). ci.run_prepare
retries the two steps that download things, and only when the failure reads as
transient -- a failed patch hunk still fails on the first try, because retrying
a broken tree only spends a runner to reach the same answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): say what the stealth gate actually guarantees now

The README described a `ci` role that is not merged into sundial's master and
so is not deployed, and a kill switch that had since been flipped. Both claims
now match what is running.

The substantive change is separating the two halves of the guarantee, because
only one of them is enforced by the server: `guest` is refused the
private-vector bundle by sundial's middleware, and this repository refuses to
process anything that is not a score payload. What is still missing is a server
that refuses the *request* -- which is what the `ci` role adds, and why the
upgrade path is worth keeping written down rather than implied.

Also documents ci.run_prepare, since "the build retries" is the kind of thing
that needs its limits stated: the network, and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): authenticate to sundial with either shape of credential

`SUNDIAL_AUTOMATION_KEY` has been two different things over this repository's
life -- an automation key for `/automated?key=`, which is what sundial's
`make pages-automation-keys` mints and what resolves to the `guest` role, and a
password for the login form. They are indistinguishable by inspection, and the
gate only knew how to use it as a password, so a repository holding a key would
have failed to log in with no hint as to why.

Try the key route first, since it needs no username and so nothing has to be
kept in step with whatever `GUEST_USER` was set to, and fall back to the form.
When the secret really is a password the only cost is one extra request that
401s. If both routes fail the error names both attempts and says what the secret
is supposed to be.

Also sends browser headers on every request rather than `User-Agent:
camoufox-harness`. Cloudflare sits in front of this host and refuses a document
request from a non-browser agent before it reaches sundial at all, which
produces a 403 that looks like a permissions problem and is not one.

test_a_disabled_gate_makes_no_request now makes every route out fatal --
authenticate() and both login functions, not just the one main() used to call --
so the kill switch cannot be bypassed through a path the test does not watch.
Mutation-checked: making the gate ignore `enabled:` fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): never let run_prepare invent a success

`--attempts 0` would have fallen straight past the loop bound and returned 0
without running the step at all, which is the one answer it must not invent.
Clamps to one attempt and makes falling out of the loop an assertion rather
than a bare success.

Also corrects a direction in the screencast comment: the native path it
contrasts with sits above _startSnapshotScreencast, not below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): a sundial category nobody has ruled on now fails the gate

ci/sundial.yml partitions sundial's taxonomy: its nine SECTIONS labels
(Identity, Security, JS Engine, Graphics, Display, Locale, Audio, CPU, Network)
are exactly the six gated plus the three ungated, with nothing left over. That
is now asserted, because it is the property the pass rate depends on and
nothing was checking it.

If sundial grows a tenth section, every check in it used to fold into
"out of scope" -- measured, never gated, and indistinguishable in the summary
from a category someone had deliberately decided not to gate. That answers
"does Camoufox claim this?" by default, in the only direction that never fails
a build: a stealth blind spot that reads as a clean run.

The count is now carried separately and fails the gate, with a note saying to
put the new section in one list or the other. Only ever a count -- which
category, like which vector, does not leave redact().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): stream the prepare log, and make its timeout real

Two problems with capturing the output of `make dir` / `make mozbootstrap`
instead of streaming it. The visible one: the step printed nothing for minutes
while aria2c pulled a 500MB tarball, which looks exactly like a hung job. The
one that mattered: draining the pipe on the calling thread blocks in readline
until EOF and only *then* reaches proc.wait(timeout=...), so a step that wedged
without printing anything -- a stalled download, precisely the failure this
module exists for -- would never have been timed out at all.

The reader now runs on its own thread, so output appears as it arrives and the
timeout covers a silent hang. Both are asserted against real processes:
stdout and stderr both survive into the text the transient-classifier reads,
and `sleep 60` under a 2s timeout dies in 2s with exit 124 rather than
reporting success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): two comments that said less than they meant to

The skiplist header claimed to quote failure text it only described, and the
sundial header comment had lost the word that made it a sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): the stealth job comment described the role we do not have yet

It claimed the credential is for a role sundial refuses to serve a report to.
That is the `ci` role, which is not deployed. Says what is actually true of
`guest` instead: the request asks for counts, the role cannot load the private
vectors, and the gate refuses a payload that is not a score.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* test(ci): name what this section actually asserts

The heading said "the score-only `ci` role" and the docstring said the gate
runs as a role sundial refuses to serve a report to. Neither is true yet --
that role is not deployed. What the tests actually pin is that score mode is a
requirement rather than a preference: a full report is refused whoever asked
for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): the sundial credential must not ride out on an error message

Whatever reaches result.note() is written to the results artifact and posted as
a pull request comment. An exception raised inside urllib can carry the URL that
produced it -- and for the token route that URL *is* the credential,
percent-encoded in the query string. A 401 from a stale key would have published
the key.

Every string built from an exception now goes through scrub() first, which
removes both the raw secret and its percent-encoded form. The message still says
which routes were tried and what the secret is supposed to be, so a real
misconfiguration is still diagnosable from the log alone.

Mutation-checked: with the replacement removed, both new tests fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): say which role the private-vector gate actually blocks

"serves that bundle to nobody else" read as though sundial withheld the private
vectors from everyone. It withholds them from `guest` specifically -- admin and
private still get them -- which is the whole reason CI authenticates as the
least privileged account rather than whichever one was to hand. Verified against
the deployed middleware: guests get a 200 with a no-op body, not a 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): the Cloudflare 403 is narrower than the comment claimed

It said any non-browser User-Agent is refused. In fact `POST /__auth/login`
worked for months with `User-Agent: camoufox-harness` -- the block tracks
document-shaped requests, which is what `/automated?key=` is. Browser headers
everywhere are still right, but as "cheaper than remembering which hop is
which", not as a fix for an outage that was never happening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): verify the sundial role instead of assuming it

The CI log for the first real run says `sundial auth OK (automation key)` --
the stored credential is an automation key, not a form password. Which matters
more than it sounds: sundial's `/automated?key=` resolves to the **private**
role when handed the private key and to `guest` when handed the guest one, and
the two are indistinguishable by looking at them. `private` is served the
private vectors.

So the guarantee this gate has been documenting -- "CI runs as a role sundial
withholds the vectors from" -- was resting on whoever set the secret having
picked the right key. redact() does not help here: it governs what this
repository *publishes*, not what the browser is *given*, and the thing being
prevented is a public runner holding the vectors at all.

The gate now reads sundial's own /__auth/me and refuses to open the browser
unless the session is a role the vectors are withheld from. Not knowing the role
counts as not safe: an absent or unreadable endpoint fails closed, because the
alternative is loading them on the assumption that the credential was right.

Probed live: a bogus cookie yields no role and the check refuses, as it should.
The positive case is what the next run confirms -- and if that credential turns
out to be the private key, this goes red, which is the correct and useful
outcome.

Also corrects the Cloudflare comment, which I had just rewritten on a false
premise. The form-login route was never exercised in CI (the gate shipped
disabled), so "POST /__auth/login worked for months with camoufox-harness" was
unfounded. What is measured: /automated?key= answers 401 with a browser
User-Agent and 403 with urllib's default -- and since that is the route the real
credential uses, the browser headers were load-bearing, not cosmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): record the sundial role in the evidence, not just in the log

`result.metrics["sundial_role"]` was set before the scan and then thrown away:
redact()'s output replaces result.metrics wholesale a few lines later. The check
itself was unaffected -- it ran, and it fails closed -- but the saved artifact
did not say which role had been confirmed, which leaves "the vectors were never
served to this session" unverifiable after the fact. That is most of the reason
to record it.

Caught by reading the artifact the live run actually published, not by the 105
tests, none of which exercised gate() end to end. There is now one that does,
and it fails when the re-assignment is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* test: delete the vendored Playwright fork, keep only what is ours

`tests/` held a fork of a ~v1.55-era playwright-python suite, run in CI as
`playwright_vendored` alongside upstream's own suite. Measuring the two against
the same binary says the fork was strictly the weaker of them:

  * 73 of the 74 tests it skipped as "Not supported by Camoufox" PASS in
    upstream's copy. Those skips predate main-world execution and were never
    revisited, so the fork was asserting the browser is worse than it is.
  * Of its passing tests, eight had no upstream counterpart. Six were genuinely
    Camoufox-specific; the other two were tests upstream had since renamed.
  * Everything else was upstream code, one generation stale, run twice.

So the fork goes, and the six tests that were doing real work become
`tests/camoufox/` -- three modules that `ci/suite.py` overlays into the fetched
upstream checkout, where they run against its conftest and its server at
whatever tag was resolved. Nothing frozen, so nothing to go stale.

Two of them take over from skiplist entries that had been pointing at the fork:

  * the worker locale. Upstream expects a worker NOT to inherit the context
    locale (playwright#38919); Camoufox sets it below that layer, so its workers
    agree with the main thread. A page/worker disagreement is a free signal, so
    the replacement asserts they match rather than hardcoding one string.
  * the User-Agent. The bare binary advertises `Camoufox/<version>`, not
    `Firefox/<version>`, and only the Python package rewrites it. The
    replacement asserts a well-formed Gecko token and, more usefully, that the
    wire and the DOM agree on it.

A skiplist entry that hands its job to another test now says so in a
`replaced-by:` field, and `ci/summarize.py` fails the run if that file does not
exist -- otherwise a rename quietly turns "covered elsewhere" into "not covered".

Also here, because deleting the fork exposed them:

  * `tests/local-requirements.txt` was the only thing holding the suite below
    pythonlib's `playwright = "<1.63"` ceiling, and it is gone. `ci/versions.py`
    now applies that ceiling directly, so a future Firefox bump cannot silently
    resolve to a client the shipped package refuses to install.
  * the pytest header named `<plugin dir>/skiplist.yml` whatever it had actually
    read. Since ci/suite.py copies the plugin into the checkout, that was a path
    with no file at the end of it -- misleading precisely when someone is chasing
    down a skip. It now reports the file it loaded.
  * the summary line printed "Camoufox 152.0.4 ... against Playwright v1.61.0,
    which targets Firefox 151.0", which reads like a misconfiguration. Playwright
    trails Firefox and skips generations -- it pinned 151 then 153, never 152 --
    so it now says which rule picked the tag and that the browser is Firefox 152.

`make tests` runs the one suite. Verified: the three overlaid modules pass
against 152.0.4-beta.31 (8/8), the overlay refuses to shadow an upstream module
and is idempotent across a reused checkout, and both new guards were
mutation-checked. ci/tests: 112 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* feat(ci): a local way to ask where the stealth failures are

"Which five sundial checks failed?" has no answer in CI, and the reason is worth
writing down rather than rediscovering: score mode's payload is buckets keyed
"<Category>|<class>" holding two integers each. It carries no check names and no
ids, so a failing check's identity is not something the gate discards -- it is
something sundial never sends. No artifact, log or sealed report can recover it.

What the payload does know is which category the failures are in, and the gate
was throwing that away too. `--explain` prints a per-category breakdown, and:

  * it prints, never records -- `result` is untouched, so the artifact still
    carries only `_PUBLISHABLE`;
  * it is refused outright under GITHUB_ACTIONS, before anything is sent. A
    category table is not a vector, but "Graphics 3/17" is the most useful
    single fact an adversary could take from a public log, which is precisely
    why redact() does not publish one;
  * it says in its own output that names need --allow-full-report and a role
    sundial serves full reports to, so a reader does not mistake the category
    view for the whole answer;
  * an unclassified category is flagged there too, on the same rule the gate
    uses -- a new sundial section must not default to ignored.

Also corrects ci/README.md, which claimed "identities in the results file are
HMACs". They are not, and have not been: redact() ships no per-check rows at
all, deliberately, because a map of HMACs still says how many distinct checks
fail and lets a reader follow one across releases. The README was describing a
weaker guarantee than the code actually makes.

The CI refusal is mutation-checked. ci/tests: 116 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): 193 of the 202 tests the skiplist skipped actually pass

The skiplist was wrong, and wrong in the way that matters: it claimed the
browser could not pass tests it passes, and the stated reasons made that look
checked.

Where it came from: the first version of ci/skiplist.yml took all nine
`tests/async/*.disabled` modules from the vendored suite -- a 1:1 match, nothing
independently derived -- and gave each a plausible justification without running
any of them. That is the exact thing the file's own header calls "a regression
wearing a disguise", committed in the commit that wrote the header.

Ran every entry with the skiplist disabled, against 152.0.4-beta.31:

  test_element_handle.py   59 skipped   0 fail
  test_popup.py            26 skipped   0 fail
  test_dispatch_event.py   12 skipped   0 fail
  test_check.py             9 skipped   0 fail
  test_launcher.py          9 skipped   0 fail
  test_focus.py             6 skipped   0 fail
  test_fill.py              3 skipped   0 fail
  test_click.py            71 skipped   2 fail
  client_certificates        5 skipped   5 fail
  (two individual tests)     2 skipped   2 fail

Seven of nine modules failed nothing at all. 193 of 202 passed. The suite was
reporting 1339 passing while silently excluding 193 more that also pass -- and
nothing would have caught those 193 regressing.

Some reasons were not just over-broad but wrong. test_popup.py was skipped for
"Camoufox resolves the User-Agent from its fingerprint config, so an arbitrary
override does not and must not stick"; the two UA tests in it pass, because the
bare binary under plain Playwright does honour `user_agent=` -- the fingerprint
resolution is in the pythonlib wrapper, which this suite does not use. The
neighbouring test_request_headers_should_work entry gets that distinction right,
four entries earlier. test_dispatch_event.py was skipped for "Camoufox only
emits trusted events"; that is true and those tests never assert isTrusted.

So: 13 entries down to 5. test_click.py narrows from the module to the two tests
that fail -- Playwright's stable-position wait polls the bounding box then
dispatches instantly, and the humanized path spends real time travelling, so an
animating button is clicked mid-flight (offset 100, expected 300). The
client-certificate module stays whole: all five fail at the TLS layer, which is
support that genuinely is not compiled in. The `[chromium]` and `[webkit]`
patterns are deleted -- the runner pins `--browser firefox`, so they matched 0
tests and only made the list look more considered than it was.

And the guard, because a reason is an assertion about the browser and nothing
was checking it: ci/run_skiplist_audit.py runs every entry with the skiplist
disabled and FAILS THE BUILD if a skipped test passes. It is cheap exactly
because a correct skiplist is short -- 9 tests, 8 seconds -- so it runs on every
PR in tier 3a and is a required gate. Mutation-checked both directions: exit 1
naming the newly-passing tests when a stale entry is re-added, exit 0 now.

ci/tests: 118 passed, including that every shipped entry is actually auditable
(a `pattern` entry cannot be, and now fails that test rather than riding along
unverified).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* feat(ci): run the sync suite too, and say what is left out

The Playwright gate ran `tests/async/` and nothing else. Upstream v1.61 collects
2306 tests; that was 1584 of them. The other 722 -- `tests/sync/` (715),
`tests/common/` (4), `test_reference_count_async.py` (2), `test_installation.py`
(1) -- never ran, and unlike a skiplist entry there was nothing anywhere saying
so. No reason was recorded because there was no decision: the vendored fork in
tests/ carried `async/` and `async_imp/` and no sync suite, and this runner was
pointed at the same shape without checking what upstream had. Same inherited
shape as the stale skiplist, one level up.

TARGETS now names `tests/async/` and `tests/sync/`. The sync API is a greenlet
wrapper over the same Juggler traffic, so much of it duplicates async at the
protocol level; it is here because pythonlib ships a sync API users actually
drive, and the wrapper has its own timeout and reentrancy behaviour the async
tests cannot reach.

ISOLATED_TARGETS runs `tests/common/` and `test_reference_count_async.py` in a
second pytest process, on the first shard only. They cannot share a process with
the others: each calls sync_playwright()/async_playwright() inside the test body,
which cannot start while the session fixtures hold a loop. Together all six fail
with "Cannot run the event loop while another loop is running"; alone all six
pass. They are worth the extra invocation rather than dropping, because
ProtocolCallback objects accumulate when the browser never replies to a protocol
message -- and this fork patches Juggler heavily, so that leak can be ours.

EXCLUDED holds the one real exclusion with its reason: test_installation.py
pip-installs playwright to check packaging, which exercises Playwright's release
process and not this browser. A self-test requires every exclusion to carry a
reason.

And unclaimed() closes the level above the skiplist: if upstream adds a test path
that is in neither set, the run errors instead of quietly getting narrower. It
ignores assets/ and golden-*/ fixtures, and a self-test proves it catches a new
tests/integration/.

Verified against the CI-built binary: 2225 passed, 0 failed, 66 skipped, 2291
collected. ci/tests: 122 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): client certs are only unsupported where the BROWSER does the handshake

The skiplist audit failed its first CI run, on an entry written one commit
earlier. It was right to.

`tests/async/test_browsercontext_client_certificates.py` was skipped as a whole
module, reasoned as "client-certificate support is not built into the Camoufox
binary". On the GitHub runner two of its five tests pass:

  test_should_throw_with_untrusted_client_certs         PASSES
  test_should_work_with_global_api_request_context      PASSES
  test_should_work_with_new_context                     fails
  test_should_work_with_new_context_passing_as_content  fails
  test_should_work_with_new_persistent_context          fails

The split is not noise. Playwright offers client certificates two ways:

  playwright.request.new_context(client_certificates=...)  the Node driver does
      the TLS handshake itself -- works, and those are the two that pass.
  browser.new_context(client_certificates=...)             the BROWSER does the
      handshake -- and this build has nothing to do it with.

So the reason was over-broad rather than wrong, and a module entry was exactly
the shape that hid the distinction. Now six per-test entries, async and sync,
naming the browser-side handshake specifically.

Worth recording how the bad entry got written, because the mechanism matters
more than the entry: locally all five fail, because this machine's Node/OpenSSL
rejects the fixture server outright ("wrong version number"). That looked like
uniform absence of support and it was not. A local run is a hypothesis; CI is
the authority for what fails. The audit is what turned that from an opinion into
a build failure, one run after the mistake.

The sync entries assume the same split from identical test names; the audit will
confirm or correct that on the next run rather than my asserting it from here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): async and sync cannot share a pytest process

Adding tests/sync/ to the same pytest run as tests/async/ produced 50 tests that
passed only on retry. 47 were fixture-setup errors in two fetch modules:

    RuntimeError: Runner.run() cannot be called from a running event loop

Upstream's sync suite is a greenlet wrapper; its async suite runs under
pytest-asyncio. In one process, whichever runs second breaks the other's loop.
Measured, against the CI-built binary:

    tests/async alone                       1526 passed, 1 timing flake
    the two fetch modules alone              102 passed
    async fetch + one sync module             14 failed
    sync module first, then async fetch       46 errors

So sharding was not hiding anything -- async alone is clean -- and the fault is
the mixing, not the size of the run.

What makes this worth a structural fix rather than a retry budget: the damage
lands in async FIXTURE SETUP, so it presents as "the fetch tests are flaky" --
a browser-shaped symptom for a harness-shaped cause -- and the retry pass then
makes it vanish. Left alone, CI goes green with 50 silent retries, and the
natural response to any that stuck would be a skiplist entry recording a browser
failure that does not exist. That is the failure this branch has spent its last
several commits removing.

GROUPS now names three sets, each run in its own process: tests/async/,
tests/sync/, and (tests/common/ + test_reference_count_async.py). The first two
shard; the third is six tests and does not, because splitting it hands some
shard an empty selection and pytest exits 5 for that. The pairing in the third
group is measured, not assumed -- those two run together cleanly (6 passed).
Retries stay inside their own group, for the reason the groups exist.

Verified: 2225 passed, 4 failed, 66 skipped, 2295 collected, and 1 retry-passer
instead of 50. All four failures are the two client-cert tests that go through
playwright.request.new_context(), which fail only on this machine -- its
Node/OpenSSL rejects the fixture server ("wrong version number"). They pass on
the runner, which is why they are not skiplisted; the audit is what will hold
that claim honest. Expect 2229/0 in CI.

ci/tests: 124 passed, three of them pinning this shape -- async and sync in
different groups, no target in two groups, and the small group unsharded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): refuse to test a Firefox the branch does not pin

Suite selection follows the browser version correctly: ci/versions.py resolves
Firefox 155 to Playwright v1.62.0 and 152 to v1.61.0, and the workflow threads
`browser_version` from workflow_call/dispatch through resolve into the Playwright
job, which fetches that tag. That half works.

The other half does not. Nothing else honours the input:

  ci.run_build / make       read upstream.sh
  fetch-browser             `python -m camoufox fetch`, whatever is current
  ccache key                uses the resolved version (cosmetic)

So `browser_version: 155.0` on a branch pinning 152.0.4 compiles 152 and judges
it against the suite chosen for 155. It passes, it means nothing, and no output
anywhere says the browser and the suite are describing different releases. For a
gate whose entire job is to make an upgrade provable, that is the worst
available outcome.

The legitimate flow never had this in it: an upgrade to a new Firefox is a
branch that edits upstream.sh -- that is what an upgrade is -- and resolution
then reads it by default, so the build, the suite and the input cannot disagree.
The mismatch only arises from a dispatch that asks for a version the branch does
not pin.

`--check-upstream` refuses that combination with a message saying what would
have happened and what to do instead (bump upstream.sh). The workflow passes it,
and a self-test asserts the workflow passes it, because a guard nothing invokes
is decoration. Mutation-checked.

Not fixed here, and deliberately: making the build honour an arbitrary version
would mean synthesising an upstream.sh -- version plus release tag plus
closedsrc_rev -- for a release that may not exist. Refusing the contradiction is
the honest amount of machinery for it.

ci/tests: 126 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): the fetched browser must be the generation the suite was chosen for

A driver-only pull request does not build. It downloads the current release,
which is the right browser to judge a driver change against -- it is what users
run. But the Playwright suite is chosen from upstream.sh, and those two agree
only until an upgrade window opens.

The sequence that breaks: the version agent bumps upstream.sh to Firefox 155 and
that merges. No build of 155 is published yet. The next driver-only PR resolves
its suite for 155, fetches the published 152, and tests the old browser against
the new suite. It passes, and says nothing.

Today they happen to agree exactly -- upstream.sh pins 152.0.4-beta.31 and the
latest release is v152.0.4-beta.31 -- which is timing, not a guarantee, and
precisely the kind of coincidence that hides this until the upgrade it is
supposed to protect.

So the fetch step now reports which build it installed and refuses a mismatch.
Beta drift inside a generation is fine and expected: beta.30 against beta.31
does not change which Playwright tag is right, and demanding an exact match
would fail every run between a bump and a release. A generation apart is not
fine, and that is what is checked.

Fails closed on input it cannot read, rather than passing by accident.
Mutation-checked. A self-test asserts the workflow actually invokes it.

ci/tests: 128 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): a driver-only pull request cannot satisfy a required build

The summary was told to require the `build` suite on every run:

    required="pythonlib native_rules"
    if [ "$browser_changed" != "skip" ]; then
      required="$required build patch_guards ..."
    fi

`browser_changed` is only ever `true` or `false` -- nothing emits `skip` -- so
the branch is always taken and `build` is always required. But the build job is
deliberately skipped whenever the browser was fetched rather than compiled, and
only that job writes a `build` result. summarize then reports

    `build` is required but produced no result file. A suite that did not run
    has not passed.

which is the correct rule applied to a suite that was never supposed to run,
and the gate goes red.

That blocked every pull request touching only pythonlib/, ci/, tests/ or the
documentation -- most of them, and precisely the cheap path ci/README.md
advertises as "driver-only pull requests never build". It was never seen
because this branch edits the Makefile and additions/, so its own runs always
took the build path.

The browser suites stay required either way: fetched or built, the browser is
there and they run against it. `build` is the only one that follows.

`test_build_is_required_only_when_the_browser_was_built` extracts the workflow's
own `required=` assembly and runs it under bash for both values, rather than
pattern-matching the shell -- the bug was a comparison that read as deliberate,
and only running it says what it does. Mutation-checked: restoring `!= "skip"`
fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): sundial being down is a skip, not a verdict on the browser

The stealth check drives a service on another host. Any failure reaching it --
DNS, a refused connection, the edge answering 502 for a minute -- came back as
ERROR, failed the job, and failed the merge gate. So an outage over there
blocked every pull request in this repository, including ones that had nothing
to do with stealth, and the only fix available to a contributor was to wait.

That is not a statement about the browser. When sundial cannot be reached the
browser was not measured at all, so neither a pass nor a failure is true. It is
now recorded as SKIP with the reason attached, the job exits 0, and
`ci/summarize.py --allow-skip sundial` tolerates it: shown on the summary table
with its own icon and its reason, and not a merge block.

The line is drawn at whether sundial answered:

  down       no HTTP reply at all (URLError, timeout, connection reset), or a
             5xx, or a 429 -- the origin is broken or is refusing everyone.
  answered   everything else. 401 is a bad credential, 403 is the edge refusing
             a non-browser request, and both are this repository's problem to
             fix. Skipping past those would turn a misconfigured stealth gate
             into a permanently green one.

And an answer stays an answer further in: a role sundial would serve the
private vectors to, a full report where a score was requested, a pass rate under
the floor -- all still fail, as before.

Both auth routes are tried, and the key route 401s whenever the stored secret is
a password rather than an automation key. One real reply is enough to know
sundial is up, so `authenticate()` reports an outage only when neither route got
an answer.

The skip note goes through `scrub()` like every other published string: the
token route puts the credential in the URL, and a URLError carries the URL that
raised it.

`--allow-skip` is per suite and nothing else is on the list. Every other suite
runs on the runner; none of them has this excuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* feat(ci): run the suite isolated first, and count what needs the main world

The conformance suite forced `disableWorldIsolation` for every run. That made
upstream's tests pass -- they assert upstream semantics, reading globals their
own page scripts defined -- but it measured a mode nobody ships, and it produced
no number at all for what isolation costs. Isolation is the reason this fork
exists; running 2229 tests against it turned off says less than it looks like.

Each group is now run up to three times:

  1. isolated -- the configuration users get. A test reading a page-defined
     global fails here by design.
  2. the same failures again, still isolated. A pass is a flake, and a flake
     must not be counted as a world difference.
  3. what is still failing, with isolation off.

A test that passes in 3 is a **main-world fallback**: it counts as a pass -- the
browser does honour the contract -- and its identity is recorded in
`metrics.main_world_fallbacks`, with the count on the summary table. That count
is the isolated-world conformance gap. It is invisible in the pass/fail totals
by construction, which is exactly why it has to be printed: a jump in it means
the isolation boundary moved, and nothing else in this pipeline would say so.
A test failing in *both* worlds is a plain failure, as before.

`CI_WORLD` selects the world and defaults to isolated, so a plain
`pytest -p pw_camoufox_plugin` by hand measures the browser as it ships.
`_apply_world()` *clears* a stale `disableWorldIsolation` as well as setting it:
the passes are separate processes inheriting one job environment, and a
leftover flag would make the isolated pass quietly measure the main world --
which would silently zero the very number this is for.

Two things this depends on, fixed here:

  Each group gets its own pytest cache. All three run in one checkout, and
  pytest only drops a `lastfailed` entry when that test is collected again and
  passes -- so with the shared cache the async group's rerun was selecting from
  a set the sync group had also written into. Depending on which groups had
  failed that meant re-running the whole group or selecting nothing at all.
  Per-group, `--last-failed` means what it says, and passes 2 and 3 can use it
  to name exactly the right tests.

  `ci/run_skiplist_audit.py` pins the MAIN world. The suite now counts a test
  needing the main world as a fallback rather than a failure, so a skiplist
  entry has to claim the test cannot pass in either world -- auditing under
  isolation would let an entry justify itself with a failure the suite would
  never have counted, which is the same class of untrue-but-plausible reason
  the audit exists to catch.

Shard merging sums the fallback counts and unions the identity lists; taking
the first shard's, as the generic metric merge did, would report a sixth of the
number. Mutation-checked, along with the stale-flag clear.

The first CI run on this is what establishes the real fallback count. The
plugin's own note put it at roughly 37; that was measured a while ago and is not
a promise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs: describe the pipeline that exists, not the one that is coming

CONTRIBUTING.md and the pull request template still described the process this
branch replaced: run both suites by hand, screenshot the output, paste it in.
CLAUDE.md was already pointing at CONTRIBUTING.md for "the full pipeline" that
CONTRIBUTING.md did not mention. Since the premise of this work is that the rule
in CONTRIBUTING.md was never enforced, leaving it unchanged left the rule
describing the wrong thing.

Both now say what CI does and what the one required check is. The template no
longer asks for a screenshot: nothing checked that the browser in one was built
from the branch under review, which is the whole reason the pipeline exists, and
CI leaves its own report as a comment. The local commands stay, because running
build-tester by hand while working on a spoofing patch is still the fastest way
to find out whether it did what you meant.

Several comments described this repository as containing an auto-update harness
that is not in it: `ci/results.py` credited `verify.py` and "the repair agent"
with computing the verdict that `ci/summarize.py` computes, and `ci/sundial.yml`
and `ci/build-tester.yml` sited themselves relative to a `harness/policy.yml`
nobody can open. Each now names what actually decides here and marks the harness
as the out-of-repository caller it is. `run_sundial.py waive` says outright that
the file it prints a stanza for is not in this repository, which is worth
knowing before going to look for it.

Also: PEP 8 blank lines around `resolve_verstr()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* perf(ci): drop the same-world retry -- 7m50s a shard, nothing recovered

The first real run of the isolated-first suite took 21m13s a shard against
4m34s before. Most of that was a pass that could not have worked. Shard 4, both
sharded groups:

    isolated (full)   335s + 331s   35 and 11 failures
    isolated retry    205s + 265s   0 recovered
    main world         19s +  12s   46 recovered

The retry was there so that a flake could not be mistaken for a world
difference. It cannot do that job:

  * These failures are deterministic. A test reading a global its own page
    script defined does not intermittently stop seeing it.
  * Failing that way is slow. The read returns undefined rather than throwing,
    so the test sits on a Playwright timeout -- which is why re-running 46
    known failures cost nearly eight minutes while proving them in the other
    world cost thirty-one seconds.
  * Upstream already reruns. The `105 rerun` on that first line is every one of
    those 35 failures having been retried three times by the suite's own
    pytest-rerunfailures before the run reported them. A flake does not survive
    that, so there was nothing left for a fourth and fifth attempt to find.

So: isolated, then the main world, then -- only for what failed in BOTH -- one
retry. That last set is normally empty, so the retry is free on a healthy run
and still answers the one open question on an unhealthy one: a test no world
satisfies is either broken or flaky. It runs in the main world, where a pass
means "not reproducible" rather than "needed isolation off", which is already
known by then.

A flake surviving upstream's three reruns and then passing in the main world
would now be counted as a fallback rather than as a flake. That is the trade,
and it is worth it: the count is reported, not gated, and no verdict moves.

Also adds the guard that was missing between the phases. `--last-failed` with
nothing previously failed does not select nothing -- pytest declines to filter,
and runs the whole group. Unguarded, a group that passed cleanly under
isolation would have been re-run end to end in the main world, silently
replacing the result it was meant to refine. Both rerun passes are now behind a
non-empty check, and a self-test asserts it of every `--last-failed` in the
loop. Mutation-checked, as is the single-isolated-pass rule -- "retry it in the
same world first, just to be safe" reads as obviously correct and costs eight
minutes a shard.

Expected shard time is now ~11 min: the isolated pass, which is the
measurement, plus half a minute to resolve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* perf(ci): bound the isolated pass, which is where the timeouts come from

Some isolated failures do not fail. They hang for the full 180s per-test
timeout and are then rerun three more times, so one test can cost twelve
minutes. The main-world baseline had zero timeouts across all six shards, so
this arrived with isolation.

Reproduced against a build, and the cause is not the harness:

    page script calls window.exposedFn()   isolated -> HANG      main -> resolves
    evaluate() calls window.exposedFn()    isolated -> resolves  main -> resolves

`expose_function` installs its binding on the isolated world's global. Page
script calling `window.fn()` looks at the page's own window, does not find it,
and the call never reaches Python -- so a test awaiting the future that call was
meant to resolve waits forever, because that await has no Playwright timeout
behind it. evaluate() works because it runs in the same world as the binding.

That is isolation doing exactly what it is for. A page that can reach an
automation binding can detect it, which is the reason this fork exists. These
tests assert a behaviour Camoufox deliberately does not have, and they cannot be
fixed -- only recognised, which pass 2 does in about half a second each.

What can be fixed is the price of recognising them. Pass 1 is a classifier: its
only question is whether a test passes as Camoufox ships, and a test that hangs
has already answered it. So pass 1, and only pass 1, is bounded:

  per-test timeout 90s.  The slowest test in the entire main-world baseline was
  30.4s of 2295; two exceeded 30s and none exceeded 45s. A Playwright action
  times out at 30s. Three times the slowest honest thing that happens, and half
  the previous bound.

  upstream's reruns off, via CI="".  tests/conftest.py sets `reruns = 3`
  whenever $CI is set, and that is the only thing it reads $CI for. It is
  insurance that almost never pays out -- 2 reruns across all 2295 baseline
  tests -- and under isolation it turned every deterministic world difference
  into four attempts: 138 reruns in a single shard's isolated pass, recovering
  nothing. Note that `--reruns 0` as an argument would not work; conftest
  overwrites config.option.reruns in pytest_configure, so the environment is the
  only lever that holds.

A hang now costs one 90s wait instead of up to 720s. A flake missed by not
rerunning is not lost: it fails pass 1, passes pass 2, and is counted as a
fallback -- noise in a reported metric, not a change in any verdict.

Passes 2 and 3 keep upstream's conditions untouched. They are the ones deciding
what an answer means, and they run against a handful of tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* perf(ci): stop rebuilding a browser that has not changed

Every push to this branch recompiled Firefox, 24 minutes at a time, including
the three in a row that changed nothing but ci/ and documentation.

`browser_changed` is computed against the pull request's BASE, not against the
push, so a branch that touched patches/ or additions/ even once keeps rebuilding
forever. That part is correct and should stay: the published release does not
contain this branch's browser changes, so testing against it would test a
different browser than the one under review. Using the release is not the
alternative.

The alternative is not building the same thing twice. The build job now asks a
narrower question first -- not "does this branch change the browser" but "has
the browser changed since the last one we built" -- and answers it with a cache
keyed on a hash of every input that can alter the binary: the same path list
browser_changed greps for, plus this workflow, which pins the toolchain the
build runs on. On a hit the 634 MB dist is restored and every build step is
skipped, which is the difference between 24 minutes and about one.

Two things that would otherwise have made this quietly wrong:

  A hit still has to report a `build` result. It is a required suite whenever
  the browser was built rather than fetched, and a required suite that produced
  no result is -- correctly -- a failure. Same trap as requiring `build` on a
  driver-only pull request, reached from the other side. The hit path writes one
  recording that the browser was restored and under which key, so "this run
  compiled nothing" is a fact in the evidence rather than an absence in it.

  No restore-keys. Everywhere else in this workflow a prefix match is right; a
  partly warm ccache is still warm. Here it would hand the test jobs a browser
  built from different sources while every suite reported on it looking
  perfectly healthy.

The self-tests hold the two lists together: if a path is ever added to the
browser_changed grep without being added to the cache key, a change there would
neither force a build nor invalidate the cache, and the run would silently test
a browser that predates it. Mutation-checked, along with the guards on each
expensive step and the absence of restore-keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* perf(ci): a Juggler JavaScript change does not need libxul relinked

Juggler is mostly JavaScript, and JavaScript does not need a compiler. Measured
on a real build, ccache reported a 98.63% hit rate (4167/4225), so almost none
of those 24 minutes was compiling C++ -- it was Rust, linking libxul, and
packaging, none of which a .js file affects.

And there is no omni.ja to rebuild either. CI archives the UNPACKAGED dist/bin;
omni.ja only exists in dist/camoufox/, which `mach package` produces and nothing
here uses. In dist/bin, Juggler is loose files under chrome/juggler/ -- symlinks
into the source tree, dereferenced into the artifact by `tar -ch`. Delivering
new JavaScript is a copy.

So the build cache is now keyed on a hash of the COMPILED inputs only. If that
hash matches, the compiled half is identical by construction and this branch's
resources are laid over the restored browser. The hash is the classification:
there is no "did only JavaScript change?" diff, because a diff answers the wrong
question -- it compares against the pull request's base, while what matters is
whether the cached browser has the same native sources. Same hash, same binary,
whatever the diff says.

Two traps, both closed and both mutation-tested, because either one silently
serves a browser that is not the one under review while every suite reports
green:

  additions/juggler/ is not all JavaScript. It also holds the screencast encoder
  and the remote-debugging pipe -- 5 .cpp, 5 .h, 2 .idl, 3 components.conf, 4
  moz.build -- compiled into libxul. Only the files jar.mn lists are treated as
  resources; everything else, including any extension nobody has considered yet,
  is native and forces a build. jar.mn is itself native, so a resource removed
  from it cannot leave a stale copy behind.

  The mapping is per-file, not a prefix. jar.mn maps TargetRegistry.js to
  content/TargetRegistry.js (a level added), content/FrameTree.js to
  content/content/FrameTree.js (preserved), and content/JugglerFrameChild.sys.mjs
  to content/JugglerFrameChild.sys.mjs (dropped). Two files in one source
  directory land at different depths. A prefix rule writes one of them to the
  wrong path and leaves the old copy in place.

Verified against a real build rather than reasoned about: all 22 jar.mn entries
resolve to files that exist in dist/bin, and overlaying this branch onto a dist
built before it turns FrameTree.js from 0 occurrences of nukeSandbox to 1, with
all 22 resources byte-identical to the branch afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* docs(ci): name the mechanism that actually hangs, not the one I found first

The comment blamed expose_function. That mechanism is real -- verified against a
build -- but no upstream test has that shape, so it is not what hangs in CI.

Running the full async suite isolated against a build names them: four tests,
always the same four, all in tests/async/test_route_web_socket.py. 225 of 1509
fail under isolation; four of those hang.

The general shape, which is worth stating because it will recur: a Playwright
feature implemented by installing something on the page's global lands in the
isolated world instead, so anything the PAGE originates never reaches the
automation. route_web_socket replaces window.WebSocket from an init script;
isolated, that replacement is in the sandbox and a socket the page's own script
opens is never intercepted. expose_function puts its binding on the sandbox
global, so page script calling window.fn() finds nothing -- called from
evaluate() it works, which is why it does not hang.

They hang rather than fail because the waits involved have no Playwright timeout
behind them: a Twisted future from the test server, an asyncio future a binding
was meant to resolve. Everything else isolation breaks fails at Playwright's 30s.

One thing to flag beyond the test suite: the route_web_socket half is not a test
artifact. Measured with a page whose own script opens a socket -- what a real
site does -- the handler fires in the main world and never fires isolated. A
user calling page.route_web_socket() against a real site gets no interception
and no error. It is not fixable here, because the feature works by replacing a
page global and that is exactly what an isolated world exists to prevent a page
from seeing, but it deserves an issue of its own rather than a comment in a CI
runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): the isolated pass cannot bound a hang, so stop asking it to

Four shards ran for two hours each and were killed by timeout-minutes, three
runs in a row. The cause is not the duration of anything.

Measured on run 34799668707, with ISOLATED_TIMEOUT already lowered to 90s:

    tests/async/   isolated pass completed in 296s. The bound works.
    tests/sync/    test_should_work_with_ws_close printed pytest-timeout's
                   "+++ Timeout +++" banner at exactly 90s -- and the process
                   then sat there for the remaining 1h50m.

So the signal fires and the test dies; the process does not. pytest-timeout's
signal method raises at the next bytecode boundary, and Playwright's sync API is
parked in a greenlet switch that never reaches one cleanly, so the raise lands
inside the dispatcher and wedges it. `--timeout-method=thread` fires reliably but
kills the interpreter and takes the other ~1500 tests in the group with it. No
per-test value bounds this, which is why lowering 180 -> 90 changed nothing.

Declared rather than discovered, then. The isolated pass cannot learn that these
hang without hanging, so ISOLATION_HANGS tells it: they are --ignore'd out of
pass 1 and run directly in the main world, where they pass and are counted as
fallbacks exactly as if isolation had failed them honestly. Coverage is not lost
-- the same tests run, in the world that can run them. Verified in the same run:

    tests/async/test_route_web_socket.py::test_should_work_without_server
      isolated -> Timeout        main -> PASSED

Deliberately not ci/skiplist.yml. That list means "fails in the most permissive
world", and run_skiplist_audit.py enforces it by running every entry with
CI_WORLD=main and failing the build on any that pass. These pass there, so an
entry would be rejected by the audit and would be untrue as written. The tests
keep the two lists apart.

The second half is the backstop, because the next unboundable hang will not be
this one. Each pytest invocation was bounded at args.timeout, default 10800s --
three hours, against a job capped at 120 minutes. It could never fire: GitHub
hard-killed the runner first, taking the junit and diagnostics uploads with it.
Now --group-timeout, 1200s, roughly four times the slowest healthy invocation
measured, and the job drops 120 -> 40. A wedge costs twenty minutes and still
reports what it collected, instead of two hours and nothing.

The browser bug underneath is real and is not a test artifact: route_web_socket
replaces window.WebSocket from an init script, which under isolation lands in the
sandbox, so a socket the page's own script opens is never intercepted and the
caller gets no error. Filed as #775, with the native-interception fix that keeps
it undetectable. When that lands these stop hanging and the declaration goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): a cache-hit build cannot repack the archive over its own input

The prebuilt-browser path failed every time it actually hit:

    zstd: camoufox-dist.tar.zst already exists; stdin is an input -
          not proceeding.
    tar: -: Cannot write: Broken pipe

`zstd -o` refuses an existing destination and exits 1, and the overlay step
repacks to the same filename it just unpacked from. It went unnoticed because
until now every run changed the browser sources and rebuilt instead -- the cache
restored, the overlay ran ("overlaid 22 resource(s)"), and the step died one
line later. The first pull request that did not touch the browser found it.

Repack to a temporary name and mv it into place. That sidesteps the refusal, and
means a repack that dies partway cannot leave a truncated archive where the
restored one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

* fix(ci): a six-test module cannot be sharded six ways

The declared-hang pass ran with the shard filter still applied, so a shard that
owned none of the module got:

    collected 6 items / 6 deselected / 0 selected
    ============================ 6 deselected in 0.05s ===========================

pytest exits 5 on an empty selection and writes no junit, which is exactly what
"the module did not run" looks like -- so the guard that exists to catch lost
coverage failed three of six shards instead. The other three owned some of the
six and passed, which is why it looked intermittent.

Run it unsharded on the first shard, the way tests/common/ already is and for
the same reason. Running the module whole also keeps its fallback accounting in
one place rather than spread across shards that each saw a fraction of it.

CI_SHARD is cleared rather than dropped: ci/_util.run() layers env over
os.environ, so an omitted key would still inherit one. parse_shard() reads empty
as "no shard", the same way _NO_UPSTREAM_RERUNS clears $CI.

The rest of the run confirms the mechanism. On shard 3, which completed:

    tests/sync/test_route_web_socket.py::test_should_work_with_ws_close PASSED

in 3.07s -- the test that wedged a runner for 1h50m two runs ago -- and the
shard finished 386 passed, 0 failed, 0 errored. No shard hung. The whole run
took 26 minutes against 2h35m, and the build was 53s against 24m now that the
prebuilt cache can repack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1UY3f8gm2jA1J23C3ew9s

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:52:05 +00:00
Jake WriterandClaude Opus 5 eb5dc3bc5b chore(release): v152.0.4-beta.31, pythonlib 0.5.6
Browser: beta.31, cut from the integration merge in 35b45c1 (#756). Tagging
publishes it as a prerelease; beta.30 stays the latest stable release.

pythonlib: 0.5.6b1 -> 0.5.6. The pre-release existed because
PLAYWRIGHT_BROWSER_FLOORS pins Playwright >= 1.61 to browser beta.30, and
beta.30 was itself only a prerelease, so the floor pointed at a build the
resolver would not hand to users. beta.30 is now the latest stable release, so
the floor resolves and 0.5.6 can ship as final.

Verified on linux x86_64 before cutting:
  build-tester      all categories pass; the one varying slot is a
                    cross-profile uniqueness collision across 3 random draws
                    (macOS Screen, then Linux Screen, then macOS Canvas over
                    three runs), not a regression
  patch guards      16/16
  Playwright suite  13 failed, 1083 passed vs stock beta.30's 14/1082
  pythonlib tests   209 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
2026-09-05 19:25:50 -06:00
Jake WriterandClaude Opus 5 a80abb452a feat(patches): report a touchscreen digitizer, not a phone
navigator.maxTouchPoints could already be spoofed, but nothing moved with it,
so a spoofed digitizer contradicted itself in two places a script reads in one
line: (any-pointer: coarse) stayed false, and window.TouchEvent and
window.Touch were absent entirely.

Restore the aID branch in force-default-pointer.patch so the coarse bit joins
the *any-pointer* set, and only when maxTouchPoints > 0. The primary pointer
stays Fine|Hover: a touchscreen laptop still drives its trackpad, and
reporting (pointer: coarse) would claim a phone while the accompanying desktop
UA said otherwise. The host LookAndFeel value is still not consulted -- the
capability set must not vary with the machine the browser runs on.

Expose the touch interfaces by moving TouchEvent::PrefEnabled only, never
LegacyAPIEnabled. dom.w3c_touch_events.legacy_apis.enabled is false everywhere
but Android, so a real Windows touchscreen laptop exposes TouchEvent and Touch
while 'ontouchstart' in window is false. Matching that shape matters more than
exposing the whole touch API: a build that switches touch on wholesale is more
detectable than one that does nothing.

Rename mobile-fingerprint-spoofing.patch to touchscreen-fingerprint-spoofing
.patch, since the rationale is the ordinary Windows touchscreen laptop rather
than a phone, and carry the new TouchEvent.cpp hunk there beside the existing
Navigator.cpp one. The rename moves it after navigator-spoofing.patch in
basename order, so its Navigator.cpp hunk now lands with an offset; verified
to still apply cleanly with no rejects.

Warn at launch whenever navigator.maxTouchPoints is set, separately from the
blanket navigator warning, because the knock-on effects reach past navigator
into the CSS pointer media queries and the TouchEvent interfaces.

tests/patches/touchscreen-digitizer.py checks all 16 signals and asserts that
maxTouchPoints=0 still looks like a machine with no digitizer. It fails on a
binary built without this change (13/16) and passes on one built with it.

The reference values it carries are RECONSTRUCTED, not captured: the recording
from the Dell XPS 15 9510 was not reachable from the build host, so eight
values come from the specification and eight from Gecko's own gating logic.
Each is marked in the table. Check them against the real capture when the
reference machine is available; the capture wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2RfR387Mh1JhZ9LZvkptP
2026-09-05 17:30:17 -06:00
Jake WriterandClaude Opus 5 0e1f9a816d fix(python): bound headful geometry again after the get_screen_cons flip
PR #315 corrects get_screen_cons()'s inverted guard (`headless is False` ->
`headless is True`), which is right on its own. But the call site passes
`headless or has_display(env)`, folding two separate questions into one
boolean, so with the corrected guard a headful run on a real display now
reads as headless and the display bound is skipped:

  headless=False, has_display=True  -> arg=True  -> None   (want Screen)
  headless=False, has_display=False -> arg=False -> Screen (want None)

That drops the monitor bound for every ordinary headful launch, which is
the constraint 2266f27 added for #499 -- a 1366x768 laptop goes back to
being handed a 2560x1440 fingerprint and a window drawn past the edge of
the screen.

Pass `headless` alone and gate on has_display() separately.
largest_display() already returns None when there is nothing to probe, so
the no-display case still yields None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
2026-09-05 14:26:37 -06:00
Jake Writer 0169975638 fix(config): declare media:spoof_codecs, and guard the whole class
PR #562 added a `media:spoof_codecs` read on the C++ side --
MaskConfig::GetBool("media:spoof_codecs") in MP4Decoder and
MatroskaDecoder -- but never declared the key in settings/. Since
validate_config() drops any key it does not recognise, the documented
usage was inert:

    AsyncCamoufox(config={"media:spoof_codecs": True})
    -> "Skipping unknown patch media:spoof_codecs : True"

The key never reached the browser, so the feature could not be turned on
through the supported path at all. Declared in both properties.json and
camoucfg.jvv (bool, beside mediaDevices:enabled).

The new test is the general form rather than a check for this one key:
it scans patches/ and additions/ for MaskConfig::Get*/Has*("key") reads
and fails when a key is not declared in settings/properties.json. A
patch and its schema entry are two halves of one change, and shipping
only one half is a mistake this project has now made in both directions
-- canvas:seed (#721) and navigator.maxTouchPoints (#696) were declared
but unconsumed; this one was consumed but undeclared. Across the tree
the scan finds 63 reads against 109 declared keys, and media:spoof_codecs
was the only gap.

Note the runtime reads properties.json from the *installed browser
bundle*, not the repo, so this fix only takes effect for a build
packaged after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 375b0fca4529a722220022c7993c030b83439db1)
2026-09-05 14:26:37 -06:00
Anton Poliatskii fff2c730be fix(pythonlib): derive navigator.appVersion from the preset's user agent
from_preset() set userAgent, platform and oscpu from the captured device
but never appVersion. Firefox reports appVersion as "5.0 (<OS tokens>)",
so leaving it unset let the host's own value through — and a page reading
two properties saw them disagree.

Measured on 152.0.4-beta.29, macOS host, os="linux", fingerprint_preset:

    navigator.platform    Linux x86_64
    navigator.appVersion  5.0 (Macintosh)     <- the host

The value is derived from the user agent rather than from the platform,
because 20 of the 65 bundled Linux presets carry a distro token
("X11; Ubuntu") that a platform lookup would flatten to "X11" — a smaller
mismatch than the host leaking, but the same kind. Firefox builds
appVersion from the same OS tokens as the UA, minus the architecture and
the Gecko revision, with Windows collapsed to its family name; checked
against 800 browserforge fingerprints, the derivation is exact on every
one, including Android and the Ubuntu variant.

A preset that ships its own appVersion keeps it, and a user agent the
rule cannot parse leaves the key unset rather than inventing a value.

(cherry picked from commit 759e4ab2fa)
2026-09-05 14:26:37 -06:00
luandhgt 1934b3532d Enable TLS verification for public IP lookups
public_ip() called requests.get with verify=False and wrapped it in a
context manager that silenced urllib3's InsecureRequestWarning, so the
disabled verification produced no output either.

These requests are routed through the user's proxy, which is the exact
position an attacker occupies. A forged response controls the value
public_ip() returns, and that value is used to spoof the WebRTC IP --
so the leak the function exists to prevent becomes attacker-selectable.
validate_ip() bounds this to a well-formed address, but the address is
still theirs to choose.

Set verify=True and drop the warning suppression. requests raises
SSLError, a subclass of RequestException, which the existing loop
already catches -- a host with a bad certificate is now skipped in
favour of the next one in URLS instead of being trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9de97513b0)
2026-09-05 14:26:37 -06:00
luandhgt b2d842177a Verify sha256 of downloaded release assets before extracting
check_asset() already reads the asset's digest from the GitHub API and
stores it as installed_sha256, and AvailableVersion carries a sha256
field through to version.json. Nothing compared either against the
bytes that were downloaded: every sha256 equality check in the package
compares metadata to metadata when selecting an installed version, and
hashlib appeared only in utils.py to key a config cache.

So the archive that gets extracted over the install directory, and then
chmod 755'd and executed, was accepted on transport security alone. The
digest needed to catch a substituted or truncated asset was already in
hand and unused.

Add verify_sha256() and call it between download and extraction on both
install paths -- install_versioned() for the CLI and InstallWorker for
the GUI. It hashes in 1 MiB blocks so a multi-hundred-megabyte asset
does not have to be held in memory, and rewinds the buffer afterwards
so unzip() still reads from the start.

When no digest is published the install proceeds with a warning rather
than failing: some sources publish no digest, and refusing to install
from them would be a regression, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 65cda21b4f)
2026-09-05 14:26:37 -06:00
codechrl 6b8b08646d fix(addons): re-download addons with a missing manifest
maybe_download_addons() treated an addon as already downloaded whenever its
directory existed. A download that fails partway leaves an empty directory
behind, which is then trusted on every later launch, so confirm_paths()
raises InvalidAddonPath: manifest.json is missing and never recovers. Gate
the check on manifest.json presence and rmtree the partial directory on
failure. Closes #308.

(cherry picked from commit 0a8211969b)
2026-09-05 14:26:37 -06:00
Andrew Collier e36e0fe3e1 fix: Return None if headless
(cherry picked from commit bd319ec988)
2026-09-05 14:26:37 -06:00
Jake WriterandClaude Opus 5 8cb7914328 feat(python): warn when a supplied binary predates the Playwright in use
A managed install below the version floor is upgraded by pkgman, but
executable_path deliberately bypasses that -- the caller supplied the binary,
so we neither replace it nor download another. That left one pairing nothing
checked: an old build driven by Playwright >= 1.61, which sends viewport fields
the older Juggler schema rejects. The user saw a bare

    Protocol error (Browser.setDefaultViewport)

with nothing naming the cause.

Warn rather than raise, because the pairing is not always fatal. Camoufox
defaults to no_viewport when it spoofs window dimensions (sync_api), and
Playwright then never sends Browser.setDefaultViewport -- so the default path
works fine on an old build. Measured against a real beta.29 binary on
Playwright 1.62:

    default path                     WORKS
    new_context(viewport=...)        BREAKS
    new_context(no_viewport=False)   BREAKS
    new_context(viewport=..., is_mobile=False)  BREAKS

Refusing to launch would break the setups in the first row. A build with no
version.json beside it -- an unpackaged objdir build -- tells us nothing, so it
is left alone rather than nagged about.

Verified end to end: warns on the real beta.29 build under Playwright 1.62,
silent on beta.30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:34:52 -06:00
Jake WriterandClaude Opus 5 b68a4fb940 chore(release): mark pythonlib 0.5.6 as a pre-release (0.5.6b1)
Pairs the library with the browser: v152.0.4-beta.30 is published as a GitHub
pre-release, so the library that requires it should be one too.

PEP 440 puts 0.5.6b1 after 0.5.5 and before 0.5.6, and pip skips pre-releases
by default -- so `pip install camoufox` still resolves 0.5.5, and only
`pip install --pre camoufox` or an explicit pin picks this up. That is what
makes the conditional browser floor safe to exercise in the wild: the users who
opt in are the ones who get moved to beta.30.

Verified against the live release: with Playwright 1.61 installed the effective
floor resolves to beta.30 and the fetcher selects the real published asset
(camoufox-152.0.4-beta.30-lin.x86_64.zip). 172 tests pass, the 3.8 vermin gate
holds, and the package builds as camoufox-0.5.6b1.

Known wrinkle, not introduced here: _parse_semver() does int("6b1"), fails, and
substitutes 0, so 0.5.6b1 parses to (0, 5, 0). The browser constraint still
resolves correctly because repos.yml's only entry is min 0.5.0 / max 1, but a
future entry gating on a patch version would silently miss a pre-release
install. Worth making that parser PEP 440-aware separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:10:27 -06:00
Jake WriterandClaude Opus 5 fc3392e427 fix(python): resolve the bundle from executable_path, not the managed install
get_env_vars() and _generate_fontconfig() read the bundled fontconfig and fonts
through get_path(), i.e. the managed install, even when the caller supplied
their own binary. _load_properties() already honours executable_path for
properties.json; these two did not.

Before the floor could reject anything this silently mixed one build's fonts
into another build's launch. Once the floor is live it becomes fatal: every
launch raises UnsupportedVersion while the caller is holding a perfectly good
binary, because resolving the bundle drags in the managed install and that is
what gets version-checked.

Thread executable_path through both, matching _load_properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:19:46 -06:00
Jake WriterandClaude Opus 5 b1fe7227fa fix(python): key the browser floor on Playwright instead of a flat minimum
The incompatibility is two-dimensional -- it needs both a Playwright >= 1.61
and a browser < beta.30 -- but MIN_VERSION only knows about the browser. To
stay safe a flat floor has to assume the worst Playwright, which means:

  * every 0.5.6 user re-downloads the browser, including the majority on
    <1.61 who are in no danger;
  * installs pinned to an older build lose the pin, and prerelease/alpha users
    are moved off their channel, since every alpha sorts below beta.30;
  * the library cannot run at all until the matching browser release is
    published, making the PyPI-after-release ordering load-bearing.

Key it on the resolved Playwright instead. Measured: 1.60 works on beta.29 and
beta.30; 1.61 and 1.62 fail on beta.29 and pass on beta.30.

  playwright <1.61  -> floor alpha.1  -> every install kept
  playwright >=1.61 -> floor beta.30  -> below-beta.30 installs upgraded
  version unreadable -> floor alpha.1 -> kept; a spurious forced re-download is
                                        worse than leaving a working install

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:08:26 -06:00
Jake WriterandClaude Opus 5 df35ae79d2 chore(release): v152.0.4-beta.30, pythonlib 0.5.6
Raises the browser floor to beta.30 because 0.5.6 permits Playwright >= 1.61,
which sends viewport isMobile/screenSize in Browser.setDefaultViewport. Only
beta.30's Protocol.js schema accepts those; on beta.29 every new_context()
fails with "Protocol error (Browser.setDefaultViewport)". Measured: 1.60 works
on both builds, 1.61 and 1.62 fail on beta.29 and pass on beta.30.

The floor means pythonlib 0.5.6 cannot run until the beta.30 release assets are
published -- it must not reach PyPI first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake WriterandClaude Opus 5 ce87cf7dab fix(python): report an unsatisfiable version floor instead of recursing
camoufox_path() ended in `return camoufox_path()` after a fetch. When the
newest published build is still below CONSTRAINTS.MIN_VERSION, install() is a
no-op ("already installed") and that tail recursed ~1000 times -- each
iteration firing another GitHub API call, which exhausts the unauthenticated
rate limit (60/hr) long before the RecursionError lands.

That is precisely the state a library published ahead of its browser release
puts every user in, and it is reachable now that the floor is raised. It also
hits permanently for anyone using a repos.yml source that does not carry the
required build.

Re-check after the fetch instead, and raise UnsupportedVersion naming the
required minimum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake WriterandClaude Opus 5 da67775257 fix(python): reach the fetch path when the installed build is below the floor
Raising CONSTRAINTS.MIN_VERSION is how this library has always forced a browser
upgrade (beta.12 -> beta.15 -> beta.17 -> beta.18 -> beta.19); the floor only
became 'alpha.1' incidentally, in an unrelated PR. That left the branch dead,
and it had rotted: camoufox_path() probed INSTALL_DIR/version.json, which only
the pre-multiversion flat layout ever wrote. With a versioned install below the
floor it raised FileNotFoundError instead of falling through to a fetch, so
raising the floor would have crashed every existing user rather than upgrading
them.

Treat a missing root version.json as "no legacy install here" so the caller
falls through to CamoufoxFetcher().install() as intended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:06:48 -06:00
Jake Writer 17f873abc8 build(deps): raise the playwright cap to <1.63 instead of removing it
The cherry-picked #742 replaced `playwright = "<1.61"` with `"*"`. The
breakage it fixes is real, but an unbounded range removes the tripwire
rather than making the browser forward-compatible: `camoufox.server`
imports `playwright._impl._driver.compute_driver_executable`, a private
API with no compat guarantee, and `additions/juggler` is a fork of one
Playwright vintage that every minor release is free to break again --
1.61 sending `viewport.isMobile` is exactly that, and it will recur.

So the cap moves up rather than away, to the tested-current 1.62.0. No
lower bound is added: this package has never carried one, and the launch
path was exercised against 1.53.0 as well as the 1.62.0 the Playwright
suite runs on.

build-tester/requirements.txt mirrors this pin by its own comment, so it
moves with it.
2026-08-29 13:05:31 -06:00
LamerLink 2b662a8fc3 Support Playwright 1.61+.
- Remove package pin to allow Playwright >=1.61.
- Adds WebSocket frame timestamps to Juggler events.
- Extends viewport and setViewportSize protocol data with screenSize,
  isMobile, and deviceScaleFactor.
- Adds WebP screenshot support, including a default quality of 100.
- Updates the protocol schemas to describe the new fields and screenshot
  format.

Playwright 1.61 sends viewport.isMobile in Browser.setDefaultViewport, which
the juggler's protocol schema rejected -- every context creation failed, so
the whole Playwright suite errored out at fixture setup rather than reporting
results. This fixes the schema rather than capping the version.

Cherry-picked from daijro/camoufox#742 (closes #653).

Co-authored-by: LamerLink <36551116+LamerLink@users.noreply.github.com>
2026-08-29 13:05:31 -06:00
Jake WriterandD Yiapanis d6a806e2b5 fix(fingerprint): keep the WebGL renderer coherent with the screen (#729)
BrowserForge picks navigator/screen; the GPU is drawn separately from
webgl_data.db weighted only by OS. Nothing ties the two together, so the
synthetic path emits pairs no real machine ships -- a discrete desktop GPU
behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read
that as masking even though every individual value is plausible on its own.

Builds on @dyiapanis's #730, which identified the problem and the GPU-class
thresholds, with three changes:

  * Constrain the GPU to the screen rather than the screen to the GPU.
    sample_webgl_for_screen does rejection sampling, so the GPU keeps
    webgl_data.db's real OS-weighted distribution and the geometry -- already
    reconciled against the real display and the window box by
    clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions
    / clamp_window_position -- is left alone.
  * Where no coherent GPU exists at all (BrowserForge still carries
    netbook-era geometry, and nothing in the pool drives a sub-1366x768
    panel), raise_screen_to_gpu_floor lifts the screen instead. It measures
    the screen-to-avail gap BEFORE mutating -- #730 computed it after
    overwriting screen.height, which turned a 1024x600 -> 1080 bump into a
    520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE
    clamp_screen_to_display so a genuinely small monitor still wins and a
    headful window cannot be pushed back off its own display (#499).
  * No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac
    Studio, which drive whatever external monitor is attached, so pinning it
    to 2560x1600 would reject real hardware and shrink the pool for nothing.

Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall
from 54.3% to 0%, with avail <= screen and availHeight < height holding in
every trial. The screen floor is a no-op for the Linux and Windows pools
(0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost
is confined to the implausible tail it exists to remove.

Co-authored-by: D Yiapanis <d@yiapanis.co>
2026-08-29 13:05:31 -06:00
Jake Writer 160c806ad1 fix(stealth): make spoofed speech voices fail closed (#731)
Firefox registers the host's speech-dispatcher / SAPI / NSSpeech voices
unless something stops it, and nsSynthVoiceRegistry only stopped it when the
explicit `voices:blockIfNotDefined` flag was set. Nothing set that flag, so
the host was suppressed only as a side effect of a non-empty spoofed list --
and the Python layer built that list inside a bare `except Exception: pass`.

Any path that left the list empty or unset therefore fell through to the host
backend. On a stock Linux box that exposes 14805 espeak-ng voices to the page
under a fingerprint claiming macOS or Windows, which both leaks the real host
OS and contradicts the rest of the profile. Reproduced on 152.0.4-beta.29:

    config                     voices exposed
    generation raises          14805  (all host speechd)
    {"voices": []}             14805  (all host speechd)
    valid list                   115  (correct)

Three changes, so the failure is closed at both layers:

  * nsSynthVoiceRegistry::AddVoice now also blocks when MaskConfig carries a
    `voices` array at all -- including an empty one, or one whose entries were
    all rejected as malformed. An empty spoofed list must mean "no voices",
    never "all of the host's". With no `voices` key the browser still behaves
    like stock Firefox, so a bare binary is unaffected.
  * launch_options pins `voices:blockIfNotDefined` (via set_into, so an
    explicit caller value still wins) and degrades a generation failure to an
    empty list rather than leaving the key unset. It also passes the spoofed
    navigator.language through, so the default voice matches the locale.
  * validate_voices rejects the shapes MaskConfig::MVoices() silently drops --
    bare "Name:lang:type" strings and half-filled objects -- before launch
    instead of letting them degrade into a host-voice leak.

Both failure paths now expose 0 voices; the normal path still exposes 115.
2026-08-28 13:21:32 -06:00
Jake Writer 15c513296d Merge branch 'main' of https://github.com/daijro/camoufox into main
Conflict: pythonlib/camoufox/utils.py — both sides fixed the fontconfig
cache dir independently (#654 here, #712 upstream). The two spellings
resolve to the same path, since pkgman's INSTALL_DIR is
platformdirs.user_cache_dir("camoufox"). Kept INSTALL_DIR so the module
has one name for that directory, dropped the now-unused platformdirs
import, and kept the comment explaining why the dir must sit outside the
read-only browser bundle.
2026-08-20 10:58:28 -06:00
daijro cd83f7fd2f Bump python library to 0.5.5 2026-08-12 06:26:10 -05:00
D Yiapanis 658d00e0c7 fix: use platformdirs for fontconfig cache directory
_generate_fontconfig hardcoded ~/.cache/camoufox/fontconfig instead
of respecting XDG_CACHE_HOME. On systems where ~/.cache is read-only
(e.g. containerized environments), this causes OSError on browser launch.

Replaced os.path.join(os.path.expanduser('~'), '.cache', 'camoufox',
'fontconfig') with os.path.join(platformdirs.user_cache_dir('camoufox'),
'fontconfig'). platformdirs is already a declared dependency and
respects XDG_CACHE_HOME on Linux, ~/Library/Caches on macOS, and
%LOCALAPPDATA% on Windows.

Closes #654
2026-08-04 17:51:15 +10:00
Jake WriterandClaude Opus 5 75ee7eb267 fix(virtdisplay): keep 1x1x24 as the headless="virtual" default
Reverts the default half of 4b20b77. That commit raised Xvfb's root window
from 1x1x24 to 1920x1080x24 for #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.
- #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>
2026-07-31 00:19:20 -06:00
Jake Writer 1e838e86d5 ensure window === self, restore test 2026-07-30 15:50:10 -06:00
Jake WriterandClaude Opus 5 fa8a93577e fix(juggler): record video headful and under a virtual display (#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>
2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 75d09a3dad fix(virtdisplay): stop enabling Composite by default -- it segfaults recording
9654452 enabled Xvfb's Composite extension on the theory that #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 #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>
2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 4b20b771e2 fix(virtdisplay): give headless="virtual" a real screen and Composite (#458, #93)
Two hardcoded Xvfb arguments, both verified against a live Xvfb with xdpyinfo.

#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.

#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>
2026-07-30 14:32:42 -06:00
Aleksandr d5d7713684 fix: add allow_addon_new_tab launch option 2026-07-30 14:32:42 -06:00
Jake WriterandClaude Opus 5 2834a463d1 test(virtdisplay): assert the real post-condition of kill()
`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>
2026-07-28 00:33:55 -06:00
Jake WriterandClaude Opus 5 c61c55f108 fix(server): reject unservable persistent-context options (#161)
PR #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 #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>
2026-07-28 00:31:59 -06:00
Piero Rolando c11b920524 Fix launch_server, to be able to use persistent_conext and user_data_dir 2026-07-28 00:29:18 -06:00
Cloudymap1e e61642aaf5 fix(server): close browser when launcher exits 2026-07-28 00:29:11 -06:00
Pratyush Sharma a5afa46cfa Apply screen constraints on Windows and macOS
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 #425
2026-07-28 00:29:03 -06:00
Pratyush Sharma 22c6ffbdda Probe the host monitor in CSS pixels
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 #425
2026-07-28 00:29:03 -06:00
Pratyush Sharma fbafbcf9f0 Exclude virtual displays from the display clamp
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.
2026-07-28 00:29:03 -06:00
Pratyush Sharma 2266f27501 Clamp headful window geometry to the real display
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 #499
2026-07-28 00:29:03 -06:00
Jake WriterandClaude Opus 5 63860c9cab fix(python): keep the runtime fontconfig outside the browser bundle (#654)
PR #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-#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>
2026-07-28 00:28:52 -06:00
Andrei Zhemaituk 150328cc01 Re-using get_path for correct placement of the file(s) when XDG_CACHE_HOME is set 2026-07-28 00:27:33 -06:00
Cloudymap1e a8ad6285d6 fix(linux): prepare required runtime directory 2026-07-28 00:26:37 -06:00
Cloudymap1e 9e74311572 fix(python): preserve humanize duration types 2026-07-28 00:26:37 -06:00
Cloudymap1e 3efa5e2182 fix(python): isolate per-launch environments 2026-07-28 00:26:36 -06:00
daijro 479453d4e1 Bump pythonlib to 0.5.4 2026-07-16 17:01:21 -05:00
Jake Writer 0e4151f820 fix: page-recycle hang under spoofed window dims, and the unmerged halves of #637-#647
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/
halves of #637-#647 that were dropped when those PRs were consolidated into #666
(that PR only carried patches/ + additions/, so these never actually landed).

## new_page() hangs when window.outer* is spoofed (#666)

The outer-size hijack in browser-init.patch pinned the chrome documentElement to
the spoofed size. That caps .browserStack, which caps the content viewport, so
the content window can never reach the size Juggler asks for in
updateViewportSize() -- and awaitViewportDimensions awaits exact equality with
no timeout, so it deadlocks rather than erroring. The second new_page() hung
forever and took the context with it.

The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult
MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is
spoofed in C++ regardless of the real chrome window size. Resizing is enough.

Measured on the official v152.0.4-beta.26 build (headless):

    config      before        after
    none        pass          pass
    inner       pass          pass
    outer       HANG          pass
    both        HANG          pass  (iw:360 ih:740 ow:360 oh:800 -- exact)

This corrects the diagnosis in #666, which blamed the inner+outer combination and
the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and
dropping inner* does not help, so that guard is not the culprit.

Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for
the impossible size, so the driver now defaults to no_viewport when the config
spoofs any window dimension. That fixes the hang on already-released builds
without a rebuild. An explicit viewport=/no_viewport= from the caller wins.

## WebRTC ICE prefs (#538)

#666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped
build still has no_host=true and none of the proxy_only prefs.
proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it
prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the
stock two-candidate shape, which obfuscate_host_addresses makes leak-free.

## Also restored from the consolidation

- fix(proxy): dom.security.https_first rewrote http:// before the launch-arg
  proxy filter saw it, breaking CONNECT-only proxies (#638).
- fix(stealth): speech-voice spoofing + stop leaking host voices (#646).
- fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit
  impossible geometries that leak as tells (#647).

Refs: https://github.com/daijro/camoufox/pull/666
Refs: https://github.com/daijro/camoufox/issues/538
2026-07-16 14:38:39 -06:00
daijro 6871b114f3 Merge pull request #670 from pratyush618/fix/656-playwright-160-server-launch
Fix server launch on Playwright 1.60
2026-07-15 13:38:45 -05:00
daijro 9aebec17e5 Fixes for version pinning in GUI
- Replace icons with pins rather than checkboxes
- Add a tooltip that says "Pin version"
- Fix "Active" status bubble UX issues
2026-07-15 09:41:41 -05:00
Pratyush Sharma ab20eca72d Add regression tests for camoufox server
Cover both failure modes from #656 and pin the driver entrypoint
contract, so a future Playwright reshuffle fails in CI rather than in a
user's terminal. No browser download or launch, so they run anywhere.

Refs #656
2026-07-15 18:32:05 +05:30
Pratyush Sharma 84fadb7481 Report server exit code instead of pipe error
When the node server exits early, writing its config to the dead stdin
raised BrokenPipeError (EINVAL on Windows), burying the real cause.
communicate() ignores both, so the underlying failure stays visible.

Refs #656
2026-07-15 18:31:52 +05:30
Pratyush Sharma 5bf8081aec Fix server launch on Playwright 1.60
Playwright 1.60 bundled its internals and removed the private
lib/browserServerImpl.js that launchServer.js required, so
`python -m camoufox server` died with MODULE_NOT_FOUND. Load the
driver's package entrypoint instead, which is a bundled playwright-core
and exposes launchServer as public API.

The driver path is now passed explicitly rather than inferred from
process.cwd().

Fixes #656
2026-07-15 18:31:36 +05:30