A fast Rust CLI for the Firefox Remote Debugging Protocol. Communicates directly over TCP with Firefox's built-in debugger for minimal latency.
brew trust --formula ractive/tap/ff-rdp # Homebrew 6+: third-party taps need one-time trust
brew install ractive/tap/ff-rdpCovers macOS (Apple Silicon) and Linux (x86_64 and ARM64). The Linux bottles are statically linked (musl), so they run on any glibc or musl distribution.
Homebrew 6 introduced tap trust: formulae
from third-party taps refuse to load until trusted. brew trust --formula
scopes the trust to just this formula; brew trust ractive/tap trusts the
whole tap instead.
curl -sLf 'https://dl.cloudsmith.io/public/ractive/ff-rdp/cfg/setup/bash.deb.sh' | sudo bash
sudo apt install ff-rdpThis one-time setup script registers the Cloudsmith-hosted apt repository; afterwards apt upgrade picks up new releases automatically.
curl -sLf 'https://dl.cloudsmith.io/public/ractive/ff-rdp/cfg/setup/bash.rpm.sh' | sudo bash
sudo dnf install ff-rdp # or: yum install ff-rdp / zypper install ff-rdpscoop bucket add ractive https://github.com/ractive/scoop-bucket
scoop install ff-rdpwinget install ractive.ff-rdpcargo install ff-rdp-cli # installs the `ff-rdp` binaryDownload pre-built binaries from the GitHub Releases page. Archives are named ff-rdp-v<version>-<target>.{tar.gz,zip} and bundle the binary, LICENSE, README, and shell completions. Targets:
- Linux — x86_64 (glibc and static musl), ARM64 (static musl)
- macOS — Apple Silicon (aarch64)
- Windows — x86_64 and ARM64
Standalone .deb and .rpm packages (x86_64) are attached to each release as well; they install ff-rdp plus bash/zsh/fish completions system-wide.
Run ff-rdp with no arguments. It prints what is actually true right now —
which binary you are running, whether a daemon and a browser are up, the open
tabs, an accessibility view of the current page with --ref handles, and up to
five -> ff-rdp … commands that make sense from that state — and exits 0 even
when nothing is running, because a missing browser is state, not an error:
ff-rdp 0.3.0 — drive a live Firefox from the shell — inspect, act on, and measure the page …
bin: ~/.cargo/bin/ff-rdp
daemon: running (pid 51234, firefox port 6000)
browser: reachable at localhost:6000 (Firefox 143)
TABS
* 1 Example Domain https://example.com/
HEADINGS
h1 Example Domain
INTERACTIVE
[e1] link "More information..."
-> ff-rdp click --ref e1
-> ff-rdp page-text --query "<text>"
-> ff-rdp snapshot --query "<text>"
--format json (or any --jq filter) returns the same thing as JSON, and it is
the one command whose JSON carries hints — it is the orientation surface. It
never starts anything: launch starts Firefox and daemon start starts the
daemon.
To get that view automatically at the start of every agent session, install the
opt-in SessionStart hook:
ff-rdp install-hook --claude --dry-run # print the entry, touch nothing
ff-rdp install-hook --claude # merge it into ~/.claude/settings.json
ff-rdp install-hook --claude --uninstall # remove only the entry ff-rdp ownsIt is idempotent (a second run reports a no-op and leaves the file byte-identical), repairs its own path in place if the binary moves, and never touches any hook it does not own.
For Codex, use ff-rdp install-hook --codex (also supports --dry-run and
--uninstall) to merge a managed entry into $CODEX_HOME/hooks.json, defaulting
to ~/.codex/hooks.json when CODEX_HOME is unset or empty. First set
hooks = true under [features] in that directory's config.toml yourself. This is
ff-rdp's explicit installer opt-in policy: Codex 0.153.4 already enables hooks
by default. ff-rdp never edits config or trust and does not check effective
enterprise/project policy. Uninstall works without that opt-in.
After installing or repairing a moved command, review and trust it in Codex
/hooks. Other active hook sources accumulate, so review those for duplicate
commands too. --project is Claude-only. --opencode retains its refusal:
OpenCode 1.3.13 requires a JavaScript/TypeScript plugin module, which this Rust
installer does not ship. Contract sources: Codex hooks
and OpenCode plugins.
If anything goes wrong, run ff-rdp doctor — it pinpoints connection,
port, and version issues in one shot. The probes are:
- Daemon registry — is a daemon running and reachable?
- Port owner — who is listening on
--port(PID, process, uptime)? - RDP handshake — can we receive a Firefox greeting?
- Tabs — how many tabs are exposed by the connected target?
- Firefox version compatibility — within the tested range?
A typical first-time session looks like:
ff-rdp # what's already up? (exit 0 either way)
ff-rdp launch --headless --temp-profile # start a fresh Firefox
ff-rdp doctor # confirm everything is healthy
ff-rdp navigate https://example.com --with-page --query "<text>" # go there and read it in one turnlaunch is idempotent (iter-210). If the requested port is already held by a
Firefox ff-rdp itself launched, a second launch exits 0 and reports that
instance — results.already_running: true alongside the existing pid,
port and profile — instead of failing. An agent that cannot remember
whether it has a browser open can simply run launch and proceed.
When the port is held by anything else — a Firefox you started by hand, an
unrelated server — launch still fails immediately, before spawning Firefox,
naming the occupying process and its PID and hinting at --debug-port,
--replace and doctor. The ownership bar for "ours" is the same one
--replace must clear before it may stop anything (see below); claiming
someone else's browser as the one we launched would put a PID this command has
no claim on into results.pid. That is also a different failure from Firefox
spawning fine but not opening its debug port in time, which reports
Firefox (pid N) did not open debug port P within Ss. Keeping the two apart
matters: pre-iter-158 both printed "is the port already in use?", which sent
users hunting for a process that did not exist.
--replace stops the instance already on that port and relaunches — but only
one ff-rdp itself started. It will refuse rather than signal a process it
cannot prove it spawned, and since iter-191 that proof is an identity check,
not a liveness check: the launch-record.<port>.json it consults records the
owning process's OS start token alongside its PID, and a record whose PID the
OS has since handed to something else is rejected with "ff-rdp did not launch
… Refusing to stop a process ff-rdp does not own". Before that, a leaked
record from an earlier run was enough to aim SIGTERM and SIGKILL at whatever
process happened to inherit the number. The refused record is left on disk —
it is the ownership trail, and the launch-record sweep below is what reclaims
it. If the refusal is wrong (the port really is held by your own stale
instance), stop it yourself or pick another port with --debug-port.
launch waits 30 s by default for that port to open. Raise it with
--launch-timeout <secs> or FF_RDP_LAUNCH_TIMEOUT_SECS (the flag wins; a
malformed env value falls back to 30 s rather than failing the launch). The
effective bound is reported as meta.launch_wait_secs. This is deliberately
not the global --timeout, which is a per-socket-operation deadline. The
previous hardcoded 5 s bound failed 5/5 launches at load average 6.8 —
Firefox was measured binding its debug port at 7 s under contention.
Every known-failure-mode error in ff-rdp ends with a hint: line that names
the next concrete command to run — connection-related ones name doctor
first.
- Firefox with remote debugging enabled:
firefox --start-debugger-server 6000
- Rust toolchain (for building from source)
cargo build --releaseRun ff-rdp --help for the full command surface and options.
Key global flags: --host, --port, --tab, --jq, --timeout, --format text, --no-daemon.
--no-daemon: connect directly to Firefox, bypassing the background daemon. The daemon (default) keeps a persistent Firefox connection and buffers events for streaming commands (--follow). Use --no-daemon for one-off commands or to debug daemon issues.
All output is JSON with a standard envelope (results, total, meta). Use --jq to filter:
# List tab URLs
ff-rdp tabs --jq '.results[].url'
# Navigate to a URL
ff-rdp navigate https://example.com
# Evaluate JavaScript and extract the result
ff-rdp eval 'document.title' --jq '.results'
# Eval from a file (avoids shell quoting issues with ?. or template literals)
ff-rdp eval --file script.js
# Eval from stdin
echo 'document.querySelectorAll("a").length' | ff-rdp eval --stdin
# Target a specific tab by URL substring
ff-rdp eval 'location.href' --tab example.com
# Query DOM elements by CSS selector (default: outerHTML)
ff-rdp dom "h1"
# Get text content of matching elements
ff-rdp dom "ul li" --text
# Get element attributes as JSON
ff-rdp dom "a" --attrs
# Extract visible page text (capped at 8000 chars by default since iter-211)
ff-rdp page-text
# How much text is there, and was anything cut?
ff-rdp page-text --jq '.meta' # {"total_chars": N, "truncated": bool, "max_chars": 8000, ...}
# Lift the cap, or move it
ff-rdp page-text --full
ff-rdp page-text --max-chars 40000
# Find, don't guess (iter-211): return only the part of the page containing a
# string, with two lines of context either side. `meta.matches` counts the hits
# and `meta.match_lines` gives their line numbers in the full document.
ff-rdp page-text --query "billion"
ff-rdp page-text --query "billion" --context 5
ff-rdp page-text --query-regex '\$[0-9,]+' --jq '.meta'
# The same flag narrows the other read commands. `--query-regex` everywhere
# `--query` works; an invalid pattern is a usage error (exit 2).
ff-rdp snapshot --query "1804" # the matching nodes plus their ancestors
ff-rdp a11y summary --query "Sign in" # matching entries, refs intact
ff-rdp dom "a" --query "pricing" # matched elements filtered by name/text/attrs
# Read console messages (errors only); output includes summary.total, summary.shown, summary.by_level
ff-rdp console --level error
# Filter console messages by pattern; limit to 20 results
ff-rdp console --pattern "TypeError" --limit 20
# Check how many messages matched vs how many were shown
ff-rdp console --limit 10 --jq '.summary'
# Show network requests
ff-rdp network
# Filter network by URL substring
ff-rdp network --filter api
# Filter network by HTTP method
ff-rdp network --method POST
# The capture source is always the one you asked for. The default is the RDP
# resource watcher — the only source that carries method, status, content_type
# and transfer_size. `meta.source` names where the rows came from; an empty
# watcher buffer reports zero watcher rows rather than silently answering from
# somewhere else (iter-159 removed that substitution).
ff-rdp network # watcher (default)
ff-rdp network --source performance-api # explicit opt-out: fewer fields
# Act and see: return the page the action produced (iter-210). `navigate`,
# `click`, `type`, `reload`, `back`, `forward` and `scroll` all take it.
# `results.page` carries headings, an article `excerpt`, and interactive
# elements each with a `ref` you can pass straight to `click --ref`, so
# following a link is two commands with no selector guessing.
#
# Since iter-219 it is a READER view: Mozilla's Readability.js runs on the live
# page, every entry is tagged `zone: "content" | "chrome"`, and content sorts
# first — so on a page with 1 659 links the 50-entry cap falls on the
# navigation bar, not on the article. `chrome_omitted` says how much nav it
# dropped; `--query` reaches whatever the cap left out.
ff-rdp navigate https://en.wikipedia.org/wiki/Ada_Lovelace --with-page \
--jq '.results.page.interactive[] | select(.name == "Charles Babbage")'
# {"role":"link","name":"Charles Babbage","href":"...","zone":"content","ref":"e19"}
ff-rdp click --ref e19 --with-page --jq '.results.page.excerpt'
# the destination article's own text — no page-text round trip
# Size the excerpt, or turn it off and keep only the structure.
ff-rdp navigate <URL> --with-page --page-chars 4000
ff-rdp navigate <URL> --with-page --page-chars 0
# Narrow every part of the view at once: the excerpt becomes the window
# around each match, `interactive` keeps only the matching entries, `facts`
# only the matching rows.
ff-rdp navigate <URL> --with-page --query "sign in"
# `page.facts` (iter-225) — the key/value rows Readability throws away.
#
# Readability scores `table.infobox` as boilerplate, which is right for reading
# and wrong for answering: on the Python article "Stable release" is an infobox
# row and appears in no paragraph, so `--page-chars 4000` never contained it
# and every benchmark run paid a `page-text --query` turn to find it. A
# separate DOM pass now harvests up to 40 `{key, value}` rows from infobox
# tables, `<dl>` definition lists and `[itemprop]` microdata, in document
# order, with `facts_total` / `facts_truncated` when the cap bites. It does not
# consult Readability and cannot change the excerpt.
ff-rdp navigate https://en.wikipedia.org/wiki/Python_\(programming_language\) \
--with-page --jq '.results.page.facts[] | select(.key == "Stable release")'
# {"key":"Stable release","value":"3.13.5 / 11 June 2025"}
# `--query` searches three places, cheapest first, and `page.query_source`
# names the one that answered: the article text (`readability` / `innertext`),
# then `facts`, then — only if both missed — a window over the page's rendered
# text, fetched for that query alone. That last step is the `page-text --query`
# round trip you would otherwise have spent a turn on, folded into the command
# you already ran. `page.matches` counts every hit, matching excerpt lines
# included; a miss in all three leaves `matches: 0`, an empty excerpt, and a
# `page.hint` naming `page-text --full --query` as the exhaustive next step.
ff-rdp navigate <URL> --with-page --query "Stable release" \
--jq '.results.page | {matches, query_source}'
# {"matches":1,"query_source":"facts"}
# `page.readerable` says whether the page looks like an article at all, and
# `page.source` is `readability` or `innertext` — the fallback used on
# dashboards, forms and SPAs with no prose, which never returns an empty
# excerpt on a page that has visible text. `meta.page_parse_ms` reports what
# the in-page parse cost (~50 ms on Wikipedia); the ~32 KB bundle is shipped
# once per document, and `meta.page_readability_injected` says which happened.
#
# `a11y summary` keeps the landmark list and stays reader-free: it is the
# accessibility surface, `--with-page` is the act-and-see one.
# The page is collected LAST — after the command's own wait and after
# `document.readyState == "complete"` — so a click that navigates reports the
# DESTINATION page. `meta.page_ready` is false if that wait timed out or an
# announced navigation has not confirmed its document handover;
# `meta.page_refs_registered` says whether the refs are usable (daemon only).
#
# Since iter-220 that holds on a slow destination too. Firefox keeps handing
# back the OUTGOING document for a while after a click — same `innerWindowId`,
# same URL, only a fresh actor prefix — so refreshing the target is not enough:
# collecting from it returned the page you left, or hung until `--timeout`
# because Firefox drops a request whose docshell has gone. `--with-page` now
# waits (up to 3 s) for the navigation the action started to hand over a
# document. If that bounded wait cannot confirm handover, a successful full
# collection may still describe the OUTGOING page: `meta.page_ready=false`
# labels it explicitly. Read again with `a11y summary` after navigation settles.
# An unchanged URL does not confirm a same-URL replacement, and a navigation
# start received during target refresh or collection also makes the view unready.
# Actor refreshes and reconnects preserve an unconfirmed navigation's origin:
# a confirmed submission retires that transition before --settle/--wait-for,
# so a further navigation uses the intermediate document as its own baseline.
# An already-confirmed submission stays ready without another settle-budget wait.
# Nothing navigated, nothing waited for.
#
# And since iter-224 a connection that dies mid-collection no longer costs you
# the action. On the daemon route, roughly one hop in fifteen used to come back
# `{"error":"recv failed: Connection reset by peer (os error 54)",
# "error_type":"Transport"}` — exit 6, in under half a second, with the click
# already performed and no view of where it landed. The daemon now writes a
# named `daemon_client_closed` frame before it abandons a client, and
# `--with-page` rebuilds the connection and collects again inside `--timeout`.
# `meta.page_attempts` and `meta.page_reconnects` report what the returned view
# cost — `1` and `0` when nothing went wrong.
# Refs no longer come only from `dom <selector>`: `a11y summary` and `snapshot`
# register them too, so the first thing you read after navigating already
# carries click handles.
ff-rdp a11y summary --jq '.results.interactive[0].ref'
# Type into a search box and submit in one command. Enter is dispatched first;
# because a synthetic Enter is `isTrusted: false` Firefox performs no implicit
# form submission for it, so when nothing navigated ff-rdp falls back to
# `form.requestSubmit()`. `results.method` says which path ran.
ff-rdp type --ref e7 "Turing Award" --submit --with-page
# Navigate and capture all network traffic in one shot
ff-rdp navigate https://example.com --with-network
# Dismiss a cookie banner and capture the network in the same call — the
# consent click happens inside the capture window.
ff-rdp navigate https://www.theguardian.com --with-network --auto-consent
# Find failed requests during navigation
ff-rdp navigate https://example.com --with-network \
--jq '.results.network[] | select(.status >= 400)'
# Query Performance API resource timing entries (default: --type resource)
ff-rdp perf
# Page load waterfall (DNS, TLS, TTFB, DOM timings)
ff-rdp perf --type navigation
# First Paint and First Contentful Paint timestamps
ff-rdp perf --type paint
# Largest Contentful Paint
ff-rdp perf --type lcp
# Cumulative Layout Shift entries
ff-rdp perf --type cls
# Long tasks (>50ms)
ff-rdp perf --type longtask
# Filter resource entries by URL substring
ff-rdp perf --filter "api/"
# Core Web Vitals summary with ratings (LCP, CLS, TBT, FCP, TTFB)
ff-rdp perf vitals
# Extract a single metric
ff-rdp perf vitals --jq '.results.lcp_ms'
# Click a button
ff-rdp click "button.submit"
# Type into an input (clear first with --clear)
ff-rdp type "input[name=email]" "user@example.com"
ff-rdp type "input[name=email]" "new@example.com" --clear
# Wait for an element to appear (default timeout: 5000ms)
ff-rdp wait --selector ".results"
# Wait for text to appear on the page
ff-rdp wait --text "Success" --wait-timeout 10000
# Wait for a JavaScript expression to become truthy
ff-rdp wait --eval "document.readyState === 'complete'"
# Plain sleep, no condition or Firefox connection needed (--time is a legacy alias)
ff-rdp wait --sleep-ms 2000
# List cookies
ff-rdp cookies
# Filter cookies by name
ff-rdp cookies --name "session_id"
# Dump all localStorage
ff-rdp storage local
# Get a specific sessionStorage key
ff-rdp storage session --key "token"
# Capture a screenshot (saves PNG)
ff-rdp screenshot --output page.png
# Full-page screenshot (captures entire scrollable document)
ff-rdp screenshot --full-page --output full.png
# Screenshot at explicit height
ff-rdp screenshot --viewport-height 2000 --output tall.png
# Get computed color for an element
ff-rdp computed h1 --prop color
# Get all non-default computed styles for a selector
ff-rdp computed .card
# Get the full resolved style object
ff-rdp computed button --all
# Launch Firefox with debugging enabled
ff-rdp launch
# Launch headless Firefox with temporary profile
ff-rdp launch --headless --temp-profile
# Launch with a specific profile and debug port
# (a --profile directory that does not exist yet is created)
ff-rdp launch --profile /path/to/profile --debug-port 9222
# Allow longer for the debug port to open on a heavily loaded machine
ff-rdp launch --headless --launch-timeout 60
# List temporary profiles managed by ff-rdp (path, count, total size)
ff-rdp profiles list
# Remove stale temporary profiles (default: older than 7 days)
ff-rdp profiles prune
# Preview what --all would remove, then remove everything
ff-rdp profiles prune --all --dry-run
ff-rdp profiles prune --all
# Inspect a remote object grip (from eval output)
ff-rdp inspect server1.conn0.child2/obj19
# Recursive inspection (depth 2)
ff-rdp inspect server1.conn0.child2/obj19 --depth 2
# List all loaded JavaScript sources
ff-rdp sources
# Filter sources by URL substring
ff-rdp sources --filter vendor
# Filter sources by regex pattern
ff-rdp sources --pattern "cdn\.example\.com"
# Reload, go back, go forward
ff-rdp reload
ff-rdp back
ff-rdp forward
# Reload and wait until network is idle (replaces sleep)
ff-rdp reload --wait-idle
ff-rdp reload --wait-idle --idle-ms 1000 --reload-timeout 30000
# Generate a shell completion script (bash, zsh, fish, elvish, powershell)
ff-rdp completions zsh > ~/.zsh/completions/_ff-rdp
eval "$(ff-rdp completions zsh)" # or load straight into the current shellThe .deb/.rpm packages already install bash/zsh/fish completions system-wide, so completions is mainly for other shells or for refreshing the script after an upgrade.
ff-rdp ships a Claude Code skill, ff-rdp-debug, that turns
ff-rdp into a symptom-routed debugger for web bugs. Install it once and
it's available in any repo on your machine:
ff-rdp install-skill --claude
# → installs the ff-rdp-debug skill to ~/.claude/skills/ff-rdp-debug/
# Skill is then available in any repo on your machine.The skill's command reference — its one-line description, quick start, command
groups, and the --ref / --query / --with-page idioms — is generated from
the CLI's own tables (crates/ff-rdp-cli/src/commands/skill_doc.rs), the same
ones the no-args home view reads. Regenerate the marked region with
cargo run -p xtask -- gen-skill; cargo run -p xtask -- check-skill-drift
fails CI when the committed file and the generator disagree, so the skill cannot
quietly describe a CLI that no longer exists.
Inside Claude Code, trigger it with /ff-rdp-debug or natural-language
prompts like "debug this page", "login doesn't work", "why is X
failing in the browser". The skill routes the symptom to one of 10
deterministic playbooks (Set-Cookie strip, ChunkLoadError, React
controlled-input, consent banner, …) and runs probe commands against a
live Firefox tab. See kb/skills/ff-rdp-debug.md for the full skill
guide.
By default, the first CLI invocation auto-starts a background daemon that holds a persistent Firefox RDP connection and buffers watcher events. Subsequent invocations connect through the daemon for faster execution and cross-command workflows.
How it works:
- First
ff-rdpcall spawns a daemon process (ff-rdp _daemon) in the background - The daemon connects to Firefox, subscribes to watcher resources (network, console, errors), and listens on a random TCP loopback port
- Subsequent CLI calls connect to the daemon instead of Firefox directly
- The daemon transparently proxies RDP frames and also exposes a
"daemon"virtual actor for draining buffered events, for the recorded frame-target snapshot (click --frame,consent accept), and for status - Firefox RDP replies carry no request id, so the daemon lets one client have Firefox-bound requests in flight at a time. Concurrent invocations queue for that channel and all succeed; a client that waits out the queue budget gets a
daemon_busyerror naming the wait and the cap. Use--no-daemonfor a private connection when you want true parallelism. - Daemon exits automatically after 5 minutes of inactivity (configurable via
--daemon-timeout) - The watcher resource subscriptions (
network-event,console-message,error-message) belong to the daemon, not to any one CLI invocation. The daemon therefore drops a proxied client'sunwatchResourcesfor those types (it already does the same forunwatchTargets) — otherwise one command's teardown would destroy Firefox'sNetworkObserveron the shared connection, taking the session's URL block-list and throttling config with it (iter-164) - Auto-start waits up to 20 s for the freshly spawned daemon to register; override with
FF_RDP_DAEMON_START_TIMEOUT_MS. If it gives up, the command still runs over a direct connection —meta.routereports"direct"and, under--verbose,meta.daemon_fallbacknames why daemon mode degraded instead of silently discarding the reason (iter-164)
Cross-command workflows (enabled by daemon):
# Navigate, then inspect network traffic as separate commands
ff-rdp navigate https://example.com
ff-rdp network
# Object grips from eval survive across invocations
ff-rdp eval 'document.querySelector("h1")'
ff-rdp inspect server1.conn0.child2/obj19Disabling the daemon:
# Connect directly to Firefox (original behavior)
ff-rdp --no-daemon eval "1+1"Registry and logs:
- Registry file:
~/.ff-rdp/daemon.json(PID, port, Firefox target) - Log file:
~/.ff-rdp/daemon.log - Stale registry files are cleaned up automatically when the daemon PID is dead
Health and diagnostics (iter-240):
ff-rdp daemon statusreports whether the daemon is working, not just running:dispatcher.alive,dispatcher.frames_started/frames_finished/in_flight,dispatcher.current_frame_age_ms,dispatcher.last_frame_kind,rpc_slot.owner/rpc_slot.held_secs, andclients_dropped_on_write. A single thread routes every Firefox frame, soin_flight > 0with acurrent_frame_age_msthat keeps growing is a wedged daemon — before these fields it was indistinguishable from a slow page, because the CLI reported only a generic 10 sphase: recvtimeout.ff-rdp doctorruns the same check as adaemon_dispatcherprobe and fails when the dispatcher has exited or has been stuck on one frame for 15 s.- Every daemon→client write is bounded by a 10 s deadline
(
client_write_deadline_ms). A client that stops reading is dropped and counted inclients_dropped_on_write, rather than parking the dispatcher — which used to take the whole daemon down with it, silently and permanently. --log-level <level>on the invocation that auto-starts the daemon also configures the daemon, asRUST_LOGin its environment; an exportedRUST_LOGreaches it through ordinary inheritance. Either way the output lands in~/.ff-rdp/daemon.log. Raising the level does not reach a daemon that is already running — stop it first.
Troubleshooting:
- If the daemon seems stuck, run
ff-rdp daemon status(orff-rdp doctor) first — it will say whether the dispatcher is stuck; thenff-rdp daemon stop - Use
--no-daemonto bypass the daemon and test direct connectivity - Check
~/.ff-rdp/daemon.logfor daemon-side errors
Temporary profile cleanup:
ff-rdp daemon stopattempts to delete the temporary profile directory it launched Firefox with (never a directory passed via--profile). Cleanup runs only after the daemon has confirmed the stop; the stop JSON reports whether it happened viaprofile_removed/profile_removed_path.ff-rdp launchprunes orphanedff-rdp-profile-*directories left behind by crashes orkill -9: entries older thanFF_RDP_PROFILE_PRUNE_DAYS(default 7) are removed, at mostFF_RDP_PROFILE_PRUNE_MAX(default 50) per launch. A directory only counts as stale when both its own mtime and its newest top-level file mtime are past the threshold — a profile that a long-running Firefox is still writing into is not treated as an orphan.- Every managed profile carries an
.ff-rdp-owner-pidmarker. It is written the instant the profile directory is created, holding the launching ff-rdp process's own PID, and rewritten with the Firefox PID as soon as the browser is spawned (iter-175). Before that, the marker was written only after the spawn, so a launch that died in between — a failed spawn, a browser that exited immediately, a killed CLI — left a directory nothing could attribute and only the seven-day mtime gate could ever reclaim. Any age-gated prune — the automatic launch sweep andprofiles prune --older-than— first checks whether that owner process is still alive and, if so, keeps the profile regardless of age. This is a positive "still in use" signal that closes the gap where a fully-idle-but-running Firefox could look stale by mtime alone. - A marker naming a dead PID is reclaimed by the very next
launchimmediately, without waiting out the age threshold — a dead owner is definitive proof of abandonment, not just "old enough to guess at" (iter-142; fixes an observed 62 profiles / 2.7 GB accumulating in a single day, all younger than the old 7-day gate). - The marker is a pair: alongside the PID,
.ff-rdp-owner-startrecords that process's OS start time, and both are written together — the old token is cleared first, so the pair can never describe two different processes. A profile directory outlives the Firefox that owned it, so its PID marker does too — and once the OS recycles that PID, a bare liveness check reports the abandoned profile as still in use, so no age-gated prune will ever reclaim it. Comparing the recorded start time against the live PID's makes the check an identity check: a recycled PID no longer passes for the original owner (iter-171). A profile written by an older ff-rdp has no start marker and keeps the previous PID-only behaviour. - A
launchthat fails after creating its profile directory removes that directory on the way out (iter-175) — spawn failure, a Firefox that exits immediately, a debug port that never opens, or a--auto-consentextension install that fails. A directory the user passed via--profileis never touched by this. - Directories left by a pre-iter-175 failed launch are reclaimed too: a
managed directory with no owner marker at all, holding nothing but the
user.jsff-rdp writes before the spawn, is proof that no Firefox ever opened it, so the nextlaunchremoves it after a ten-minute race grace instead of after seven days. Age gating is unchanged for every other directory — a profile Firefox actually used still keeps the fullFF_RDP_PROFILE_PRUNE_DAYSthreshold, andprofiles prune --older-thanstays a pure age query. - Every
ff-rdp launchalso sweeps~/.ff-rdp/housekeeping files: stale per-port spawn locks, the per-port registry write locks (iter-172), the legacy port-lessdaemon.spawn.lockname,daemon.<port>.throttle.jsonstate files whose recorded daemon PID is no longer alive (iter-142), andlaunch-record.<port>.jsonfiles whose recorded PID is no longer alive (iter-186). Previously this only ran on the rare daemon-autostart path, so a session that reused an already-running daemon never triggered it at all. - Launch records needed their own sweep because nothing else reclaimed them.
daemon stopdeletes the record only on a clean stop, and reading a record with a dead PID deletes it only when that same port is read again — and ports come from an ephemeralbind(:0), so that port essentially never recurs. Measured on one dev machine: 4803 records / 20 MB accumulated over ten days; the first launch carrying the iter-186 sweep took that to 14 files / 1.0 MB, and three further launches left it at 13, 13, 13. A record whose PID is still alive is never removed, so a running instance's record survives a sweep that happens while it is up. - The daemon registry
daemon.<port>.jsonis only ever published by an atomicrename, and the lock that serializes writers lives in a siblingdaemon.<port>.write.lock(iter-172). Locking the published path itself — which is what earlier builds did — meant a zero-byte record existed for the whole span between taking the lock and the rename, and a client that read it in that window gave up on the daemon and silently ran the command over a direct connection instead. A zero-byte record left behind by such a build is now read as "no daemon registered" rather than as a parse error, so the next invocation starts a daemon normally instead of degrading forever. FF_RDP_HOMEoverrides the base directory for all of ff-rdp's per-user state: the daemon registry and launch records under$FF_RDP_HOME/.ff-rdp/, and (since iter-188) the temporary-profile root at$FF_RDP_HOME/ff-rdp/profiles/. Before iter-188 the profiles root ignored it, so setting the variable gave you a split state directory — registry redirected, profiles still landing in the real per-user path. Unset, the profiles root resolves as before:$XDG_STATE_HOME(Linux), else~/Library/Application Support(macOS) /%LOCALAPPDATA%(Windows), plusff-rdp/profiles.$FF_RDP_HOMEmust be a directory only you can write. ff-rdp trusts an owner-PID marker found under it to decide whether a Firefox process is safe to kill; a directory another account can write to (or rename/replace) lets that account plant a marker and get ff-rdp to authorise a kill on its behalf.ff-rdp profiles list/ff-rdp profiles pruneinspect and reclaim the profile directory explicitly;ff-rdp doctorwarns when the profile store grows past 100 entries or 1 GiB.profiles prune --allskips the age gate entirely — do not run it while a Firefox launched by ff-rdp is still using one of these profiles.--allstill removes a live-owner profile (it is the explicit escape hatch) but logs a warning per directory and lists each such basename underremoved_livein the JSON output.
ff-rdp has the same power as Firefox DevTools — it can read httpOnly cookies, execute arbitrary JavaScript, capture screenshots, and navigate to URLs. The security model is "same as opening DevTools": the user is the operator.
Transport: Firefox RDP uses plaintext TCP with no TLS. By default ff-rdp connects to localhost only. For remote debugging, use SSH tunneling (ssh -L 6000:localhost:6000 remote-host) rather than exposing the debug port directly.
URL validation: The navigate command rejects javascript: and data: URLs by default to prevent accidental code execution in the page context. Allowed schemes are http:, https:, file:, and about:. Use --allow-unsafe-urls to bypass this check if needed.
Daemon trust model: The daemon listens on 127.0.0.1 (loopback only). Any local process can connect and send RDP commands through it — the same trust boundary as Firefox DevTools. The registry file (~/.ff-rdp/daemon.json) is created with owner-only permissions (0600 on Unix).
Regex limits: The --pattern flag (used by console and sources commands) applies a 1 MiB NFA size limit to prevent denial-of-service from pathological regular expressions.
Not designed for untrusted networks. Do not expose the Firefox debug port to the network. All RDP traffic (page content, cookies, eval results) is transmitted in plaintext.
- ff-rdp-core — Protocol library: blocking TCP transport, length-prefixed JSON framing, typed errors
- ff-rdp-cli — CLI binary: clap args, jq output pipeline, command dispatch, daemon proxy
Releases run through the shared ractive/release-workflows pipeline. The repo-local release.yml is a thin caller that pins the shared workflow version and passes the ff-rdp-specific inputs (targets, package names, winget identifier, Cloudsmith repo, completions packaging).
- Bump the workspace version in
Cargo.toml([workspace.package] version) and commit. - Cut the release:
gh release create vX.Y.Z --generate-notes(the tag must match the workspace version).
On a published release the shared pipeline builds every target, publishes ff-rdp-core and ff-rdp-cli to crates.io, updates Homebrew / Scoop / winget, uploads the versioned archives, SBOMs, and .deb/.rpm packages to the GitHub release, and pushes the .deb/.rpm to the hosted Cloudsmith apt/rpm repos.
Dry-run rehearsal: gh workflow run release.yml (a workflow_dispatch) builds, tests, and packages everything as workflow artifacts without touching any external channel — use it to validate a release before tagging.
Recovery workflows (both workflow_dispatch):
cloudsmith-republish.yml— re-push an existing release's.deb/.rpmto Cloudsmith (the release-time Cloudsmith step is non-blocking).- The shared repo's
publish-crates.ymlre-publishes a crate to crates.io if that step failed during a release.
live.yml runs the Firefox-dependent live tests on each release and weekly as a drift canary (not per-PR).
toolchain-watch.yml runs cargo fmt --check and cargo clippy -D warnings against main weekly (and on workflow_dispatch). ci.yml lints on pull_request only, so a new stable release can red-line main with no commit pushed and nothing would notice until an unrelated PR absorbed the cost — which is exactly what happened when 1.98.0 shipped. See kb/decision-log.md DEC-044.
Every release binary is signed via Sigstore-backed build provenance attestations. To verify a downloaded artifact:
gh attestation verify ff-rdp-v0.3.0-aarch64-apple-darwin.tar.gz --owner ractiveEach native target also ships a CycloneDX SBOM for both ff-rdp-cli and ff-rdp-core (the *.cdx.json files on the release).
The same verification command runs as a PR-time smoke check in ci.yml (verify-attestation job), so a regression in the attestation pipeline is caught before release.
Package repository hosting is graciously provided by Cloudsmith. Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that enables your organization to create, store and share packages in any format, to any place, with total confidence.
ff-rdp bundles Mozilla's Readability.js
(@mozilla/readability 0.6.0, Apache-2.0) — the algorithm behind Firefox Reader
View — in crates/ff-rdp-cli/js/readability/. It is injected into the live page
so --with-page can tell the article apart from the site chrome. The files are
committed rather than downloaded at runtime, and cargo run -p xtask -- check-vendored-js pins each one's SHA-256 against the VERSION manifest beside
them, so an upgrade is a deliberate, reviewable commit. Upstream's licence text
ships alongside the code as js/readability/LICENSE.
MIT