Skip to content

Feat/camoufox engine#3

Merged
behr-davide merged 9 commits into
mainfrom
feat/camoufox-engine
Apr 21, 2026
Merged

Feat/camoufox engine#3
behr-davide merged 9 commits into
mainfrom
feat/camoufox-engine

Conversation

@behr-davide

Copy link
Copy Markdown

No description provided.

davide and others added 9 commits April 20, 2026 15:36
Unit 1 of the Camoufox engine plan. Gives BrowserManager an engine-tagged
BrowserBackend value so every action-layer function that used to accept a
bare &CdpClient now accepts &BrowserBackend and dispatches on the variant.

- New modules: native/backend.rs (enum + delegating send_command family),
  native/camoufox_client.rs (stub for Unit 3), native/cdp/camoufox.rs
  (stub process for Unit 3).
- BrowserProcess grows a Camoufox variant so the enum is total once the
  sidecar lands.
- `launch` matches "camoufox" after validation and returns a structured
  not-yet-implemented error rather than panicking.
- Chrome-only subsystems (inspect proxy, screencast stream) assert on
  BrowserBackend::Cdp at entry via `require_cdp_for` and surface
  `engine-incompatible` on Camoufox.
- Action-layer modules (actions, interaction, element, snapshot,
  screenshot, cookies, network, storage, state, tracing) now take
  &BrowserBackend. Internal helpers keep &CdpClient because they are
  reachable only through a public entry that has already extracted the
  CDP client — i.e. they are enum-arm-body helpers.
- New smoke test `backend_refactor_smoke.rs` locks in the structured-error
  shape for `--engine camoufox open` so later units cannot regress the
  characterization snapshot without an intentional update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands Unit 2 of the Camoufox engine plan: a minimal Python sidecar that
agent-browser will spawn when --engine camoufox is selected. The sidecar
emits {"event": "ready"} on startup, speaks JSON-line frames over stdio,
and cleanly shuts down on stdin EOF, SIGTERM, or a close command.

- packages/camoufox-sidecar/ (new): protocol.py, session.py, __main__.py,
  __init__.py, pyproject.toml, README.md
- Launch-kwarg allowlist from camoufox.com/python/usage; persistent_context
  and user_data_dir explicitly rejected in v1 via distinct error code
- tests/test_lifecycle.py covers all 5 Unit 2 scenarios (ready+close,
  stdin-EOF cleanup, unknown-launch-option, camoufox-not-installed,
  SIGTERM child cleanup)
