Grow the CLI from 12 primitives to a full browsing/scraping surface. Every new command is a thin CLI/IPC wrapper over the existing `api` layer (no new protocol plumbing), and `output.rs` is refactored into a pure, testable `render()`: - Reading: text, html, links, data (og/jsonld/meta), url - Navigation/waiting: wait, reload, back, forward - Cookies/headers: cookie, header - Interaction: click (selector or x,y trusted event), fill, type, press, hover, select, scroll - Tabs: tabs, close-tab - screenshot --selector / --clip, and a viewport-origin default clip Public-release preparation and review hardening: - Add MIT LICENSE and repository metadata to Cargo.toml - Make the camoufox-browse plugin portable: discover the binary via $CAMOUFOX_BIN / PATH / ./target/release instead of hardcoded container paths (SKILL.md and hook.json) - Sanitize internal client references from docs and code comments - CHANGELOG: list the new commands under Added (not Changed) - wait_for_selector fails fast on invalid selectors instead of burning the full timeout under the daemon lock - Correct punctuation keyCodes in key_descriptor (`press .` no longer collides with Delete's keyCode) - Add --timeout to the tabs command - Document the daemon's serial-execution behavior in README - Add integration tests: trusted click, reading, interaction, screenshot scroll-offset regression, and wait fail-fast Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
camoufox-rs
Pure Rust client for controlling Camoufox through the Firefox Juggler protocol.
This crate implements the full stack needed to automate a Camoufox browser process over the -juggler-pipe transport: process launch, null-delimited JSON framing, protocol request/response/event routing, and ergonomic Browser / BrowserContext / MainFrame wrappers.
Current Scope
- Library crate with a synchronous API for Juggler domains (
Browser,MainFrame,Network,Runtime,Heap) - Optional CLI (
--features cli) with a Unix socket daemon for multi-instance management - Unix-first implementation (Linux/macOS style process + fd pipe model)
- Protocol reference docs in-repo:
docs/PROTOCOL.mddocs/UNDERSTANDING.md
Requirements
- Rust 1.70+ (see
Cargo.toml) - Unix-like OS for full functionality (process spawning + Unix sockets)
- Camoufox binary available on disk
The CLI daemon resolves the Camoufox binary in this order:
--executable <path>passed tolaunch- the
CAMOUFOX_BINenvironment variable $HOME/.cache/camoufox/camoufox(falling back to/root/.cache/camoufox/camoufoxwhenHOMEis unset)
Build
Library only:
cargo build
CLI binary:
cargo build --features cli --bin camoufox
CLI Quick Start
The CLI uses a daemon process and newline-delimited JSON over a Unix domain socket.
Start daemon (run in a dedicated shell):
cargo run --features cli --bin camoufox -- serve --foreground
Launch an instance:
cargo run --features cli --bin camoufox -- launch
Create a page:
cargo run --features cli --bin camoufox -- new-page <instance_id>
Navigate and evaluate:
cargo run --features cli --bin camoufox -- navigate <instance_id> <page_id> https://example.com
cargo run --features cli --bin camoufox -- evaluate <instance_id> <page_id> "document.title"
Navigate with wait-until and status_code:
# Block until the load event fires (bounded by --timeout seconds):
cargo run --features cli --bin camoufox -- navigate <instance_id> <page_id> https://example.com \
--wait-until load --timeout 30
# Or wait only until DOMContentLoaded:
cargo run --features cli --bin camoufox -- navigate <instance_id> <page_id> https://example.com \
--wait-until domcontentloaded --timeout 15
# --wait-until accepts: load, domcontentloaded. Any other value is an error.
# navigate always succeeds even on 4xx/5xx responses.
# --json output includes status_code (final main-document HTTP status after following
# all redirects; null if uncapturable, e.g. about: pages or navigation errors):
# { "ok": true, "data": { "navigation_id": "...", "status_code": 200 } }
Export the session cookie jar:
# Export all cookies for all instances (includes HttpOnly cookies):
cargo run --features cli --bin camoufox -- cookies <instance_id>
# --json returns full cookie objects (name, value, domain, path, httpOnly, secure, ...):
cargo run --features cli --bin camoufox -- --json cookies <instance_id>
# The exported jar can drive host-side fetches without in-session XHR:
# curl --cookie "name=value" https://example.com/gated-endpoint
Set a cookie or an extra request header:
# Bind the cookie to the page's current URL (or pass --url / --domain explicitly):
cargo run --features cli --bin camoufox -- cookie <instance_id> <page_id> 'session=abc123'
cargo run --features cli --bin camoufox -- cookie <instance_id> <page_id> 'tracker=xyz' \
--domain example.com --path / --secure
# Extra request headers accumulate per page across calls:
cargo run --features cli --bin camoufox -- header <instance_id> <page_id> 'Accept-Language: fr-FR'
Read the page (no hand-written extraction JS required):
# Rendered text, whole page or scoped to a selector:
cargo run --features cli --bin camoufox -- text <instance_id> <page_id>
cargo run --features cli --bin camoufox -- text <instance_id> <page_id> --selector 'article'
# outerHTML of one element, or the whole document when --selector is omitted:
cargo run --features cli --bin camoufox -- html <instance_id> <page_id> --selector h1
# Every <a href> as `text → href`, with hrefs resolved to absolute URLs:
cargo run --features cli --bin camoufox -- links <instance_id> <page_id>
# Structured metadata; no flag returns og + jsonld + meta together:
cargo run --features cli --bin camoufox -- data <instance_id> <page_id> --og
# Current URL (stdout) and title (stderr):
cargo run --features cli --bin camoufox -- url <instance_id> <page_id>
Wait for content to appear:
# Polls document.querySelector until it matches, or --timeout seconds elapse:
cargo run --features cli --bin camoufox -- wait <instance_id> <page_id> \
--selector '#results' --timeout 15
Interact with the page (all input is trusted browser-level input):
# Click by CSS selector — resolves the element, scrolls it into view, clicks its centre:
cargo run --features cli --bin camoufox -- click <instance_id> <page_id> '#submit'
# Or dispatch a trusted left-click at raw viewport coordinates (x, y). Because the event
# originates from the browser (not JavaScript), the page sees isTrusted === true, so it
# can drive widgets like Cloudflare Turnstile that reject synthetic click events:
cargo run --features cli --bin camoufox -- click <instance_id> <page_id> 200 300
# Fill a field, then submit with a real Enter keypress:
cargo run --features cli --bin camoufox -- fill <instance_id> <page_id> 'input[name=q]' camoufox
cargo run --features cli --bin camoufox -- press <instance_id> <page_id> Enter
# Also available: type <text>, hover <selector>, select <selector> <value>, scroll [selector]
Take a screenshot:
# Current viewport:
cargo run --features cli --bin camoufox -- screenshot <instance_id> <page_id> --format png -o /tmp/example.png
# Cropped to one element (scrolled into view first), or to an explicit region:
cargo run --features cli --bin camoufox -- screenshot <instance_id> <page_id> --selector 'article' -o /tmp/article.png
cargo run --features cli --bin camoufox -- screenshot <instance_id> <page_id> --clip 0,0,800,600 -o /tmp/region.png
Manage pages as tabs:
cargo run --features cli --bin camoufox -- tabs <instance_id>
cargo run --features cli --bin camoufox -- close-tab <instance_id> <page_id>
Inspect and stop:
cargo run --features cli --bin camoufox -- list
cargo run --features cli --bin camoufox -- stop <instance_id>
cargo run --features cli --bin camoufox -- shutdown
JSON output mode is available for all commands:
cargo run --features cli --bin camoufox -- --json list
Socket resolution:
--socket <path>to override- else
$XDG_RUNTIME_DIR/camoufox/daemon.sock - else
/tmp/camoufox-<uid>/daemon.sock
Library Bootstrap Example
The low-level lifecycle is:
- Build
LaunchConfig - Spawn process (
process::unix::spawn) - Wait readiness sentinel on stderr
- Build
PipeTransport - Build
Connection+ root session Browser::connect(...)
use std::path::PathBuf;
use camoufox::api::{Browser, BrowserOptions, ContextOptions};
use camoufox::config::LaunchConfig;
use camoufox::process;
use camoufox::protocol::client::Connection;
use camoufox::transport::pipe::PipeTransport;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let profile_dir = std::env::temp_dir().join("camoufox-rs-profile");
std::fs::create_dir_all(&profile_dir)?;
let config = LaunchConfig {
executable: PathBuf::from("/root/.cache/camoufox/camoufox"),
profile_dir: Some(profile_dir),
headless: true,
..Default::default()
};
let mut launched = process::unix::spawn(&config)?;
process::readiness::wait_for_ready(&mut launched.child, config.timeout)?;
let transport = PipeTransport::new(launched.command_pipe, launched.response_pipe);
let conn = Connection::new(Box::new(transport));
let root = conn.root_session();
let browser = Browser::connect(conn, root, BrowserOptions::default())?;
let _context = browser.new_context(ContextOptions::default())?;
browser.close()?;
Ok(())
}
Pages via MainFrame
BrowserContext::new_main_frame() returns a fully wired MainFrame — a page
handle that is structurally pinned to the top frame. The call blocks until
the page target, top frame, and main-world execution context are all resolved
from authoritative protocol responses, so there is no manual session or
execution-context wiring to do.
This is the fix for the cross-origin-iframe attach bug: on sites that embed an
early out-of-process iframe (e.g. an ad pixel), the old handle could bind to the
iframe instead of the page — evaluate then ran in the wrong document.
new_main_frame() applies three filters so it can only ever resolve to the real
top frame:
- Layer 1 — accept only
Browser.attachedToTargetevents wheretargetInfo.type == "page" - Layer 2 — accept only the top frame's
Page.frameAttached(emptyparentFrameId) - Layer 3 — accept only the main-world
Runtime.executionContextCreatedwhoseauxData.frameIdmatches the top frame
use std::time::Duration;
// ...continuing from the bootstrap example, after `Browser::connect`:
let context = browser.new_context(ContextOptions::default())?;
// One call — no manual attachedToTarget / frameAttached / executionContext wiring:
let main_frame = context.new_main_frame()?;
// navigate(url, NavigateOptions, timeout) -> NavigateOutcome { nav_id, status_code }.
// status_code is the final main-document HTTP status after following redirects.
let outcome = main_frame.navigate("https://example.com", Default::default(), Duration::from_secs(30))?;
println!("status: {:?}", outcome.status_code);
// evaluate(expr, timeout) — the cached execution context is maintained internally,
// so no execution-context id is threaded through.
let title = main_frame.evaluate("document.title", Duration::from_secs(15))?;
println!("title: {title}");
A complete, runnable version is in examples/web_browse.rs:
cargo run --example web_browse -- --url https://example.com
End-to-end wiring is also exercised in tests/integration.rs and src/cli/instance.rs.
Reliability
- Every protocol request is bounded —
Client::sendenforces a default 60s deadline, andnavigate/evaluate/screenshotaccept an explicittimeout(CLI:--timeout <seconds>), so a stuck call can no longer hang the daemon. - Navigations that the browser diverts into a download (e.g. a
Content-Disposition: attachmentURL) are detected viaBrowser.downloadCreatedand surfaced promptly as aNavigationBecameDownloaderror instead of blocking forever waiting for a navigation response that never arrives.
Architecture
Core layers (top to bottom):
api/: high-levelBrowser,BrowserContext,MainFrameprotocol/: request IDs, pending map, session state, event router, reader threadtransport/: transport traits + Unix pipe transportcodec/: null-byte-delimited JSON framing (NulJsonCodec)process/: spawn/readiness/lifecycle around Camoufox child processcli/(feature-gated): daemon + command dispatch over Unix socketcompat/: Camoufox detection/version capability checksobs/: protocol logging helpers
Testing
Unit tests:
cargo test
Integration tests against a real Camoufox binary are ignored by default:
cargo test --test integration -- --ignored --test-threads=1
Integration tests resolve the binary the same way as the daemon: CAMOUFOX_BIN,
else $HOME/.cache/camoufox/camoufox. Override per run with:
CAMOUFOX_BIN=/path/to/camoufox cargo test --test integration -- --ignored --test-threads=1
Observability
Use log + env_logger filters to inspect protocol behavior:
RUST_LOG=camoufox=trace cargo test
obs::ProtocolLogger formats command, response, and event traces with bounded payload previews.
Known Limitations
- Windows pipe transport is not implemented (
src/transport/pipe/windows.rshard errors at compile time) - API is synchronous/blocking today (no async runtime integration)
MainFrameis top-frame only; there is no public API for operating on sub-frames- CLI daemon uses in-memory instance state only
- CLI daemon executes commands serially — it holds a single lock for the duration of each
request across all instances. A long-polling command (e.g.
wait --timeout 60) on one page therefore blocks every other instance and page until it returns. Fine for driving a single browser; keep timeouts tight if you drive multiple instances from one daemon. back/forwardare wired toPage.goBack/Page.goForwardbut are inert against the Camoufox builds tested here: pages created viaBrowser.newPageexpose no session history (history.length === 0), so both always report "no history entry". Re-navigateinstead.
License
MIT (see crate metadata in Cargo.toml).