Browser Bridge is a local MCP server + Manifest V3 Chrome extension that lets AI coding agents - Claude Code and OpenAI Codex CLI - control the Chrome you use every day. Because it runs inside your real profile, the agent inherits your cookies, HttpOnly sessions, SSO, and 2FA state automatically. Ask it to "read my feed and summarize it" or "capture the API traffic on this page and show me the responses" - and it works against the live, authenticated app.
It ships 78 tools spanning everyday browsing, DevTools-grade network capture, a full web-security testing toolkit, playbooks (saved, self-healing recipes), session recording - record an interaction into a self-contained, offline-faithful HTML replay (full-page, custom-designed player, with a smooth mouse-trail / click / keystroke overlay) and export it to a high-resolution MP4 - and watch mode, which runs the whole thing in reverse: you browse, and the agent reads a live, labeled timeline of what you did and what the page sent.
Contents
- Quick start
- Why Browser Bridge?
- See it in action
- Architecture
- Features & tools
- Playbooks
- Recording
- Watch mode
- Setup
- Security & responsible use
- Limitations
- Roadmap
- Contributing
- License
Quick start
Build both packages, run the server, load the extension, connect your agent - about 60 seconds:
git clone https://github.com/vitalysim/browser-bridge.git && cd browser-bridge
( cd server && npm install && npm run build ) # MCP server (TypeScript → dist/)
( cd extension && npm install && npm run build ) # bundles background.js + options.js
( cd server && npm run install-service ) # run the server at login (systemd/launchd)
-
Load the extension - open
chrome://extensions, enable Developer mode, click Load unpacked, select theextension/folder, then open its options and paste the token from~/.browser-bridge/token→ Save & connect. -
Connect your agent:
Claude Code
claude mcp add --transport http --scope user browser-bridge \ http://127.0.0.1:8765/mcp --header "Authorization: Bearer $(cat ~/.browser-bridge/token)"OpenAI Codex CLI - export the token so Codex can read it, then register the server in one command:
export BROWSER_BRIDGE_TOKEN="$(cat "$HOME/.browser-bridge/token")" # add to your shell profile codex mcp add browser-bridge --url http://127.0.0.1:8765/mcp --bearer-token-env-var BROWSER_BRIDGE_TOKEN
Verify: curl -s http://127.0.0.1:8765/health → {"ok":true,"extensionConnected":true}. Full details, autostart options, and the manual config alternatives are in Setup.
Update an existing installation
Update the server and extension together from the existing checkout:
git pull --ff-only
( cd server && npm ci && npm run build )
( cd extension && npm ci && npm run build )
( cd server && npm run install-service ) # reinstall/restart the login service on macOS/Linux
Then toggle Browser Bridge Off → On at chrome://extensions to load the rebuilt service worker. For a manually started server, stop the old process and run cd server && npm start instead. Use bridge_status to verify that both versions are 0.19.2. Existing tokens and MCP client configuration stay valid.
Version 0.19.2 removes the replay logo and decorative header, fixes recording startup/navigation loss and input masking, and makes canvas pixels and MP4 dimensions reliable. The appearance and rendering fixes apply to newly generated HTML; existing saved HTML keeps its embedded player.
Why Browser Bridge?
- One server, two clients. Both agents connect to the same token-authed endpoint
http://127.0.0.1:8765/mcp. Anthropic's Claude in Chrome is Claude-only and OpenAI's Codex browser extension is desktop-app-only - neither gives the Codex CLI a real browser. Browser Bridge does. - Extension, not a debug port. Chrome 136+ blocks
--remote-debugging-porton your default profile. A Manifest V3 extension runs inside that profile instead - banner-free by default, withchrome.debugger(CDP) power features attached only on demand. - Outbound WebSocket, no native-messaging host. The extension dials
ws://127.0.0.1:8765/ws; there are no host-manifest files to install, and the WS keepalive keeps the MV3 service worker alive. - Localhost-only + token auth. The server binds
127.0.0.1, checks a bearer token on both the MCP endpoint and the WS handshake, and rejects any WS origin that isn'tchrome-extension://.
See it in action
With Chrome open and logged in, just ask your agent in natural language:
# Everyday
list my open tabs
open github.com, read my notifications, and summarize them
# Screenshots
take a full-page retina screenshot of this page and save it to ~/Downloads/page.png
# Network capture
start a network capture on this tab, reload it, and show me the JSON API responses
# Record & export
record my session, I'll click around and type, then stop - build the HTML replay and a 2x MP4
# Web-security (authorized targets only)
capture identity "A" for app.example.com, then log in as B and capture "B";
replay the invoices request as A, B and anon and show me the authz_matrix
# Remote desktop / canvas (e.g. a CTF AttackBox)
screenshot the DCV desktop, click the terminal, type "id" and press Enter, then screenshot the output
# Watch mode - you drive, the agent follows
watch what I do on this site, with network capture
...browse normally...
what did I just do? what did that "Publish" click actually send?
Both Claude Code and Codex drive the same live browser through the same endpoint.
Architecture
Features & tools
- Batch multi-step work -
browser_batchruns a predictable sequence (navigate → click → fill → press_key → screenshot) in one round trip instead of five. The per-call round trip, not the browser, is what makes multi-step tasks slow, so this is the single largest speedup available. - Browse & interact - tabs, navigation, and click / fill / hover / type / scroll with auto-wait actionability (found + visible + enabled, auto-escalating to a trusted CDP click when a target is overlay-covered), plus file and image upload - all reaching into iframes (incl. cross-origin) and open shadow DOM - and coordinate-level trusted input (
input) for<canvas>remote desktops (VNC/RDP/Amazon DCV), games, and drawing apps. - Read & inspect - rendered page text, interactive-element snapshots with stable refs, a compact accessibility tree (role + name + states, refs the action tools accept), screenshots (viewport → full-page retina, element clip, save-to-disk), and JavaScript evaluation that bypasses strict CSP via CDP.
- DevTools-grade capture - full request/response bodies, response headers,
Set-Cookie, timings, and WebSocket/SSE frames - with durable on-disk persistence and HAR / MHTML evidence export. - Emulate the environment - drive a page as a specific device (viewport/DPR/touch/UA presets), network (slow-3G → offline), CPU, locale/timezone, or geolocation, then
emulate_resetback to normal. - Web-security toolkit - named identities, an in-session request replayer, BOLA/IDOR/BFLA access-control diffing (
authz_matrix), Burp-style live interception, an intruder-style fuzzer (sniper/pitchfork/clusterbomb/race), passive header/CORS/secret analysis, JWT decode, and copy-as-curl. - Session & storage - read/write the real cookie jar (incl.
HttpOnly),localStorage/sessionStorage, and console + CSP + exception logs. - Watch mode (copilot) - the inverse of everything above: you browse and the agent follows along. A content script emits already-labeled events (
click <button#publish> "Publish", typed text, SPA route changes) into a cursor-addressable timeline you read withwatch_read({since})- with requests and console errors folded in and attributed to the action that caused them.
See docs/EMULATION.md for the preset tables and lifecycle (overrides are in-memory and revert when the debugger detaches).
Blindness is a first-class timeline entry: gap actions (extension-disconnected, sw-restarted, watch-lost, ring-evicted, unscriptable) and health.state distinguish "you were idle" from "capture was down" - including the case where the socket is connected but the extension no longer holds the watch, which otherwise reads as perfectly healthy. Also exposed over plain HTTP at GET /watch so a Claude Code UserPromptSubmit hook can prepend recent activity to every message - continuous awareness with no tool call and no approval prompt. Details: docs/WATCH.md.
screenshot defaults to a banner-free capture of the visible viewport. Options (these use chrome.debugger, so they show the debugging banner):
savePath returns { path, bytes, width, height } instead of a multi-MB inline image. Capture maxes out at Chrome's ~16384px surface size, and an oversized PNG auto-falls back to JPEG.
Playbooks
Repetitive tasks - "delete this kind of post", "run a BOLA check on an endpoint" - cost expensive first-time understanding (snapshot, find selectors, trial and error). A playbook saves that resolved knowledge to a local Markdown file so the next run is cheap.
A playbook is a self-healing document, not a macro: it records intent + robust role/accessible-name locators + checkpoints + the hard-won "understanding" - never raw clicks, coordinates, snapshot refs, or captured requestIds (those are ephemeral and break next run). The agent runs it with judgment - re-perceiving each step, verifying the target before acting, and re-deriving a step that has drifted - which is the whole advantage over a brittle record-replay macro. Destructive/irreversible steps require confirmation.
Playbooks live at ~/.browser-bridge/playbooks/<slug>.md (global) or ./playbooks/<slug>.md (project-local, git-shareable); every connected agent learns the convention automatically via the MCP instructions field. Capture one with record mode: playbook_record_start → do the task once → playbook_record_stop → distill the draft → playbook_save.
Format, execution protocol, heal/safety rules, and two worked examples (a DOM delete + a BOLA/IDOR flow): docs/PLAYBOOKS.md.
Recording
Record a live interaction and replay it. session_record_start captures the DOM + mutations + input/scroll/mouse over time (rrweb), banner-free; interact with the page; session_record_stop assembles a single self-contained HTML file that plays the whole thing back with a play button, timeline scrubber, and speed control - offline, in any browser.
With allFrames:true, the extension also injects the recorder into cross-origin iframes (which page-level rrweb can't). Injection is CSP-immune, so it records sites and embedded frames a normal recorder can't even load on. Events stream to ~/.browser-bridge/recordings/*.events.jsonl during capture (surviving long sessions), then get inlined with the rrweb-player into the HTML on stop.
The replay is truly self-contained / offline-faithful: on stop, the server fetches every external asset the capture references — cross-origin stylesheets, fonts, images, and lazy-loaded images — through the extension (background fetch under <all_urls> has no CORS wall + sends your session cookies) and inlines them, and strips nodes injected by your other extensions. So it renders from captured data, not by re-fetching the live site (page-level rrweb can't read cross-origin CSS at all).
The player is a minimal unbranded UI: the replay fills the window at the recorded page's exact aspect ratio (rescaling on resize), with a play/scrub timeline, speed, skip-idle, and fullscreen. A toggleable interaction overlay annotates the playback with a smooth mouse trail, click ripples, and a keystroke HUD (typed text, plus physical keys like Enter / ⌘C on new recordings). Then render_recording_video turns a saved .html into a high-resolution MP4 (fps, scale up to 4× ≈ retina/4K) - a deterministic, frame-exact render (seek + lossless PNG per frame → ffmpeg H.264) whose motion matches the live playback. Note: input masking is OFF by default (cleartext values; maskInputs:true to redact); recordCanvas:true captures <canvas>/WebGL as image frames the replay (and the MP4 export) paints - live video pixels still aren't. Details: docs/RECORDING.md.
Recording now registers its disk sink before injection, rejects duplicate starts without replacing the active recording, and flushes final clicks and mutations before navigation. maskInputs:true also covers open shadow-root inputs, contenteditable text, and identifying printable key codes. Slow frames time out independently at stop and are reported in warnings. MP4 output uses the initial recorded viewport × scale, independent of the viewer window: 1600×1000 at scale:1, 3200×2000 at scale:2. Results include the actual encoded width and height.
Watch mode
Everything else in Browser Bridge is agent-driven. Watch mode is the inverse: you browse and the agent follows along, so you can ask "what did I just do?", "what did that click actually send?", or "where did I get logged out?" at any moment.
watch_start({ tabId }) → { watchId, cursor, banner:false }
…you browse normally…
watch_read({ since: cursor }) → the timeline + the next cursor
WATCH live tabs=5 actions=6 dropped=0 more=false
12:04:39.8 #411 click <input#user_login> "Username or Email Address"
12:04:41.0 #412 input <input#user_login> = "attacker" (8 keys / 1.2s)
12:04:41.3 #413 key ↵
12:04:41.4 #414 net POST /wp-login.php → 302 (91ms) ← #412
12:04:41.9 #415 console error "Uncaught TypeError: x is undefined" ← #414
Events are labeled in the page, where the DOM is, so a click arrives as <button#publish> "Publish" with a selector click/fill can reuse - not as an opaque node id. Typing is coalesced into one action with the final value. Requests and errors are attributed to the action that caused them, and the attribution chains through navigations so a page load doesn't hang eighty requests off one click.
This is deliberately not derived from the rrweb recording stream. That was measured first and rejected: rrweb node ids restart at 1 in every document (one missed batch mislabels every later click), SPA routes go up to 30s stale or never appear, a click that navigates in under 300ms is lost to the recorder's batching, and an 8-hour session costs ~910MB against ~120KB. The two are independent and compose - run session_record_start on the same tab when you also want the visual replay. Details: docs/WATCH.md.
Setup
Prerequisites
- Node.js 20+ and Chrome, Chromium, or Edge - on Linux, macOS, or Windows (Chromium-based; not Brave/Arc, not WSL)
- Claude Code ≥ 2.0.73 and/or OpenAI Codex CLI
1 · Build
git clone https://github.com/vitalysim/browser-bridge.git
cd browser-bridge
( cd server && npm install && npm run build ) # MCP server
( cd extension && npm install && npm run build ) # bundles background.js + options.js
2 · Run the server
The server generates a random bearer token on first run and stores it at ~/.browser-bridge/token (0600). The startup log prints that file path, never the token itself. Read it with cat ~/.browser-bridge/token. The server refuses to start with an empty or too-short token. Pick one:
Autostart (recommended) - one command installs a background service for your OS (systemd user service on Linux, launchd LaunchAgent on macOS):
cd server && npm run install-service # runs at login, restarts on crash
# Linux: to start at boot without an active login: loginctl enable-linger "$USER"
# remove later with: npm run uninstall-service
Manual - just run it in a terminal:
cd server && npm start # or: nohup node dist/index.js >/dev/null 2>&1 & (it writes ~/.browser-bridge/server.log itself)
Environment variables (all optional):
Logs. The server writes its own log to ~/.browser-bridge/server.log and rotates it by size (10 MB × 3 old files, all 0600) - launchd can't rotate a StandardErrorPath, which is how the old setup grew a single unbounded log. The macOS LaunchAgent points launchd's stdout/stderr at a separate ~/.browser-bridge/launchd.log and sets BRIDGE_LOG_STDERR=0, so that file only ever receives output from before the logger starts (an import-time crash after a bad build, a fatal Node error); the server trims it on startup if it ever passes 1 MB. Under systemd, stderr also goes to the journal. Every line in the server log is prefixed with an ISO timestamp, and each tool call writes one line - <time> [call] <tool> tab=<id> <ms>ms ok|error|notActionable <short reason> - so failures and latency can be measured from the log. Parameter values are never logged. If the port is already taken (usually a second instance), the server logs one line saying so and exits after 30 s instead of crash-looping under launchd/systemd.
Verify: curl -s http://127.0.0.1:8765/health → {"ok":true,"extensionConnected":false} (becomes true once the extension is loaded). Logs: tail -f ~/.browser-bridge/server.log (Linux also journalctl --user -u browser-bridge -f; macOS early-crash output: ~/.browser-bridge/launchd.log).
3 · Load the extension
- Open
chrome://extensions, enable Developer mode. - Load unpacked → select the
extension/folder. - Open the extension's popup/options → paste the token from
~/.browser-bridge/token→ Save & connect. The status turns green.
The token never travels over the WebSocket: the server sends a nonce, the extension answers with an HMAC of it keyed by the token, and the server must answer with its own HMAC before the extension sends anything or runs any command. Something else squatting the port while the bridge is down therefore learns nothing and gets no control; the status shows Server failed authentication. Extension and server versions must match - a v0.18-or-older server rejects the new handshake (status: version mismatch), so rebuild/restart the server when you update the extension. (The server still accepts the old ?token= URL from an older extension for one release, logging it as deprecated.)
Verify: curl -s http://127.0.0.1:8765/health → {"ok":true,"extensionConnected":true}.
Which build is actually running? bridge_status reports the server version and the loaded extension's build stamp. Reloading an unpacked extension gives no feedback, and a stale bundle is indistinguishable from a code bug - if the stamp predates your last npm run build, reload at chrome://extensions. Likewise, if a rebuilt server seems to have no effect, check that only one instance is running: launchctl list | grep browser-bridge (macOS) should show exactly one job, and lsof -nP -iTCP:8765 -sTCP:LISTEN exactly one listener. A second, older service that loses the port race will keep serving stale code while restarts appear to succeed - npm run uninstall-service && npm run install-service resolves it.
Data directory. Everything under ~/.browser-bridge is owner-only: the server runs with umask 077, creates its directories 0700 and files 0600, and on startup tightens existing ones in playbooks/, recordings/, watch/ (captures and recordings contain cookies, auth headers and cleartext form input). recordings/ and watch/ grow without bound unless you opt into BRIDGE_RETENTION_DAYS.
Rotating the token (e.g. after it leaked into an old log): node scripts/rotate-token.mjs --dry-run --update-claude-configs shows what would change; drop --dry-run to write a new token (the old one is kept as token.prev for rollback) and update literal Bearer headers for browser-bridge in ~/.claude.json / ~/.claude-shadow/.claude.json (a .bak is written first). Then paste the new token into the extension options, restart the service, and open a new shell if you export BROWSER_BRIDGE_TOKEN. To remove historical log lines that contain the token: node scripts/scrub-log.mjs --dry-run (counts only), then without --dry-run, then restart the service.
Reloading the extension after an update may prompt for new permissions (e.g.
webNavigation,debugger,downloads) - re-enable it if Chrome disables it.
4 · Connect your agent
Claude Code
claude mcp add --transport http --scope user browser-bridge \
http://127.0.0.1:8765/mcp --header "Authorization: Bearer $(cat ~/.browser-bridge/token)"
Codex CLI - export the token, then register with one command:
export BROWSER_BRIDGE_TOKEN="$(cat "$HOME/.browser-bridge/token")" # add to your shell profile
codex mcp add browser-bridge --url http://127.0.0.1:8765/mcp --bearer-token-env-var BROWSER_BRIDGE_TOKEN
Or edit ~/.codex/config.toml directly:
[mcp_servers.browser-bridge]
url = "http://127.0.0.1:8765/mcp"
bearer_token_env_var = "BROWSER_BRIDGE_TOKEN"
Codex reads the token by env-var name, so
BROWSER_BRIDGE_TOKENmust be exported in the shell that launchescodex(hence adding it to your profile). Verify withcodex mcp list.
Platform support
Security & responsible use
- The agent acts with your real cookies. Treat every tool call as running as you.
- Prompt injection is the ambient risk. Pages the agent reads are untrusted input; a malicious page can try to steer it. Keep write-capable tools behind your MCP client's permission prompts and don't point it at pages you don't trust.
- Debugger mode is powerful. While attached it can read full request/response bodies (including auth headers) and dispatch trusted input. It shows Chrome's "started debugging this browser" banner, auto-detaches after ~5 min idle (unless actively capturing), and can be ended immediately with
debugger_detach. - The security-testing tools are raw offensive primitives with no built-in scope guard - by design. Like Burp Suite Repeater/Intruder/Autorize, staying within an authorized engagement's scope is the operator's responsibility. Only point Browser Bridge at systems you are authorized to test.
- No secrets in the repo. The bearer token lives in
~/.browser-bridge/(gitignored); binding is localhost-only. See SECURITY.md to report a vulnerability.
Limitations
- Interaction auto-waits for actionability and, on
click, auto-escalates to a trusted CDP click when the target is overlay-covered (via:"trusted", shows the banner); force it anytime withtrusted:true. A covered/hidden/disabled target returns a structured{notActionable, reason}. - A strict page CSP (
script-srcwithout'unsafe-eval') blocks the banner-freeeval_js; it auto-falls back tocdp_eval(CDPRuntime.evaluate, banner shown), which CSP cannot block. The isolated-world read/interact tools and all CDP/background tools are unaffected by CSP either way. chrome.debuggerrequires sole access to a tab - it can't attach if DevTools is open on that tab.net_capture_startonly records traffic sent after it's called (navigate/reload to capture a page load).- The capture buffer is in-memory (a ring of
maxEntriesrequests/tab, default 500, ~512 KB/body). An active capture is not torn down by the idle sweep; for a durable record beyond the ring cap usenet_capture_start(persist:true, savePath:…), which streams to disk. Persistence is durability, not continuity - if the service worker dies the debugger detaches and capture stops until restarted; the file holds what was captured before that. - One extension connection at a time (last connect wins); multiple MCP clients can share it concurrently. A call in flight when the extension disconnects fails fast instead of waiting out the timeout. The connect path holds a synchronous in-flight guard so two triggers in the same tick (a settings save, a startup alarm) can't open duplicate sockets, and a superseded socket can no longer flip the status to disconnected underneath the live one.
- Tool results that would overflow are truncated structure-aware: a large list drops trailing items into a still-valid-JSON
{truncated, returned, total, hint}envelope rather than a blind slice that yields a half-object no agent can parse. Every tool error also carries a stable machinecode(TIMEOUT,NO_EXTENSION,TAB_NOT_FOUND,NOT_SCRIPTABLE,CSP_BLOCKED,RESULT_TOO_LARGE,UNKNOWN) prefixed to the human-readable message. - An oversized reply no longer drops the connection: the server accepts frames up to 256 MiB and the extension refuses anything past a safe threshold with a
RESULT_TOO_LARGEerror instead of a frame that would close the socket (which failed every in-flight call). Captured identities and any active session recording are persisted tochrome.storage.session, so they survive a service-worker eviction; on restart the extension also reconcileschrome.debuggertargets, detaching any attach an evicted worker left dangling. inputcoordinates are CSS viewport pixels, but screenshots are device pixels - map with thedprthe screenshot returns (coord = screenshotPixel / dpr).inputis delivered even to a backgrounded tab, but a hidden tab's frame is throttled, so you can't observe the result until it's foregrounded (activate:true/tab_activate);screenshotflags this viavisibilityState.- Linux Chromium can still delay hidden-tab frames with
keepAlive:true; thehidden-tab-raf-commitregression test exposes this timing/commit limitation. Usetab_activatewhen an action requires an immediate rendered result. - The default
screenshotuseschrome.tabs.captureVisibleTab, which Chrome rate-limits to roughly 2 captures/second. Back-to-back screenshots are therefore spaced automatically (~550 ms apart) rather than failing on the quota; an isolated screenshot - the normal case - waits nothing. ThefullPage/scale/selectorpath uses CDPPage.captureScreenshotand is not subject to this limit. - Watch mode cannot see pages content scripts can't run on (
chrome://, the Web Store,view-source:, other extensions' pages) -watch_statusreports those tabs asunscriptable, and the timeline records agaprather than letting a blind spot look like idleness. Typed values are captured in cleartext unless redacted (redact:"auto"is the default and masks password/secret-looking fields;redact:"none"disables it).network:trueneedschrome.debuggerand therefore shows the banner for the whole session; everything else in watch mode is banner-free. Selectors on hash-heavy CSS frameworks may not be re-queryable. download_resourcealways uses the browser's live session - it can't download as a capturedidentity.Cookie/Host/Origin/Referer/Content-Lengthin itsheadersparam are browser-forbidden and silently ignored. If Chrome's "Ask where to save each file" setting is enabled, downloads may prompt for a location instead of completing automatically.
Roadmap
Shipped (newest first):
- v0.19.2 · Reliable recording and clean replay - unbranded HTML player with compact controls; recording startup, duplicate-start and rapid-navigation fixes; shadow-input/contenteditable/keystroke masking; visible 2D/WebGL/iframe/shadow canvas pixels with document-aware seeking; exact MP4 dimensions, compatible legacy exports and failure cleanup. 255 server tests and 95 e2e tests pass, including six new recording regression scenarios with screenshot and decoded-video pixel assertions. → Recording details
- v0.19–0.19.1 · Reliable agents and full tool coverage - bounded browser operations, dialog handling, realistic pointer events, better snapshots and form filling, authenticated WebSocket challenge-response, owner-only data files, configurable toolsets and full e2e coverage of all 78 tools. → Release notes
- v0.18.1 · Concurrency correctness - found by stress-testing with several agents driving real navigation and clicking at once.
screenshotcould return another tab's pixels:captureVisibleTabshoots whichever tab is active in the window, and the activation sat outside the capture lock, so concurrent callers stole each other's active tab - 12 of 16 concurrent captures came back as a different page, reported as success, which for an agent is worse than an error. Activation and capture are now atomic per window.wait_forno longer fails on the commonest usage: a poll landing while the page committed a navigation threwFrame with ID 0 was removed(14 of 24 failures at 8 clients) - a failed poll is just "not there yet". Captures also serialize per window instead of tripping Chrome's capture quota, wait for a real paint signal (tworequestAnimationFrameticks) rather than a blind 350 ms, and share an in-flight capture of the same tab. Net: 19 errors → 0 across 648 calls, screenshot p95 ~9 s → 6.7 s, same-tab bursts p95 ~5 s → 0.85 s, and 16/16 captures return the right tab. - v0.18 · Performance - a measurement-driven pass (30+ tool calls timed end-to-end through the live bridge, on a small form, a ~15k-element synthetic page and Wikipedia). Memory: MCP sessions were only freed when a client sent DELETE, so every crashed or short-lived client leaked its own copy of all 76 tool registrations - observed at 2,303 sessions opened / 26 closed over ~5 days and a 2.9 GB footprint growing ~560 MB/day. Idle sessions are now reaped (never one with a request in flight, so a
watch_readlong poll is safe) and an unknown session id answers 404 instead of 400, which is the signal an MCP client needs to re-initialize; footprint 2,983 MB → 70 MB. Tokens: JSON results are compact instead of 2-space-indented, which had inflated them 36-96% -snapshoton the big page went 49,489 → 29,873 chars (-40%). Latency: a ref that no frame can resolve now fails immediately withstale-ref/unknown-refand advice to re-snapshot instead of retrying for the full timeout (5,107 ms → instant; selector misses still retry andnotActionablestill auto-waits); a plainformat:"jpeg"screenshot no longer attaches the debugger (no banner) and its viewport-meta read runs concurrently with the capture;net_get_requests({includeBodies})finally uses its 6-slot semaphore instead of awaiting each body in turn. Your pages: watch mode built each event's selector + label before checking whether the tab was being watched, so unwatched tabs paid for it on every click and keystroke while any watch was active - all 8 listeners now return early. - v0.17 · Control surface & reliability - a big pass on what the agent can see, drive, and trust. New reads/controls:
ax_snapshot(compact accessibility tree - roles, accessible names, states - with[ref=N]s that driveclick/filllike asnapshotref, across iframes + open shadow DOM, banner-free → docs/AX-SNAPSHOT.md); environment emulation (emulate_device/emulate_network/emulate_cpu/emulate_locale/emulate_geolocation/emulate_reset→ docs/EMULATION.md);watch_detail(full request/response for one watch-timelinenetline); andrecordCanvasnow actually captures<canvas>/WebGL as image frames the replay and MP4 paint. Reliability: survives the MV3 rough edges - theconnect()double-socket race, oversized replies (servermaxPayload+ a structuredRESULT_TOO_LARGEinstead of a dropped socket), and service-worker eviction (identities + active recordings restored fromchrome.storage.session); faithful redirect-chain capture on an indexed ring; decodable base64 body truncation;session_record_stopno longer loses the tail (drain + streaming parse);bridge_statuswarns on a server/extension version mismatch; structure-aware, always-valid-JSON result truncation with machine errorcodes; the bearer token is no longer written to the log; and watch mode gains per-watchwatch_stop, server-restart recovery, single-action long-poll wake, correct click→request causality, agent-vs-human labels, and a real SPA-route hook. 101 tests (from 24). - v0.16 · Watch mode (copilot) - the inverse of agent-driven browsing: you browse, the agent follows. A persistent content script emits already-labeled events (click targets with real labels and reusable selectors, coalesced typing, instant SPA route changes) into a cursor-addressable timeline (
watch_start/watch_read/watch_status/watch_stop), with network + console folded in and causality that chains through navigations. Blindness is a first-class entry, so silence never reads as inactivity. Also onGET /watchfor a prompt hook. Plus five fixes to shipped capture code:flushRecord/flushCaptureno longer discard batches when the socket is down, tab close keeps the recorder's last batch,debugger.onDetachreasons are surfaced, abandoned intercepts can no longer hang a page forever, and captured requests carry a real timestamp (fixingexport_har's waterfall).net_capture_startalso gainsexcludeExtensionTraffic- on one ordinary page load, 66 of 69 captured requests were other installed extensions injecting scripts and font blobs. First automated tests in the repo (24, vianode --test). → docs/WATCH.md - v0.15 · Batching & latency -
browser_batchcollapses a predictable tool sequence into one round trip (allowlisted to tools that add no approval surface when batched). Plus a latency pass: screenshots no longer pay a flat 350 ms when the tab is already foregrounded (407 ms → 100 ms median, with explicit throttling so bursts can't trip Chrome's capture quota),snapshotwalks the DOM once instead of twice and caps the walk itself, andanalyze deep/session_record_stopbatch what were serial per-item round trips. Three latent fixes:timeoutMsnow reaches the hub timer, the capture sink no longer blocks the event loop onfsync, and concurrent debugger attaches on a cold tab share one attach. - v0.14 · MP4 export -
render_recording_videorenders a saved replay to a high-resolution, frame-exact H.264 MP4 (deterministic seek + lossless-PNG frames → ffmpeg); the mouse-trail / click / keystroke motion matches the live playback.chrome:trueincludes the player UI. - v0.13 · Designed replay player - a custom replay player with full-page fit-to-viewport rendering and a toggleable interaction overlay: smooth mouse trail, click ripples, and a keystroke HUD (typed text + physical keys).
- v0.12 · Correctness & reliability - an audit-driven pass (56-agent review): a silent capture-loss guard on socket replacement, MV3 memory-leak caps, cross-session recording, and robust
allFramesinjection. - v0.11 · Offline-faithful replay -
session_record_stopinlines every external asset (cross-origin CSS / fonts / images, fetched through the extension - no CORS wall) and strips foreign-extension nodes, so the HTML is truly self-contained. - v0.10 · Session recording - record a live interaction and replay it as a self-contained HTML timeline (rrweb;
session_record_start/stop/status), banner-free, capturing cross-origin iframes. → docs/RECORDING.md - v0.9 · Playbooks - saved, self-healing task recipes (Markdown at
~/.browser-bridge/playbooks/; record-mode capture viaplaybook_record_start/stop/playbook_save). → docs/PLAYBOOKS.md - v0.8 · Remote-desktop pentest feedback - coordinate-level trusted input (
input) for canvas / VNC / RDP / DCV,get_page_text(includeHidden),cdp_eval/eval_jstimeoutMs, andscreenshotdpr/visibilityState. - v0.7 · Durable capture & handoff -
net_capture_start(persist)+maxEntries, copy-as-curl (request_to_curl),analyze deep, plus reliability fixes. - v0.6 · Self-healing interaction, recon & evidence - passive recon (
analyze) +jwt_decode, HAR export, fuzz modes +viaAppClientreplay. - v0.5 · Security-toolkit foundation - interception, fuzzing, cookie / storage, console / CSP capture, and MHTML.
Exploring next:
- A realtime-screencast video mode - to also capture time-based page animation (CSS keyframes / GIF /
<video>) that deterministic seek can't pin. - Source-map de-minification on downloads.
- GraphQL introspection helpers.
- A client-side DOM-XSS / postMessage / prototype-pollution suite.
Project structure
server/ MCP server (TypeScript · @modelcontextprotocol/sdk · ws · express)
src/index.ts HTTP MCP endpoint + auth + session management
src/hub.ts single extension socket, request/response correlation, capture sinks
src/capture-sink.ts durable on-disk JSON-Lines sink for persist captures
src/tools.ts the 68 MCP tools
src/watch.ts watch mode: fold, action ring, cursor, causality, renderer
test/ node --test suites (run with `npm test`)
extension/ Manifest V3 extension (bundled with esbuild via build.mjs)
manifest.json
src/background.ts service worker: WS client, injection, chrome.debugger (CDP) layer
src/watch-entry.ts watch-mode activity listener (ISOLATED world content script)
src/watch-main.ts watch-mode console + history shim (MAIN world, opt-in)
src/options.ts token + connection UI
icons/ generated app icons
scripts/ install-service.mjs / uninstall-service.mjs (systemd or launchd)
docs/ PLAYBOOKS.md · RECORDING.md · WATCH.md · WATCH-SELFTEST.md · banner.svg · architecture.svg
server/vendor/ rrweb-player (inlined into session-replay HTML)
extension/vendor/ rrweb-record.js · bb-watch.js · bb-watch-main.js (injected; built by build.mjs)
Contributing
Contributions are welcome - see CONTRIBUTING.md for the build and dev loop (including the MV3 service-worker reload gotcha) and how to add a tool. Found a security issue in the bridge itself? Please report it privately per SECURITY.md.
License
MIT - see LICENSE. Bundled third-party components (rrweb, rrweb-player - both MIT) are credited in THIRD-PARTY-NOTICES.md.
Disclaimer
Browser Bridge is provided for authorized automation, research, and security testing only. You are responsible for complying with the terms of service of the sites you automate and with the scope of any security engagement. The authors accept no liability for misuse.