- scripts/sync-version.js: syncs pyproject + __init__ version alongside
  the crate, converting the npm-style version to PEP 440 local form
  (0.26.0-celeria-stealth.2 -> 0.26.0+celeria.stealth.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills in the stubs from Unit 1: CamoufoxClient now drives the Python sidecar
over a JSON-line protocol, and BrowserManager::launch spawns the sidecar,
waits for its ready event, and hands back a working BrowserManager whose
navigate/close dispatch through the sidecar. After this unit,
`agent-browser --engine camoufox open <url>` actually launches Camoufox
and loads the page; open/close/reopen in a loop produces zero leaked
processes.

Process lifetime: the sidecar is spawned as its own process-group leader
via setpgid, so SIGTERM → (graceful wait) → SIGKILL on the group tears
down the full tree (Python → Camoufox → plugin-containers). waitpid is
used directly in Drop so teardown stays synchronous and deterministic
even when callers drop BrowserManager outside a tokio context.

The refactor drops the ambient `pub client: Arc<CdpClient>` field on
BrowserManager in favour of a `fn client()` accessor that pulls from the
backend enum — actions.rs callers already go through `backend.require_cdp()?`
for engine-aware paths, so the change narrows the "accidentally reach
into CDP on a Camoufox backend" surface to a single clearly-named
assertion point.

Integration tests (cli/tests/camoufox_launch.rs) gated behind the
`camoufox-integration` cargo feature cover open+close cleanup, a 10x
loop smoke test, --stealth redundancy, and the two rust-only error
paths that run unconditionally. All 696 existing tests stay green.
…l, gettext (Unit 4)

Ports the five command families the celeria coding sandbox relies on onto
the Camoufox sidecar path. Both @eN-ref and CSS-selector invocations work
for click/fill/gettext, and navigation invalidates the sidecar-owned
ElementHandle cache so stale refs surface as ``{"code": "ref-stale"}``
instead of silently rebinding to reloaded elements.

Python sidecar:
- refs.py owns a ``RefCache`` keyed by @en with frame.navigated invalidation
- snapshot.py walks the DOM in a single ``page.evaluate``, tags ref-worthy
  elements with ``data-__ab-ref``, and re-resolves each ref to an
  ElementHandle on the Python side
- session.py grows ``snapshot``, ``click``, ``fill``, ``get_text`` handlers
  with Playwright-error → structured-code translation
  (ambiguous-selector, selector-not-found, element-detached, timeout)
- __main__.py wires the new commands and a ``page.navigate`` alias

Rust action layer:
- handle_snapshot, handle_click, handle_fill, handle_gettext each grow a
  Camoufox arm that forwards to the sidecar via
  ``BrowserManager::camoufox_client().call(...)``; the Rust RefMap is
  mirrored from the sidecar response so screenshot-annotation / diff paths
  keep working
- handle_navigate rejects per-request --headers on Camoufox (Fetch.* is
  Chrome-only) rather than panicking through ``mgr.client()``

Tests:
- cli/tests/fixtures/form.html + form-chrome-golden.json — shared
  Chrome↔Camoufox parity fixture
- cli/tests/camoufox_parity.rs (gated on camoufox-integration feature):
  structural role/name set parity against the Chrome golden, ref-based
  click/fill/get-text roundtrip, CSS-selector path without snapshot,
  ref-stale after navigation
- packages/camoufox-sidecar/tests/test_commands.py — all 9 Unit 4 scenarios
  from the plan (snapshot, click-by-ref, fill-by-ref, click-by-selector,
  gettext, ref-stale, ambiguous-selector, selector-not-found, cross-nav
  integration)
Completes the v1 command surface for the Camoufox engine: per-tab page
map inside the sidecar, Rust-owned `t<N>` counter plumbed through
`tab.new/switch/close/list`, `page.screenshot` with `--full-page`, and
structured `engine-incompatible` errors on the raw CDP surfaces (cdp_url,
screencast_start, screencast_stop) that will never ship for Camoufox.

Tab id coordination (deferred-to-implementation decision from the plan):
the Rust `BrowserManager` owns `next_tab_id` and hands the canonical
`t<N>` string down on every `tab.new` — the sidecar just stores pages in
a `Dict[str, Tab]` keyed by whatever Rust assigned. Keeps "tab ids never
reuse" as a single invariant with one authoritative counter instead of
splitting it across a process boundary.

Python sidecar:
- `Session` holds a `Dict[str, Tab]` + `_active_tab_id` in place of the
  Unit-4 single `_page`. Each `Tab` owns its own `RefCache` so `click @e1`
  on t2 can't resolve against a t1-scoped handle (was possible in Unit 4,
  silent cross-tab bleed).
- New handlers: `tab.new`, `tab.switch`, `tab.close`, `tab.list`,
  `page.screenshot`. All existing handlers (snapshot/click/fill/get_text/
  page.goto) accept an optional `tabId` arg.
- `_wire_page_events` fans `page.console` + `page.crashed` out as protocol
  events for every tab the sidecar creates — finishes the broadcast
  wiring called out in the Unit 4/5 next-unit notes.
- Screenshots write to disk (auto-allocated path when none given) and
  return `{path, format, fullPage}` — no base64 in the frame, matching
  the Chrome CDP response shape and staying under the asyncio readline
  64KB line limit for full-page captures.

Rust action layer (`cli/src/native/actions.rs`):
- `handle_tab_new/switch/close` grow Camoufox arms that delegate to
  `BrowserManager::camoufox_tab_*` (new helpers; keep `self.pages` in
  sync so `tab_list`/`resolve_tab_ref`/labels work engine-agnostically).
- `handle_screenshot` grows a Camoufox arm that forwards `fullPage` +
  `format` + `path` to the sidecar; `--annotate` is explicitly rejected
  on Camoufox (Chrome-only; relies on CDP DOM methods).
- `handle_screencast_start/stop`, `handle_cdp_url`: gated with
  `require_cdp_for(...)` so Camoufox sessions surface a structured
  `engine-incompatible` error rather than a panic.
- `camoufox_navigate` now allocates `t1` through the Rust counter before
  the first `page.goto` (was previously relying on a sidecar-side
  `_ensure_default_tab` stopgap).

Tests:
- `packages/camoufox-sidecar/tests/test_tabs.py`: 8 scenarios covering
  Rust-assigned ids, switch-routes-commands, per-tab ref cache isolation,
  screenshot PNG + full-page-is-larger, tab-not-found error code, and
  `page.console` event fan-out via the shared Protocol.
- `cli/tests/camoufox_tabs.rs`: 5 CLI-driven scenarios (tab list after
  open+new, t3 after close t2 never reusing, refuse-last-tab, viewport
  vs full-page PNG size, `engine-incompatible` on `get cdp-url`).
  Feature-gated on `camoufox-integration` to match the Unit 3/4 suites.
