Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2e080f5
docs: stage the direct-path scope (WebRTC after authorization)
nedtwigg Sep 10, 2026
b9aea5a
feat(remote): the direct path's shared signaling and cutover
nedtwigg Sep 10, 2026
1e59d93
feat(remote): the peer wrapper both direct ends share
nedtwigg Sep 10, 2026
ab479ea
feat(remote): both ends offer, answer, and cut over to the direct path
nedtwigg Sep 10, 2026
cbd67be
test(remote): drive the direct path end to end, in process
nedtwigg Sep 10, 2026
2233879
docs: promote the direct path above the fold
nedtwigg Sep 10, 2026
9b9b68c
fix(remote): end a session whose peer switched onto an abandoned channel
nedtwigg Sep 10, 2026
7a9103f
test(remote): spell the rejected control character, so the file stays…
nedtwigg Sep 10, 2026
e9c7fdc
feat(remote): the standalone Burrow answers a direct offer
nedtwigg Sep 10, 2026
0c6f8bf
test(remote): share the end-to-end loop the ceremonies run in
nedtwigg Sep 10, 2026
f0373a0
test(remote): run the direct path over the real addon, end to end
nedtwigg Sep 10, 2026
30c98a9
chore(website): disclose the WebRTC addon and its per-platform prebuilds
nedtwigg Sep 10, 2026
4c8dd56
docs: promote the standalone Burrow's direct-path answer
nedtwigg Sep 10, 2026
b32f8c8
docs(security): state the direct path's place in the remote trust model
nedtwigg Sep 10, 2026
19d534f
lint(e2e): refuse an ICE server, and a Relay that names an SDP
nedtwigg Sep 10, 2026
91909c9
docs(security): say publicly that a session may leave the Relay
nedtwigg Sep 10, 2026
9f86c30
fix(remote): copy a held direct frame out of the event's buffer
nedtwigg Sep 10, 2026
5676865
docs(security): describe the ICE listener as measured
nedtwigg Sep 10, 2026
9bbee8e
build(standalone): assert sidecar externals from the metafile
nedtwigg Sep 10, 2026
3134f06
refactor(remote): put the direct attempt's lifecycle in DirectCutover
nedtwigg Sep 10, 2026
ccd9af3
build(website): describe an absent prebuild from its declared sibling
nedtwigg Sep 10, 2026
b686fe3
refactor(remote): one DirectEndpoint, two ends
nedtwigg Sep 10, 2026
45dda53
refactor(remote): let a closed peer retain nothing, and drop dead exp…
nedtwigg Sep 10, 2026
5d5881b
test(remote): share the fakes the remote suites had each copied
nedtwigg Sep 10, 2026
613f193
refactor(host): the native peer factory takes no options
nedtwigg Sep 10, 2026
44ee25d
docs(remote): point the direct path's rules at the endpoint that hold…
nedtwigg Sep 10, 2026
0a68f27
fix(remote): size the direct path's holding queue for a real terminal…
nedtwigg Sep 10, 2026
d3254d3
fix(remote): one relay-frame path for both ends, and decode inside it
nedtwigg Sep 10, 2026
5bff45d
fix(remote): stop a chunked message at the send that ended the session
nedtwigg Sep 10, 2026
038237e
fix(remote): a replaced Client session takes its peer with it
nedtwigg Sep 10, 2026
953129f
fix(remote): give the answerer the setup deadline that fires first
nedtwigg Sep 10, 2026
dc0a0f0
refactor(remote): copy a channel frame at the hold, and nowhere else
nedtwigg Sep 10, 2026
8d0911d
fix(remote): a closed peer cancels the gathering deadline it is waiti…
nedtwigg Sep 10, 2026
24de2d8
refactor(remote): share the endpoint's post-await guard, and tighten …
nedtwigg Sep 10, 2026
bf1fee7
docs(remote): say where a signal is read out of the receipt
nedtwigg Sep 10, 2026
9189d6b
feat(remote): bound and guard what the direct path rides on
nedtwigg Sep 10, 2026
6252a21
build(standalone): check the signed sidecar can load the WebRTC addon
nedtwigg Sep 10, 2026
3fb268c
test(remote): negotiate a real browser against the real addon
nedtwigg Sep 10, 2026
99436f7
refactor(remote): hand the phone a cause, not an attempt's failure text
nedtwigg Sep 10, 2026
30ab785
fix(remote): close the review's holes, and stop claiming the one that…
nedtwigg Sep 10, 2026
98a7f79
docs: keep "cutover" meaning one thing in the remote specs
nedtwigg Sep 10, 2026
ee8394a
fix(remote): retire old sessions before replacement handshakes
nedtwigg Sep 10, 2026
15cc5a7
fix(remote): retire a session no earlier than the request that races it
nedtwigg Sep 10, 2026
53e6aa7
Merge remote-tracking branch 'origin/main' into pocket-webrtc-2
nedtwigg Sep 10, 2026
a4b84c7
fix(remote): say why a session stayed relayed, for the operator too
nedtwigg Sep 10, 2026
f843ff9
refactor(remote): one place decides which path a byte takes
nedtwigg Sep 11, 2026
14327ef
fix(standalone): keep the inline check loud when the manifest stops d…
nedtwigg Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/audit/application-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ in one and quietly absent from another is a finding.
The end-to-end boundary is where the depth goes. Its modules are
`remote-lib-common/src/security/noise.ts`, `noise-transport.ts`,
`e2e-ceremony.ts`, `e2e-bounds.ts`, `token-bucket.ts`, `push-seal.ts`,
`pairing-invitation.ts`, `presence.ts` and `acl.ts`;
`pairing-invitation.ts`, `presence.ts`, `acl.ts` and `direct-path.ts`;
`remote-lib-common/src/remote/wire.ts` (the frame shapes and their guards);
`lib/src/remote/direct/direct-endpoint.ts` and `direct-peer.ts` (the data
channel the same session may move onto, and the one switching policy both ends
run — `docs/specs/security-remote.md` -> "Direct path");
`lib/src/remote/burrow/burrow-runtime.ts` (both ceremonies, every Burrow bound);
`lib/src/remote/burrow/push-delivery.ts`; `lib/src/remote/client/pocket-client.ts`
and `lib/src/remote/pocket-app/sw.ts` (the phone, and the render sink);
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig

Two macOS packaging edge cases the script enforces; each would ship a release that fails only on the user's machine:

- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, node-pty prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and `require('node-pty')` from it.
- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it.
- **Build the `.tar.gz` with `COPYFILE_DISABLE=1`** and re-scan the result for `._*` entries — AppleDouble resource-fork files make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`.

### Packaged app logging
Expand Down
37 changes: 37 additions & 0 deletions docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,43 @@ nothing** — there is no frame to send — and this Client's relay socket is to
Source of truth: `PocketClient.sendKeepalive` / `#reapedByBurrow` and the injected
timer, clock, and visibility seams in `lib/src/remote/client/pocket-client.ts`.

## The path the session takes

**Pocket offers a direct path once the connection outcome says `ok`**, over the
browser's own `RTCPeerConnection` with no ICE servers, and keeps the session on
the relay when the browser has none or the Burrow declines
([remote-api.md](./remote-api.md) → Direct path owns the whole protocol).

**Must retire the previous session — its peer, its channel, and its pending
requests — immediately before the replacement's connection request goes out, and
never report burrow loss for it.** The Burrow closes the old channel at
promotion, and on a direct path that close travels peer-to-peer while the
outcome travels over the relay, so it can arrive first and fail the
replacement's own waiter. **Never earlier than that**: a presence proof the user
dismisses, or a handshake that fails, leaves a working session untouched, and a
replacement refused after the request has gone leaves none. Pinned by
`preserves a replacement connection when the old channel closes before its
outcome arrives` and `leaves a working session alone when the replacement never
reaches the Burrow` in `lib/src/remote/client/pocket-client.test.ts`.

**The connected header names the live path** — `relay` or `direct`, captioned,
never coloured — so a relayed fallback is visible rather than silent, **with the
reason behind it in the hover text and never in the label**: an attempt that
quietly stayed relayed is still `relay`, and a third state for the common case
would read as a fault. **The transport hands up a `DirectRelayCause`, never its
failure text**, and Pocket owns the sentence for each: what an attempt fails
with includes a runtime's own exception message, which belongs in the operator's
log. **A
channel that dies after this session has switched is burrow loss**: the phone
leaves the wall exactly as it does for a `burrow-gone`, and returning costs a
fresh handshake and one WebAuthn prompt. Before the switch a failed channel
costs nothing.

Source of truth: `PocketClient.connect` / `transportPath` /
`setOnTransportChanged` in `lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` /
`TRANSPORT_RELAY_CAUSES` / `transportTitle` in
`lib/src/remote/pocket-app/App.tsx`.

## An expired session drops to sign-in

Sessions live only in the Relay's memory ([relay.md](./relay.md)), so they end
Expand Down
3 changes: 3 additions & 0 deletions docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,9 @@ framed as application messages on the Noise session (below).
reorder (which Noise's counter turns into a decrypt failure), or a framing
violation destroys it and every later call throws — there is no
resynchronization point in a stream cipher.
- **The control messages are the two ceremonies' outcomes and the direct path's
four signals** ([remote-api.md](./remote-api.md) → Direct path), which the
Relay routes without reading, like every other ciphertext.
- **Prologues are `lengthPrefixedConcat`** of `dormouse/e2e/v1`, the ceremony
kind, the `burrowId`, and — for a connection — the connection id; for a pairing,
every field of its invitation in QR order ("Setup tokens and the pairing QR"
Expand Down
145 changes: 142 additions & 3 deletions docs/specs/remote-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,140 @@ Source of truth: the surface model the wire shapes reuse — `dor/src/protocol.t

Source of truth: `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`.

### Direct path

After authorization the same Noise session moves off the Relay onto a WebRTC
data channel. **The presence protocol is inherited unchanged and the Relay is
never trusted with authorization.** **The standalone Burrow answers**, over
`node-datachannel`'s W3C polyfill in the sidecar — **loaded at the first offer,
never at boot**, a load failure declining from then on
([standalone.md](./standalone.md) → "Burrow service"). **VS Code declines**: it
carries no addon ([Future](#future)).

**Every signal rides inside the session**, as one of four control messages
([relay.md](./relay.md) → E2E framing) on the established session over the relay
path: `direct-offer` (Client→Burrow, SDP), `direct-answer` (Burrow→Client, SDP),
`direct-decline` (Burrow→Client), `direct-switch` (either direction) — each
`{ v: 1, t }` with exact keys and no other field. **The Relay never sees an SDP,
a candidate, or that a direct path exists.** **An unknown control shape on an
established session is ignored, never a session failure**, so a peer without
this stack simply stays relayed.

**The Client offers once, after `ConnectionOutcomeV1 { ok: true }`, and never
retries**; it is always the offerer and creates the one ordered, reliable data
channel (`dormouse`, `arraybuffer`). **The Burrow answers at most one offer per
session**, and declines where it has no peer to build. **Each side sends its
whole description only after ICE gathering completes** — no trickle — bounded by
`DIRECT_GATHER_TIMEOUT_MS`, past which what it has is what travels. **The
answerer's setup budget is the shorter one** (`DIRECT_ANSWER_TIMEOUT_MS`, not
`DIRECT_SETUP_TIMEOUT_MS`), since it arms a relay hop later and must be the end
that gives up first. **An SDP
over `MAX_DIRECT_SDP_LENGTH` is never sent**: the Client skips the offer, the
Burrow declines. That bound derives from `CONTROL_PAYLOAD_SIZE`, so a maximal
signal always fits one control body.

**No ICE servers**, and **never a public STUN or TURN default**: `iceServers:
[]` at both ends, host candidates only. (rationale)

**The two shipped stacks are proven against each other by hand.** No CI job has
a browser, so `scripts/direct-interop/run.mjs` negotiates a real browser against
the real addon over the shipped `DirectPeer` — measuring the browser's offer
against `MAX_DIRECT_SDP_LENGTH`, which is a property of the host's interfaces
rather than of the code (rationale).

**Every byte on the channel is a Noise transport message of the promoted
session**: one message per channel frame, raw bytes, the same two `CipherState`s
and counters. **Every inbound channel frame is bounded at
`NOISE_MAX_MESSAGE_LENGTH` before decryption**, and a frame over it — or a
non-binary channel message — disposes the session. (rationale)

**The channel a session rides is reliable, ordered, and named
`DIRECT_CHANNEL_LABEL`**, and one whose association reports a per-message limit
under `NOISE_MAX_MESSAGE_LENGTH` is refused: both are checked before the open is
reported, so either abandons the attempt while the relay is still carrying the
session, and an answerer that refuses before it has answered declines rather
than leaving the offerer to wait out its setup budget. A limit the
implementation does not report is not treated as small.

**Two limits of those checks are known and accepted.** The reliability flags
reach only as far as the implementation reports them, and `node-datachannel`'s
polyfill rebuilds an incoming channel with its own defaults — so on the
standalone Burrow only the label comparison is load-bearing (rationale). And the
message limit is the *remote's* advertised one, so it is per direction: where
the two ends disagree, a peer that has already switched loses the session rather
than staying relayed.

**A sender bounds its own queue rather than the implementation's.** Past
`DIRECT_BUFFER_HIGH` of buffered channel data the ciphertext queues, draining at
`DIRECT_BUFFER_LOW`; once anything is queued everything queues, so nothing
overtakes a frame encrypted before it. **A frame is written once or not at all** —
the implementation's send either consumes a message or throws, and a retry would
put counted ciphertext on the wire twice. Overflowing
`MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` disposes the session,
as the receiver's hold does. **Each failure is reported in its own words**: this
end's queue overrunning and the channel refusing a write are opposite diagnoses,
and the reason is all an operator reading a burrow-loss log has to tell them
apart.

**The switch preserves order per direction:**

* A sender's `direct-switch` is its **last** message on the relay path; every
later message, keepalives included, goes on the channel.
* A receiver processes relay frames until it decrypts `direct-switch`, holding
channel frames meanwhile — at most `MAX_DIRECT_PENDING_FRAMES` /
`MAX_DIRECT_PENDING_BYTES`, **overflow disposing the session** — then drains
them in arrival order through the same decrypt path.
* **After inbound has switched, a relay `transport` frame disposes the
session**, refused before any decrypt, as does a `ct` that will not decode.
* **After either direction has switched, the channel closing or erroring
disposes the session**: the Client reports burrow loss exactly as a
`burrow-gone`, the Burrow disposes the established entry. **Before any switch
a channel failure only abandons the attempt** — including a channel not open
by `DIRECT_SETUP_TIMEOUT_MS` — and the session stays relayed.
* **A `direct-switch` arriving at an end that has abandoned its channel ends the
session** too: nothing that peer sends can arrive, and the alternative is a
session whose every request hangs unanswered.
* **A peer that does not switch back within `DIRECT_HANDOFF_TIMEOUT_MS` ends the
session.** From its own switch this end sends only on the channel, so the wait
is its own deadline rather than however long the hold takes to fill; an end
whose peer had already switched waits on nothing.
* **A connection reporting `failed` or `closed` ends the attempt at once, and
`disconnected` is waited out** for `DIRECT_DISCONNECTED_GRACE_MS` — ICE reports
it on gaps that recover, and after the switch ending one costs a fresh
handshake and a WebAuthn prompt.

**The Relay stays the lifecycle authority.** `client-gone`, `burrow-gone`, and
either relay socket closing dispose the session, channel included, exactly as
they do relayed; the idle deadline, keepalives, and every Burrow bound are
path-agnostic — a keepalive decrypted off the channel refreshes the deadline
like any other ([remote-security-model.md](./remote-security-model.md) → Burrow
bounds).

**One peer connection per session**, created at the offer, closed on every
disposal path, never existing before promotion. **Both ends build it through an
injected factory** — `PocketClientDeps.createDirectPeer`,
`BurrowOptions.createDirectPeer`, threaded through `BurrowServiceOptions` —
`null` where a runtime has none, so neither end reaches a WebRTC global.
**Pocket shows which path carries the session**, and where it stayed relayed
which of the three `DirectRelayCause`s it was — **a closed set, never an
attempt's failure text** ([pocket-app.md](./pocket-app.md)).

Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals,
their guard, the constants, the `DirectFrameQueue` both queues are, and the
`DirectCutover` both ends run),
`lib/src/remote/direct/direct-peer.ts` (`DirectPeerLike` and the negotiation),
`DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` (the whole
direct-path policy, one per authorized session; `onRelayFrame` is both ends' only
way in from the relay and `send` their only way out; constructed at promotion by
`PocketClient.#directEndpoint` in `lib/src/remote/client/pocket-client.ts` and
`BurrowRuntime.#promoteConnection` in
`lib/src/remote/burrow/burrow-runtime.ts`); pinned by
`remote-lib-common/test/direct-path.test.mjs`,
`lib/src/remote/direct/direct-endpoint.test.ts`,
`lib/src/remote/direct/direct-peer.test.ts`, and the end-to-end cases in
`lib/src/remote/client/pocket-client.test.ts` and
`lib/src/remote/burrow/burrow-bounds.test.ts`.

### Envelope

Requests are correlated by `requestId`, events by `subId` (`RemoteRequest`, `RemoteResponse`, `RemoteEventMsg`).
Expand Down Expand Up @@ -277,9 +411,14 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ

**Window lease.** A VR session may request `window.lease { windowRef }`, declaring itself that Window's primary display. Sizing needs no lease — last-attach-wins already hands VR the panes it displays — so the lease is presentational: that Window tethers wholesale instead of pane by pane, and panes created in it while the lease is held open tethered to the leaseholder. One lease per Window; the Burrow user can always reclaim it locally. Phones never need it.

### 8. WebRTC rendezvous
### 8. Direct path (WebRTC)

**Scope: direct-path** — latency. The shipped half is [Transport → Direct path](#direct-path), which Pocket and the standalone Burrow speak today. What remains, in staged order:

1. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`).
2. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale.

Latency. WebRTC replaces only the relay *transport* of the same Noise transport messages ([Transport](#transport)), and only after authorization: the Relay signals but is never trusted with authorization, and the presence protocol is inherited unless separately reviewed.
Relay-supplied ICE servers are unstaged (SaaS), as is a session surviving relay loss.

### 9. Audio

Expand All @@ -292,4 +431,4 @@ Browser surfaces can produce audio; VR will want it (spatial, per-panel).

### Open questions

* **Browser media**: screencast frames over the WebSocket first; when WebRTC arrives, a video track would be smoother for VR. Possibly phone=frames, VR=track, negotiated in the hello.
* **Browser media**: screencast frames over the WebSocket first; once the direct path ships, a video track would be smoother for VR. Possibly phone=frames, VR=track, negotiated in the hello.
Loading