ADR 0004 — Remote debug sessions
Status: Proposed · Date: 2026-07-11
Context
Section titled “Context”The bridge (packages/hakka-bridge/src) is a localhost dev tool today: one
BridgeHub per process, bound to 127.0.0.1 by default, every connected
peer sees every other peer’s frames, a fresh peer gets a full replay of the
buffer on connect, and the only auth is an optional static shared token plus
an Origin allowlist (server.ts). Every client — hakka-web’s
desktopBridge, hakka-node’s bridgeClient, RN/iOS/Android, and
hakka-mcp — speaks the same versionless envelope
({ type: 'request' | 'control', payload }, protocol.ts). Control frames
(ControlCommand in packages/hakka-core/src/engine/control.ts: mock.add,
breakpoint.add, throttle.set, …) are applied wherever they land — the web
worker’s storeClient.ts, RN’s core/bridge.ts, iOS’s BridgeClient.swift
— with zero gating: any peer on the hub, including hakka-mcp (which
exists specifically to call sendControl()), can rewrite a live response
today. That is an acceptable trust boundary on loopback, where the only
peers are the developer’s own processes.
The ask is PageSpy/chii-class: turn a capture session into something you can share with another person over a network, not just view alone on localhost. ADR 0002 already flagged this exact moment — “Bridge hardening… not needed by the pull model but required before any push-to-browser prod transport” — this ADR is that hardening pass, scoped to sharing, not to production capture (0002’s cohort/ring-buffer model is unrelated and unaffected).
Prior art. chii (server/lib/ChannelManager.js) pairs one target
(the inspected device) to N clients by an explicit target id the client
supplies on connect — a Chrome-remote-debugging-style 1:N channel, no
implicit broadcast. page-spy-web calls this a “room”: a device opens a
room inside a group namespace (getSpyRoom(group)), and a viewer polls the
group’s room list and explicitly picks one room’s address to join
(SelectRoom, now deprecated in their frontend but the model stands). Both
tools require explicit, scoped joining — nobody sees a stream they
didn’t ask to join. Hakka’s current hub does the opposite: implicit,
unscoped, broadcast-to-everyone. That gap is the core of this ADR.
The hello-handshake lesson. protocol.ts carries no version field —
parseBridgeMessage recognizes exactly two type values and drops anything
else (parseBridgeMessage test: { type: 'hello' } → null, i.e. an
unrecognized type is silently and safely dropped, never a crash — that
mechanism is sound). But “drop silently” is also exactly how the protocol
already drifted once for real: the dormant Noodle desktop fork still
decodes {type:'request', request} / {type:'batch', requests} against
today’s {type:'request', payload} wire, and nothing detects the mismatch —
every frame just throws inside Noodle’s parser and the UI stays empty, with
no signal to either side that they’ve diverged. There is no handshake, so
there is no way to ask a peer what it understands; incompatibility is
discovered by silence, not by negotiation. Any wire change this ADR makes
must not repeat that: it must be structurally impossible for an old client
or an old hub to misinterpret a new frame as something else, and additive
enough that “connects and gets nothing” doesn’t become “connects and gets
malformed data.”
Decisions
Section titled “Decisions”(a) Session rooms vs. 1:1 pairing
Section titled “(a) Session rooms vs. 1:1 pairing”Chosen: rooms on the hub — an id + join token, with relay and buffered
replay scoped per room, replacing today’s single implicit global room.
Rejected — pure 1:1 pairing (one device, one viewer, no multiplexing).
Hakka already has tested multi-peer fan-out on loopback (server.test.ts:
“receives a request frame, buffers it, and relays to other peers” — a
sender plus a viewer, both ordinary peers). Collapsing to 1:1 would be a
regression for the existing desktop-app-plus-browser-overlay case, not just
a missed opportunity for the new one. A room is the strict generalization:
today’s implicit single room becomes “the default room every unauthenticated
loopback peer already joins,” so 1:1 pairing solves nothing rooms don’t
already cover, while foreclosing multi-viewer sharing (pairing a teammate in
and keeping the local desktop app subscribed).
Rejected — single shared namespace with auth bolted on (any valid token joins the one global stream). Rejected because it isn’t a session at all — there’s no scoping, so one leaked token exposes every buffer the hub has ever held, including buffers from apps the token-holder was never meant to see. “Shareable debug session” implies the share boundary is per-session.
(b) Auth
Section titled “(b) Auth”Token transport — rejected: URL query param, despite server.ts
already shipping ?token= for the existing LAN shared-secret feature. Flag
this explicitly rather than silently reusing the pattern: a share link is
the artifact most likely to get pasted into Slack, email, or a ticket, and
URL params land in browser history, reverse-proxy access logs, and any CDN
or load-balancer request logging in front of the hub — exactly where a share
token must not appear. The existing ?token= stays as-is for its original
purpose (a static pre-shared secret for trusted-LAN device debugging,
config-file lifetime, not a pasted link); it is a different threat model and
out of scope to change here.
Considered — WebSocket subprotocol (Sec-WebSocket-Protocol). Rejected:
subprotocol values still appear in some proxy/load-balancer upgrade-request
logs (less exposed than a URL, not zero), and ws’s subprotocol negotiation
is a single-string allow-list handshake — awkward for carrying a room id and
a token as two distinct values without inventing a delimiter convention.
Chosen — first-frame auth message. After the WS upgrade completes, the
hub requires the connection’s first frame to be a join request carrying the
room id and token before it does anything else — no buffer replay, no
relay registration. This keeps the secret off the transport-level handshake
entirely, stays inside the existing frame-based envelope (additive frame
type, see (d)), and gives the hub one clean rejection point — close 1008,
same signal server.ts already uses for origin/token failures — before any
data has moved.
Token generation & expiry. Server-minted per-room, cryptographically
random (the module already imports node:crypto for timingSafeEqual;
randomBytes is the natural extension), one token per room rather than per
viewer — simplest shape for “give this code to whoever you’re sharing with.”
TTL from room creation (short default) plus explicit revoke; an
expired or revoked token is rejected exactly like a wrong one. Rooms and
their tokens live only as long as the hub process — no persistence across
restarts, matching the buffer’s own lifetime today.
Non-goal, stated explicitly: no NoodleApps cloud relay/pairing service in 1.2. Self-hosted only. Brand rationale, not a resourcing excuse: a cloud relay means Hakka’s operator now custodies someone else’s live request and response bodies — tokens, PII, everything the redaction model in (e) is trying to keep off the wire in the first place — on NoodleApps infrastructure, which turns a developer-respect dev tool into a company with uptime and breach liability. “No accounts, no enterprise” means sharing a session is “open your port, hand out a link” — same posture as any other localhost dev server. A user who needs off-LAN reach runs their own tunnel (ngrok, Tailscale, Cloudflare Tunnel); Hakka does not become that infrastructure. This is durable, not “not yet.”
(c) Viewer permissions
Section titled “(c) Viewer permissions”Chosen — read-only by default per room. Control frames (mock.*,
breakpoint.*, throttle.set) are relayed only when the room’s creator
(the sharing side — the device being inspected) set an explicit
allow-control flag at room-creation time. Default off.
Enforcement point is the hub, not the SDK. Today, applyControlCommand
runs unconditionally wherever a control frame arrives — the web worker, RN’s
bridge, iOS’s BridgeClient.swift — trusting every peer equally, which is
fine when “every peer” means loopback processes the developer started
themselves. It stops being fine once a room can be joined by someone on
another network. The fix belongs at the relay: protocol.ts already
distinguishes request from control frames, and the hub already knows
which room a frame arrived in and that room’s allow-control flag — so a
disallowed control frame is dropped at relay time and never reaches the
device’s applyControlCommand at all. This does not require the SDK-side
engines to change, and does not depend on every current or future SDK target
self-policing correctly.
Rejected — granular per-capability grants (separate allow-mock / allow-breakpoint / allow-throttle bits). Premature for 1.2: the real use case is “I trust this one person enough to let them poke at my app together with me,” which one coarse bit covers. A permissions matrix is easy to add later if usage shows a need for it; nothing here forecloses that.
Rejected — control allowed by default, opt out to restrict. Same allowlist-beats-denylist principle ADR 0002 already established for prod body capture: a share link goes to someone outside the developer’s own process tree, so “off unless the sharer turns it on” is the only safe default.
(d) Transport & wire compatibility
Section titled “(d) Transport & wire compatibility”Chosen — reuse the existing hub and frame envelope; add room scoping as
one new, additive frame type. A connection that never sends a join frame
lands in an implicit default room with today’s exact behavior: one shared
buffer, broadcast to every other peer, replay on connect. This is not a
fallback bolted on for compatibility’s sake — it is today’s behavior,
unchanged, for anyone who doesn’t opt in. Every currently-deployed client
(hakka-web, hakka-node, RN, iOS, Android, hakka-mcp) keeps working
against a room-aware hub with zero changes and zero awareness that rooms
exist. Room-scoped behavior — explicit join, token check, allow-control
gating, per-room buffer isolation — only activates for a connection that
sends the new join frame first.
Compatibility direction that matters: rooms are a hub-side (server.ts)
capability, so the only real compatibility question is new hub, old
client — already handled by the default-room fallback above. The reverse
(new client against an old, not-yet-upgraded hub) degrades safely for the
same reason the “hello” test case already proves: parseBridgeMessage
drops any unrecognized type and returns null; an old hub receiving a
join frame just ignores it and treats the connection as an ordinary
unauthenticated peer of the single global room — the pre-1.2 behavior,
not a crash, not corrupted data.
Applying the hello-handshake lesson: the new join frame is additive —
one more recognized type alongside request/control, never a
repurposing of payload or the existing type enum. This ADR does not
add a protocol version field or a capability-negotiation handshake; that is
a real gap the versionless design has (the Noodle drift is proof), but
closing it is out of scope for 1.2. The mitigation here is narrower and
sufficient for this feature: new behavior is opt-in via a new frame, so
“peer doesn’t understand rooms” degrades to “peer behaves as it always did,”
never to “peer misparses new data as old data.” Flag versionless-protocol
negotiation as a real follow-up, not solved here.
(e) Privacy invariant — redaction before frames leave the device
Section titled “(e) Privacy invariant — redaction before frames leave the device”Stated as a testable invariant: no unredacted sensitive header or body
value may ever be included in a frame handed to bridgeClient.send() (or
the browser desktopBridge socket send). Redaction must complete inside the
same synchronous capture path that builds the NetworkRequest — before that
object is serialized onto the wire — regardless of which room it’s destined
for. Rooms only change who can receive an already-redacted frame; they
must never become a reason to redact later. “Redact at the hub, now that
there are remote viewers” is explicitly the wrong design: it would mean an
unredacted body has already transited process memory and the wire on its
way to the hub, which is precisely the exposure the invariant exists to
prevent.
Existing test anchors (real, today): packages/hakka-core/src/utils/headerRedaction.test.ts
and packages/hakka-core/src/utils/bodyRedaction.test.ts cover the redaction
functions themselves. packages/hakka-core/src/capture/fetch.ts calls
isSensitiveHeader / redactJsonBody while building
recordedReqHeaders/recordedResHeaders/the redacted body before
constructing the NetworkRequest, and hakka-node/src/bridgeClient.ts’s
send() only ever receives that already-built object. No existing test
asserts the order end-to-end — nothing proves a raw secret never reaches
bridgeClient.send’s JSON.stringify, only that the redaction functions
work correctly in isolation.
Test to add (prerequisite for shipping remote sharing, not something
rooms introduce): an integration test — bridgeClient.test.ts or a new
capture/redactionBoundary.test.ts — that runs a request/response
containing a known sensitive header and a known sensitive JSON body field
through the real capture path (not the redaction unit directly), intercepts
the exact string handed to ws.send at the bridge-client boundary, and
asserts the raw secret substring is absent from it. Local-only usage
tolerated the absence of this test because only the developer’s own
processes ever saw the frame; that assumption ends the moment a frame can
leave the machine toward a remote viewer.
(f) Backpressure, replay limits, rate limits
Section titled “(f) Backpressure, replay limits, rate limits”Chosen — one BridgeHub instance per room, not a shared hub filtered by
room id. Replay-on-connect scopes to the joining room’s own buffer only.
maxRecords stays configurable per room (already an option on BridgeHub
and startBridgeServer) rather than one global cap, so one room’s chatty
app can’t starve another room’s buffer — and, more importantly, can’t leak
into another room’s replay. A structurally separate hub instance per room is
the safer implementation than a shared instance with a room-id filter: a
filtering bug leaks data across rooms, a separate instance structurally
cannot.
Rate limits. Room creation is sharer-initiated — the sharing side
already trusts the local machine, so this is lower severity than relay
volume from a joined viewer. The real gap is per-connection message rate on
the relay path: a joined-but-misbehaving viewer flooding control frames, or
a compromised/buggy device flooding request frames. A simple token bucket
per socket, closing (1008) on sustained violation, covers both; the clients
set iteration in server.ts is already the chokepoint to instrument.
Backpressure. bridgeClient.ts already bounds the sending side (a
1000-record / 5 MB offline queue). The gap is the hub’s relay side: a slow
remote viewer. safeSend in server.ts today is fire-and-forget with no
bufferedAmount check — fine when every peer is loopback-speed, not fine
once a real network viewer with real latency is a peer. Add a per-socket
bufferedAmount ceiling; past it, drop further relay to that viewer (don’t
queue it server-side) until it drains. This is genuinely new: nothing in the
current trust model anticipates a peer that isn’t loopback-fast.
(g) LAN spike — GO/NO-GO for shipping 1.2
Section titled “(g) LAN spike — GO/NO-GO for shipping 1.2”Spike definition: two physical devices on one LAN — one running the app
plus hakka-bridge bound non-loopback (host: '0.0.0.0', already a
documented, existing BridgeServerOptions field), the other a remote viewer
on the same network, no VPN/tunnel in the loop. One shared room, joined by
typing in (or scanning/pasting) the room id and token.
GO criteria — all must hold:
- Viewer joins with a valid token, sees the buffered replay, then sees new requests stream live.
- A wrong or expired token is rejected before any buffer replay happens (no partial leak on the way to rejection).
- A viewer without the allow-control flag cannot mutate mock, breakpoint, or throttle state on the shared device — checked as an explicit negative case, not inferred from “nothing broke.”
- Closing one room does not affect any other concurrently open room on the same hub process.
NO-GO criteria — any one blocks shipping the feature (not the ADR):
- The token appears in any log line reachable under default OS, router, or proxy logging observed during the spike — the exact failure mode (b) exists to prevent.
- A slow or flaky remote viewer measurably degrades throughput for the local/loopback peers — a backpressure-isolation failure per (f).
- The redaction-boundary test from (e) fails against a real captured secret carried over the LAN path.
The spike proves the room + auth + gating mechanics under real network conditions with the minimum topology that can prove them; it is not a performance or scale test. Multi-viewer and multi-room-under-load are out of scope for this GO/NO-GO gate, though the room-isolation GO criterion above is cheap to check with two rooms instead of one.
Sizing
Section titled “Sizing”- (a) rooms on the hub — M.
BridgeHubalready isolates buffer and dedup logic per instance; keying multiple instances by room id plus adding join/room bookkeeping toserver.tsis bounded, not a rewrite. - (b) auth (join frame, token mint/expiry/revoke) — M. One new frame
type plus token lifecycle bookkeeping; no new crypto primitives (
randomBytesandtimingSafeEqualare already in use inserver.ts). - (c) viewer permission gating — S. One boolean per room, checked at a relay chokepoint that already exists.
- (d) wire compatibility — S. Additive frame type, default-room fallback preserves all current behavior byte-for-byte; the cost here was confirming that, not writing it.
- (e) privacy invariant + test — S. The invariant already holds structurally today; this is one new boundary test, and it should pass without a source change unless it finds a real gap.
- (f) backpressure, replay/rate limits — M. Per-room hub instances are
straightforward;
bufferedAmountceilings and token-bucket rate limiting on the relay path are genuinely new operational code with new failure modes to get right the first time. - (g) LAN spike — S. The infrastructure it depends on (multi-room hub, join auth, control gating) has to exist first; the spike itself is a manual two-device test against already-built pieces.
Overall: M. The pieces most likely to slip are (f) — new failure modes (slow viewers, rate limiting) that loopback-only Hakka has never had to handle — and the log-leak half of (b), which depends on whatever reverse proxy or infrastructure a self-hosting user puts in front of the hub, i.e. partially outside Hakka’s control. That half is a documentation obligation (tell users plainly: don’t front the bridge with a proxy that logs upgrade request lines) as much as a code one.
Verification plan
Section titled “Verification plan”- Unit: join-frame parsing (valid/expired/wrong-token/missing-room, all
reject without side effects); per-room
BridgeHubisolation (a request ingested in room A never appears in room B’sgetRecords()or replay); control-frame relay is dropped when a room’s allow-control flag is unset, relayed when set. - Integration: extend the
server.test.tse2e pattern — two rooms, two senders, two viewers, one viewer per room; assert each viewer only ever receives its own room’s replay and live frames, and that a control frame from a disallowed viewer never reaches the sharing peer’s socket. Add the redaction-boundary test from (e) as a named prerequisite, gating this feature’s rollout, not folded silently into unrelated capture tests. - Security: token compared in constant time (mirror the existing
isTokenValidpattern); a room’s join secret never appears in any server-side log line the bridge itself emits; a rejected join closes before any buffer replay (assert ordering, not just the eventual close code) — same disciplineserver.test.ts’s origin/token tests already apply to the existing gates. - Manual (LAN spike): the GO/NO-GO checklist in (g), run against two real devices before 1.2 ships the feature.