- `conftest.py`'s `read_frame` now skips `event` frames by default so
  response reads don't race the new async broadcasts;
  `include_events=True` opts back in.

Verification:
- `pytest packages/camoufox-sidecar/tests/` — 22 passed.
- `cargo test --test camoufox_tabs --features camoufox-integration` —
  5 passed.
- `cargo test --test camoufox_parity --features camoufox-integration` —
  4 passed (Unit 4 regression check, clean).
- `cargo test --bins` — 690 passed, 0 failed.
- 20× `tab.new/tab.close` churn script: zero zombie Firefox processes
  after session close; only the single live browser process tree during
  the run.
Adds a non-fatal `agent-browser doctor` probe that reports camoufox
availability in three independent steps (python3 runtime, camoufox
package, browser binary). Failures surface as `info` with a distinct
reason string so a missing python, a missing package, and a
not-yet-fetched browser binary each look different to the user.

Threads `"engine": "<name>"` through every `--json` response envelope
so downstream telemetry can segment by backend. `success_response`
and `error_response` now take the current `state.engine` and embed it
directly; the CLI-side `Response` struct grows a matching
`engine: Option<String>` field. The launch-error path in main.rs was
stripping the daemon's structured response — it now routes through
`print_response_with_opts` so the engine label survives validation
failures.

Chrome and Lightpanda payloads still carry their existing labels
(now explicit). Camoufox payloads carry `"engine": "camoufox"`.

Adds cli/tests/doctor_camoufox.rs covering all four Unit 6 test
scenarios (missing python / nonexistent python path / missing package
/ camoufox label / chrome label).
Pure formatting fixes to satisfy CI (`cargo fmt --check` and
`cargo clippy -- -D warnings`). No behavior changes.

- cargo fmt across the 12 files touched by Units 1-6 (mostly
  line-wrapping, debug-struct chain formatting, arg wrapping).
- Fix `doc_overindented_list_items` lint on the `not-yet-supported`
  entry in `camoufox_client.rs` module doc (continuation line
  indented too far under list marker).
Cuts the release carrying Units 1-6 of the Camoufox engine work. On
merge to main the release.yml workflow will read package.json, build
the 7 platform binaries, and create the v0.26.0-celeria-camoufox.1
GitHub release (npm publish skipped on this fork).

- package.json + pnpm run version:sync (propagates to cli/Cargo.toml,
  cli/Cargo.lock, packages/dashboard, and the camoufox-sidecar
  pyproject/__init__).
- CHANGELOG.md: new ## 0.26.0-celeria-camoufox.1 section at the top
  wrapped in fresh <!-- release:start --> / <!-- release:end -->
  markers (the release workflow greps between these for the GitHub
  release body). Previous stealth.1 block demoted to
  <!-- old-release:* --> markers, matching the e93dca3 rotation
  pattern.
Two correctness bugs flagged by automated review on the PR:

1. interactive_only=True renumbered refs before handle resolution.
   The JS walker stamps data-__ab-ref="e1".."eN" using its pre-filter
   counter, then Python filters to interactive roles and renumbers
   entry["ref"] to e1..eM. The subsequent
   page.query_selector("[data-__ab-ref='eK']") loop used the
   re-numbered refs, which no longer match what the walker stamped on
   the DOM — so handles for interactive elements came back None (or,
   if a prior snapshot had an eK still sitting around, pointed at the
   wrong element). Preserve the pre-filter ref as _dom_ref during
   renumbering and query by that.

2. selector-scoped take_snapshot walked the whole document. The call
   site passed ``(root) => (_SNAPSHOT_JS)({...})`` to
   scope_root.evaluate, but the IIFE signature was
   ``({ interactiveRoles, contentRolesWithNames }) => ...`` — root was
   received by the outer arrow and dropped on the floor. The walker
   hard-coded document.querySelectorAll('*'), so
   take_snapshot(..., selector="#form") returned refs for the entire
   page. Thread root through the IIFE and replace the walker source
   with [root, ...root.querySelectorAll('*')] so the scope element and
   its descendants are enumerated. For the unscoped call site we pass
   document.documentElement and skip the self-prepend (documentElement
   itself isn't ref-worthy).

Both paths were untested — test_commands.py covers the happy
snapshot path but no interactive_only=True or scoped-selector scenario.
Follow-up test coverage is worth adding but kept out of this fix to
keep the diff minimal for the already-open review.
@behr-davide behr-davide merged commit adb9d34 into main Apr 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant