CDP connector internals

How bmc captures already-decrypted Chrome traffic over the DevTools Protocol — per-folder Chrome, the daemon, and remote control.

The cdp-connector is a local Node CLI (bmc, "Busymate Chrome") that captures a Chrome instance's already-SSL-decrypted request/response traffic over the Chrome DevTools Protocol (CDP) and streams it into the Busymate dashboard feed — with no MITM proxy, no Root CA install, and no cert trust.

It lives at cli/cdp-connector. Captures appear in the dashboard like any iOS or proxy-server device, tagged source: "cdp" and rendered with a Chrome badge.

Why CDP instead of MITM

Capturing HTTPS the classic way means installing + trusting a Root CA and routing through a proxy (the iOS VPN tunnel, or web/proxy-server for non-iOS clients). For a desktop Chrome user that's heavyweight and brittle. Chrome already exposes plaintext request/response data over the CDP Network domain — so the connector reads it directly. The trade-off: CDP is pull (Chrome is the server, clients attach to its debug port), so bmc must run on the same machine as the Chrome it captures.

Data flow

Chrome--remote-debugging-port=PORTbmccli/cdp-connector · 365-day device JWTingestsupabase/functions/ingest · RLS-scopedpublic.entries → dashboard feedsource = "cdp"CDP over WebSocket · already-decryptedHTTPS batches (entries[] payload) · Bearer device JWTRealtime insert

Device model: daemon-only, devices by name

You sign in once per machine (bmc login, stored at ~/.busymate-cdp/auth.json). bmc is daemon-only: a background supervisor manages your browsers as named devices (there is no folder-mode / ./.bmc.json-per-directory model — it was removed).

  • bmc create <name> provisions a device (auto port + Chrome profile) under ~/.busymate-cdp/daemon/devices/<uuid>/, holding its UUID + 365-day device JWT (so capture never needs the user session again). --browser <id> bakes in a browser profile's binary + default flags; --from <base> clones one; --start starts it.
  • bmc start <name> starts the device — auto-creating it if the name is new. Each device launches its own Chrome on its own free debug port + profile, so devices never collide. --all starts every device; stop/restart mirror it.
  • bmc edit <name> re-configures a device in place (name / browser / binary / flags); bmc remove <name> deletes it (server-side row + captures + local state).
  • The supervisor runs each device as a bmc __run <uuid> child (the capture engine, pointed at the device's state dir). opStart waits + verifies the child stays alive before reporting success, so a browser that dies on launch surfaces a real error to the CLI and the dashboard. Children are killed on exit (graceful SIGINT/SIGTERM).
  • You control everything from the live bmc console (keyboard), the dashboard, or MCP — the daemon pairs one host device (model bmc-daemon) whose Realtime channel carries bmc.daemon.* (start/stop/restart/create/edit/remove/list) over the same cdp-command/cdp-result wire as per-device automation.

Source layout

FileResponsibility
index.tsxCLI routing + the __run <uuid> capture engine (the only in-process capture path; start/<name> route to the daemon). Mounts the Ink TUI.
daemon.ts / daemonCli.ts / ipc.ts / hostDevice.ts / registry.tsThe supervisor + its dispatcher (start/stop/restart/create/edit/remove/list, --all), the local IPC + remote host channel, and the named-device registry.
cdp.tsMinimal CDP WebSocket client. Connects to the browser-level endpoint, Target.setAutoAttach({flatten:true}) to follow every tab/frame, Network.enable per session. Resilient reconnect (retries until the debug port is back — needed after a Chrome relaunch).
translate.tsCorrelate Network.requestWillBeSent / responseReceived / loadingFinished / loadingFailed into the shared TrafficLogEntry shape; fetch bodies via Network.getResponseBody / getRequestPostData (1 MB cap, text vs base64).
chrome.tsFind the browser binary (--chrome / CDP_CHROME_BIN / per-OS auto-detect), launch it with the right flags, findFreePort for multi-instance, waitForCdp readiness.
proxyForwarder.tsLocal auth-injecting proxy used when the device's external proxy needs credentials (see below).
settings.tseffective_settings_for_device RPC → capture gate (SSL-proxy domains / inspect-all / ignore-hosts), breakpoint patterns, and the external proxy. Refreshed live via Realtime.
breakpoints.tsRequest/response breakpoints via Chrome's Fetch domain (pause → edit raw HTTP → continue/abort), mirroring the proxy-server + dashboard flow.
device.tsA device's .bmc.json (in its daemon state dir): pair (via device-pair), load, rename, remove (via device-remove).
ingestClient.tsIn-memory batch (≈1 s / 100 entries) → POST /functions/v1/ingest; strips NUL is handled server-side.
realtime.tsdevice:<uuid> channel: settings push, live rename, unpair, breakpoint-continue. Mirrors iOS RealtimeSubscriber.
heartbeat.tsKeeps device_status fresh (Online + source = "cdp").
report.tsReports the device's real egress country once (flag in the dashboard).
auth.tsGlobal sign-in (browser loopback or headless CI), refresh-token storage.

External proxy (with auth)

A device can be configured (in the dashboard) to egress through an upstream HTTP proxy. Chrome's proxy is a launch-time flag, so:

  • bmc start reads the device's effective externalProxy and launches Chrome with --proxy-server=host:port.
  • Changing the proxy in the dashboard auto-restarts Chrome with the new route (the CDP client reconnects automatically once the new Chrome is up).
  • Authenticated proxies can't carry credentials in --proxy-server, and we don't want Chrome popping an auth dialog. So when the proxy has a username/password, bmc starts a tiny local forwarder (proxyForwarder.ts) on 127.0.0.1:<ephemeral>, points Chrome at that, and relays to the real proxy with a Proxy-Authorization: Basic … header injected. CONNECT (HTTPS) tunnels are authenticated on the CONNECT line then piped opaquely; the capture/breakpoint path is untouched.

Breakpoints

CDP devices support the same request/response breakpoints as proxy-server. Breakpoint patterns (per-device + global) arm Chrome's Fetch domain; a match pauses the exchange, inserts a breakpoint_events row (fanned out on device:<uuid>), and the dashboard Breakpoints view lets you edit the raw HTTP and continue/abort. The global Pause all requests toggle (a catch-all * pattern) works here too.

Remote browser control

Beyond capture, bmc can be driven remotely: the dashboard (a device's Automation tab) and BusyBro (chat / Telegram / MCP) issue Chrome DevTools Protocol commands that the locally-running connector executes against its Chrome and replies to — a full two-way flow over the device's existing Realtime channel.

Wire contract (device:<uuid>, private):

DirectionEventPayload
caller → devicecdp-command{ id, method, params?, sessionId? }
device → callercdp-result{ id, ok, result?, error? }

method is forwarded verbatim to Chrome via the connector's CdpClient.command (raw CDP passthrough — e.g. Page.navigate, Runtime.evaluate, Page.captureScreenshot), with two connector-side meta methods handled locally: bmc.targets (list the open page targets + their sessionId) and bmc.activePage (resolve a page session to drive). The device JWT may broadcast cdp-result back on its own device:<uuid> topic (Realtime write policy from migration 0017); a new read policy lets a non-admin owner receive those replies.

Opt-in (default OFF). The connector refuses every cdp-command unless the per-device flag settings_device.data.cdpControlEnabled is ON — read live through effective_settings_for_device by SettingsCache, so toggling it in the dashboard takes effect with no restart. The RemoteCdpExecutor (src/remoteControl.ts) enforces this before touching Chrome, bounds concurrency, and times each command out.

Gating (caller side): owner-or-admin + devices:edit, plus the opt-in. The raw passthrough (browser_cdp tool) is additionally admin-only (arbitrary-code power); the ergonomic wrappers (browser_targets / browser_open / browser_eval / browser_screenshot / browser_snapshot) need only devices:edit.

Oversize results. A result over ~180 KB (e.g. a Page.captureScreenshot PNG) would exceed the ~256 KB Realtime frame, so the executor uploads it to the private cdp-artifacts Storage bucket (<uuid>/<id>.<ext>, device-write / owner-admin-read RLS) and returns a 1-hour signed { url, storagePath, bytes } instead of inline base64.

The shared wire helper lives at supabase/functions/_shared/cdp.ts (sendCdpAndAwait), imported by both the BusyBro brain and the MCP server so the protocol never drifts; the dashboard uses the browser-client equivalent sendDeviceCommand in app/lib/apiFetch.ts.

Distribution + updates

The connector is distributed as source (the repo is private):

  • The dashboard serves a one-line installer at cdp.busymate.net/install.sh (bash) and cdp.busymate.net/install.ps1 (PowerShell, Windows), plus a source tarball at cdp.busymate.net/bmc.tar.gz (all Next.js route handlers; the tarball is tar of cli/cdp-connector minus node_modules / .env* / e2e, and carries both the bin/bmc bash launcher and the bin/bmc.cmd Windows launcher).
  • macOS/Linux: curl -fsSL https://cdp.busymate.net/install.sh | bash. Windows: irm https://cdp.busymate.net/install.ps1 | iex — extracts with the built-in tar.exe into %LOCALAPPDATA%\busymate-cdp, drops a bmc.cmd shim, and adds it to the user PATH. Both link bmc onto the PATH and run npm install --omit=dev.
  • bmc update re-checks /api/version (components["cdp-connector"].build) and re-runs the matching installer (bash on Unix, PowerShell on Windows) when a newer build is out. bmc start reports the running build back to the device (set_device_app RPC) so the dashboard shows the build that's actually running.

Windows specifics. The runtime is otherwise identical (it ships .ts and runs via tsx). findChromeBinary (src/chrome.ts) probes the standard %ProgramFiles% / %LOCALAPPDATA% install paths for Chrome, Edge, Brave, and Chromium; Chrome is killed with taskkill /T /F (killChrome) so its renderer/GPU children don't orphan on shutdown.

Shipping note: a connector release needs BOTH a cli/cdp-connector/package.json build bump (the CLI reads package.json.build) AND a dashboard deploy (the VPS git pull refreshes /api/version + the served /bmc.tar.gz).

Reuse

The connector deliberately mirrors existing patterns: the entry shape + text/base64/1 MB-cap body handling from web/proxy-server, the URL-pattern matcher (*.host/path* wildcards) shared with proxy-server + dashboard, device pairing from supabase/functions/device-pair, and the breakpoint wire protocol from the proxy-server

  • dashboard Breakpoints flow.