☕ Like this project? Support the author on Buy Me a Coffee
Local bridge and WebExtension for monitoring, diagnostics, and explicitly permitted control of Zen Browser/Firefox. The project provides a human or AI agent with structured access to selected tabs—or, after a separate dangerous confirmation, to all available tabs: DOM snapshot, navigation, synthetic actions, console, network, performance, screenshots, and browser events.
Current version: 0.1.7. Command protocol: 1.
The project is local-first:
- The bridge listens only on
127.0.0.1:18765by default; - HTTP, SSE, and WebSocket are secured with a random 256-bit token;
- Data is not sent to the cloud by this project's code;
- The bridge connection itself does not open tabs for the agent;
- Secure standard mode requires manually granting each tab Observe or Control;
- A separate persistent setting ALL ACCESS after explicit confirmation grants Control to all existing and new tabs;
- Regular per-tab grants live only in memory; ALL ACCESS is saved as a setting until the user turns it off or clicks Stop all.
ALL ACCESS — dangerous mode. It is intended for short trusted local debug sessions. It extends the internal grant of Zen Debug Bridge, but does not bypass Firefox limitations: DOM
about:/browser UI, foreign extension realms, their private console and storage remain inaccessible.
Important for AI agent: this project is completely independent of Kimi WebBridge, Chrome DevTools bridge, and any third-party browser-control extensions. There is no need to install, run, or check Kimi WebBridge. For normal operation, use only the CLI/HTTP API Zen Debug Bridge from this repository. Selenium, WebDriver BiDi, and Firefox DevTools are needed only for a separate Deep Debug scenario.
- Brief contract for AI agent
- Implemented features
- Architecture
- Manifest and browser permissions
- Access model
- Installation and launch
- Extension interface
- On-page action overlay
- Settings
- CLI
- Full command catalog
- DOM snapshot and refs
- Console, network, performance, and events
- HTTP API
- Extension WebSocket protocol
- SSE and event buffers
- Redaction and handling sensitive data
- Error codes
- Deep Debug
- Limitations
- Troubleshooting
- Development, tests, and build
- Project structure
If this README is read by an AI, use the following order. It is a safe standard workflow for the project.
cd ~/Projects/zen-debug-bridgeDo not search for another browser bridge until the user explicitly requests to use a different tool.
npm run status
node bridge/cli.mjs call ping
node bridge/cli.mjs call capabilities
node bridge/cli.mjs call get_stateInterpretation:
extension_not_connected— bridge is running but the extension is not connected;bridgeStatus: "connected"— transport is working;sessionMode: "off"— no tabs have been granted yet;sessionMode: "observe"— read-only commands are available;sessionMode: "control"— at least one tab has been granted Control;allAccess: true— the user has enabled persistent global Control for all tabs that the WebExtension can see;allAccessTabs— the number of live tabs that have entered the current global-access lifecycle;trackedTabs: 0— the agent should not attempt to read the list of regular tabs;technicalConsent: true— performance/extension metadata capabilities are enabled.
Never ask the user to send a token in chat. The CLI reads the local token file itself.
The user must open a regular page http:, https: or file:, click the Zen Debug Bridge icon, and select:
- Observe — reading DOM, screenshot, console, network, and events;
- Control — everything from Observe plus navigation, opening/closing tabs, and DOM actions.
Do not bypass this check via OS commands, another browser bridge, or changing storage. Manual grant is part of the security model.
If get_state already shows allAccess: true, an additional per-tab grant is not needed: all tabs visible to the extension have effective Control. The AI should not enable ALL ACCESS on its own, change the extension storage for this purpose, or persuade the user to leave the mode enabled. Enabling is done only by a person in Settings → High-risk capabilities, with a separate confirm dialog.
node bridge/cli.mjs call list_tabs '{}'By default, the command returns only explicitly granted tabs and tabs created by the agent. With allAccess: true, every tab available to the WebExtension is tracked, including new tabs. Do not use IDs from an old response after closing or restarting the browser.
node bridge/cli.mjs call snapshot '{"tabId":123}'Prefer semantic refs of the form 0:e7 over CSS selectors. The ref is bound to a specific frame and the last snapshot of that frame. After navigation, major DOM update, or error STALE_ELEMENT_REF, get a new snapshot.
node bridge/cli.mjs call click '{"tabId":123,"ref":"0:e7"}'
node bridge/cli.mjs call fill '{"tabId":123,"ref":"0:e8","value":"example"}'After an action, call snapshot, list_console or list_network again to confirm the result. Do not consider the absence of a transport error as proof that the site accepted synthetic input.
node bridge/cli.mjs call open_tab '{"url":"https://example.com","active":true}'Firefox sometimes returns an intermediate about:blank from tabs.create(). Verify the actual URL with a repeated list_tabs or snapshot.
Control — including global Control through ALL ACCESS — means technical capability, not universal user consent for any actions. Without a separate explicit request, you cannot:
- send messages, forms, or comments;
- delete data;
- purchase, publish, or confirm transactions;
- change account settings;
- disclose tokens, cookies, personal data, or content of other tabs.
The user can click Stop tab or Stop all. In ALL ACCESS mode, the per-tab Stop tab control is intentionally disabled; Stop all immediately turns off global mode, saves allAccess=false, revokes all grants, and clears runtime buffers. The AI should treat SESSION_ACCESS_DENIED, TAB_ACCESS_DENIED, and ALL_ACCESS_ACTIVE as normal boundaries rather than errors to bypass.
- semantic DOM snapshot with accessibility-like tree;
- refs with frame prefix, for example
0:e12or5:e3; - traversal of regular DOM and open Shadow DOM roots;
- snapshots of multiple available frames, including cross-origin frames;
- optional reading of current form control values;
- interception of
console.debug/log/info/warn/error/tracein page realm; window.errorandunhandledrejection;- lifecycle network requests, statuses, headers, redirects, timings, and errors;
- optional capture of text request/response bodies;
longtask, Largest Contentful Paint, and layout shift entries;- batched DOM mutations;
- visible-viewport screenshots in PNG/JPEG format;
- metadata of installed extensions and lifecycle events via
managementAPI; - diagnosis of sizes, loading, and errors of popups.
- list and search for available tabs;
- open a new tab;
- activate a tab and focus the window;
- navigate to URL;
- reload with optional bypass cache;
- back/forward;
- close a tab;
- click by ref or CSS selector;
- fill for input, textarea, select, and contenteditable;
- hover;
- scroll page or element;
- optional JavaScript evaluation in isolated content-script realm;
- open
about:debuggingfor manual Inspect.
- HTTP command API;
- WebSocket between extension and bridge;
- server-side ring buffer events;
- real-time SSE stream;
- CLI
zdb/bridge/cli.mjs; - multiple browser clients with selection by
clientId; - systemd user unit;
- ZIP/XPI build without local pairing secret;
- separate launch scripts for Zen Flatpak Deep Debug.
┌──────────────────────────────── Zen / Firefox ────────────────────────────────┐
│ │
│ page realm ── page-hook.js ── window.postMessage ── content.js (all frames) │
│ │ │
│ │ runtime messages │
│ ▼ │
│ popup/options ─────────────────────────────── persistent background │
│ core + commands + network │
│ │ │
└───────────────────────────────────────────────────────┼───────────────────────┘
│ authenticated WS
│ /extension?token=…
▼
Node.js bridge on 127.0.0.1:18765
│
┌─────────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
CLI / AI agent HTTP API SSE clients
Components:
page-hook.jsoperates in the JavaScript realm of the page and wraps the console API;content.jsoperates in an isolated WebExtension realm for each available frame;- background stores grants, ring buffers, and WebSocket connection;
- Node bridge authenticates clients, routes commands, and stores a second ring buffer of events;
- CLI is a thin HTTP API client that automatically reads the local token.
The extension uses Manifest V2 with a persistent background. Firefox extension ID: zen-debug-bridge@local.dev.
Minimum versions in the manifest:
- desktop Gecko:
140.0; - Gecko Android:
142.0.
| Permission | Purpose |
|---|---|
<all_urls> |
Content script in frames of user-selected websites and network observation |
activeTab |
Context of the active tab after user interaction with the popup |
management |
Metadata/lifecycle of installed extensions with separate opt-in |
storage |
Settings and stable instanceId |
tabs |
Tab metadata, create/update/remove/captureVisibleTab |
tabGroups |
Native purple AI group for tabs created through the bridge |
webNavigation |
Frame enumeration and navigation events |
webRequest |
Request lifecycle, headers, and optional body capture |
webRequestBlocking |
Access to the extended webRequest surface; the project does not block or modify traffic |
The content script is declared with run_at: document_start, all_frames: true, and match_about_blank: true. Actual instrumentation remains disabled until the tab receives Observe/Control.
The manifest declares Firefox data collection permissions because data exits from the add-on into a separate local Node process:
- required:
browsingActivity,websiteActivity,websiteContent; - optional:
technicalAndInteraction.
Optional technical permission is used for performance entries, extension inventory, and passing the browser name/version. The presence of an API permission in the manifest does not cancel the internal model of grants: without Observe/Control, tab data is not collected by the bridge.
Extension Content Security Policy: script-src 'self'; object-src 'none'. Remote scripts are not loaded.
| Mode | What is Allowed | What is Forbidden |
|---|---|---|
off |
ping, capabilities/state, and popup diagnostics |
Tab metadata, DOM, telemetry, and management |
observe |
Snapshot, screenshot, console, network, page events, extension inventory | Navigation, click/fill/hover/scroll, evaluate, close |
control |
Everything from Observe plus control commands | Privileged pages and trusted OS input are still unavailable |
Each issued tab has its own mode. sessionMode is equal to the maximum mode among all issued tabs:
- one Observe tab gives
sessionMode=observe; - the presence of at least one Control tab gives
sessionMode=control; - closing the last Control tab reduces the session to Observe;
- revoking the last tab returns
off.
open_tab checks for session-level Control. A new tab created by the agent automatically receives Control and is marked as agentCreated: true. Other commands additionally check the mode of the specific target tab.
allAccess=true is an intentional exception to the per-tab model. It can only be enabled in Settings → High-risk capabilities → ALL ACCESS. When transitioning to false → true, the options page shows a separate confirmation with access description; background additionally rejects UI requests without confirmAllAccess=true with code ALL_ACCESS_CONFIRMATION_REQUIRED.
After enabling, the extension:
- retrieves a list of all tabs visible to WebExtension through
browser.tabs.query({}); - remembers the previous mode for each tab (
off,observe, orcontrol); - sets an effective
controlfor each tab; - automatically adds each new tab to Control through
tabs.onCreated; - reconfigures content instrumentation after URL/status updates;
- maintains
sessionMode=controlwhile the global mode is active.
Public state fields:
allAccess— effective runtime state of the mode;allAccessTabs— number of current tab records for which the previous mode is saved;trackedTabs— number of tabs currently accessible to commands;capabilities.allAccess— machine-readable duplicate of effective capability.
During normal shutdown of ALL ACCESS, background restores saved per-tab modes. Tabs that were Off before enabling or appeared during global mode again become Off; their collected runtime data is cleared. Tabs with previous Observe/Control return to it. Closed tabs are simply removed from bookkeeping.
During ALL ACCESS:
- badge for each controlled tab shows a red
ALL; - popup displays
ALL ACCESS, disables Observe/Control/Stop tab, and offers Settings or Stop all; - a per-tab revoke request returns
ALL_ACCESS_ACTIVEbecause it contradicts the global guarantee; - manual grant through the internal API changes the mode that will be restored after shutdown, but effective mode remains Control;
- Stop all is an emergency switch: it also persistently sets
allAccess=false.
ALL ACCESS means "all tabs that Firefox allows this WebExtension to see," not browser process privileges. It does not provide DOM access to browser chrome, most about:/Mozilla-protected pages, private contexts of other extensions or their background console/storage. Private-window tabs fall within scope only if the user separately grants the extension to work in Private Windows. Metadata and browser-level operations with some protected tabs may be accessible even when content-script DOM actions are unavailable.
Normal per-tab grants are not written to browser.storage and are reset upon:
- restarting Zen;
- reloading temporary extension;
- uninstalling/reinstalling the extension;
- clicking Stop tab or Stop all.
Settings, token, and instanceId are saved separately and can survive reload. allAccess is also a saved setting: if it was left enabled, after restart/reload the extension will again apply Control to all tabs that it sees in the new runtime session. Stop all disables and saves it as false.
OBS— active tab granted Observe;CTL— active tab granted Control;- red
ALL— ALL ACCESS is enabled and the tab falls under global Control; !— bridge is not connected, and the tab does not have a grant;- empty badge — connected, current tab is Off.
- Linux with Zen Browser or compatible Firefox;
- Node.js
>=20.19.0; - npm;
zipandjqfor building;- for the described Flatpak workflow — application
app.zen_browser.zen.
cd ~/Projects/zen-debug-bridge
npm installRuntime dependency is one: package ws.
cd ~/Projects/zen-debug-bridge
npm startExpected output:
[zen-debug-bridge] listening on http://127.0.0.1:18765
[zen-debug-bridge] token file: /home/<user>/.local/share/zen-debug-bridge/token
The first launch creates:
- a directory at
~/.local/share/zen-debug-bridgewith mode0700; - a token of 32 random bytes, recorded as 64 hex characters;
- a file named token with mode
0600.
npm run devNode restarts the bridge upon changes to server-side files. The extension still needs to be reloaded manually via about:debugging.
The unit assumes that the repository is located at %h/Projects/zen-debug-bridge. If the path is different, first change WorkingDirectory and ExecStart.
mkdir -p ~/.config/systemd/user
cp systemd/zen-debug-bridge.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now zen-debug-bridge.service
systemctl --user status zen-debug-bridge.serviceThe unit limits filesystem access through ProtectSystem=strict, makes home read-only, and allows writing only to ~/.local/share/zen-debug-bridge.
npm run tokenThe command prints the secret. Do not insert it into an issue, README, agent log, or public shell transcript.
Zen Flatpak has an important peculiarity: selecting manifest.json through the file portal may give the sandbox access to that file alone, but not to neighboring JS/CSS files. Symptoms include an empty icon, a tiny white popup, and a background script that does not connect. Therefore, use a single ZIP/XPI located in ~/Downloads for Flatpak.
Build a secure archive without the token:
cd ~/Projects/zen-debug-bridge
npm run build
VERSION="$(jq -r .version extension/manifest.json)"
cp "artifacts/zen-debug-bridge-$VERSION.zip" \
"$HOME/Downloads/zen-debug-bridge-$VERSION.xpi"Then:
- Open
about:debugging#/runtime/this-firefox. - Click Load Temporary Add-on….
- Select
~/Downloads/zen-debug-bridge-<version>.xpi. - Open the extension's Settings.
- Specify
ws://127.0.0.1:18765/extension. - Paste the result of
npm run token. - Save the settings and check the status of
CONNECTED.
The temporary add-on is removed upon a full browser restart and must be reloaded again.
If the browser has normal filesystem access:
- Open
about:debugging#/runtime/this-firefox. - Click Load Temporary Add-on….
- Select
extension/manifest.json. - Configure URL/token.
This option is not recommended for Zen Flatpak due to file portal sandboxing.
For a fully local development build, the extension can read extension/pairing.local.json:
{
"bridgeUrl": "ws://127.0.0.1:18765/extension",
"bridgeToken": "<64-hex-characters>"
}Properties:
- The file is ignored by Git;
npm run buildalways excludes it from the standard ZIP;- During initialization, a correct local pairing has priority over storage;
pairingSourceinget_statewill belocal-file;- If the user manually saves the token in Settings, the current runtime may show
storage, but after a reload, the local file again has priority.
If you package pairing.local.json into a private XPI, this XPI contains a working secret. Store it with mode 0600, locally only, do not attach to issues, and do not publish as an artifact.
The standard ZIP/XPI of this project is not signed. For permanent installation in standard Firefox/Zen, a Mozilla AMO signing process is needed, such as unlisted distribution. Disabling signature enforcement in the main profile is not recommended.
Popup displays:
- connection status;
- mode of the current tab;
- title and URL of the active tab;
- a live AI activity card for commands currently executing through the local bridge, with a minimal recent history;
- Observe, Control, Stop tab buttons;
- quick toggles for Console, Network, Performance, DOM mutations;
- warning about high-risk settings;
- Reconnect, Settings, Stop all.
Observe/Control are only active for regular http:, https: and file: pages. about:, browser UI, AMO-protected pages, and other privileged documents cannot be instrumented by a normal extension.
When ALL ACCESS is enabled, the popup uses a red mode badge, shows a permanent warning, and blocks per-tab buttons. Stop all remains available and disables ALL ACCESS along with all grants. The switch itself is only on the full Settings page to prevent global access from being accidentally enabled in the compact popup.
The activity card is intentionally evidence-based: the animated state starts only after the extension actually receives a bridge command and completes or fails when that command returns. It stores and renders command metadata only (action, tab ID, timing, outcome), never command arguments, page content, selectors, or form values. It also respects prefers-reduced-motion.
Version 0.1.6 adds a visible animation layer directly inside every granted page. It is powered by the bundled local copy of Anime.js; no remote script or page data is used. The overlay has pointer-events: none, so it cannot intercept a click or typed input.
It makes these actions observable while they happen:
- scanning line while the agent reads a DOM snapshot;
- element spotlight and animated cursor for hover, click, and text entry;
- scrolling, navigation, reload, history, screenshot, and tab-transition indicators;
- neutral inspection indicator for console, network, event, and isolated-realm evaluation calls.
The overlay deliberately uses fixed action labels only. It does not display form values, selectors, URLs, page text, or command arguments. It is limited to web-page document content: Firefox does not allow an extension to draw above Zen's browser chrome or the physical tab strip. Switching a tab remains visible through the native browser UI, then the selected page shows the corresponding in-page cue. Reduced-motion preferences use a brief static indication instead of animated motion.
Tabs created through open_tab are also placed into a native purple tab group named AI. This is a persistent visible marker in the Zen/Firefox tab strip, rather than an artificial page overlay. Later AI-created tabs in the same window, including after an extension reload, join that group. The behavior is feature-detected: opening tabs still works normally in builds where native tab groups are unavailable.
Performance and Extension inventory require Firefox data permission technicalAndInteraction. The request must occur directly from a user click/change handler, so permission cannot be granted by a remote AI command.
After enabling Performance or Extension inventory, the browser displays a system consent dialog. The user must select Allow. Check:
node bridge/cli.mjs call get_stateExpected fields:
{
"technicalConsent": true,
"capabilities": {
"performance": true,
"extensions": true
}
}Browser name/version are sent to the bridge only when this consent is present.
Popup records stages:
script-start;first-animation-frame;window-load;render-completeorrender-error;after-250ms;window-error;unhandled-rejection;permission-or-settings-error.
Diagnostics include viewport and HTML/body sizes, but not a screenshot of the popup.
node bridge/cli.mjs call list_diagnostics '{"limit":20}'
node bridge/cli.mjs call open_popup_debug '{}'
node bridge/cli.mjs call clear_diagnostics '{}'open_popup_debug opens the popup as a regular extension tab, which is convenient when there are issues with the toolbar panel size.
| Key | Default | Range/Condition | Purpose |
|---|---|---|---|
bridgeUrl |
ws://127.0.0.1:18765/extension |
Only ws:/wss: |
Extension WebSocket endpoint |
bridgeToken |
empty | 64 hex recommended | Pairing secret |
reconnect |
true |
boolean | Automatic reconnect with backoff 1–30 seconds |
allAccess |
false |
High risk, manual confirm | Persistent Control of all existing and new tabs visible to WebExtension |
monitorConsole |
true |
boolean | Page console and JS errors after grant |
monitorNetwork |
true |
boolean | WebRequest lifecycle for granted tabs |
monitorMutations |
false |
boolean | Batch DOM mutations; may be noisy |
monitorPerformance |
false |
Requires technical consent | Long tasks, LCP, layout shifts |
monitorExtensions |
false |
Requires technical consent | Inventory and lifecycle of other add-ons |
captureBodies |
false |
High risk | Textual response bodies |
captureRequestBodies |
false |
High risk | Form/raw request bodies |
allowEval |
false |
High risk + Control | Execution of JS in isolated extension realm |
exposeUntrackedTabs |
false |
Privacy-sensitive | Show titles/URLs of untracked tabs in list/find |
redactQueryValues |
true |
Recommended not to disable | Replace query param values with [redacted] |
maxEvents |
1000 |
100…10000 |
Extension-side limit of each ring buffer |
maxBodyBytes |
262144 |
1024…1048576 |
Limit of one captured body |
Changing settings reconfigures tracked frames and forcefully reconnects the WebSocket.
The allAccess=false → true transition requires synchronous user confirmation. The true → false transition does not require confirmation and immediately restores previous per-tab grants. A programmatic UI call without a confirmation marker is rejected. The saved value survives restart; Stop all forcefully saves false.
Reset defaults changes only form values; to apply, click Save.
CLI is located in bridge/cli.mjs. After npm link, the name zdb is available; without a link, use node bridge/cli.mjs.
node bridge/cli.mjs helpnpm run status
npm run token
node bridge/cli.mjs call <action> '<json-args>'
node bridge/cli.mjs events [after-id] [limit]
node bridge/cli.mjs follow
node bridge/cli.mjs screenshot [output-path] [tab-id]JSON args must be one shell argument:
node bridge/cli.mjs call snapshot '{"tabId":123,"limit":800}'CLI:
- automatically reads host/port/token through
loadConfig(); - uses Bearer auth;
- prints JSON with indentation;
- returns a non-zero exit code on HTTP/command error;
followprints each SSE event as one JSON string;screenshotdecodes data URL and writes PNG/JPEG to disk.
node bridge/cli.mjs call ping
node bridge/cli.mjs call get_state
node bridge/cli.mjs call list_tabs '{}'
node bridge/cli.mjs call snapshot '{"tabId":123}'
node bridge/cli.mjs call click '{"tabId":123,"ref":"0:e4"}'
node bridge/cli.mjs call list_console '{"tabId":123,"level":"error"}'
node bridge/cli.mjs call list_network '{"tabId":123,"limit":100}'All commands are called like this:
node bridge/cli.mjs call ACTION 'JSON_ARGS'HTTP envelope of a successful response:
{
"ok": true,
"result": {}
}On command error, the bridge responds with HTTP 502 or 504:
{
"ok": false,
"error": "Tab 123 requires control access; current mode is observe",
"code": "TAB_ACCESS_DENIED"
}Args: {}.
Returns pong, timestamp, and public state. The best two-way health check: unlike /health, the command proves that the HTTP bridge, WebSocket, and background extension are responding.
Args: {}.
Returns protocolVersion, a full list of actions, and machine-readable limitations.
Args:
{ "tabId": 123 }tabId is optional. Returns version, pairing, consent, bridge/session state, trackedTabs, allAccess, allAccessTabs, mode of the specified tab, effective capabilities, and agentActivity. agentActivity exposes only bridge-command metadata for the popup: current action/status, active count, timestamps, outcome code, and up to six recent outcomes.
Args: { "limit": 100 }, limit 1…500.
Returns public state and the latest popup diagnostics.
Args: {}. Clears popup diagnostics.
Args: {}. Opens popup/popup.html?debug=tab in the active extension tab and returns tabId. The command is intended only for UI extension diagnostics.
Args:
{ "includeUntracked": true }By default, it returns only tracked tabs. With ALL ACCESS, this includes all tabs visible to the WebExtension. In normal per-tab mode, untracked metadata will appear only if both of the following conditions are met:
- setting
exposeUntrackedTabs=true; includeUntrackedis not equal tofalse.
This reveals metadata but does not grant Observe/Control access to the tab.
Each tab contains:
id,windowId,index;active,pinned,audible,discarded,status;title, sanitizedurl,favIconUrl;mode;agentCreated.
Args:
{
"url": "example.com",
"active": false,
"includeUntracked": false
}You can pass url or query; case-insensitive search by URL and title. active=true selects the active match.
Args:
{
"url": "https://example.com",
"active": true,
"windowId": 1
}- default URL:
about:blank; activedefaulttrue;- allowed
http:,https:,file:, andabout:URLs; - a string without a scheme gets
https://; - other schemes are rejected;
- the created tab automatically receives Control.
You can open about:, but content commands on secure pages are usually unavailable.
{ "tabId": 123, "focusWindow": true }Makes the tab active. focusWindow default true.
{ "tabId": 123, "url": "example.com/path", "active": true }Uses the same URL rules as open_tab.
{ "tabId": 123, "bypassCache": true }{ "tabId": 123 }{ "tabId": 123 }After the tab is closed, the extension removes its grant, cancels body filters, and clears buffered data for that tab.
{
"tabId": 123,
"allFrames": true,
"frameId": 0,
"limit": 500,
"includeFormValues": false
}tabIdcan be omitted only if the active tab is already tracked;allFramesdefaulttrue;- when
allFrames=falseis used,frameId, default0; limitdefault500, clamp20…2000per frame;includeFormValues=falsehides values as[redacted:N chars];- password is always returned as
[redacted-password]; - an inaccessible frame returns an object with
error, without crashing the entire snapshot.
{
"tabId": 123,
"ref": "0:e7",
"pointerSequence": true
}Instead of ref, you can pass selector and frameId. The element is scrolled into the center and receives focus. When pointerSequence=true, synthetic pointer/mouse over/down/up events are sent before .click().
The response always marks trusted:false.
{
"tabId": 123,
"ref": "0:e8",
"value": "hello"
}Supports:
- input/textarea through native value setter;
- select — searches for option first by value, then by visible text;
- contenteditable through selection +
execCommand("insertText"); - synthetic
inputandchangeevents after changes.
The command replaces the target's current value entirely. There is no separate key-press/Enter command; click the submit button, or use evaluate only when the high-risk setting is explicitly enabled.
{ "tabId": 123, "selector": ".menu-item", "frameId": 0 }Sends synthetic pointer/mouse over/enter/move events. trusted:false.
Scroll to element:
{
"tabId": 123,
"ref": "0:e20",
"block": "center",
"inline": "nearest",
"behavior": "smooth"
}Relative scroll of the window:
{ "tabId": 123, "x": 0, "y": 900, "behavior": "instant" }{
"tabId": 123,
"frameId": 0,
"code": "({title: document.title, links: document.links.length})"
}Restrictions:
- code must be non-empty and less than or equal to 100 KB;
- executed through
browser.tabs.executeScript; - realm —
isolated-content-script; - DOM is visible, but page-defined JS globals are usually not visible;
- the result must be serializable via WebExtension messaging;
- setting is disabled by default.
{
"tabId": 123,
"format": "png",
"quality": 80,
"activate": true,
"restore": true
}- captures only the visible area, not full page;
- format:
pngdefault orjpeg; - JPEG quality clamp
1…100; - Firefox can capture only the active tab window;
- inactive tabs require
activate=true; - the previous active tab is restored if
restoreis notfalse; - API returns
dataUrl; CLIscreenshotdecodes it into a file.
{
"tabId": 123,
"allTabs": false,
"level": "error",
"text": "failed",
"limit": 250
}allTabs=trueaggregates all tracked tabs;- level is compared exactly after lower-case conversion;
- text is searched as a case-insensitive substring in JSON entry;
- limit clamp
1…2000.
{ "tabId": 123 }To clear all tracked tabs, use { "allTabs": true }.
{
"tabId": 123,
"allTabs": false,
"type": "performance.entry",
"limit": 250
}Returns extension-side pageEvents: DOM mutations, performance entries, and other content events, except console, which is stored separately.
{
"tabId": 123,
"allTabs": false,
"limit": 250,
"status": "completed",
"text": "/api/"
}- status is usually
pending,completed, orerror; - text filters sanitized URL;
- limit clamps
1…2000.
{ "requestId": "12345" }Returns the full saved record, including headers/body only if they were captured. Additionally checks that the request tab still has Observe.
{ "tabId": 123 }For all tracked tabs: { "allTabs": true }.
{ "query": "ublock" }Requires:
sessionMode >= observe;monitorExtensions=true;technicalConsent=true.
Returns only metadata available to browser.management: id, name, version, description, type, enabled, mayDisable, installType, homepage/options URL, API permissions, and host permissions.
{ "id": "extension@example" }Does not read storage, private console, or background context of another extension.
{ "active": true, "extensionId": "extension@example" }Opens about:debugging#/runtime/this-firefox. extensionId is only returned in the response as a hint; automatic selection of Inspect is not available. Firefox prohibits normal WebExtension scripts from scripting about:debugging.
Snapshot builds a compact tree of visible meaningful elements:
[0:e1] heading "Example Domain"
[0:e2] p "This domain is for use in documentation examples…"
[0:e3] link "Learn more" href="https://iana.org/domains/example"
Ref format:
<frameId>:<elementRef>
0:e3
5:e12
Background strips the prefix before sending the content command and directs it to the appropriate frame.
Each item may contain:
ref;tag;- inferred or explicit
role; - accessible-ish
name; - direct text;
- selector hint;
- disabled/checked;
- input type, placeholder, and redacted value;
- link href.
Name is computed from aria-label, aria-labelledby, associated labels, alt, title, placeholder, name or direct text. This is a practical semantic snapshot but not the full Firefox accessibility tree.
Snapshot:
- skips script/style/noscript/template/meta/link/head;
- skips invisible, hidden, and
aria-hidden=trueelements; - limits nesting depth to 24;
- enters open ShadowRoots;
- cannot enter closed ShadowRoots;
- clears the previous ref map of this frame when a new snapshot is taken;
- returns
truncated:trueif the limit is reached.
CSS selector is first searched in the document, then recursively in open shadow roots. Complex selectors across multiple shadow boundaries are not supported as a single chain, but direct matches within a shadow root will be found.
After Observe/Control and monitorConsole=true content script injects page-hook.js into the page realm. Hook wraps:
console.debug;console.log;console.info;console.warn;console.error;console.trace.
The original console method is always called. A serialized copy of arguments goes to the bridge.
Serializer:
- depth up to 3;
- up to 50 elements in an array/object keys;
- supports Error, BigInt, Function, and Element summaries;
- marks circular values;
- truncates element text;
- can read data only after enabling the hook — old console entries are not restored.
Also captures window.error and window.unhandledrejection.
Network monitor listens to:
onBeforeRequest;onBeforeSendHeaders;onHeadersReceived;onBeforeRedirect;onCompleted;onErrorOccurred.
A record may contain:
- request/tab/frame/parent IDs;
- sanitized URL and origin URL;
- method/type;
- start/completion timestamps;
- status/statusCode/statusLine/error;
- redacted request/response headers;
- content type, cache flag, IP;
- TTFB and duration;
- redirect URL;
- optional request/response body and truncation flags.
Response capture uses filterResponseData, records a copy up to maxBodyBytes, and passes the original bytes back to the page without modification. The body is saved only for text/JSON/JS/XML/SVG/GraphQL/form-urlencoded content types.
When monitorMutations=true MutationObserver collects a 250 ms batch:
- number of added/removed nodes;
- number of attribute changes;
- number of characterData changes;
- up to 12 sample targets.
Event type: dom.mutations.
When monitorPerformance=true and technicalConsent=true, the extension creates PerformanceObserver instances for:
longtask;largest-contentful-paint;layout-shift.
Event type: performance.entry. Entry contains type, name, startTime, duration and available value, hadRecentInput, size.
Unsupported entry types are silently skipped by the browser.
When access is at least Observe, monitorExtensions=true, and technical consent is present, the bridge sends:
extension.installed;extension.uninstalled;extension.enabled;extension.disabled.
These are lifecycle/metadata events, not console output from another add-on.
Bridge/server can see:
bridge.client_connected,bridge.client_disconnected;bridge.extension_ready;access.granted,access.revoked,access.stopped;access.all_access_enabled,access.all_access_tab_added,access.all_access_disabled;tab.created,tab.removed,tab.updated,tab.activated;navigation.committed;console.debug/log/info/warn/error/trace;window.error,window.unhandledrejection;dom.mutations;performance.entry;network.request,network.response_started,network.redirect,network.completed,network.error,network.body;extension.installed/uninstalled/enabled/disabled;popup.diagnostic.
Default base URL:
http://127.0.0.1:18765
All /api/* endpoints require:
Authorization: Bearer <token>Server also understands the token query parameter, but Bearer header is safer: URLs are more likely to end up in logs/history.
Example without printing token in terminal output:
TOKEN="$(node bridge/cli.mjs token)"
curl -s \
-H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:18765/api/status | jq
unset TOKENDoes not require auth and reveals only:
{
"ok": true,
"version": "0.1.7",
"extensionConnected": true
}This is not proof of a command round-trip; use ping for that.
Returns the server version, bind address/port, uptime, event-buffer size, and connected clients.
Client includes:
- stable extension
instanceId; - timestamps connection/last message;
- extension ID/version;
- browser name/version only with technical consent.
{
"action": "snapshot",
"args": { "tabId": 123 },
"clientId": "optional-specific-browser-instance",
"timeoutMs": 30000
}Rules:
actionis required, string length up to 128;argsmust be a plain JSON object;clientIdis optional;- without
clientId, the last active client with hello is selected; timeoutMsclamp250…120000;- default timeout is set by
ZDB_COMMAND_TIMEOUT_MS; - maximum HTTP JSON body size is 1 MiB.
Query parameters:
after— return ID strictly greater than the specified;limit— maximum 2000;type— exact event name filter.
curl -s \
-H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:18765/api/events?after=0&limit=100&type=console.error'Clears only server-side event buffer. Does not clear extension console/network/page buffers.
Opens Server-Sent Events stream. The server sends a heartbeat comment every 15 seconds.
Endpoint:
ws://127.0.0.1:18765/extension?token=<token>
Invalid path/token receives HTTP 401. Maximum WebSocket payload — 16 MiB.
{
"type": "hello",
"instanceId": "zen-…",
"extensionId": "zen-debug-bridge@local.dev",
"extensionVersion": "0.1.7",
"browserName": "Firefox",
"browserVersion": "…"
}Browser fields are equal to null without technical consent.
{
"type": "hello_ack",
"serverVersion": "0.1.7",
"clientId": "zen-…"
}{
"type": "command",
"id": "cmd-…",
"action": "ping",
"args": {}
}{
"type": "result",
"id": "cmd-…",
"ok": true,
"value": {}
}Error:
{
"type": "result",
"id": "cmd-…",
"ok": false,
"error": {
"code": "TAB_ACCESS_DENIED",
"message": "…"
}
}{
"type": "event",
"event": "console.error",
"timestamp": "2026-01-01T00:00:00.000Z",
"data": {}
}The server sends a WebSocket ping every 20 seconds. The extension reconnects with exponential backoff of up to 30 seconds.
There are two different levels of memory:
- console events;
- page events;
- network records;
- internal bridge events;
- up to 100 popup diagnostic entries.
The main limit is set by maxEvents, default 1000. These data live in background memory and are cleared at extension/browser restart. Revoke tab clears the data for that tab; Stop all clears all console/page/network buffers.
All events actually received via WebSocket get a monotonically increasing id and are saved in Node memory. The default limit is 5000 and is configured through ZDB_EVENT_LIMIT.
A server restart resets the IDs and buffer. This is not a persistent audit log.
Redaction is best effort, not a security guarantee.
What is Automatically Hidden
- values of all URL query parameters if
redactQueryValues=true; - headers
Authorization,Cookie,Set-Cookie,Proxy-Authorization,X-API-Key,X-Auth-Token; - form fields whose names resemble password/secret/token/auth/cookie/session/API key/card/CVV;
- secret-shaped keys in raw/text bodies;
- JWT-like strings;
- password input values in snapshot;
- other form values are replaced by length summary by default.
- URL path and domain;
- page title;
- DOM text and accessibility names;
- console arguments;
- screenshot pixels;
- request/response headers not included in the list of sensitive names;
- arbitrary body of non-standard format;
- IP and extension metadata;
- form values when
includeFormValues=true; - query values if the user disables redaction.
Raw output cannot be sent to a public model/log without verification. Page content is also untrusted input and may contain prompt injection.
| Code | Cause | Normal AI Action |
|---|---|---|
SESSION_ACCESS_DENIED |
Session below Observe/Control | Ask the user to manually grant the mode |
TAB_ACCESS_DENIED |
Specific tab not granted or mode too low | Do not bypass; ask for a grant for the necessary tab |
ALL_ACCESS_ACTIVE |
Per-tab revoke requested while global mode is active | User disables ALL ACCESS in Settings or clicks Stop all |
ALL_ACCESS_CONFIRMATION_REQUIRED |
UI attempted to enable global mode without an explicit confirm marker | Open Settings and ask the user to manually confirm the mode |
TAB_NOT_FOUND |
No tab/match found | Refresh the tab list with list_tabs |
FRAME_ACCESS_DENIED |
Content frame not configured | Check the grant, URL, and reload the page |
CONTROL_REQUIRED |
Frame works only in Observe | Ask for Control |
STALE_ELEMENT_REF |
Ref is outdated or new snapshot cleared the map | Take a new snapshot |
ELEMENT_NOT_FOUND |
CSS selector did not match | Snapshot, then ref |
NOT_EDITABLE |
Fill on non-editable element | Select the correct input/contenteditable |
OPTION_NOT_FOUND |
No select option by value/text | Snapshot/inspect options |
INVALID_ARGUMENT |
Missing required argument/wrong format | Fix JSON |
URL_NOT_ALLOWED |
Scheme not in allowlist | Use http/https/file/about |
TAB_NOT_ACTIVE |
Screenshot target is inactive | Pass activate:true |
EVALUATE_DISABLED |
allowEval=false |
Do not enable without explicit need and consent |
REQUEST_NOT_FOUND |
Network record missing/overwritten | Update list_network |
EXTENSION_MONITORING_DISABLED |
Setting is disabled | User enables in Settings |
TECHNICAL_CONSENT_REQUIRED |
No system consent | User grants permission through UI |
UNKNOWN_COMMAND |
Action not supported | Call capabilities |
TIMEOUT |
Extension did not respond in time | Check connection, tab state, and repeat read-only check |
DISCONNECTED |
WebSocket closed during command | Wait for reconnect, then ping |
HTTP-level errors:
401 unauthorized— invalid/missing token;400— malformed URL/JSON/body;413— HTTP body larger than 1 MiB;503 extension_not_connected— no WebSocket extension client;503 client_not_found— unknownclientIdspecified;502— extension command error;504— command timeout.
A regular WebExtension intentionally cannot:
- read private background console of another add-on;
- read storage of another add-on;
- execute code in a foreign extension realm;
- script browser chrome and
about:pages; - click DevTools Inspect;
- receive trusted input.
For privileged diagnostics, launch a separate Zen Remote Agent/WebDriver BiDi instance with a separate profile:
scripts/start-zen-deep-debug.shDefaults:
- profile:
$HOME/.zen/zdb-debug-profileinside Flatpak namespace; - remote port:
9222; - separate browser instance through
--no-remote; - system/chrome access is disabled.
Variables:
ZDB_ZEN_PROFILE_PATH="$HOME/.zen/another-profile" \
ZDB_REMOTE_PORT=9223 \
scripts/start-zen-deep-debug.shPrivileged Gecko/system access is enabled only explicitly:
ZDB_SYSTEM_ACCESS=1 scripts/start-zen-deep-debug.shscripts/zen-flatpak-wrapper.sh additionally separates host /tmp from Flatpak and is intended for Selenium, which creates temporary profiles.
Deep Debug is a separate layer. It is not used automatically by Zen Debug Bridge CLI commands. For it, an external compatible WebDriver BiDi/Firefox DevTools client is needed, such as Mozilla Firefox DevTools MCP. The Remote Agent does not have built-in auth: keep the port on loopback and use a separate profile without valuable sessions.
Before using, read SECURITY.md.
- Synthetic click/hover/input have
event.isTrusted === false. - Websites with trusted input checks, CAPTCHA, or anti-bot measures may ignore actions.
- There is no native keyboard primitive and pressing Enter is not supported.
- There is no file upload primitive.
- PDF export is not available.
- Screenshot captures only the visible viewport.
- HAR export is not available, although network records can be obtained via JSON commands.
- Response interception/alteration or request blocking is not possible;
webRequestBlockingis not used for mutating traffic. - There are no cookie/localStorage/sessionStorage API commands.
- Download management is not supported.
- Reading Firefox DevTools console history before a grant is not possible.
- Reading closed Shadow DOM roots is not supported.
- Restricted frames may return per-frame errors.
evaluatedoes not work in the page main world.list_extensionssees metadata but does not access the internal execution of the add-on.- Protected Mozilla pages and browser UI are not accessible to a regular content script.
- ALL ACCESS does not change these sandbox/privileged-page restrictions and does not expose private console/storage of other add-ons.
- Scope ALL ACCESS includes private tabs only if Firefox separately allows this add-on to work in Private Windows.
- Redaction does not guarantee the removal of all secrets.
- Event buffers are non-persistent.
- Multiple HTTP AI clients do not have separate permission scopes: they all use one token and current user grants.
Most likely, Zen Flatpak received only manifest.json through the portal but not sibling files.
Solution:
- Create a single archive via
npm run build. - Copy/rename it to
.xpiinside~/Downloads. - Remove the old temporary add-on.
- Load XPI through
about:debugging.
npm run status
curl -s http://127.0.0.1:18765/health | jqCheck:
- The bridge process is running;
- The URL is exactly
ws://127.0.0.1:18765/extension; - The token matches
npm run token; - XPI contains the current version;
- After changing the archive, Reload was pressed;
- Reconnect is enabled.
Fixed starting with 0.1.3. Firefox requires Promise/sendResponse from runtime.onMessage. Install/Reload version 0.1.4 or newer.
Fixed in 0.1.4: permissions.request() is now called as the first asynchronous operation in the user-gesture handler.
- Reload extension.
- Manually enable Performance or Extension inventory.
- Select Allow in the system dialog.
- Check
technicalConsentthroughget_state.
AI cannot confirm this consent instead of the user.
connected means only transport/pairing. Check:
node bridge/cli.mjs call get_stateIf sessionMode=off, the user must grant Observe/Control. If the target tab is missing in list_tabs, the grant was given to another tab.
This is an expected Firefox limitation, not an incomplete global grant. Check get_state: the combination of allAccess=true, sessionMode=control, and the tab's presence in list_tabs means that the Zen Debug Bridge permission model has granted access. However, the content script still cannot be injected into browser chrome, most about:/Mozilla-protected documents, or pages belonging to other extensions.
Browser-level commands like activate/navigate/close can be used where Firefox allows them. For DOM browser UI, a separate isolated Deep Debug/WebDriver BiDi script is needed; ALL ACCESS does not automatically include it.
Starting from 0.1.5, Stop all saves allAccess=false. If the mode returns:
- Ensure that version
0.1.5or newer is displayed in popup/manifest; - Reload the actual private XPI;
- Open Settings and check that the checkbox is unchecked;
- Do not save the old already open Settings page without reloading it;
- Check
node bridge/cli.mjs call get_state.
This is a race in the response from browser.tabs.create(). Wait for navigation to complete and check:
node bridge/cli.mjs call list_tabs '{}'
node bridge/cli.mjs call snapshot '{"tabId":<new-id>}'- The page may be privileged/restricted;
- The grant may have been issued while the content script was being reloaded;
- The tab could have navigated to a different URL;
- Frame might not allow injection;
- Visible meaningful elements might still not have rendered.
Try reloading an ordinary HTTP(S) page, a new snapshot, and allFrames=false for the top frame.
Response clicked:true means DOM .click() call, not trusted OS click. Get a new snapshot, check console/network. For strict isTrusted, use a separate Deep Debug/WebDriver BiDi script.
- Capture starts only after grant;
- The corresponding toggle must be enabled;
- Requests/logs that occurred earlier are not restored;
- Update the page after enabling;
- Check the correct
tabIdorallTabs=true.
Bridge is already running or port is occupied:
npm run status
ss -ltnp '( sport = :18765 )'Do not start a second server on the same port. For a separate instance, set another ZDB_PORT and corresponding bridgeUrl.
CLI and server read token from one ZDB_DATA_DIR. Extension might have saved an old token. Update Settings or private pairing and Reload. Do not print token in diagnostic log.
This is expected after browser restart. Re-load XPI through about:debugging. For permanent installation, a signature is needed.
| Variable | Default | Purpose |
|---|---|---|
ZDB_HOST |
127.0.0.1 |
Bind address; do not use 0.0.0.0 without separate protection |
ZDB_PORT |
18765 |
HTTP/WebSocket port, 0…65535 is allowed; 0 is convenient for tests |
ZDB_DATA_DIR |
$XDG_DATA_HOME/zen-debug-bridge or ~/.local/share/zen-debug-bridge |
Token/runtime data directory |
ZDB_EVENT_LIMIT |
5000 |
Server-side event ring size |
ZDB_COMMAND_TIMEOUT_MS |
30000 |
Default command timeout |
The extension URL must match the actual host, port, and path.
npm run checkServer, CLI, and all extension JS files are checked through node --check.
npm testThe suite covers:
- session-mode downgrade;
- URL/header redaction;
- Firefox runtime message Promise responses;
- order of technical consent request relative to user gesture;
- HTTP auth;
- WebSocket command relay;
- event buffering;
- rejection of invalid WebSocket token.
Server tests open a random loopback port.
npx --yes web-ext@latest lint \
--source-dir extension \
--warnings-as-errorsnpm run buildOutput:
artifacts/zen-debug-bridge-<version>.zip
artifacts/zen-debug-bridge-<version>.xpi
Build script:
- reads version from manifest;
- creates temporary archive;
- excludes source maps, editor backups,
.DS_Store; - always excludes
pairing.local.json; - atomically moves the archive to
artifacts/and writes an equivalent.xpicopy for Zen/Firefox.
Local secret absence check:
unzip -Z1 artifacts/zen-debug-bridge-*.zip | \
grep -Fx 'pairing.local.json' && echo 'ERROR: secret included'The command should not find the file.
When bumping the version, synchronize:
package.json;package-lock.json;extension/manifest.json;SERVER_VERSIONinbridge/server.mjs;- version-specific examples in the documentation as needed.
After changes:
npm run check
npm test
npm run buildzen-debug-bridge/
├── bridge/
│ ├── cli.mjs # CLI HTTP/SSE client
│ ├── config.mjs # token, host, port, limits
│ └── server.mjs # HTTP + SSE + WebSocket router
├── extension/
│ ├── background/
│ │ ├── core.js # state, pairing, grants, WS, redaction
│ │ ├── network.js # webRequest lifecycle/body capture
│ │ ├── commands.js # public command implementation
│ │ └── main.js # runtime/browser event wiring
│ ├── icons/
│ ├── options/ # full settings UI
│ ├── popup/ # toolbar UI and diagnostics
│ ├── content.js # frame snapshots/actions/observers
│ ├── page-hook.js # page-realm console/error capture
│ ├── manifest.json
│ └── pairing.local.json # optional ignored local secret
├── scripts/
│ ├── build-extension.sh
│ ├── start-bridge.sh
│ ├── start-zen-deep-debug.sh
│ └── zen-flatpak-wrapper.sh
├── systemd/
│ └── zen-debug-bridge.service
├── test/
├── README.md
├── SECURITY.md
└── package.json
Zen Debug Bridge distinguishes five different concepts:
- Server running — Node listens on port.
- Extension connected — token and WebSocket are working.
- Tab granted — user has allowed a specific browser tab.
- ALL ACCESS enabled — user has temporarily replaced the per-tab boundary with global persistent Control.
- Capability enabled — corresponding toggle/consent is enabled.
For a command to succeed, every relevant level must be satisfied. connected never implies automatic access to the entire browser; such access exists only when allAccess=true is explicitly visible.
In case of doubt, use the safe sequence:
npm run status
node bridge/cli.mjs call ping
node bridge/cli.mjs call get_state
node bridge/cli.mjs call capabilities
node bridge/cli.mjs call list_tabs '{}'And only then perform actions within the user-granted mode.
If this project is useful to you, you can support the author — it really helps to keep working on it:
