Feat/camoufox engine#3
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.