Skip to content

Two-way tunnel (wire protocol v2) - #1

Merged
edward-rosado merged 14 commits into
mainfrom
feature/two-way-tunnel
Aug 7, 2026
Merged

edward-rosado merged 14 commits into
mainfrom
feature/two-way-tunnel

Conversation

@edward-rosado

@edward-rosado edward-rosado commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Two-way tunnel (wire protocol v2)

Stacked on ItsRiprod#152. Review that one first — this branch targets it, so the diff here is only the v2 work. If ItsRiprod#152 lands, this retargets to main cleanly.

Why

v1 is one-directional in a specific way: only the device can open a stream, and every stream goes to one hardcoded destination on the computer (btbridge.swift's targetHost/targetPort, SERVER_ADDR in the Linux helper, SERVER_PORT in the Windows one). That makes an entire class of work impossible — no remote debugging, no log retrieval, no way to reach the device at all without a USB cable.

The device's firmware has no screencap, so today there is literally no way to see what's on its screen without pointing a camera at it.

What v2 adds

Either side can open a stream, and an OPEN says what it wants to reach.

  • Stream IDs split by their high bit — device keeps 0x00000001…0x7FFFFFFF, the computer takes 0x80000001…0xFFFFFFFF. Both ends allocate without coordinating, and allocation wraps inside its own half. An OPEN arriving in the wrong half is refused: an ID collision would silently cross-wire two live TCP streams, which is a correctness and a confidentiality bug.
  • HELLO carries version, capability bits, and the computer's clock. The Car Thing has no RTC and nothing else on it sets the time — a wrong clock fails every TLS handshake the device ever makes, in ways that look exactly like a tunnel bug.
  • OPEN target descriptors — empty (the DeskThing server, unchanged from v1), a named service, or a literal host:port (defined but not yet accepted; that's the internet-sharing follow-up).
  • OPEN_ACK distinguishes refused / unreachable / unknown-service / bad-namespace. A bare CLOSE can't tell "policy denied" from "device rebooted."

Backward compatibility, by construction

Three properties make a v1 and a v2 peer interoperate:

  1. An empty OPEN payload keeps its v1 meaning. The existing golden vectors are unchanged and now serve as the compatibility proof.
  2. A peer that never sends HELLO is treated as v1 — no inbound, no services offered.
  3. An unknown frame type is skipped, not treated as a desync. Previously the Swift helper did rxBuf.removeAll() on an unknown type, which would discard the in-flight bytes of every other stream. That's fixed here.

Verified on hardware: a v2 helper against the still-v1 device (I can't reflash it without USB) reports inbound: false, offers no services, refuses a forward with a clear message, and carries Spotify exactly as before.

What it immediately unlocks

The cdp service forwards the device's chromium debugger to a loopback port on the computer:

POST /forward/open {"service":"cdp"}  →  {"ok":true,"port":51234}

Point chrome://inspect at that port and you are debugging the Car Thing over Bluetooth with no cable — and it's the only route to a screenshot of a device with no screencap.

Security

Default deny, enforced independently on both ends. This isn't hardening, it replaces a protection being removed: the hardcoded 127.0.0.1:8891 was the only thing standing between the device and everything on the computer's loopback.

  • Named services only. The device is not a router, so 127.0.0.1:5037 (adb, unauthenticated) is inexpressible, not merely filtered — there is no parser to trick with 0177.0.0.1 or [::ffff:127.0.0.1].
  • The descriptor parse is treated as a trust boundary and rejects malformed input rather than guessing. The rejection cases are locked in test_protocol.py.
  • Port 8891 is permanently absent from the registry and must stay that way: it's the mux's own listener, so an inbound stream there would be handed to handle_local and forwarded back over the link — an unbounded loop that eats the entire 155 KB/s.
  • Forwarded listeners bind loopback only, and are torn down when the radio link drops so nothing accepts a connection it can't serve.

Tests

  • v2 golden vectors + descriptor-rejection table join the cross-implementation contract.
  • A new suite drives the device mux's real inbound path against a fake radio: namespace rejection, unknown/malformed services, host:port refusal, the 8891 exclusion, HELLO exchange, stream bookkeeping, and a genuine end-to-end relay from a live local service.
  • 36 vitest + 59 Python, all passing. Typecheck clean.

Honest limitations

  • Linux and Windows are untested on hardware. They implement the same test-locked protocol. The Windows helper is type-checked with clang against stub Win32 headers (-Wall -Wextra clean), which catches signature and type errors but is not a substitute for running it.
  • No UI in this PR. The forward is drivable over IPC (window.electron.bluetooth.openForward) and the control API. A Device Inspector panel is a natural follow-up, but the tunnel is the hard part and is worth reviewing on its own.

Both directions are now hardware-verified: the device runs the v2 btmux.py, and every screenshot in this PR was captured over Bluetooth through the inbound cdp service with no cable attached. The v2-computer ↔ v1-device compatibility direction was verified earlier, before the device was reflashed.

🤖 Generated with Claude Code


Added since opening: internet sharing

The tunnel being two-way makes one more thing possible, and it is on this
branch now.

The Car Thing has no route to the internet. With sharing switched on, the
computer becomes its exit node:

chromium --proxy-server=socks5://127.0.0.1:1080
   │
   ▼  SOCKS5 CONNECT
btmux.py  ──►  OPEN kind 0x02 (host:port)  ──►  helper  ──►  the internet

The device resolves nothing — hostnames ride the tunnel verbatim and the
computer resolves them, because the device has no resolver at all. Chromium
always does proxy-side DNS for socks5://, so no DNS server is needed anywhere.

The computer is the policy point, since the device is now pointing a socket
at whatever it likes:

  • off by default, per-session, and reset whenever the link drops — this is
    deliberately not a persisted setting
  • ports 80/443 only
  • loopback, private, link-local and cloud-metadata ranges refused, re-checked on
    the resolved address so a hostname aimed at a private range is refused too
  • the device may still only ask for the DeskThing server or the internet; named
    services on the computer stay unreachable

superbird/setup-browser-proxy.sh configures the browser once during
provisioning and can undo it (enable / disable / status, idempotent, keeps
a pristine backup). Its bypass list is not optional: this is Chromium 69,
which predates Chrome 72's implicit localhost bypass, so without it the client's
own http://localhost:8891 traffic would be proxied and the /__bt transport
probe would stop being answered locally — the badge would silently flip to USB.
The semicolons are quoted because ; starts a comment in supervisord's INI
parser.

Verified on hardware, over Bluetooth, with the USB path removed

adb reverse for the proxy port was deleted first, so nothing could cross USB:

  • HTTPS 200 from the device (559 bytes, 0.27s)
  • example.com rendered in the device browser
  • a network image loaded and rendered
  • with sharing off, the same request is refused — the gate holds
  • the client's /__bt probe still returns {"transport":"bluetooth"}

13 new tests drive the device's real SOCKS handler: framing of the host:port
OPEN, the stream-id namespace, success/refused/unreachable replies, no payload
leaking on a refused stream, BIND rejected, and ack-waiter cleanup.

What this is not for

Video does not play on this device. The browser reports codec support it
does not have — a genuine VP8+Vorbis file fails with MediaError.code 4 even
served from 127.0.0.1 with no proxy in the path, while curl on the device
downloads the same file completely. Internet sharing is for reaching the
network; it will not get you a video player. See docs/device-debugging.md.

edward-rosado and others added 9 commits August 4, 2026 11:25
Published app releases (e.g. spotify-v0.11.1.zip) declare "macos" in their
manifest platforms list, which is not a valid PlatformTypes value. On Mac,
the compatibility check looks for "mac", so these apps are falsely flagged
"App not compatible with mac platform" even though they run fine.

Add a shared normalizePlatforms helper that maps legacy identifiers
(macos/osx/darwin -> mac, win/win32 -> windows) and lowercases case
variants, and apply it in constructManifest and both renderer
compatibility checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
macOS removed Bluetooth PAN, so there is no IP-over-Bluetooth to lean
on. Instead, a small TCP multiplexer runs over a single RFCOMM serial
channel: btmux.py owns 127.0.0.1:8891 on the device (the endpoint the
client already talks to) and frames its TCP streams across the radio to
a helper on the computer, which replays them onto the local DeskThing
server and serves a control API for the UI. After one-time provisioning
the device only needs power and reconnects on its own on every boot —
verified by cold-booting a flashed Car Thing: it re-establishes the
Bluetooth link and resumes streaming Spotify with zero intervention.

The device mux binds a real AF_BLUETOOTH RFCOMM socket (no rfcomm-binary
tty bindings to leak) and a PING/PONG heartbeat on both sides tears down
a half-open link so reconnects always recover cleanly.

Pairing works the way the Car Thing originally did: the computer
initiates, the device's screen shows a 6-digit code (btagent.py serves
it locally for the client to draw), and the person confirms the same
code. Helpers clear stale half-bonds before pairing fresh.

Every supported OS gets a helper speaking the same frame protocol and
control API: macOS in Swift over IOBluetooth (compiled at build time by
bt_source/build-btbridge.js — no binaries in git), Linux in Python over
BlueZ, Windows in C over Winsock RFCOMM + the Win32 Bluetooth
authentication API. Python golden-vector tests lock the frame protocol
across implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main process owns the helper's lifecycle through a small manager
facade: bridgeProcess starts and stops the packaged helper with the
app, bridgeClient wraps its local control API, and the provisioner
turns a USB-connected Car Thing into a Bluetooth-ready one — installing
the mux and the pairing agent as supervisord services and reporting the
device's radio address back so the pairing wizard knows who to talk to.
Platforms without a helper get a stub manager that reports unsupported.

The renderer reaches all of it through a typed BLUETOOTH IPC domain.
Vitest suites cover the IPC dispatch, the manager's degradation when the
helper is missing or unreachable, the control-API client, and the
provisioner step machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bluetooth setup page becomes a pairing wizard: scan, pick the Car
Thing, watch the same 6-digit code appear on the device's screen and in
this dialog, confirm, done — plus one-time USB provisioning with a live
step checklist, and unpair. Device details gain a Connection Type card
with a Bluetooth/USB preference toggle, and the top bar shows an
always-visible Bluetooth chip while the link is live. Everything hides
itself on platforms without a bridge or when the helper isn't running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Song-state updates dominate client traffic and are highly repetitive;
perMessageDeflate shrinks them substantially, which matters on the
~155 KB/s Bluetooth link and costs nothing on faster transports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setup and everyday-use instructions: one-time USB provisioning, pairing
with the code shown on the device screen, going wireless, the transport
indicators, and troubleshooting. Complements the developer-oriented
bt_source/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The heartbeat added during reboot-survival work only ever landed on the
device mux and the macOS helper. btmux.py pings every 5s and drops the
link after 15s without a PONG, and neither linux/btbridge nor
win/btbridge.c handled frame type 4 at all — so on those platforms the
link could never survive 15 seconds. It would come up, go silent, get
torn down, reconnect, and repeat forever.

Both now answer PING with PONG, track inbound PONGs, and run their own
outbound heartbeat so a half-open link is torn down locally instead of
lingering — matching the macOS helper and the device.

Tests: PING/PONG golden vectors join the cross-implementation contract,
plus a functional suite driving the Linux helper's real frame pump
against a fake socket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1 was one-directional: only the device opened streams, and every stream
went to one hardcoded destination on the computer. That made the whole
class of computer-to-device work impossible — no remote debugging, no
log retrieval, no way to reach the device without a USB cable.

v2 lets either side open a stream, and an OPEN says what it wants to
reach:

- Stream IDs are split by their high bit (device keeps the low half, the
  computer takes the high half) so both ends allocate without
  coordinating. An OPEN in the wrong half is refused — a collision would
  silently cross-wire two live TCP streams.
- HELLO carries version, capabilities, and the computer's clock. The Car
  Thing has no RTC and nothing else sets its time; a wrong clock fails
  every TLS handshake it makes in ways that look like a tunnel bug.
- OPEN carries a target descriptor. Named services only, default deny,
  enforced independently on both ends — the device is not a router, so
  127.0.0.1:5037 is inexpressible rather than merely filtered.
- OPEN_ACK distinguishes refused / unreachable / unknown-service /
  bad-namespace, which a bare CLOSE cannot.

Backward compatible by construction: an empty OPEN payload keeps its v1
meaning, a peer that never sends HELLO is treated as v1, and an unknown
frame type is skipped rather than resetting the receive buffer. Verified
on hardware — a v2 helper against the still-v1 device reports
inbound:false, offers no services, and carries music exactly as before.

What this immediately enables: 'cdp' forwards the device's chromium
debugger to a loopback port here, so chrome://inspect debugs the Car
Thing over Bluetooth with no cable — also the only way to screenshot a
device whose firmware has no screencap. 8891 is permanently absent from
the registry: an inbound stream there would be handed back to
handle_local and forwarded over the link again, an unbounded loop.

Tests: v2 golden vectors and descriptor-rejection cases join the
cross-implementation contract; a new suite drives the device mux's real
inbound path against a fake radio. 36 vitest + 59 Python.

The Windows helper is type-checked with clang against stub Win32 headers
(-Wall -Wextra clean); it and the Linux helper remain untested on real
hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both surfaced the first time protocol v2 ran against the real device;
neither was reachable from the unit tests as written.

1. The helper crash-looped with SIGTRAP the moment the device answered
   HELLO. applyHello runs on the state queue (drainFrames dispatches
   there) and was calling q.sync on that same queue — a same-queue
   dispatch_sync, which GCD traps. It now assigns directly, matching the
   convention the neighbouring PONG handler already follows.

2. Inbound streams silently dropped the peer's first payload. Opening a
   local service is async, but the peer sends its request straight after
   OPEN, so DATA arrived while open_connection was still in flight and
   was discarded — the far end then waited forever for a reply to a
   request the device never saw. DATA is now parked while a stream is
   connecting and flushed once the socket is up, and an early CLOSE
   abandons the pending open. A regression test drives that interleaving.

Verified end to end over Bluetooth with no cable: capability negotiation,
the cdp forward, chromium's debug endpoints answering through the tunnel,
and a 150 KB page screenshot pulled off a device whose firmware has no
screencap. Music kept streaming throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edward-rosado

Copy link
Copy Markdown
Owner Author

Now verified end-to-end on hardware — and it caught two real bugs

Deployed v2 to the device and ran the full inbound path over Bluetooth with no cable. Both bugs below were invisible to the unit tests as written; both are fixed with regression coverage.

1. The helper crash-looped with SIGTRAP the instant the device answered HELLO. applyHello runs on the state queue (drainFrames dispatches there) and was calling q.sync on that same queue — a same-queue dispatch_sync, which GCD traps. Fixed by assigning directly, matching what the neighbouring PONG handler already does. I audited the other q.sync sites: they belong to the State and ServiceForwarder queues and are reached from other threads, so they're fine.

2. Inbound streams silently dropped the peer's first payload. Opening a local service is async, but the peer sends its request immediately after OPEN — so DATA arrived while open_connection was still in flight and was thrown away. The far end then waited forever for a reply to a request the device never saw. On hardware this looked like: chromium accepts the connection, then nothing. DATA is now parked while a stream is connecting and flushed once the socket is up; an early CLOSE abandons the pending open. The new test drives exactly that interleaving.

What now works over Bluetooth, cable-free

POST /forward/open {"service":"cdp"}  →  {"ok":true,"port":60000}

$ curl http://127.0.0.1:60000/json/version
{
   "Browser": "Chrome/69.0.3497.128",
   "webSocketDebuggerUrl": "ws://127.0.0.1:60000/devtools/browser/767c0a1b-..."
}

Capability negotiation reports inbound: true with two services offered; the device log shows inbound stream 2147483649 -> cdp (0x80000001 — the computer's namespace, exactly as designed). I then drove CDP over the tunnel and captured a 150 KB screenshot of the device's screen — something previously impossible, since the firmware has no screencap.

Music kept streaming the whole time; the link stayed up and the normal client stream was unaffected.

Also confirms an assumption from the internet-sharing design: the device really is Chrome 69, which is why browsing to modern YouTube in it won't work regardless of bandwidth.

36 vitest + 62 Python, typecheck clean.

edward-rosado and others added 4 commits August 5, 2026 18:11
The Car Thing's firmware has no screencap, so there was no way to see
what it was rendering. Protocol v2's cdp service forwards its chromium
debugger, and this turns that into something usable:

  ./carthing-debug.py info                 what's running, what page is loaded
  ./carthing-debug.py shot screen.png      screenshot the display
  ./carthing-debug.py eval <js>            run JS in the live page
  ./carthing-debug.py console              stream console output
  ./carthing-debug.py reload               pick up a fresh client build

Standard library only, so it runs from a plain checkout. Transport is
automatic: the Bluetooth forward when the link is up, otherwise adb over
USB, so it works whichever way the device is attached.

docs/device-debugging.md covers the workflow and records device facts
measured through it, several of which are easy to get wrong:

- The browser is Chrome 69 (QtWebEngine 5.12), viewport 800x480.
- Video must be VP8 in WebM. VP9 does NOT play, and neither does
  H.264/MP4 — there is no proprietary-codec build on this device.
  Audio: Opus, Vorbis and MP3 yes, AAC no.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eval command silently swallowed JS exceptions: a thrown error comes
back as a successful CDP response carrying exceptionDetails, and the
tool reported only the undefined result — printing "null" while hiding
a real SyntaxError. It now reports the exception and exits non-zero.

The device-facts section claimed video worked in VP8, based on
canPlayType. canPlayType is wrong on this device: the QtWebEngine build
ships the format tables without the decoders. A genuine VP8+Vorbis WebM
fails with MediaError code 4 even served from 127.0.0.1 over adb reverse
with no proxy, internet, CORS or TLS in the path, while curl on the
device downloads that same file completely and an <img> over the same
path renders fine. Decode failure, not transport — so video is not
achievable in the stock browser by any codec or bandwidth choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Car Thing has no route to the internet. With sharing switched on the
computer becomes its exit node: chromium points at a SOCKS5 port the mux
serves on the device, each CONNECT becomes a host:port stream over the
existing tunnel, and the computer dials out.

The device resolves nothing — hostnames ride the tunnel verbatim and the
computer resolves them, because the device has no resolver. Chromium
always does proxy-side DNS for socks5://, so no DNS server is needed.

The computer is the policy point: off by default and per-session, ports
80/443 only, and loopback/private/link-local/metadata ranges refused —
re-checked on the RESOLVED address, so a hostname aimed at a private
range is refused too.

setup-browser-proxy.sh configures the browser during provisioning and
can undo it. Its bypass list is not optional: Chromium 69 predates
Chrome 72's implicit localhost bypass, so without it the client's own
localhost:8891 traffic would be proxied and the /__bt probe would stop
being answered locally.

Proven over USB before wiring it to Bluetooth: the device fetched HTTP
and HTTPS through the Mac and rendered a real page and a network image,
with the /__bt probe still working.

38 vitest + 75 python.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captures frames back to back and reports when the screen changed, so
'the display feels slow' becomes a number instead of an impression.

Capture is not free — a frame crosses the same link as everything else —
so the cadence is measured and printed rather than assumed, and should
be read as the resolution of the measurement (~1s over Bluetooth at
default quality; lower --quality for a finer grid).

One caveat worth knowing: anything animating, such as the progress bar,
changes the frame every capture. For a specific milestone, drive it from
the page instead and time from navigation:

  ./carthing-debug.py eval "new Promise(r => { ... performance.now() ... })"

which is how the 11.9s load-to-track figure behind the client reconnect
fix was measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edward-rosado
edward-rosado changed the base branch from feature/bluetooth-transport to main August 7, 2026 14:40
@edward-rosado
edward-rosado merged commit 81054ac into main Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants