From 2e080f54a6b512982bde7ba98ec14fc91e63089f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 17:48:38 -0700 Subject: [PATCH 01/46] docs: stage the direct-path scope (WebRTC after authorization) Replaces remote-api.md's one-line Future item 8 with the staged design: signaling inside the Noise session, host-candidates-only ICE, per-direction cutover with bounded holding, the Relay kept as lifecycle authority. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/remote-api.md | 23 ++++++++++++++++++++--- scripts/spec-word-budgets.json | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 6a6a0ce96..cd677e7f1 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -277,9 +277,26 @@ 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) -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. +**Scope: direct-path** — latency. After authorization the same Noise session moves off the Relay onto a WebRTC data channel between phone and laptop; the presence protocol is inherited unchanged and the Relay is never trusted with authorization. Staged order: + +1. **Shared plumbing** — `remote-lib-common`: the signaling controls, their guards, the cutover state machine and its bounds; a `direct` module under `lib/src/remote/`: one `RTCPeerConnection`-shaped peer wrapper both ends share, its peer factory injected (`PocketClientDeps.createDirectPeer`, `BurrowOptions.createDirectPeer`), null where a runtime has none. +2. **Pocket offers; the standalone Burrow answers** — Pocket over the browser's `RTCPeerConnection`; the sidecar over `node-datachannel`'s W3C polyfill, a native addon declared in `standalone/sidecar/package.json` beside `node-pty`, loaded lazily at the first offer, a load failure answering `direct-decline`. The VS Code Burrow declines. +3. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, the supply-chain disclosure regenerated, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. +4. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). +5. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. + +The design stages 1–3 build: + +* **Signaling rides inside the session**, as control messages (`docs/specs/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). **The Relay never sees an SDP, a candidate, or that a direct path exists**; a peer without the stack ignores or declines, and the session stays relayed — an old Pocket or Burrow needs nothing. +* **Offered only after `ConnectionOutcomeV1 { ok: true }`**, once per session, never retried. The Client is the offerer and creates one ordered, reliable data channel; each side sends its SDP after ICE gathering completes (no trickle), bounded by `CONTROL_PAYLOAD_SIZE` — a Burrow whose answer would exceed it declines. +* **No ICE servers.** `iceServers: []` at both ends, host candidates only: the shipped deployment is a tailnet, so the Burrow's tailnet address is a host candidate and the phone's mDNS-obfuscated one is learned peer-reflexively. **Never a public STUN or TURN default** — it would hand a third party the user's address. Relay-supplied ICE servers are unstaged (SaaS). +* **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 frame bounded at `NOISE_MAX_MESSAGE_LENGTH` before decryption. DTLS beneath is transport hygiene the model does not rely on; its fingerprints are authentic because the SDP arrived inside the session. +* **Cutover preserves order per direction.** A sender's `direct-switch` is its last message on the relay path; every later message 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. After the switch, a relay transport frame on that connection, or the channel closing, disposes the session: burrow loss, a fresh handshake to return. A channel not open by `DIRECT_SETUP_TIMEOUT_MS` is closed and the session stays relayed. +* **The Relay stays the lifecycle authority.** `client-gone`, `burrow-gone`, and either relay socket closing dispose the session, channel included, exactly as today; the idle deadline, keepalives, and every Burrow bound are path-agnostic. A session surviving relay loss is unstaged. +* **One peer connection per session**: created at the offer, closed on every disposal path, never existing before promotion. +* **Pocket shows which path carries the session**, so a relayed fallback is visible rather than silent. ### 9. Audio @@ -292,4 +309,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. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index a13393796..85ab847a3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/notepad.md": 3700, "docs/specs/pocket-app.md": 4050, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 3600, + "docs/specs/remote-api.md": 4050, "docs/specs/remote-security-model.md": 4200, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, From b9aea5a9fc4ba67c8f1a861a66a0c6d2d8f472bf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 17:58:21 -0700 Subject: [PATCH 02/46] feat(remote): the direct path's shared signaling and cutover The four `direct-*` control shapes with their guard, the bound that keeps a signal inside one control message, and the per-end `DirectCutover` that moves a session off the relay in order (`docs/specs/remote-api.md` -> Future -> "Direct path", stage 1). No endpoint uses it yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- remote-lib-common/src/index.ts | 1 + remote-lib-common/src/security/direct-path.ts | 233 ++++++++++++++++++ remote-lib-common/test/direct-path.test.mjs | Bin 0 -> 7373 bytes 3 files changed, 234 insertions(+) create mode 100644 remote-lib-common/src/security/direct-path.ts create mode 100644 remote-lib-common/test/direct-path.test.mjs diff --git a/remote-lib-common/src/index.ts b/remote-lib-common/src/index.ts index 816b98908..0f4dde2e7 100644 --- a/remote-lib-common/src/index.ts +++ b/remote-lib-common/src/index.ts @@ -28,6 +28,7 @@ export * from './security/push.js'; export * from './security/push-seal.js'; export * from './security/pairing.js'; export * from './security/e2e-bounds.js'; +export * from './security/direct-path.js'; export * from './security/token-bucket.js'; export * from './security/pairing-invitation.js'; export * from './security/e2e-ceremony.js'; diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts new file mode 100644 index 000000000..c07ca1b2e --- /dev/null +++ b/remote-lib-common/src/security/direct-path.ts @@ -0,0 +1,233 @@ +/** + * The direct path's shared half: the four signaling controls that ride inside + * an established session, and the per-end cutover state machine that moves that + * session off the relay (`docs/specs/remote-api.md` -> Transport -> "Direct + * path"). + * + * One implementation, so the two ends cannot disagree about what a switch + * means. Nothing here knows about `RTCPeerConnection`, the relay envelope, or + * either endpoint's plumbing: a signal is a control message like any other + * (`docs/specs/relay.md` -> "E2E framing") and a held frame is bytes. + */ + +import { isBoundedString } from './bytes.js'; +import { CONTROL_PAYLOAD_SIZE } from './noise-transport.js'; + +/** + * How long a peer waits for the channel to open before abandoning the attempt + * and staying relayed. Generous: gathering, DTLS, and SCTP all fit inside it on + * a link that works at all, and the cost of waiting is nothing — the session is + * carrying traffic on the relay the whole time. + */ +export const DIRECT_SETUP_TIMEOUT_MS = 15_000; + +/** + * How long a peer waits for ICE gathering to finish before sending whatever + * local description it has. There is no trickle path — the SDP crosses inside + * the session as one control message — so a gatherer that never completes must + * not strand the attempt. + */ +export const DIRECT_GATHER_TIMEOUT_MS = 3_000; + +/** + * The most SDP one signal may carry, in characters. + * + * Derived from {@link CONTROL_PAYLOAD_SIZE}, which every control body is padded + * to and may not exceed: with the SDP restricted to the characters SDP is made + * of ({@link isDirectSdp}), JSON encodes each of them as at most two bytes, so + * `2 * MAX_DIRECT_SDP_LENGTH` plus the envelope always fits. The relationship + * is pinned by `remote-lib-common/test/direct-path.test.mjs`. + * + * A description longer than this is not sent: the offerer skips the attempt and + * an answerer declines, and the session stays relayed. + */ +export const MAX_DIRECT_SDP_LENGTH = 2000; + +/** How many channel frames a receiver holds while awaiting the peer's switch. */ +export const MAX_DIRECT_PENDING_FRAMES = 64; + +/** How many bytes of held channel frames a receiver holds, whatever the count. */ +export const MAX_DIRECT_PENDING_BYTES = 1024 * 1024; + +/** + * Which path carries a session's traffic. `direct` only once **both** + * directions have switched — until then the relay is still carrying half of it, + * and telling the user otherwise would be a claim about a path that is not yet + * the only one. + */ +export type DirectPath = 'relay' | 'direct'; + +/** + * The signaling messages, as `control` transport plaintexts on an established + * session. Versioned and discriminated so a peer that does not know them + * ignores them rather than failing the session. + * + * `direct-offer` is Client->Burrow, `direct-answer` and `direct-decline` are + * Burrow->Client, and `direct-switch` travels in either direction as its + * sender's last message on the relay path. + */ +export type DirectSignalV1 = + | { readonly v: 1; readonly t: 'direct-offer'; readonly sdp: string } + | { readonly v: 1; readonly t: 'direct-answer'; readonly sdp: string } + | { readonly v: 1; readonly t: 'direct-decline' } + | { readonly v: 1; readonly t: 'direct-switch' }; + +/** The `t` of every signal, so a dispatcher cannot invent a fifth. */ +export type DirectSignalType = DirectSignalV1['t']; + +/** + * The characters an SDP may be made of: printable US-ASCII plus CR and LF. + * + * Restricting them is what turns {@link MAX_DIRECT_SDP_LENGTH} from a character + * bound into a byte bound — every one of these JSON-encodes to at most two + * bytes — and every description either end generates is already inside it. A + * description that is not stays out of the session: the attempt is abandoned + * and the relay keeps carrying it. + */ +export function isDirectSdp(value: unknown): value is string { + if (!isBoundedString(value, MAX_DIRECT_SDP_LENGTH)) return false; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code === 0x0d || code === 0x0a) continue; + if (code < 0x20 || code > 0x7e) return false; + } + return true; +} + +/** + * Structural validation of a decrypted control message that claims to be a + * signal. Authenticated by Noise, which proves *who* sent it and nothing about + * its shape — so the keys are exact, the version is the one this end speaks, + * and the SDP is bounded before anything reads it. + * + * Answers `false` for every other control message rather than throwing: an + * unknown control shape on an established session is ignored, never a session + * failure, which is what lets a peer without this stack stay talking. + */ +export function isDirectSignalV1(value: unknown): value is DirectSignalV1 { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const signal = value as Record; + if (signal.v !== 1) return false; + const keys = Object.keys(signal); + switch (signal.t) { + case 'direct-offer': + case 'direct-answer': + return keys.length === 3 && isDirectSdp(signal.sdp); + case 'direct-decline': + case 'direct-switch': + return keys.length === 2; + default: + return false; + } +} + +/** What a relay transport frame may do once this end has read the peer's switch. */ +export type DirectRelayOutcome = 'process' | 'violation'; + +/** What one inbound channel frame turned out to be. */ +export type DirectChannelOutcome = 'process' | 'held' | 'overflow'; + +/** + * One end's view of the cutover, which is per direction and never negotiated: + * each side switches its own sends and learns about the peer's when the peer's + * `direct-switch` decrypts. + * + * **Order is preserved per direction** because a sender's switch is its last + * relay message and a receiver holds channel frames until it has read that + * switch — so nothing sent after it can be processed before what was sent + * before it. The holding queue is bounded in both frames and bytes; a peer that + * overruns it is one this end cannot keep in order, which is a dead session + * rather than a dropped frame. + */ +export class DirectCutover { + #outbound: DirectPath = 'relay'; + #inbound: DirectPath = 'relay'; + readonly #held: Uint8Array[] = []; + #heldBytes = 0; + + /** Where this end's own messages go. */ + get outbound(): DirectPath { + return this.#outbound; + } + + /** Where the peer's messages come from, as of the last switch it sent. */ + get inbound(): DirectPath { + return this.#inbound; + } + + /** What carries the session as a whole; see {@link DirectPath}. */ + get path(): DirectPath { + return this.#outbound === 'direct' && this.#inbound === 'direct' ? 'direct' : 'relay'; + } + + /** Whether either direction has left the relay — what a channel loss ends. */ + get switched(): boolean { + return this.#outbound === 'direct' || this.#inbound === 'direct'; + } + + get pendingFrames(): number { + return this.#held.length; + } + + get pendingBytes(): number { + return this.#heldBytes; + } + + /** + * Move this end's sends onto the channel. The caller sends its + * `direct-switch` on the relay *first*: this is the line after which nothing + * else may. + * + * Answers `false` for a second call, so a duplicate open cannot put two + * switches on the wire. + */ + switchOutbound(): boolean { + if (this.#outbound === 'direct') return false; + this.#outbound = 'direct'; + return true; + } + + /** + * One transport frame arriving on the relay. Once the peer has switched there + * is nothing left for it to send there, so a frame that arrives anyway is a + * peer this end can no longer keep in order with the channel. + */ + onRelayTransport(): DirectRelayOutcome { + return this.#inbound === 'direct' ? 'violation' : 'process'; + } + + /** + * The peer's `direct-switch` decrypted: everything held is now known to come + * after it. Returns the held frames in arrival order, and empties the queue. + */ + onSwitchDecrypted(): Uint8Array[] { + this.#inbound = 'direct'; + const drained = [...this.#held]; + this.#held.length = 0; + this.#heldBytes = 0; + return drained; + } + + /** + * One inbound channel frame: processed once the peer's switch has been read, + * held until then, and `overflow` when holding it would break the bound. + */ + onChannelFrame(frame: Uint8Array): DirectChannelOutcome { + if (this.#inbound === 'direct') return 'process'; + if ( + this.#held.length >= MAX_DIRECT_PENDING_FRAMES || + this.#heldBytes + frame.length > MAX_DIRECT_PENDING_BYTES + ) { + return 'overflow'; + } + this.#held.push(frame); + this.#heldBytes += frame.length; + return 'held'; + } + + /** Release held frames; a disposed session has nothing left to drain them. */ + clear(): void { + this.#held.length = 0; + this.#heldBytes = 0; + } +} diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1f5ee7a1bc53c08daf38d9e4375a5be8f0b164e0 GIT binary patch literal 7373 zcmcIp|4ti8628BAin=J0P1ZkvWVMkKuZ{&L%PIi`&gu5D5i~Pxd&0P9wz~(1MM(D= z`v~_O_a^rw_f>a~?J;&RD3?faJkwoWKfd~^s@;C}Y=fRreD2A zLmC!B#|`)JA}dsk{|QT%Qs#z4SVxI6Nh~Q-k_ASYvN}s?ER7LE*&yMzNs0=aT}e&G z3M*+WqEVtGU1WAdQpJXJp;{17T8L+PmMCldnXyi;Bqf$$DV?XuMce2o*w^;n2p`Q{ zO5L*3*p@ZtEUwfbQO#Uu5ftMSn>K6WgCb$1ff00`XVIm!%_vQz!sZ}>+AQPzqR={< zG@+$U#&Y_WRc>!=BxC2(8{%DbO9P#asiv}6cKAu{Rk=bKBjNnH_x?%SSe-${`f{j<}9fA$)9zJGAi+v}gb-R=L}J2~qgyzd=-=%2kmoqKoM z>wh?2eC1-j=L5lO-|zn8EXX?U9qu0-zCHWt*M85*S@Q19$?p3*FHiT6&))S8-}ZlI ziBd%{dWy2+R3L`(i{*ET@nAcR^U7BTx4&=mv%(Hu_MC{uM(M>?3z0Rpov2uTX?-^A zBcKd^#?jQJ+EsV!H~pK+x=q@q9XdFqgX0&pz4JqB3xBuyo#{6D!*;jGtz{YQHZ_|~ zmre_a=jRXpv$4UEuGd5q$=tzK(jY4UcxN9H24GWwJ13S*jjT1OzDeC5i6iY%(8@0B zQ!fR#)NkmjL)*y04%Ix;o7rF>HTIi0?>H~qZf?GMuoN)Co`J7C5M?!Kq*1QEgTUdq2*_gBs>1vd)={DVmJz*ubi<-pd^!kyJE^NczGo% zgG^K1W9>?$1xRi{A61d24FVg=0n7$HmQe|&H0Xy0eOwZ?si~hnZMvY(hyAX(?nEEg zTf`P{-K9Zc3lLyX8X4tmfpv!Yx5HL}{XAtxLb0J*ig=E!hbq(3czH~x03{gt8vH9$ zaSb= zHR-wm7m$Q&uDKxQtMY5viT5U0gOW&|O3jY%(8rpwh3b5%eY%VJwZu=6A_i9K4m~gB zAjn+a?IbH>s9{dY8j+a$hF~j~`>HR+$3;0y+|=L6cDujd+p#A|jh! z;}q0;_7+2ATnvXPmQJ!FjVVu}%e6vX<))Gj7TIZe^uS==Qs^{J9M~EmKe-~RA)>0u zH4dz*>q06iIwQ`tJnxyaboZk+2g6?MJW@)Pc9~7?3R;Kzs&Z!`c zMRObp%k?6$Ek|UNOruZZ7TYnCBP^CS7MK{&WuqYl38AV^J!t5c)1yO@CITtVGZv@+ z1cL_TArQXH;;J3S4m_I%%s1ts9aAU=u9fl_6FB=zG8U;r;()6fL`-cCV@ri%=DGmu zI)Jj^mu*=2J*3IBy8F(75sxnQ$vyeW{Ts0!Ffb_GrD+%8NQZ9yq8V-7;R(Of1H6>> z84!U6v);PSE^c{#O$OKX>ZX}=rx6kFZ>3TVk)p4=D~A$R|NQBVy$ykIKzw>LxBJ%T z4JaLT)aBCCn`N-N4G9@WNygzhlZm4f{s?6<7jX;-IGpBbfhi8ryu_T~&MAS7x;NzWHIV1QY*nqHT4>*C2}kw926b~)lGZwnqghRtm#$Hak# zqY%%!LKY=|@PA*p!Ar>reLx+0xvRC{H26bqwgyR>))iC=Qv|wW4Oo@!F-8V=XW=I? zOb({vXrI5O_Fdv5{L~x9?)qK#hSjaCu#Vj^QMl7;tCuEf-IaVrw1z<6k@7S%5SUF@ zpPZ=^9u%e%eti?t!?g-4JXh_5Of;Lg>WEYU&aEImD7P_9uc?$Ks3Svo(~dGg1ZPrQAHI7kyLWORB}yN+!t+# zFC}K`)Q$4{GSb(%mGM`QK7((F^lH2XMQ7{g0EQL_7l&val#(erE60a?L6we-`?aL8DVt(N=7f;xwFnr zpf*Nfa3`W++WCqv{mP%Xd2P^3EadpPj>CBcwY0ltJY(De_gq;EJ8|VvS_=+w*6(b7 zx>uJ{>m^mE1Xi#wXzdpwZF@ZF`+FTXMf(#EcO0# z=l$i}CPL!jfN-BaQ(^&kuJfgYGc6FAxlH1QY>;M?X3l68$dv-ygLb(VsO$*TrK40+;mvpw|ght6AeF~ zikw?ojId?*9D>A3J|;{)^DmMiqO?Ra_e%h^X@|Sfb^N-}PC@WOfc}3%svNP(nAL2w zfRF`CJb_oC5^O=gTP9~E)?Y(>HP}%qg=RLvD399hM-_BS_?6CpknxP~r!l;u15hsX lj&UCqaR19sYr$I2)-aL-U0UPR`br6}Slgfn@i!CC{{SA9W^4ce literal 0 HcmV?d00001 From 1e59d935df8832f2731ea25da3c14391586379c0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:02:13 -0700 Subject: [PATCH 03/46] feat(remote): the peer wrapper both direct ends share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DirectPeerLike` / `DirectChannelLike` — the subset of the W3C API this stack calls, so the browser's `RTCPeerConnection`, a native polyfill, and the linked in-memory fake all satisfy one shape — plus `DirectPeer`, which owns one negotiation, the single ordered channel, and the gathering and setup deadlines. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/direct/direct-peer.test.ts | 210 +++++++++++++++ lib/src/remote/direct/direct-peer.ts | 300 ++++++++++++++++++++++ lib/src/remote/direct/test-fake-peer.ts | 286 +++++++++++++++++++++ 3 files changed, 796 insertions(+) create mode 100644 lib/src/remote/direct/direct-peer.test.ts create mode 100644 lib/src/remote/direct/direct-peer.ts create mode 100644 lib/src/remote/direct/test-fake-peer.ts diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts new file mode 100644 index 000000000..2c1950300 --- /dev/null +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -0,0 +1,210 @@ +/** + * The peer wrapper over the linked fake pair (`docs/specs/remote-api.md` -> + * Transport -> "Direct path"): one negotiation, one channel, and the four ways + * it can end. The end-to-end cases that drive it inside a real session are in + * `../client/pocket-client.test.ts` and `../burrow/burrow-runtime.test.ts`. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; + +import { DIRECT_CHANNEL_LABEL, DirectPeer, type DirectPeerHandlers } from './direct-peer'; +import { FakeDirectNetwork, type FakeDirectNetworkOptions } from './test-fake-peer'; + +/** Armed timers a test fires by hand, so no case waits fifteen seconds. */ +function fakeTimers() { + const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; + return { + setTimer(run: () => void, delayMs: number): () => void { + const timer = { run, delayMs, cancelled: false }; + armed.push(timer); + return () => { + timer.cancelled = true; + }; + }, + get live() { + return armed.filter((timer) => !timer.cancelled); + }, + /** Fire the one armed timer whose delay is `delayMs`. */ + fire(delayMs: number): void { + const timer = this.live.find((entry) => entry.delayMs === delayMs); + if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); + timer.cancelled = true; + timer.run(); + }, + }; +} + +function handlers(): DirectPeerHandlers & { + frames: Uint8Array[]; + opens: number; + closes: string[]; + violations: string[]; +} { + const record = { + frames: [] as Uint8Array[], + opens: 0, + closes: [] as string[], + violations: [] as string[], + onOpen: () => void (record.opens += 1), + onFrame: (frame: Uint8Array) => void record.frames.push(frame), + onClosed: (reason: string) => void record.closes.push(reason), + onViolation: (reason: string) => void record.violations.push(reason), + }; + return record; +} + +/** Both ends of one negotiation, sharing a clock the test drives. */ +function pair(options: FakeDirectNetworkOptions = {}) { + const network = new FakeDirectNetwork(options); + const timers = fakeTimers(); + const client = handlers(); + const burrow = handlers(); + return { + network, + timers, + client, + burrow, + clientPeer: new DirectPeer({ + peer: network.createOfferer(), + handlers: client, + setTimer: timers.setTimer, + }), + burrowPeer: new DirectPeer({ + peer: network.createAnswerer(), + handlers: burrow, + setTimer: timers.setTimer, + }), + }; +} + +/** Let the fake network's queued microtasks run. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('DirectPeer', () => { + it('negotiates one ordered channel and carries raw bytes both ways', async () => { + const { network, clientPeer, burrowPeer, client, burrow } = pair(); + + const offer = await clientPeer.offer(); + expect(offer).toContain('m=application'); + const answer = await burrowPeer.answer(offer!); + expect(answer).toContain('a=setup:active'); + await clientPeer.acceptAnswer(answer!); + await settle(); + + expect(client.opens).toBe(1); + expect(burrow.opens).toBe(1); + expect(clientPeer.isOpen).toBe(true); + // The label is fixed: one channel per session, named the same at both ends. + expect(network.offererChannel!.label).toBe(DIRECT_CHANNEL_LABEL); + // Set before a frame can arrive, so every message is bytes. + expect(network.offererChannel!.binaryType).toBe('arraybuffer'); + + clientPeer.send(Uint8Array.of(1, 2, 3)); + burrowPeer.send(Uint8Array.of(4, 5)); + await settle(); + + expect(burrow.frames).toEqual([Uint8Array.of(1, 2, 3)]); + expect(client.frames).toEqual([Uint8Array.of(4, 5)]); + }); + + it('sends the description it has when gathering never completes', async () => { + const { clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + let settled = false; + void offering.then(() => (settled = true)); + await settle(); + // Nothing to send yet: there is no trickle path, so the SDP waits. + expect(settled).toBe(false); + + timers.fire(DIRECT_GATHER_TIMEOUT_MS); + expect(await offering).toContain('m=application'); + }); + + it('sends as soon as gathering completes, without waiting out the deadline', async () => { + const { network, clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + await settle(); + network.completeGathering(); + + expect(await offering).toContain('m=application'); + // The gathering deadline is cancelled; only the setup one is still armed. + expect(timers.live.map((timer) => timer.delayMs)).toEqual([DIRECT_SETUP_TIMEOUT_MS]); + }); + + it('abandons a channel that never opens, and closes the connection', async () => { + const { clientPeer, burrowPeer, client, timers } = pair({ opening: 'never' }); + + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await settle(); + expect(client.opens).toBe(0); + + timers.fire(DIRECT_SETUP_TIMEOUT_MS); + + expect(client.closes).toEqual(['the direct channel did not open in time']); + expect(clientPeer.isOpen).toBe(false); + // And nothing may be sent on it afterwards. + expect(() => clientPeer.send(Uint8Array.of(1))).toThrow(); + }); + + it('reports a channel that dies under a live session, at both ends', async () => { + const { network, clientPeer, burrowPeer, client, burrow } = pair(); + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await settle(); + + network.dropChannels(); + + expect(client.closes).toEqual(['the direct channel closed']); + expect(burrow.closes).toEqual(['the direct channel closed']); + }); + + it('reports a channel message this protocol has no reading for', async () => { + const { network, clientPeer, burrowPeer, client } = pair(); + const offer = await clientPeer.offer(); + await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); + await settle(); + + network.offererChannel!.receiveRaw('a text frame'); + // Bounded before it reaches a cipher, exactly as a relay ciphertext is. + network.offererChannel!.receiveRaw(new ArrayBuffer(NOISE_MAX_MESSAGE_LENGTH + 1)); + + expect(client.violations).toEqual([ + 'a direct channel message was not binary', + 'a direct channel frame exceeds one Noise message', + ]); + // A violation is the endpoint's to act on: the channel is not torn down here. + expect(client.frames).toEqual([]); + }); + + it('skips a description too large to travel inside the session', async () => { + const offering = pair({ oversize: 'offer' }); + expect(await offering.clientPeer.offer()).toBeNull(); + + const answering = pair({ oversize: 'answer' }); + const offer = await answering.clientPeer.offer(); + expect(await answering.burrowPeer.answer(offer!)).toBeNull(); + }); + + it('answers null rather than throwing when the negotiation fails', async () => { + const timers = fakeTimers(); + const record = handlers(); + const peer = new FakeDirectNetwork().createOfferer(); + vi.spyOn(peer, 'createOffer').mockRejectedValue(new Error('no transport')); + const wrapper = new DirectPeer({ peer, handlers: record, setTimer: timers.setTimer }); + + expect(await wrapper.offer()).toBeNull(); + expect(record.closes).toHaveLength(1); + expect(peer.closed).toBe(true); + }); + + it('closes idempotently, reporting nothing', () => { + const { clientPeer, client } = pair(); + clientPeer.close(); + clientPeer.close(); + expect(client.closes).toEqual([]); + }); +}); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts new file mode 100644 index 000000000..124d63282 --- /dev/null +++ b/lib/src/remote/direct/direct-peer.ts @@ -0,0 +1,300 @@ +/** + * The `RTCPeerConnection`-shaped seam both ends of the direct path share + * (`docs/specs/remote-api.md` -> Transport -> "Direct path"). + * + * **Structural, never the DOM globals.** The interfaces below are the subset of + * the W3C API this stack calls, so the browser's `RTCPeerConnection`, the + * sidecar's native polyfill, and the in-memory fake all satisfy the same shape + * and neither endpoint reaches for a global. Constructing one is the injected + * factory's job (`PocketClientDeps.createDirectPeer`, + * `BurrowOptions.createDirectPeer`), which is also where `iceServers: []` is + * set — nothing here knows what an ICE server is. + * + * The wrapper owns the two halves of one negotiation and the channel's four + * events. It owns no policy: what a closed channel *means* depends on whether + * the session has already switched, which is the endpoint's question. + */ + +import { + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + NOISE_MAX_MESSAGE_LENGTH, + isDirectSdp, +} from 'remote-lib-common'; +import { realTimer, type RemoteTimer } from '../ws'; + +/** The label of the one data channel a session opens. */ +export const DIRECT_CHANNEL_LABEL = 'dormouse'; + +/** The four `RTCSdpType` values, so a real description assigns to ours. */ +export type DirectSdpType = 'offer' | 'answer' | 'pranswer' | 'rollback'; + +/** As much of `RTCSessionDescriptionInit` as one negotiation needs. */ +export interface DirectSessionDescription { + readonly type: DirectSdpType; + readonly sdp?: string; +} + +/** The subset of `RTCDataChannel` a Noise transport rides on. */ +export interface DirectChannelLike { + binaryType: string; + readonly readyState: string; + send(data: ArrayBuffer): void; + close(): void; + addEventListener(type: string, handler: (ev: unknown) => void): void; +} + +/** The subset of `RTCPeerConnection` one negotiation needs. */ +export interface DirectPeerLike { + createDataChannel(label: string, init?: { ordered?: boolean }): DirectChannelLike; + createOffer(): Promise; + createAnswer(): Promise; + setLocalDescription(description: DirectSessionDescription): Promise; + setRemoteDescription(description: DirectSessionDescription): Promise; + readonly localDescription: DirectSessionDescription | null; + readonly iceGatheringState: string; + addEventListener(type: string, handler: (ev: unknown) => void): void; + close(): void; +} + +/** How one endpoint constructs a peer, or answers that it has none. */ +export type DirectPeerFactory = () => DirectPeerLike | null; + +export interface DirectPeerHandlers { + /** The channel is open: this end may switch its sends onto it. */ + onOpen(): void; + /** One inbound channel frame, already bounded at `NOISE_MAX_MESSAGE_LENGTH`. */ + onFrame(frame: Uint8Array): void; + /** + * The channel went away — closed, errored, or never opened in time. Whether + * that is burrow loss or an abandoned attempt is the endpoint's call. + */ + onClosed(reason: string): void; + /** + * The peer put something on the channel this protocol has no reading for: a + * non-binary message, or one too large to be a Noise transport message. The + * session dies whichever direction has switched, because a peer speaking a + * different protocol on the channel is not one the counters can be kept + * synchronized with. + */ + onViolation(reason: string): void; +} + +export interface DirectPeerDeps { + readonly peer: DirectPeerLike; + readonly handlers: DirectPeerHandlers; + /** Every deadline below; see {@link RemoteTimer}. */ + readonly setTimer?: RemoteTimer; +} + +/** + * One session's peer connection and its single ordered, reliable data channel. + * + * **One negotiation, no trickle**: each side sends its whole description once + * ICE gathering has completed (or `DIRECT_GATHER_TIMEOUT_MS` has passed), so + * the candidates travel inside the session with the SDP and the relay never + * sees either. **The channel must be open by `DIRECT_SETUP_TIMEOUT_MS`** or the + * attempt is abandoned and the session stays relayed. + */ +export class DirectPeer { + readonly #peer: DirectPeerLike; + readonly #handlers: DirectPeerHandlers; + readonly #setTimer: RemoteTimer; + #channel: DirectChannelLike | null = null; + #cancelSetup: (() => void) | null = null; + #open = false; + #closed = false; + + constructor(deps: DirectPeerDeps) { + this.#peer = deps.peer; + this.#handlers = deps.handlers; + this.#setTimer = deps.setTimer ?? realTimer; + } + + /** Whether the channel has opened and not since gone. */ + get isOpen(): boolean { + return this.#open && !this.#closed; + } + + /** + * The offerer's half: create the channel, describe it, and answer with the + * SDP to put in a `direct-offer`. + * + * Answers `null` where there is nothing to send — a description this peer + * would not accept back, or a negotiation that threw — and the caller simply + * never offers. + */ + async offer(): Promise { + this.#armSetupTimeout(); + try { + this.#adopt(this.#peer.createDataChannel(DIRECT_CHANNEL_LABEL, { ordered: true })); + const offer = await this.#peer.createOffer(); + await this.#peer.setLocalDescription(offer); + return await this.#gatheredSdp(); + } catch (error) { + this.#fail(`could not describe a direct path: ${String(error)}`); + return null; + } + } + + /** + * The answerer's half: take the offer, wait for the channel it describes, and + * answer with the SDP to put in a `direct-answer`. `null` means decline. + */ + async answer(offerSdp: string): Promise { + this.#armSetupTimeout(); + try { + // Registered before the remote description is set: the channel event can + // fire inside `setRemoteDescription`, and a listener added after it would + // miss the only one this negotiation sends. + this.#peer.addEventListener('datachannel', (ev) => { + const channel = (ev as { channel?: DirectChannelLike } | null)?.channel; + if (channel) this.#adopt(channel); + }); + await this.#peer.setRemoteDescription({ type: 'offer', sdp: offerSdp }); + const answer = await this.#peer.createAnswer(); + await this.#peer.setLocalDescription(answer); + return await this.#gatheredSdp(); + } catch (error) { + this.#fail(`could not answer a direct path: ${String(error)}`); + return null; + } + } + + /** The offerer's second half, once `direct-answer` has decrypted. */ + async acceptAnswer(answerSdp: string): Promise { + try { + await this.#peer.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + } catch (error) { + this.#fail(`could not accept a direct answer: ${String(error)}`); + } + } + + /** + * One Noise transport message as one channel frame — raw bytes, never base64 + * or JSON, so the channel carries exactly what the relay would have. + * + * Throws if the channel cannot take it, which is the same signal a refused + * relay send is: the caller's session is dead either way. + */ + send(ciphertext: Uint8Array): void { + const channel = this.#channel; + if (!channel || !this.isOpen) throw new Error('the direct channel is not open'); + // Copied into its own buffer: `send` takes an `ArrayBuffer`, and a view's + // backing buffer is the transport's reused one. + channel.send(ciphertext.slice().buffer as ArrayBuffer); + } + + /** Close the channel and the connection. Idempotent, and reports nothing. */ + close(): void { + this.#closed = true; + this.#clearSetupTimeout(); + try { + this.#channel?.close(); + } catch { + // Already closing. + } + try { + this.#peer.close(); + } catch { + // Already closed. + } + } + + // --- Internals ------------------------------------------------------------- + + /** Wire one channel's four events, whichever side created it. */ + #adopt(channel: DirectChannelLike): void { + if (this.#channel) return; + this.#channel = channel; + // Set before any message can arrive, so every frame is bytes rather than a + // `Blob` this stack has no synchronous way to read. + channel.binaryType = 'arraybuffer'; + channel.addEventListener('open', () => { + if (this.#closed || this.#open) return; + this.#open = true; + this.#clearSetupTimeout(); + this.#handlers.onOpen(); + }); + channel.addEventListener('message', (ev) => this.#onMessage(ev)); + channel.addEventListener('close', () => this.#fail('the direct channel closed')); + channel.addEventListener('error', () => this.#fail('the direct channel failed')); + } + + #onMessage(ev: unknown): void { + if (this.#closed) return; + const data = (ev as { data?: unknown } | null)?.data; + let frame: Uint8Array; + if (data instanceof ArrayBuffer) { + frame = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + frame = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } else { + this.#handlers.onViolation('a direct channel message was not binary'); + return; + } + // Bounded before it reaches a cipher, exactly as a relay ciphertext is: one + // channel frame is one Noise transport message and can be no larger. + if (frame.length > NOISE_MAX_MESSAGE_LENGTH) { + this.#handlers.onViolation('a direct channel frame exceeds one Noise message'); + return; + } + this.#handlers.onFrame(frame); + } + + /** + * The local description once gathering has settled, or `null` if it is not one + * that fits a signal. Bounded here rather than at the caller so both halves + * refuse the same descriptions. + */ + async #gatheredSdp(): Promise { + await this.#awaitGathering(); + if (this.#closed) return null; + const sdp = this.#peer.localDescription?.sdp; + return isDirectSdp(sdp) ? sdp : null; + } + + /** + * Wait for `iceGatheringState === 'complete'`, bounded by + * {@link DIRECT_GATHER_TIMEOUT_MS} — after which the description as it stands + * is what gets sent, candidates gathered so far included. + */ + #awaitGathering(): Promise { + if (this.#peer.iceGatheringState === 'complete') return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + let cancel: (() => void) | null = null; + const finish = (): void => { + if (settled) return; + settled = true; + cancel?.(); + resolve(); + }; + cancel = this.#setTimer(finish, DIRECT_GATHER_TIMEOUT_MS); + this.#peer.addEventListener('icegatheringstatechange', () => { + if (this.#peer.iceGatheringState === 'complete') finish(); + }); + }); + } + + #armSetupTimeout(): void { + this.#clearSetupTimeout(); + this.#cancelSetup = this.#setTimer(() => { + this.#cancelSetup = null; + if (this.#open || this.#closed) return; + this.#fail('the direct channel did not open in time'); + }, DIRECT_SETUP_TIMEOUT_MS); + } + + #clearSetupTimeout(): void { + this.#cancelSetup?.(); + this.#cancelSetup = null; + } + + /** Report the channel gone, once, and take the connection down with it. */ + #fail(reason: string): void { + if (this.#closed) return; + this.close(); + this.#handlers.onClosed(reason); + } +} diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts new file mode 100644 index 000000000..d3d3d87d3 --- /dev/null +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -0,0 +1,286 @@ +/** + * Two {@link DirectPeerLike}s linked in memory, as the direct path's tests drive + * them. + * + * Test-only, and shared for the reason `../test-relay.ts` is: the Client and the + * Burrow both negotiate against *the same* idea of what a peer connection does, + * and two private copies would be two opinions about when a channel opens. + * Nothing here is a `RTCPeerConnection` — it answers descriptions, links the one + * data channel, and delivers frames in order — which is exactly the surface + * `direct-peer.ts` declares, so a case that passes here exercises the shipped + * wrapper rather than a stub of it. + * + * **The one thing it deliberately cannot do is reorder against the relay.** A + * channel frame overtaking the peer's `direct-switch` is a race between two + * transports, and the in-memory relay delivers synchronously; the knob for it is + * `TestRelay.holdToClient()`, on the side that is actually slow. + */ + +import { + DIRECT_CHANNEL_LABEL, + type DirectChannelLike, + type DirectPeerLike, + type DirectSessionDescription, +} from './direct-peer'; + +/** When the linked channel reports itself open. */ +export type FakeChannelOpening = + /** As soon as the offerer has accepted the answer, on a microtask. */ + | 'auto' + /** Only when {@link FakeDirectNetwork.openChannels} is called. */ + | 'manual' + /** Never — what a peer behind a symmetric NAT looks like from here. */ + | 'never'; + +export interface FakeDirectNetworkOptions { + readonly opening?: FakeChannelOpening; + /** + * Leave `iceGatheringState` at `gathering` forever, so the wrapper falls back + * on its own gathering deadline instead of an event. + */ + readonly gathering?: 'complete' | 'pending'; + /** Which side describes itself with an SDP over the signal's bound. */ + readonly oversize?: 'offer' | 'answer'; +} + +/** One end of the linked pair; the offerer creates the channel. */ +export type FakePeerRole = 'offerer' | 'answerer'; + +export class FakeDirectNetwork { + readonly #options: FakeDirectNetworkOptions; + readonly #peers = new Map(); + #offererChannel: FakeChannel | null = null; + #answererChannel: FakeChannel | null = null; + + constructor(options: FakeDirectNetworkOptions = {}) { + this.#options = options; + } + + /** The Client's side: it creates the channel and describes it first. */ + createOfferer(): FakePeer { + return this.#create('offerer'); + } + + /** The Burrow's side: it learns of the channel through `datachannel`. */ + createAnswerer(): FakePeer { + return this.#create('answerer'); + } + + get offererChannel(): FakeChannel | null { + return this.#offererChannel; + } + + get answererChannel(): FakeChannel | null { + return this.#answererChannel; + } + + /** Both ends report open, in the order a linked pair would settle. */ + openChannels(): void { + this.#offererChannel?.open(); + this.#answererChannel?.open(); + } + + /** The channel goes away under a live session — a radio gap, a peer that quit. */ + dropChannels(): void { + this.#offererChannel?.drop(); + this.#answererChannel?.drop(); + } + + /** Let both peers finish gathering, for a run constructed with `pending`. */ + completeGathering(): void { + for (const peer of this.#peers.values()) peer.completeGathering(); + } + + #create(role: FakePeerRole): FakePeer { + const peer = new FakePeer(role, this.#options, this); + this.#peers.set(role, peer); + return peer; + } + + /** Called by a peer that has just created or received the channel. */ + registerChannel(role: FakePeerRole, channel: FakeChannel): void { + if (role === 'offerer') this.#offererChannel = channel; + else this.#answererChannel = channel; + const offerer = this.#offererChannel; + const answerer = this.#answererChannel; + if (offerer && answerer) { + offerer.link(answerer); + answerer.link(offerer); + } + } + + /** The offerer accepted the answer: the negotiation is complete. */ + negotiated(): void { + if ((this.#options.opening ?? 'auto') !== 'auto') return; + queueMicrotask(() => this.openChannels()); + } +} + +/** + * One end of the pair. Descriptions are plausible rather than parsed: nothing + * reads them but the signal guard, and the pairing is done by the network. + */ +export class FakePeer implements DirectPeerLike { + readonly #role: FakePeerRole; + readonly #options: FakeDirectNetworkOptions; + readonly #network: FakeDirectNetwork; + readonly #listeners = new Map void>>(); + #local: DirectSessionDescription | null = null; + #gathering: string; + closed = false; + + constructor( + role: FakePeerRole, + options: FakeDirectNetworkOptions, + network: FakeDirectNetwork, + ) { + this.#role = role; + this.#options = options; + this.#network = network; + this.#gathering = options.gathering === 'pending' ? 'gathering' : 'complete'; + } + + get iceGatheringState(): string { + return this.#gathering; + } + + get localDescription(): DirectSessionDescription | null { + return this.#local; + } + + createDataChannel(label: string): DirectChannelLike { + const channel = new FakeChannel(label); + this.#network.registerChannel(this.#role, channel); + return channel; + } + + async createOffer(): Promise { + return { type: 'offer', sdp: this.#describe('offer') }; + } + + async createAnswer(): Promise { + return { type: 'answer', sdp: this.#describe('answer') }; + } + + async setLocalDescription(description: DirectSessionDescription): Promise { + this.#local = description; + } + + async setRemoteDescription(description: DirectSessionDescription): Promise { + if (description.type === 'offer') { + // The answerer learns of the channel here, exactly as a real one does. + const channel = new FakeChannel(DIRECT_CHANNEL_LABEL); + this.#network.registerChannel(this.#role, channel); + this.#emit('datachannel', { channel }); + return; + } + this.#network.negotiated(); + } + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#listeners.get(type) ?? []; + list.push(handler); + this.#listeners.set(type, list); + } + + close(): void { + this.closed = true; + } + + /** Finish gathering late, for the run that starts `pending`. */ + completeGathering(): void { + if (this.#gathering === 'complete') return; + this.#gathering = 'complete'; + this.#emit('icegatheringstatechange', {}); + } + + #describe(kind: 'offer' | 'answer'): string { + const body = + 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n' + + 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\n' + + `a=setup:${kind === 'offer' ? 'actpass' : 'active'}\r\na=mid:0\r\na=sctp-port:5000\r\n`; + // A machine with many interfaces describes itself in more candidates than a + // signal can carry; the attempt is skipped or declined rather than sent. + return this.#options.oversize === kind ? `${body}a=x:${'c'.repeat(4000)}\r\n` : body; + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#listeners.get(type) ?? []) handler(ev); + } +} + +/** One end of the linked data channel. */ +export class FakeChannel implements DirectChannelLike { + readonly label: string; + binaryType = 'blob'; + readyState = 'connecting'; + /** Every frame this end was asked to send, in order. */ + readonly sent: Uint8Array[] = []; + #peer: FakeChannel | null = null; + /** Frames delivered before this end opened, held as a real one would. */ + readonly #inbox: Uint8Array[] = []; + readonly #listeners = new Map void>>(); + + constructor(label: string) { + this.label = label; + } + + link(peer: FakeChannel): void { + this.#peer = peer; + } + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#listeners.get(type) ?? []; + list.push(handler); + this.#listeners.set(type, list); + } + + send(data: ArrayBuffer): void { + if (this.readyState !== 'open') throw new Error('the channel is not open'); + // Copied on the way out: the caller's buffer is the transport's reused one. + const bytes = new Uint8Array(data.slice(0)); + this.sent.push(bytes); + const peer = this.#peer; + if (!peer) return; + // A microtask, so ordering is the queue's rather than the caller's stack. + queueMicrotask(() => peer.deliver(bytes)); + } + + close(): void { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + } + + open(): void { + if (this.readyState !== 'connecting') return; + this.readyState = 'open'; + this.#emit('open', {}); + for (const frame of this.#inbox.splice(0)) this.deliver(frame); + } + + /** The channel dies under a live session: closed, with an event. */ + drop(): void { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + this.#emit('close', {}); + } + + /** One frame from the far end, held until this end is open. */ + deliver(frame: Uint8Array): void { + if (this.readyState === 'connecting') { + this.#inbox.push(frame); + return; + } + if (this.readyState !== 'open') return; + this.receiveRaw(frame.slice().buffer); + } + + /** Deliver whatever a peer speaking another protocol would put on the wire. */ + receiveRaw(data: unknown): void { + this.#emit('message', { data }); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#listeners.get(type) ?? []) handler(ev); + } +} From ab479eaaab0dfbbfe720288a35d570b76f8f866f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:09:16 -0700 Subject: [PATCH 04/46] feat(remote): both ends offer, answer, and cut over to the direct path The Client offers once the connection outcome says `ok`, the Burrow answers or declines, each end's `direct-switch` is its last message on the relay, and every byte after it is the same Noise transport message on the channel. Held frames, the relay-after-switch violation, and every disposal path closing the peer come from the shared cutover; the idle deadline and keepalives stay path-agnostic. Pocket passes a factory over the browser's `RTCPeerConnection` with no ICE servers and shows which path carries the session; `BurrowServiceOptions` carries the option so a host can pass one, and none does yet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/host/remote/service.ts | 11 + lib/src/remote/burrow/burrow-runtime.ts | 275 +++++++++++++++++- lib/src/remote/client/pocket-client.ts | 296 +++++++++++++++++++- lib/src/remote/pocket-app/App.push.test.tsx | 1 + lib/src/remote/pocket-app/App.scan.test.tsx | 1 + lib/src/remote/pocket-app/App.tsx | 45 ++- lib/src/remote/pocket-app/pocket-chrome.tsx | 3 + 7 files changed, 611 insertions(+), 21 deletions(-) diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 323acc36d..03e850353 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -37,6 +37,7 @@ import { type PairingOutcome, type WebSocketLike, } from '../../remote/burrow/burrow-runtime'; +import type { DirectPeerFactory } from '../../remote/direct/direct-peer'; import { originAllowedByConnectSrc } from './connect-src'; import { readEnrollmentOffer } from './enroll-offer'; import type { BurrowStateStore } from './burrow-state-store'; @@ -75,6 +76,13 @@ export interface BurrowServiceOptions { /** The CSP-shaped allowlist this build was compiled with (`connect-src.ts`). */ connectSrc: string; createWebSocket?: (url: string) => WebSocketLike; + /** + * How this host builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"). Threaded rather + * than defaulted: the runtimes that have one differ per host, and a Burrow + * without it declines every offer and stays relayed. + */ + createDirectPeer?: DirectPeerFactory; fetch?: typeof globalThis.fetch; now?: () => number; /** @@ -156,6 +164,7 @@ export class BurrowService { readonly #connectSrc: string; readonly #kind: BurrowKind; readonly #createWebSocket?: (url: string) => WebSocketLike; + readonly #createDirectPeer?: DirectPeerFactory; readonly #fetch?: typeof globalThis.fetch; readonly #now: () => number; readonly #readOffer: () => Promise; @@ -192,6 +201,7 @@ export class BurrowService { this.#connectSrc = options.connectSrc; this.#kind = options.kind; this.#createWebSocket = options.createWebSocket; + this.#createDirectPeer = options.createDirectPeer; this.#fetch = options.fetch; this.#now = options.now ?? (() => Date.now()); this.#readOffer = options.readOffer ?? (() => readEnrollmentOffer()); @@ -607,6 +617,7 @@ export class BurrowService { this.#burrow = new BurrowRuntime({ enrollment, createWebSocket: this.#createWebSocket, + createDirectPeer: this.#createDirectPeer, createSession: (opts) => new RemoteApiSession({ burrowId: opts.burrowId, diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index d340c22fa..501905c61 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -13,6 +13,7 @@ import { ESTABLISHED_E2E_IDLE_TIMEOUT_MS, BurrowAcl, ChallengeIssuer, + DirectCutover, MAX_ESTABLISHED_E2E_SESSIONS, MAX_PENDING_PAIRINGS, MAX_TOKENS_PER_BURROW, @@ -32,6 +33,7 @@ import { importNoiseStaticPrivateKey, isBoundedString, isConnectionRequestV1, + isDirectSignalV1, isE2eRelayToBurrowFrame, isPairingRequestV1, MAX_CLIENT_ID_LENGTH, @@ -47,6 +49,7 @@ import { DELIVERY_ID_BYTE_LENGTH, type ConnectionOutcomeV1, type ConnectionPolicy, + type DirectSignalV1, type E2eRelayToBurrowFrame, type BurrowAclRecord, type BurrowFrame, @@ -60,6 +63,7 @@ import { } from 'remote-lib-common'; import type { BurrowEnrollment } from './enrollment'; import { createSerialQueue } from '../../host/remote/serial-queue'; +import { DirectPeer, type DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; import { loadBurrowAcl } from './acl'; import type { PendingPairing } from './pairing-approval'; @@ -205,6 +209,16 @@ interface PendingConnectionSession { readonly expiresAt: number; } +/** + * One session's direct-path attempt: the peer connection and where each + * direction currently sends (`docs/specs/remote-api.md` → Transport → "Direct + * path"). + */ +interface DirectAttempt { + readonly peer: DirectPeer; + readonly cutover: DirectCutover; +} + /** An authorized session: the two cipher states plus the remote-api handler. */ interface EstablishedSession { readonly connectionId: string; @@ -212,8 +226,15 @@ interface EstablishedSession { readonly api: RemoteApiSessionLike; /** The IK-authenticated Client static — what the session cap is keyed on. */ readonly clientStaticPublicKey: string; - /** When this Burrow last **decrypted** a Client→Burrow transport message here. */ + /** + * When this Burrow last **decrypted** a Client→Burrow transport message here, + * on either path: the idle deadline is path-agnostic. + */ lastClientActivityAt: number; + /** The direct path this session took, or null while it is purely relayed. */ + direct: DirectAttempt | null; + /** **One attempt per session**: a second `direct-offer` allocates nothing. */ + directAttempted: boolean; } /** Per-client lifecycle state tracked by the Burrow, keyed by clientId. */ @@ -281,6 +302,13 @@ export interface BurrowOptions { setTimer?: RemoteTimer; /** Auto-reconnect with backoff (default true; tests pass false). */ reconnect?: boolean; + /** + * How this host builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"), or `null` where it + * has none. Absent, every `direct-offer` is declined and every session stays + * relayed. + */ + createDirectPeer?: DirectPeerFactory; } const INITIAL_BACKOFF_MS = 1_000; @@ -302,6 +330,7 @@ export class BurrowRuntime { readonly #now: () => number; readonly #setTimer: RemoteTimer; readonly #reconnect: boolean; + readonly #createDirectPeer: DirectPeerFactory | null; /** * Per-client lifecycle state keyed by clientId. Folding the three concerns @@ -418,6 +447,7 @@ export class BurrowRuntime { this.#onInvitationChanged = options.onInvitationChanged ?? (() => {}); this.#setTimer = options.setTimer ?? realTimer; this.#reconnect = options.reconnect ?? true; + this.#createDirectPeer = options.createDirectPeer ?? null; } get status(): BurrowStatus { @@ -1413,6 +1443,7 @@ export class BurrowRuntime { // Cleared with the dispose, not merely overwritten below: without a session // factory there is no replacement, and a leftover reference would route the // next frame on the old id into a handler that has already been disposed. + if (state.established) this.#disposeDirect(state.established); state.established?.api.dispose(); state.established = undefined; this.#sendControl(clientId, 'connection', pending.connectionId, pending.session, { @@ -1441,6 +1472,10 @@ export class BurrowRuntime { api, clientStaticPublicKey, lastClientActivityAt: this.#now(), + // The Client offers the direct path, and only after this outcome: nothing + // here exists before a session is authorized. + direct: null, + directAttempted: false, }; this.#armReaper(); } @@ -1530,20 +1565,48 @@ export class BurrowRuntime { } } - /** One transport frame on an authorized session: protocol-v1, or a keepalive. */ + /** + * One transport frame on an authorized session, arriving on the relay. + * + * **After the Client has switched there is nothing left for it to send here**, + * so a frame that arrives anyway is a peer whose two paths this Burrow can no + * longer keep in order (`docs/specs/remote-api.md` → Transport → "Direct + * path"). + */ #onEstablishedFrame(clientId: string, established: EstablishedSession, ct: string): void { + if (established.direct?.cutover.onRelayTransport() === 'violation') { + this.#disposeEstablished(clientId); + return; + } + this.#receiveOnSession(clientId, established, fromBase64Url(ct)); + } + + /** + * Decrypt one transport ciphertext, whichever path carried it: protocol-v1, a + * keepalive, or one of the direct path's signals. + */ + #receiveOnSession( + clientId: string, + established: EstablishedSession, + ciphertext: Uint8Array, + ): void { let receipt; try { - receipt = established.session.receive(fromBase64Url(ct)); + receipt = established.session.receive(ciphertext); } catch { // A failed decrypt is not activity: it proves only that *something* - // reached the relay, and the session is dead either way. + // reached this Burrow, and the session is dead either way. this.#disposeEstablished(clientId); return; } // The one thing that refreshes the idle deadline, keepalive or application - // data alike (`docs/specs/remote-security-model.md` → Burrow bounds). + // data alike, and on either path + // (`docs/specs/remote-security-model.md` → Burrow bounds). established.lastClientActivityAt = this.#now(); + if (receipt.kind === 'control') { + this.#onDirectSignal(clientId, established, receipt.value); + return; + } if (receipt.kind !== 'app') return; for (const message of receipt.messages) { let payload: unknown; @@ -1573,7 +1636,7 @@ export class BurrowRuntime { ): void { try { for (const ciphertext of session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); + this.#deliver(clientId, connectionId, ciphertext); } } catch { // **Only a poisoned session is burrow loss.** An over-cap message is @@ -1588,14 +1651,214 @@ export class BurrowRuntime { } } + /** + * One transport ciphertext on whichever path this Burrow has switched to. + * Every byte of an established session goes through here, so "after the + * switch, nothing on the relay" is one line rather than a rule each caller + * keeps. + */ + #deliver(clientId: string, connectionId: string, ciphertext: Uint8Array): void { + const established = this.#clients.get(clientId)?.established; + const direct = established?.connectionId === connectionId ? established.direct : null; + if (direct && direct.cutover.outbound === 'direct') { + direct.peer.send(ciphertext); + return; + } + this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); + } + #disposeEstablished(clientId: string): void { const state = this.#clients.get(clientId); if (!state?.established) return; + this.#disposeDirect(state.established); state.established.api.dispose(); state.established = undefined; this.#pruneClient(clientId); } + // --- The direct path ----------------------------------------------------- + + /** + * One decrypted signal on an established session + * (`docs/specs/remote-api.md` → Transport → "Direct path"). An unknown + * control shape says nothing this Burrow can act on and is never a session + * failure — which is what lets a Pocket without this stack simply stay + * relayed. + */ + #onDirectSignal( + clientId: string, + established: EstablishedSession, + value: Record, + ): void { + if (!isDirectSignalV1(value)) return; + switch (value.t) { + case 'direct-offer': + void this.#answerDirect(clientId, established, value.sdp); + return; + case 'direct-switch': { + const direct = established.direct; + if (!direct) return; + const held = direct.cutover.onSwitchDecrypted(); + // In arrival order, through the same decrypt path the relay's frames + // take: what was held is exactly what was sent after the switch. + for (const frame of held) { + if (this.#clients.get(clientId)?.established !== established) return; + this.#receiveOnSession(clientId, established, frame); + } + return; + } + default: + // `direct-answer` and `direct-decline` are this Burrow's own to send. + return; + } + } + + /** + * Answer one offer, or decline it. **At most one attempt per session**, so a + * second offer allocates nothing whatever the first did, and a Burrow with no + * peer factory — or one whose answer would not fit a signal — declines rather + * than leaving the Client waiting on the setup deadline. + */ + async #answerDirect( + clientId: string, + established: EstablishedSession, + offerSdp: string, + ): Promise { + if (established.directAttempted) return; + established.directAttempted = true; + const epoch = this.#epoch; + let connection; + try { + connection = this.#createDirectPeer?.() ?? null; + } catch (error) { + console.warn('[burrow] could not build a direct peer', error); + connection = null; + } + if (!connection) { + this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-decline' }); + return; + } + const peer = new DirectPeer({ + peer: connection, + setTimer: this.#setTimer, + handlers: { + onOpen: () => this.#onDirectOpen(clientId, established), + onFrame: (frame) => this.#onDirectFrame(clientId, established, frame), + onClosed: () => this.#onDirectClosed(clientId, established), + // A peer speaking something else on the channel is not one this + // session's counters can stay synchronized with, switched or not. + onViolation: (reason) => { + console.warn(`[burrow] direct channel violation: ${reason}`); + this.#disposeEstablished(clientId); + }, + }, + }); + established.direct = { peer, cutover: new DirectCutover() }; + const sdp = await peer.answer(offerSdp); + // A teardown, a replacement promotion, or a channel that already failed + // while the description was being built: this peer is no longer the one. + if ( + this.#epoch !== epoch || + this.#clients.get(clientId)?.established !== established || + established.direct?.peer !== peer + ) { + peer.close(); + return; + } + if (sdp === null) { + this.#abandonDirect(established, peer); + this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-decline' }); + return; + } + this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-answer', sdp }); + } + + /** + * The channel is open. **The `direct-switch` is this end's last message on + * the relay** — everything after it goes on the channel, which is what keeps + * order per direction. + */ + #onDirectOpen(clientId: string, established: EstablishedSession): void { + const direct = this.#directFor(clientId, established); + if (!direct) return; + this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-switch' }); + direct.cutover.switchOutbound(); + } + + /** One frame off the channel: processed, held until the peer's switch, or fatal. */ + #onDirectFrame(clientId: string, established: EstablishedSession, frame: Uint8Array): void { + const direct = this.#directFor(clientId, established); + if (!direct) return; + switch (direct.cutover.onChannelFrame(frame)) { + case 'process': + this.#receiveOnSession(clientId, established, frame); + return; + case 'held': + return; + case 'overflow': + console.warn('[burrow] a direct path outran what can be held in order'); + this.#disposeEstablished(clientId); + return; + } + } + + /** + * The channel went away. **Before either direction has switched that is + * merely an abandoned attempt**, and the session carries on relayed; + * afterwards the session is over, because what was riding the channel is gone + * and a stream cipher has no resynchronization point. + */ + #onDirectClosed(clientId: string, established: EstablishedSession): void { + const direct = this.#directFor(clientId, established); + if (!direct) return; + if (!direct.cutover.switched) { + this.#abandonDirect(established, direct.peer); + return; + } + this.#disposeEstablished(clientId); + } + + /** This session's attempt, or null once it has been replaced or disposed. */ + #directFor(clientId: string, established: EstablishedSession): DirectAttempt | null { + return this.#clients.get(clientId)?.established === established ? established.direct : null; + } + + /** Give up on the channel, leaving the session exactly as relayed as it was. */ + #abandonDirect(established: EstablishedSession, peer: DirectPeer): void { + peer.close(); + if (established.direct?.peer !== peer) return; + established.direct.cutover.clear(); + established.direct = null; + } + + /** + * The peer connection is this session's: every disposal path closes it, so + * none can outlive the session that authorized it. + */ + #disposeDirect(established: EstablishedSession): void { + established.direct?.peer.close(); + established.direct?.cutover.clear(); + established.direct = null; + } + + /** + * One signal, on the relay — the path that carries them until the switch. A + * poisoned session has nothing to say; whatever poisoned it disposes it. + */ + #sendDirectSignal( + clientId: string, + established: EstablishedSession, + signal: DirectSignalV1, + ): void { + let ciphertext: Uint8Array; + try { + ciphertext = established.session.sendControl({ ...signal }); + } catch { + return; + } + this.#sendE2e(clientId, 'connection', established.connectionId, 'transport', ciphertext); + } + // --- Shared plumbing ----------------------------------------------------- /** diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index d43257d16..f7ffb7d4f 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -13,6 +13,7 @@ import { API_ROUTES, DEFAULT_CHALLENGE_TTL_MS, DEFAULT_PAIRING_TTL_MS, + DirectCutover, E2E_ID_BYTE_LENGTH, E2E_KEEPALIVE_INTERVAL_MS, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, @@ -31,6 +32,7 @@ import { generateNoiseKeyPair, hashPasskeyPublicKey, isConnectionOutcomeV1, + isDirectSignalV1, isE2eRelayToClientFrame, isNoisePublicKey, isPairingOutcomeV1, @@ -44,6 +46,8 @@ import { utf8Encode, type ConnectionDenialCode, type ConnectionRequestV1, + type DirectPath, + type DirectSignalV1, type DirectoryEntry, type DirectorySnapshot, type E2eClientFrame, @@ -85,6 +89,7 @@ import { type KnownBurrowV1, type PendingDeletionStore, } from './pocket-db'; +import { DirectPeer, type DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; /** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ @@ -146,6 +151,13 @@ export interface PocketClientDeps { /** The keepalive timer; see {@link RemoteTimer}. */ readonly setTimer?: RemoteTimer; readonly visibility?: PocketVisibility; + /** + * How this runtime builds a peer connection for the direct path + * (`docs/specs/remote-api.md` → Transport → "Direct path"), or `null` where it + * has none. Absent, this Client never offers one and every session stays + * relayed. + */ + readonly createDirectPeer?: DirectPeerFactory; } /** Terminal stream callbacks for {@link PocketClient.attach}. */ @@ -330,6 +342,16 @@ interface EstablishedSession { lastSentAt: number; } +/** + * One session's direct-path attempt: the peer connection and where each + * direction currently sends (`docs/specs/remote-api.md` → Transport → "Direct + * path"). At most one per session, created at the offer and closed with it. + */ +interface DirectAttempt { + readonly peer: DirectPeer; + readonly cutover: DirectCutover; +} + export class PocketClient { readonly #baseUrl: string; readonly #wsBase: string; @@ -342,6 +364,7 @@ export class PocketClient { readonly #now: () => number; readonly #setTimer: RemoteTimer; readonly #visibility: PocketVisibility; + readonly #createDirectPeer: DirectPeerFactory | null; #ws: PocketSocket | null = null; #sessionToken: string | null = null; @@ -350,6 +373,11 @@ export class PocketClient { #established: EstablishedSession | null = null; #connectedBurrowId: string | null = null; #onBurrowGone: (() => void) | null = null; + /** This session's direct-path attempt, or null while it is purely relayed. */ + #direct: DirectAttempt | null = null; + #onTransportPath: ((path: DirectPath) => void) | null = null; + /** The last path announced, so an unchanged one is not announced twice. */ + #announcedPath: DirectPath = 'relay'; /** Cancels the armed keepalive, and the visibility subscription behind it. */ #cancelKeepalive: (() => void) | null = null; #cancelVisibility: (() => void) | null = null; @@ -379,6 +407,7 @@ export class PocketClient { this.#now = deps.now ?? (() => Date.now()); this.#setTimer = deps.setTimer ?? realTimer; this.#visibility = deps.visibility ?? documentVisibility(); + this.#createDirectPeer = deps.createDirectPeer ?? null; } get sessionToken(): string | null { @@ -389,6 +418,20 @@ export class PocketClient { return this.#connectedBurrowId; } + /** + * Which path carries this session, for the indicator the connected chrome + * shows (`docs/specs/pocket-app.md`). `direct` only once **both** directions + * have left the relay — before that the relay is still carrying half of it. + */ + get transportPath(): DirectPath { + return this.#direct?.cutover.path ?? 'relay'; + } + + /** Notified whenever {@link transportPath} changes. */ + setOnTransportPathChanged(callback: ((path: DirectPath) => void) | null): void { + this.#onTransportPath = callback; + } + /** * Whether this browser has been used with Dormouse before, which decides * whether the auth screen offers sign-in at all @@ -872,6 +915,9 @@ export class PocketClient { this.#established = { connectionId, session, lastSentAt: this.#now() }; this.#connectedBurrowId = burrowId; this.#startKeepalives(); + // After the outcome and never before: a peer connection that existed + // ahead of authorization would be one an unauthorized party had steered. + void this.#offerDirect(this.#established, burrowId); return { ok: true, burrowLabel: outcome.burrowLabel }; } if (outcome.code === 'pairing-required') { @@ -1116,6 +1162,190 @@ export class PocketClient { } } + // --- The direct path ----------------------------------------------------- + + /** + * Offer a direct path on a session that has just been authorized: **once per + * session, never retried** (`docs/specs/remote-api.md` → Transport → "Direct + * path"). The Client is always the offerer, and the whole description travels + * inside the session — the Relay never sees an SDP, a candidate, or that a + * direct path exists. + * + * Every failure here is silent and terminal for the attempt alone: a runtime + * with no factory, a description too large to fit one control message, a + * negotiation that threw. The session keeps running on the relay. + */ + async #offerDirect(established: EstablishedSession, burrowId: string): Promise { + const factory = this.#createDirectPeer; + if (!factory) return; + let connection; + try { + connection = factory(); + } catch { + return; + } + if (!connection) return; + const peer = new DirectPeer({ + peer: connection, + setTimer: this.#setTimer, + handlers: { + onOpen: () => this.#onDirectOpen(established, burrowId), + onFrame: (frame) => this.#onDirectFrame(established, frame), + onClosed: (reason) => this.#onDirectClosed(established, reason), + // A peer speaking something else on the channel is not one this + // session's counters can stay synchronized with, switched or not. + onViolation: (reason) => this.#loseBurrow(reason), + }, + }); + this.#direct = { peer, cutover: new DirectCutover() }; + const sdp = await peer.offer(); + // The session may have been replaced or disposed while the description was + // being built; a peer left over from one is not this one's. + if (this.#established !== established || this.#direct?.peer !== peer) { + peer.close(); + return; + } + if (sdp === null) { + this.#abandonDirect(peer); + return; + } + const signal: DirectSignalV1 = { v: 1, t: 'direct-offer', sdp }; + try { + this.#sendE2e( + this.#route(established, burrowId), + 'transport', + established.session.sendControl({ ...signal }), + ); + } catch { + this.#abandonDirect(peer); + } + } + + /** + * The channel is open. **The `direct-switch` is this end's last message on + * the relay** — everything after it goes on the channel, which is what keeps + * order per direction. + */ + #onDirectOpen(established: EstablishedSession, burrowId: string): void { + const direct = this.#directFor(established); + if (!direct) return; + const signal: DirectSignalV1 = { v: 1, t: 'direct-switch' }; + try { + this.#sendE2e( + this.#route(established, burrowId), + 'transport', + established.session.sendControl({ ...signal }), + ); + } catch { + this.#abandonDirect(direct.peer); + return; + } + direct.cutover.switchOutbound(); + this.#announcePath(); + } + + /** One frame off the channel: processed, held until the peer's switch, or fatal. */ + #onDirectFrame(established: EstablishedSession, frame: Uint8Array): void { + const direct = this.#directFor(established); + if (!direct) return; + switch (direct.cutover.onChannelFrame(frame)) { + case 'process': + this.#receiveOnSession(established, frame); + return; + case 'held': + return; + case 'overflow': + this.#loseBurrow('the direct path outran what this phone can hold in order'); + return; + } + } + + /** + * The channel went away. **Before either direction has switched that is + * merely an abandoned attempt**; afterwards it is burrow loss, reported + * exactly as a `burrow-gone` frame is, because the messages that were riding + * it are gone and a stream cipher has no resynchronization point. + */ + #onDirectClosed(established: EstablishedSession, reason: string): void { + const direct = this.#directFor(established); + if (!direct) return; + if (!direct.cutover.switched) { + this.#abandonDirect(direct.peer); + return; + } + this.#loseBurrow(reason); + } + + /** One decrypted signal; anything else on the control channel is ignored. */ + #onDirectSignal(established: EstablishedSession, value: Record): void { + // An unknown control shape on an established session says nothing this can + // act on and is never a session failure — which is what lets a Burrow + // without this stack simply stay relayed. + if (!isDirectSignalV1(value)) return; + const direct = this.#directFor(established); + if (!direct) return; + switch (value.t) { + case 'direct-answer': + void direct.peer.acceptAnswer(value.sdp); + return; + case 'direct-decline': + this.#abandonDirect(direct.peer); + return; + case 'direct-switch': { + const held = direct.cutover.onSwitchDecrypted(); + this.#announcePath(); + // In arrival order, through the same decrypt path the relay's frames + // take: what was held is exactly what was sent after the switch. + for (const frame of held) { + if (this.#established !== established) return; + this.#receiveOnSession(established, frame); + } + return; + } + default: + // `direct-offer` is the Client's own to send; a Burrow offering one is + // ignored rather than answered. + return; + } + } + + /** This session's attempt, or null once it has been replaced or disposed. */ + #directFor(established: EstablishedSession): DirectAttempt | null { + return this.#established === established ? this.#direct : null; + } + + /** Give up on the channel, leaving the session exactly as relayed as it was. */ + #abandonDirect(peer: DirectPeer): void { + peer.close(); + if (this.#direct?.peer !== peer) return; + this.#direct.cutover.clear(); + this.#direct = null; + this.#announcePath(); + } + + #announcePath(): void { + const path = this.transportPath; + if (path === this.#announcedPath) return; + this.#announcedPath = path; + this.#onTransportPath?.(path); + } + + /** + * The end-to-end session is over while the relay socket is not: dispose it, + * fail everything in flight, and tell the app — the same three steps a + * `burrow-gone` frame takes, because from here they are the same event. + */ + #loseBurrow(reason: string): void { + this.#disposeCeremony(); + this.#rejectAll(new Error(reason)); + this.#onBurrowGone?.(); + } + + /** Where this session's frames are addressed on the relay. */ + #route(established: EstablishedSession, burrowId: string): E2eRoute { + return { kind: 'connection', id: established.connectionId, burrowId }; + } + // --- Keepalives ---------------------------------------------------------- /** @@ -1129,11 +1359,9 @@ export class PocketClient { if (!established || burrowId === null) return; if (this.#reapedByBurrow(established)) return; try { - this.#sendE2e( - { kind: 'connection', id: established.connectionId, burrowId }, - 'transport', - established.session.sendKeepalive(), - ); + // Path-agnostic: a keepalive off the channel refreshes the Burrow's idle + // deadline exactly as one off the relay does. + this.#deliver(this.#route(established, burrowId), established.session.sendKeepalive()); established.lastSentAt = this.#now(); } catch { // A closed socket or a poisoned session; both have their own teardown, @@ -1223,13 +1451,27 @@ export class PocketClient { const burrowId = this.#connectedBurrowId; if (burrowId === null) throw new Error('not connected to a burrow'); if (this.#reapedByBurrow(established)) throw new Error(BURROW_SESSION_REAPED_MESSAGE); - const route = { kind: 'connection', id: established.connectionId, burrowId } as const; + const route = this.#route(established, burrowId); for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#sendE2e(route, 'transport', ciphertext); + this.#deliver(route, ciphertext); } established.lastSentAt = this.#now(); } + /** + * One transport ciphertext on whichever path this end has switched to. Every + * byte of an established session goes through here, so "after the switch, + * nothing on the relay" is one line rather than a rule each caller keeps. + */ + #deliver(route: E2eRoute, ciphertext: Uint8Array): void { + const direct = this.#direct; + if (direct && direct.cutover.outbound === 'direct') { + direct.peer.send(ciphertext); + return; + } + this.#sendE2e(route, 'transport', ciphertext); + } + #send(frame: E2eClientFrame): void { if (!this.#ws) throw new Error('relay socket is not open'); this.#ws.send(JSON.stringify(frame)); @@ -1326,20 +1568,42 @@ export class PocketClient { } /** - * One transport frame on an authorized session. **Any decrypt or framing - * failure ends it**: there is no resynchronization point in a stream cipher, - * so a poisoned session is burrow loss and the app must leave the wall. + * One transport frame on an authorized session, arriving on the relay. + * + * **After the Burrow has switched there is nothing left for it to send here**, + * so a frame that arrives anyway is a peer whose two paths this Client can no + * longer keep in order (`docs/specs/remote-api.md` → Transport → "Direct + * path"). */ #onEstablishedFrame(established: EstablishedSession, ct: string): void { + const direct = this.#directFor(established); + if (direct && direct.cutover.onRelayTransport() === 'violation') { + this.#loseBurrow('a relay frame arrived after the direct switch'); + return; + } + this.#receiveOnSession(established, fromBase64Url(ct)); + } + + /** + * Decrypt one transport ciphertext, whichever path carried it. **Any decrypt + * or framing failure ends the session**: there is no resynchronization point + * in a stream cipher, so a poisoned session is burrow loss and the app must + * leave the wall. + */ + #receiveOnSession(established: EstablishedSession, ciphertext: Uint8Array): void { let receipt; try { - receipt = established.session.receive(fromBase64Url(ct)); + receipt = established.session.receive(ciphertext); } catch { this.#teardown('the end-to-end session failed', { notifyGone: true }); return; } - // A keepalive is accepted and ignored; a control message on an established - // session is not part of protocol-v1 and says nothing this can act on. + // A keepalive is accepted and ignored; the only control messages on an + // established session are the direct path's signals. + if (receipt.kind === 'control') { + this.#onDirectSignal(established, receipt.value); + return; + } if (receipt.kind !== 'app') return; for (const message of receipt.messages) { let payload: unknown; @@ -1396,8 +1660,14 @@ export class PocketClient { /** Erase every session's cipher state; a new ceremony starts from a handshake. */ #disposeCeremony(): void { this.#stopKeepalives(); + // The peer connection is this session's: every disposal path closes it, so + // none can outlive the session that authorized it. + this.#direct?.peer.close(); + this.#direct?.cutover.clear(); + this.#direct = null; this.#connectedBurrowId = null; this.#established = null; + this.#announcePath(); } /** Fail every awaited ceremony frame and in-flight request (avoids hangs). */ diff --git a/lib/src/remote/pocket-app/App.push.test.tsx b/lib/src/remote/pocket-app/App.push.test.tsx index 25158d422..bec428093 100644 --- a/lib/src/remote/pocket-app/App.push.test.tsx +++ b/lib/src/remote/pocket-app/App.push.test.tsx @@ -61,6 +61,7 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => true; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; + setOnTransportPathChanged = () => undefined; close = () => undefined; openSocket = async () => undefined; signin = async () => ({}); diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index 3fff9bbbc..ef872913a 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -95,6 +95,7 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => fake.hasPriorUse; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; + setOnTransportPathChanged = () => undefined; close = () => fake.clientClose(); openSocket = async () => undefined; setup = (credential: { setupToken: string }, label: string) => fake.setup(credential, label); diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index d3eb320b5..0d93ad9dd 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -26,7 +26,7 @@ import { } from '../client/pocket-client'; import { PasskeyAlreadyRegisteredError, browserWebAuthn } from '../client/webauthn'; import { BURROW_IS_AN_APP, SCAN_LABEL } from '../setup-copy'; -import { probeNoiseSupport, type PairingInvitation } from 'remote-lib-common'; +import { probeNoiseSupport, type DirectPath, type PairingInvitation } from 'remote-lib-common'; import { indexedDbKnownBurrowStore, indexedDbPendingDeletionStore, @@ -120,6 +120,14 @@ export default function App({ fetch: window.fetch.bind(window), webauthn: browserWebAuthn, createWebSocket: (url) => new WebSocket(url) as unknown as PocketSocket, + // **No ICE servers**: a public STUN or TURN default would hand a third + // party this phone's address, and the shipped deployment is a tailnet + // where host candidates reach (`docs/specs/remote-api.md` → Transport → + // "Direct path"). A browser without WebRTC keeps its session relayed. + createDirectPeer: () => + typeof RTCPeerConnection === 'undefined' + ? null + : new RTCPeerConnection({ iceServers: [] }), knownBurrows: indexedDbKnownBurrowStore(), pendingDeletions: indexedDbPendingDeletionStore(), }), @@ -340,6 +348,16 @@ export default function App({ return () => client.setOnBurrowGone(null); }, [client, teardownAdapter]); + /** + * Which path carries the live session. Subscribed rather than read on render: + * the cutover happens seconds into a session, long after the wall is up. + */ + const [transportPath, setTransportPath] = useState('relay'); + useEffect(() => { + client.setOnTransportPathChanged(setTransportPath); + return () => client.setOnTransportPathChanged(null); + }, [client]); + /** The connect half, shared so a fresh pairing can continue straight into it. */ const connectTo = useCallback( async (burrow: BurrowView) => { @@ -610,7 +628,13 @@ export default function App({ // The adapter is stood up before the phase moves, so the ref is set // whenever this branch is reachable. return adapterRef.current ? ( - + ) : ( ); @@ -761,18 +785,32 @@ export const BURROWS_EMPTY = `No Burrows paired yet. ${BURROW_IS_AN_APP} On the computer, open Settings → ` + 'Remote control → Set up a phone, then scan the code.'; +/** + * What the path indicator says, and the sentence behind each. **Which path + * carries the session is shown, never inferred**, so a relayed fallback is + * visible rather than silent (`docs/specs/pocket-app.md`). + */ +export const TRANSPORT_PATH_LABELS: Record = { + relay: { label: 'relay', title: 'This session goes through the relay.' }, + direct: { label: 'direct', title: 'This session goes straight to the computer.' }, +}; + /** The connected Pocket shell: Burrow navigation chrome over the remote wall. */ export function ConnectedView({ burrow, adapter, + transportPath = 'relay', onLeave, onError, }: { burrow: BurrowView; adapter: RemotePtyAdapter; + /** Which path carries the session; see {@link TRANSPORT_PATH_LABELS}. */ + transportPath?: DirectPath; onLeave: () => void; onError?: (error: unknown) => void; }): React.ReactElement { + const path = TRANSPORT_PATH_LABELS[transportPath]; return (
@@ -780,6 +818,9 @@ export function ConnectedView({ ‹ {BURROWS_TITLE}

{burrow.label || burrow.burrowId}

+ + {path.label} +
diff --git a/lib/src/remote/pocket-app/pocket-chrome.tsx b/lib/src/remote/pocket-app/pocket-chrome.tsx index 7a29c7038..d40c42fa2 100644 --- a/lib/src/remote/pocket-app/pocket-chrome.tsx +++ b/lib/src/remote/pocket-app/pocket-chrome.tsx @@ -51,6 +51,9 @@ export const PK = { header: 'flex shrink-0 items-center gap-2 bg-header-active-bg px-4 pb-2.5 pt-[max(0.625rem,env(safe-area-inset-top))] text-header-active-fg', headerTitle: 'm-0 min-w-0 flex-1 truncate text-[13px] font-semibold tracking-[0.01em]', + // A settled fact about the band it sits in — which path carries the session — + // captioned in alpha-on-fg rather than coloured: it reports, never alerts. + headerNote: 'shrink-0 text-[11px] text-header-active-fg/70', body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]', // Safe centering: the first-run screen (install notice + the scan action + the From cbd67be7e11d00ceff79a75b2ecd14b0bb597e13 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:24:53 -0700 Subject: [PATCH 05/46] test(remote): drive the direct path end to end, in process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real `PocketClient`, the in-memory Relay, and a real `BurrowRuntime`, each holding one end of a linked fake peer pair: the signaling crosses the relay as control messages, protocol-v1 and keepalives then cross the channel and the relay sees nothing more, and every way it can end — decline, no factory, a setup timeout, a dropped channel, a relay frame after the switch, an overrun holding queue, `client-gone`, a dropped Burrow socket — lands where it should. The relay harness gains `holdToClient`, the one ordering a synchronous in-memory relay cannot otherwise produce, and `test-e2e-client` gains the Client half of the negotiation for the two Burrow suites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/burrow/burrow-bounds.test.ts | 75 +++- lib/src/remote/burrow/burrow-runtime.test.ts | 103 ++++++ lib/src/remote/client/pocket-client.test.ts | 343 +++++++++++++++++++ lib/src/remote/test-e2e-client.ts | 82 +++++ lib/src/remote/test-relay.ts | 40 ++- 5 files changed, 634 insertions(+), 9 deletions(-) diff --git a/lib/src/remote/burrow/burrow-bounds.test.ts b/lib/src/remote/burrow/burrow-bounds.test.ts index c4e95eaa6..80e016fba 100644 --- a/lib/src/remote/burrow/burrow-bounds.test.ts +++ b/lib/src/remote/burrow/burrow-bounds.test.ts @@ -19,6 +19,7 @@ import { E2E_INIT_BURST, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, E2E_KEEPALIVE_INTERVAL_MS, + MAX_DIRECT_PENDING_FRAMES, MAX_ESTABLISHED_E2E_SESSIONS, MAX_E2E_CIPHERTEXT_LENGTH, MAX_CLIENT_ID_LENGTH, @@ -43,11 +44,14 @@ import { import type { BurrowEnrollment } from './enrollment'; import type { PendingPairing } from './pairing-approval'; import { FakeSocket } from '../test-fake-socket'; +import { FakeDirectNetwork } from '../direct/test-fake-peer'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { createTestAuthenticator, e2eFramesFor, flushUntil, openConnectionSession, + openDirectPath, openPairingSession, pairThroughSocket, presenceProofFor, @@ -192,10 +196,14 @@ describe('BurrowRuntime bounds', () => { crypto.restore(); }); - function makeBurrow(enrollmentOverrides?: Partial): BurrowRuntime { + function makeBurrow( + enrollmentOverrides?: Partial, + options: { createDirectPeer?: DirectPeerFactory } = {}, + ): BurrowRuntime { const created = new BurrowRuntime({ enrollment: { ...enrollment, ...enrollmentOverrides }, reconnect: false, + ...(options.createDirectPeer ? { createDirectPeer: options.createDirectPeer } : {}), createWebSocket: () => (socket = new FakeSocket()), loadAcl: () => [] as BurrowAclRecord[], saveAcl: () => {}, @@ -954,4 +962,69 @@ describe('BurrowRuntime bounds', () => { burrow.stop(); expect(clock.armed).toBe(0); }); + + // --- The direct path, which changes none of the bounds --------------------- + + /** A Burrow that can answer an offer, and the network holding both ends. */ + function directBurrow(): FakeDirectNetwork { + burrow.stop(); + const network = new FakeDirectNetwork(); + burrow = makeBurrow(undefined, { createDirectPeer: () => network.createAnswerer() }); + return network; + } + + it('extends the idle deadline on a keepalive that arrived off the channel', async () => { + // Every bound is path-agnostic: what refreshes the deadline is a decrypted + // Client message, not the transport that carried it + // (`docs/specs/remote-api.md` → Transport → "Direct path"). + const network = directBurrow(); + const live = await establish('c1'); + const path = await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + setTimer: clock.setTimer, + }); + + for (let i = 0; i < 6; i += 1) { + clock.advance(E2E_KEEPALIVE_INTERVAL_MS); + path.peer.send(live.session.sendKeepalive()); + await settle(); + } + expect(sessions[0]!.disposed).toBe(false); + expect(burrow.establishedSessionCount).toBe(1); + + // And silence on the channel reaps it exactly as silence on the relay does. + clock.advance(ESTABLISHED_E2E_IDLE_TIMEOUT_MS); + expect(sessions[0]!.disposed).toBe(true); + }); + + it('disposes a session whose held channel frames outrun the queue', async () => { + const network = directBurrow(); + const live = await establish('c1'); + // The Client never announces its switch, so every channel frame is held — + // which is what makes the cap the only thing bounding this. + const path = await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + switchOutbound: false, + setTimer: clock.setTimer, + }); + + for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) { + path.peer.send(live.session.sendKeepalive()); + } + await settle(); + + expect(sessions[0]!.disposed).toBe(true); + expect(burrow.establishedSessionCount).toBe(0); + expect(burrow.trackedClientCount).toBe(0); + }); }); diff --git a/lib/src/remote/burrow/burrow-runtime.test.ts b/lib/src/remote/burrow/burrow-runtime.test.ts index 22475eef3..f7264e2a8 100644 --- a/lib/src/remote/burrow/burrow-runtime.test.ts +++ b/lib/src/remote/burrow/burrow-runtime.test.ts @@ -1340,4 +1340,107 @@ describe('BurrowRuntime end-to-end ceremonies', () => { expect(burrow.invitationState(invitation.inviteId)).toBe('live'); generateKey.mockRestore(); }); + + // --- The direct path ------------------------------------------------------- + + /** Pair, connect, and promote one client; the whole Client side is real. */ + async function establishSession(clientId = 'c1') { + const { authenticator, clientStatic } = await pairedClient(clientId); + const connectionId = testRoutingId(); + const { session, burrowChallenge } = await openConnection(clientId, clientStatic, connectionId); + const binding = connectionBinding( + connectionId, + burrowChallenge, + session, + authenticator.credentialId, + ); + sendE2e( + clientId, + 'connection', + connectionId, + 'transport', + toBase64Url(session.sendControl({ presence: await presenceProofFor(authenticator, binding) })), + ); + await settle(); + expect(await outcome(session, 'connection', connectionId)).toEqual({ + ok: true, + burrowLabel: BURROW_LABEL, + }); + return { session, connectionId, clientId }; + } + + /** The Burrow's transport frames on one connection; index 0 is the outcome. */ + function transportFrames(connectionId: string): Array> { + return e2eFrames('connection', connectionId).filter((frame) => frame.step === 'transport'); + } + + /** Decrypt the transport frame at `index`, which must be a control message. */ + async function controlAt( + session: NoiseTransportSession, + connectionId: string, + index: number, + ): Promise> { + const frame = await flushUntil(() => transportFrames(connectionId)[index]); + const receipt = session.receive(fromBase64Url(frame.ct as string)); + if (receipt.kind !== 'control') throw new Error(`expected a signal, got ${receipt.kind}`); + return receipt.value; + } + + /** One `direct-*` signal from the Client, on the established session. */ + function sendSignal( + clientId: string, + connectionId: string, + session: NoiseTransportSession, + signal: Record, + ): void { + sendE2e(clientId, 'connection', connectionId, 'transport', toBase64Url(session.sendControl(signal))); + } + + const OFFER_SDP = 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n'; + + it('declines an offer it has no way to answer, and answers a second one not at all', async () => { + // No `createDirectPeer`: the VS Code host, and any runtime whose native + // addon will not load (`docs/specs/remote-api.md` → Transport → + // "Direct path"). + makeBurrow(); + const { session, connectionId, clientId } = await establishSession(); + + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer', sdp: OFFER_SDP }); + + expect(await controlAt(session, connectionId, 1)).toEqual({ v: 1, t: 'direct-decline' }); + + // **One attempt per session.** A second offer allocates nothing and is not + // answered — not even with another decline. + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer', sdp: OFFER_SDP }); + await settle(); + expect(transportFrames(connectionId)).toHaveLength(2); + + // And the session is untouched: protocol-v1 still crosses it. + for (const ciphertext of session.sendApp( + utf8Encode(JSON.stringify({ requestId: 'r1', method: 'hello' })), + )) { + sendE2e(clientId, 'connection', connectionId, 'transport', toBase64Url(ciphertext)); + } + await settle(); + expect(sessions[0]!.handled).toEqual([{ requestId: 'r1', method: 'hello' }]); + expect(sessions[0]!.disposed).toBe(false); + }); + + it('ignores a control message that is not a signal, rather than failing the session', async () => { + // The compatibility rule the whole staging rests on: an established session + // carrying a control shape this peer does not know stays up. + makeBurrow(); + const { session, connectionId, clientId } = await establishSession(); + + sendSignal(clientId, connectionId, session, { v: 2, t: 'direct-offer', sdp: OFFER_SDP }); + sendSignal(clientId, connectionId, session, { t: 'something-else' }); + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-offer' }); + // A signal only the Burrow sends is ignored coming the other way. + sendSignal(clientId, connectionId, session, { v: 1, t: 'direct-decline' }); + await settle(); + + expect(transportFrames(connectionId)).toHaveLength(1); + expect(burrow.establishedSessionCount).toBe(1); + expect(sessions[0]!.disposed).toBe(false); + }); }); diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 3ed27940d..762e623d2 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -15,10 +15,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + CONTROL_PAYLOAD_SIZE, DEFAULT_PAIRING_TTL_MS, + DIRECT_SETUP_TIMEOUT_MS, E2E_KEEPALIVE_INTERVAL_MS, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, KEEPALIVE_BODY_SIZE, + MAX_DIRECT_PENDING_FRAMES, REMOTE_EVENTS, REMOTE_METHODS, SELFHOST_ACCOUNT_ID, @@ -63,6 +66,13 @@ import type { PendingDeliveryDeletionV1, } from './pocket-db'; import { FakeSocket } from '../test-fake-socket'; +import { + FakeDirectNetwork, + type FakeDirectNetworkOptions, + type FakePeer, +} from '../direct/test-fake-peer'; +import type { DirectPeerLike } from '../direct/direct-peer'; +import type { RemoteTimer } from '../ws'; import { createTestRelay, type TestRelay } from '../test-relay'; import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; import { BurrowRuntime } from '../burrow/burrow-runtime'; @@ -194,6 +204,11 @@ function expiringClock(): { now: () => number; expire: () => void } { }; } +/** Let everything already queued run, for a case asserting something did not happen. */ +async function settleTicks(): Promise { + for (let i = 0; i < 8; i += 1) await new Promise((r) => setTimeout(r, 1)); +} + /** Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. */ async function waitFor(predicate: () => boolean, what = 'a condition'): Promise { for (let i = 0; i < 400; i++) { @@ -371,6 +386,10 @@ async function makeE2eHarness( pushDeleteFails?: boolean; /** Extra `PocketClient` deps — the keepalive timer and visibility seams. */ deps?: Partial; + /** How this Burrow builds a peer for the direct path; absent, it declines. */ + burrowDirect?: () => DirectPeerLike | null; + /** The Burrow's timers, where a case has to fire one by hand. */ + burrowSetTimer?: RemoteTimer; } = {}, ): Promise { const burrowId = options.burrowId ?? randomBase64Url(16); @@ -397,6 +416,8 @@ async function makeE2eHarness( enrollment, reconnect: false, createWebSocket: () => burrowSocket, + ...(options.burrowDirect ? { createDirectPeer: options.burrowDirect } : {}), + ...(options.burrowSetTimer ? { setTimer: options.burrowSetTimer } : {}), loadAcl: options.loadAcl ?? (() => []), saveAcl: (_burrowId, records) => { savedAcl = [...records]; @@ -929,6 +950,13 @@ function fakeTimers() { timer.cancelled = true; timer.run(); }, + /** Fire the one armed for `delayMs`, where more than one deadline is live. */ + fireAt(delayMs: number): void { + const timer = this.live.find((entry) => entry.delayMs === delayMs); + if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); + timer.cancelled = true; + timer.run(); + }, }; } @@ -1082,6 +1110,321 @@ describe('keepalives on an established session', () => { }); }); +// --- The direct path -------------------------------------------------------- + +describe('the direct path, end to end', () => { + /** + * A connected phone and a real Burrow holding the two ends of one linked peer + * pair, with every timer on both sides the test's own. + * + * Nothing about the negotiation is stubbed: the signals are real control + * messages on the real session, and each side runs the shipped + * `DirectPeer` — only the `RTCPeerConnection` underneath is in memory. + */ + async function connectedDirect( + options: { + network?: FakeDirectNetworkOptions; + /** Give the Client no peer factory, as a browser without WebRTC has. */ + clientHasPeer?: boolean; + /** Give the Burrow none, as the VS Code host has. */ + burrowHasPeer?: boolean; + } = {}, + ) { + const network = new FakeDirectNetwork(options.network); + const timers = fakeTimers(); + const burrowTimers = fakeTimers(); + const clientPeers: FakePeer[] = []; + const burrowPeers: FakePeer[] = []; + const harness = await makeE2eHarness({ + deps: { + setTimer: timers.setTimer, + ...(options.clientHasPeer === false + ? {} + : { + createDirectPeer: () => { + const peer = network.createOfferer(); + clientPeers.push(peer); + return peer; + }, + }), + }, + ...(options.burrowHasPeer === false + ? {} + : { + burrowDirect: () => { + const peer = network.createAnswerer(); + burrowPeers.push(peer); + return peer; + }, + }), + burrowSetTimer: burrowTimers.setTimer, + }); + await harness.pairAndApprove(await harness.mintInvitation()); + expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); + return { + harness, + network, + timers, + burrowTimers, + clientPeers, + burrowPeers, + /** This session's routing id, read off the envelope the Client addressed. */ + connectionId: harness + .clientSocket() + .frames('e2e') + .find((frame) => frame.kind === 'connection')!.id as string, + /** Client→relay transport frames on the connection, which stop at the switch. */ + clientFrames: () => + harness + .clientSocket() + .frames('e2e') + .filter((frame) => frame.kind === 'connection' && frame.step === 'transport'), + /** Burrow→relay frames on this connection, which stop at its own switch. */ + burrowFrames: () => + harness.relay.burrowSocket + .frames('e2e') + .filter((frame) => frame.kind === 'connection'), + /** Wait until both directions have left the relay. */ + cutover: () => + waitFor(() => harness.client.transportPath === 'direct', 'the session to go direct'), + }; + } + + it('offers after the outcome and cuts over, all as control messages on the relay', async () => { + const run = await connectedDirect(); + await run.cutover(); + + // Three Client→Burrow transport frames on this connection: the connection + // request the ceremony ended with, the offer, and the switch. Nothing else + // rides the relay, and the Relay never sees an SDP — only a padded + // control body it cannot read. + expect(run.clientFrames()).toHaveLength(3); + for (const frame of run.clientFrames()) { + expect(fromBase64Url(frame.ct as string).length).toBe(1 + CONTROL_PAYLOAD_SIZE + 16); + } + // And three the other way: the outcome, the answer, and the Burrow's switch. + expect(run.burrowFrames().filter((frame) => frame.step === 'transport')).toHaveLength(3); + // Signaling only so far: the channel has carried nothing. + expect(run.network.offererChannel!.sent).toEqual([]); + }); + + it('carries protocol-v1 on the channel afterwards, and nothing more on the relay', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const burrowBefore = run.burrowFrames().length; + const chunks: TerminalDataEvent[] = []; + + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + await run.harness.client.watchDirectory(() => {}); + await run.harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); + await run.harness.client.write('surface-1', 'ls\n'); + + // The relay carried none of it, in either direction. + expect(run.clientFrames()).toHaveLength(clientBefore); + expect(run.burrowFrames()).toHaveLength(burrowBefore); + // The channel carried all of it — including the burrow→client stream. + expect(run.network.offererChannel!.sent.length).toBe(4); + expect(run.network.answererChannel!.sent.length).toBeGreaterThanOrEqual(5); + expect(chunks).toEqual([STREAMED_CHUNK]); + }); + + it('keepalives ride the channel once the session has switched', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const sentBefore = run.network.offererChannel!.sent.length; + + run.timers.fireAt(E2E_KEEPALIVE_INTERVAL_MS); + + expect(run.clientFrames()).toHaveLength(clientBefore); + const sent = run.network.offererChannel!.sent.slice(sentBefore); + // The kind byte, 32 zero bytes, and the Poly1305 tag: the same fixed-size + // keepalive the relay would have carried. + expect(sent.map((frame) => frame.length)).toEqual([1 + KEEPALIVE_BODY_SIZE + 16]); + }); + + it('stays relayed and fully working against a Burrow that declines', async () => { + const run = await connectedDirect({ burrowHasPeer: false }); + + // The decline is the Burrow's second transport frame, after the outcome. + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to decline', + ); + await waitFor(() => run.clientPeers[0]!.closed, 'the Client to close its peer'); + + expect(run.harness.client.transportPath).toBe('relay'); + const before = run.clientFrames().length; + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + expect(run.clientFrames().length).toBeGreaterThan(before); + }); + + it('never offers from a runtime with no peer connection', async () => { + const run = await connectedDirect({ clientHasPeer: false }); + await settleTicks(); + + // One transport frame each way: the connection request and its outcome. + expect(run.clientFrames()).toHaveLength(1); + expect(run.burrowFrames().filter((frame) => frame.step === 'transport')).toHaveLength(1); + expect(run.burrowPeers).toEqual([]); + expect(run.harness.client.transportPath).toBe('relay'); + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + }); + + it('leaves the session relayed when the channel never opens', async () => { + const run = await connectedDirect({ network: { opening: 'never' } }); + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to answer', + ); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + + expect(run.harness.client.transportPath).toBe('relay'); + expect(run.clientPeers[0]!.closed).toBe(true); + // Never switched, so this is an abandoned attempt rather than burrow loss. + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + }); + + it('ends both ends when the channel dies after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.network.dropChannels(); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + expect(run.harness.client.transportPath).toBe('relay'); + }); + + it('disposes the Client’s session on a relay frame that arrives after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Refused before any decrypt: the ciphertext is never even looked at. + run.harness.clientSocket().receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AAAA', + }); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('disposes the Burrow’s session on a relay frame that arrives after the switch', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.receive({ + t: 'e2e', + clientId: run.harness.relay.clientId, + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AAAA', + }); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + }); + + /** + * The race the holding queue exists for: the Burrow's answers overtake the + * `direct-switch` that precedes them on the relay. The in-memory relay routes + * synchronously, so the test holds that direction by hand. + */ + it('holds channel frames until the peer’s switch, then drains them in order', async () => { + const run = await connectedDirect({ network: { opening: 'manual' } }); + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to answer', + ); + await settleTicks(); + + run.harness.relay.holdToClient(); + run.network.openChannels(); + // The Client has switched its own sends; the Burrow's switch is held. + expect(run.harness.client.transportPath).toBe('relay'); + + const order: string[] = []; + const first = run.harness.client.hello().then(() => order.push('first')); + const second = run.harness.client.write('surface-1', 'ls').then(() => order.push('second')); + await settleTicks(); + expect(order).toEqual([]); + + run.harness.relay.releaseToClient(); + + await Promise.all([first, second]); + expect(order).toEqual(['first', 'second']); + expect(run.harness.client.transportPath).toBe('direct'); + }); + + it('ends the session when held frames outrun the queue', async () => { + const run = await connectedDirect({ network: { opening: 'manual' } }); + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to answer', + ); + await settleTicks(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.harness.relay.holdToClient(); + run.network.openChannels(); + + // One answer per request, all of them held: the cap is what stops a peer + // that never sends the switch from growing this without bound. + const pending = []; + for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) { + pending.push(run.harness.client.request('hello', {})); + } + await Promise.allSettled(pending); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.receive({ + t: 'client-gone', + clientId: run.harness.relay.clientId, + }); + + await waitFor(() => run.burrowPeers[0]!.closed, 'the Burrow to close its peer'); + expect(run.harness.burrow.establishedSessionCount).toBe(0); + }); + + it('closes the Burrow’s peer when its own relay socket drops', async () => { + const run = await connectedDirect(); + await run.cutover(); + + run.harness.relay.burrowSocket.drop(); + + await waitFor(() => run.burrowPeers[0]!.closed, 'the Burrow to close its peer'); + expect(run.harness.burrow.establishedSessionCount).toBe(0); + }); +}); + // --- Setup, sign-in, and the token a scan carries --------------------------- describe('setup + signin', () => { diff --git a/lib/src/remote/test-e2e-client.ts b/lib/src/remote/test-e2e-client.ts index f4f5b7134..b0e4da16c 100644 --- a/lib/src/remote/test-e2e-client.ts +++ b/lib/src/remote/test-e2e-client.ts @@ -34,6 +34,9 @@ import { type PresenceProofV1, } from 'remote-lib-common'; import type { FakeSocket } from './test-fake-socket'; +import { DirectPeer } from './direct/direct-peer'; +import type { FakeDirectNetwork } from './direct/test-fake-peer'; +import type { RemoteTimer } from './ws'; const subtle = globalThis.crypto.subtle; @@ -391,3 +394,82 @@ export async function openConnectionSession(options: { burrowChallenge: toBase64Url(payload), }; } + +/** + * The Client half of the direct path, as a test drives it against a real + * Burrow: offer, read the answer, accept it, and — once the channel opens — + * announce this end's own switch (`docs/specs/remote-api.md` → Transport → + * "Direct path"). + * + * It decrypts every Burrow→Client transport frame it consumes, so a caller must + * not also read the session's relay frames while it is running. + */ +export interface TestDirectPath { + /** The Client-side wrapper; `send` puts one transport ciphertext on the channel. */ + readonly peer: DirectPeer; + /** Ciphertexts the Burrow put on the channel, in arrival order. */ + readonly inbound: Uint8Array[]; + /** The signals decrypted off the relay while opening it, in order. */ + readonly signals: Array>; +} + +export async function openDirectPath(options: { + socket: FakeSocket; + burrowId: string; + clientId: string; + connectionId: string; + session: NoiseTransportSession; + network: FakeDirectNetwork; + /** This end's own `direct-switch`, once the channel is open (default true). */ + switchOutbound?: boolean; + /** The wrapper's deadlines; a Burrow suite shares its own clock's. */ + setTimer?: RemoteTimer; +}): Promise { + const { socket, burrowId, clientId, connectionId, session, network } = options; + const inbound: Uint8Array[] = []; + const signals: Array> = []; + const peer = new DirectPeer({ + peer: network.createOfferer(), + ...(options.setTimer ? { setTimer: options.setTimer } : {}), + handlers: { + onOpen: () => {}, + onFrame: (frame) => inbound.push(frame), + onClosed: () => {}, + onViolation: () => {}, + }, + }); + const transportFrames = () => + e2eFramesFor(socket, 'connection', connectionId).filter((frame) => frame.step === 'transport'); + let cursor = transportFrames().length; + const nextSignal = async (): Promise> => { + const frame = await flushUntil(() => transportFrames()[cursor]); + cursor += 1; + const receipt = session.receive(fromBase64Url(frame.ct as string)); + if (receipt.kind !== 'control') throw new Error(`expected a signal, got ${receipt.kind}`); + signals.push(receipt.value); + return receipt.value; + }; + const sendControl = (value: Record): void => { + sendE2eFrame(socket, { + clientId, + burrowId, + kind: 'connection', + id: connectionId, + step: 'transport', + ct: toBase64Url(session.sendControl(value)), + }); + }; + + const offer = await peer.offer(); + if (offer === null) throw new Error('the test peer could not describe an offer'); + sendControl({ v: 1, t: 'direct-offer', sdp: offer }); + const answer = await nextSignal(); + // A decline is an answer too: the caller reads it off `signals`. + if (answer.t !== 'direct-answer') return { peer, inbound, signals }; + await peer.acceptAnswer(answer.sdp as string); + // The Burrow's own switch is its last message on the relay. + await nextSignal(); + if (options.switchOutbound !== false) sendControl({ v: 1, t: 'direct-switch' }); + await settle(); + return { peer, inbound, signals }; +} diff --git a/lib/src/remote/test-relay.ts b/lib/src/remote/test-relay.ts index ad165271d..83c8ab652 100644 --- a/lib/src/remote/test-relay.ts +++ b/lib/src/remote/test-relay.ts @@ -39,6 +39,16 @@ export interface TestRelay { errorClient(message: string): void; /** Corrupt the `ct` of the next Burrow→Client frame, as a hostile relay would. */ tamperNextBurrowFrame(): void; + /** + * Buffer Burrow→Client frames instead of delivering them, until + * {@link releaseToClient}. The one ordering an in-memory relay cannot + * otherwise produce: this relay routes synchronously, so a direct channel + * frame can never overtake the `direct-switch` that precedes it — which is + * exactly the race the receiver's holding queue exists for. + */ + holdToClient(): void; + /** Deliver everything held, in the order it was sent. */ + releaseToClient(): void; /** Stop routing, without closing either socket. */ stop(): void; } @@ -58,6 +68,8 @@ export function createTestRelay(options: { let client: { socket: FakeSocket; bound: string | null } | null = null; let live = true; let tamper = false; + /** Buffered Burrow→Client deliveries, or null while routing normally. */ + let held: Array<() => void> | null = null; burrowSocket.onSend = (frame) => { if (!live || !client) return; @@ -70,14 +82,18 @@ export function createTestRelay(options: { if (client.bound !== burrowId) return; const ct = tamper ? flipLastCharacter(frame.ct) : frame.ct; tamper = false; - client.socket.receive({ - t: 'e2e', - burrowId, - kind: frame.kind as E2eKind, - id: frame.id, - step: frame.step, - ct, - }); + const target = client.socket; + const deliver = () => + target.receive({ + t: 'e2e', + burrowId, + kind: frame.kind as E2eKind, + id: frame.id, + step: frame.step, + ct, + }); + if (held) held.push(deliver); + else deliver(); }; return { @@ -125,6 +141,14 @@ export function createTestRelay(options: { tamperNextBurrowFrame() { tamper = true; }, + holdToClient() { + held ??= []; + }, + releaseToClient() { + const pending = held ?? []; + held = null; + for (const deliver of pending) deliver(); + }, stop() { live = false; }, From 2233879f9ef912b74b24ec1d1a72daf77abc6f4e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:26:30 -0700 Subject: [PATCH 06/46] docs: promote the direct path above the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remote-api.md` gains a shipped `### Direct path` under Transport — the four signals, the offer-once rule, the SDP bound, no ICE servers, the channel's framing, the per-direction cutover and its bounds, and the Relay keeping lifecycle authority — and its `## Future` item is cut to the four stages that remain. `relay.md` names the signals as control messages and points here; `pocket-app.md` says what Pocket offers, shows, and does when a channel dies. Evidence for the candidate story and DTLS moved to the rationale. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/pocket-app.md | 17 +++++ docs/specs/relay.md | 3 + docs/specs/remote-api.md | 100 ++++++++++++++++++++++++----- docs/specs/remote-api.rationale.md | 6 ++ scripts/spec-word-budgets.json | 4 +- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 68daa6148..e0caa6fca 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -454,6 +454,23 @@ 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). + +**The connected header names the live path** — `relay` or `direct`, captioned, +never coloured — so a relayed fallback is visible rather than silent. **A +channel that dies after the cutover 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 cutover a failed channel costs nothing. + +Source of truth: `PocketClient.transportPath` in +`lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` 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 diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 39c7c220f..9be861af0 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -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" diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index cd677e7f1..b53b09193 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -59,6 +59,84 @@ 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 between phone and laptop. **The presence protocol is inherited +unchanged and the Relay is never trusted with authorization.** Which Burrows can +answer is staged ([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 stays relayed with no negotiation at all. + +**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`, after which the local description as it stands is +what travels. **An SDP over `MAX_DIRECT_SDP_LENGTH` is never sent**: the Client +skips the offer, the Burrow declines. That bound is derived from +`CONTROL_PAYLOAD_SIZE` and the characters an SDP is made of, so a maximal signal +always fits one control body. + +**No ICE servers.** `iceServers: []` at both ends, host candidates only. +**Never a public STUN or TURN default** — it would hand a third party the user's +address. (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) + +**Cutover 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. +* **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. + +**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, so a keepalive decrypted off the channel refreshes the deadline +the same way ([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` — +answering `null` where a runtime has none, so neither end reaches a WebRTC +global itself. **Pocket shows which path carries the session** +([pocket-app.md](./pocket-app.md)). + +Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, +their guard, the constants, and the `DirectCutover` both ends run), +`lib/src/remote/direct/direct-peer.ts` (`DirectPeerLike` and the negotiation), +`PocketClient.#offerDirect` in `lib/src/remote/client/pocket-client.ts`, +`BurrowRuntime.#answerDirect` in `lib/src/remote/burrow/burrow-runtime.ts`; +pinned by `remote-lib-common/test/direct-path.test.mjs`, +`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`). @@ -279,24 +357,14 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ ### 8. Direct path (WebRTC) -**Scope: direct-path** — latency. After authorization the same Noise session moves off the Relay onto a WebRTC data channel between phone and laptop; the presence protocol is inherited unchanged and the Relay is never trusted with authorization. Staged order: - -1. **Shared plumbing** — `remote-lib-common`: the signaling controls, their guards, the cutover state machine and its bounds; a `direct` module under `lib/src/remote/`: one `RTCPeerConnection`-shaped peer wrapper both ends share, its peer factory injected (`PocketClientDeps.createDirectPeer`, `BurrowOptions.createDirectPeer`), null where a runtime has none. -2. **Pocket offers; the standalone Burrow answers** — Pocket over the browser's `RTCPeerConnection`; the sidecar over `node-datachannel`'s W3C polyfill, a native addon declared in `standalone/sidecar/package.json` beside `node-pty`, loaded lazily at the first offer, a load failure answering `direct-decline`. The VS Code Burrow declines. -3. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, the supply-chain disclosure regenerated, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. -4. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). -5. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. +**Scope: direct-path** — latency. The shipped half is [Transport → Direct path](#direct-path), which Pocket offers and answers today. What remains, in staged order: -The design stages 1–3 build: +1. **The standalone Burrow answers** — the sidecar over `node-datachannel`'s W3C polyfill, a native addon declared in `standalone/sidecar/package.json` beside `node-pty`, loaded lazily at the first offer, a load failure answering `direct-decline`. +2. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, the supply-chain disclosure regenerated, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. +3. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). +4. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. -* **Signaling rides inside the session**, as control messages (`docs/specs/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). **The Relay never sees an SDP, a candidate, or that a direct path exists**; a peer without the stack ignores or declines, and the session stays relayed — an old Pocket or Burrow needs nothing. -* **Offered only after `ConnectionOutcomeV1 { ok: true }`**, once per session, never retried. The Client is the offerer and creates one ordered, reliable data channel; each side sends its SDP after ICE gathering completes (no trickle), bounded by `CONTROL_PAYLOAD_SIZE` — a Burrow whose answer would exceed it declines. -* **No ICE servers.** `iceServers: []` at both ends, host candidates only: the shipped deployment is a tailnet, so the Burrow's tailnet address is a host candidate and the phone's mDNS-obfuscated one is learned peer-reflexively. **Never a public STUN or TURN default** — it would hand a third party the user's address. Relay-supplied ICE servers are unstaged (SaaS). -* **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 frame bounded at `NOISE_MAX_MESSAGE_LENGTH` before decryption. DTLS beneath is transport hygiene the model does not rely on; its fingerprints are authentic because the SDP arrived inside the session. -* **Cutover preserves order per direction.** A sender's `direct-switch` is its last message on the relay path; every later message 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. After the switch, a relay transport frame on that connection, or the channel closing, disposes the session: burrow loss, a fresh handshake to return. A channel not open by `DIRECT_SETUP_TIMEOUT_MS` is closed and the session stays relayed. -* **The Relay stays the lifecycle authority.** `client-gone`, `burrow-gone`, and either relay socket closing dispose the session, channel included, exactly as today; the idle deadline, keepalives, and every Burrow bound are path-agnostic. A session surviving relay loss is unstaged. -* **One peer connection per session**: created at the offer, closed on every disposal path, never existing before promotion. -* **Pocket shows which path carries the session**, so a relayed fallback is visible rather than silent. +Relay-supplied ICE servers are unstaged (SaaS), as is a session surviving relay loss. ### 9. Audio diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 70461f6d1..0f5727b17 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -12,6 +12,12 @@ In September 2026, both production installations use `createAskSurfaceProvider`: resolution selects a routing key and applies the requested size, while `streamPty` separately owns the subscription. The former `SurfaceHandle.release` was a no-op in that shared constructor; a test-only release counter suggested a second resource lifetime that neither host had. +## Direct path + +**Why host candidates alone reach.** The shipped deployment is a tailnet: the Burrow's tailnet address is a host candidate the phone can route to, and the phone's own mDNS-obfuscated candidate is learned peer-reflexively from the first packet. A STUN server would buy a public reflexive candidate the deployment does not need, at the price of telling a third party both addresses. + +**Why DTLS is not part of the trust model.** The channel is encrypted twice — DTLS underneath, Noise inside — and only the inner one is load-bearing. The DTLS fingerprints are authentic because the SDP carrying them was decrypted inside an authorized session, so DTLS adds transport hygiene rather than a second authority; a peer that broke it would still face the promoted session's ciphers. + ## Envelope **Why the clamp's upper bound is the security-relevant half.** A local resize is derived from element geometry and cannot be large, but `terminal.resize` carries a peer-supplied number straight into `term.resize` in the webview that owns the pane, and xterm bounds only the minimum before allocating `rows × cols` cells. Unbounded, one frame asking for a million by a million wedges every terminal in that window, reachable by any authorized Client (`docs/specs/security-remote.md` → "Trust boundary"). `MAX_TERMINAL_DIMENSION` is 2000 — far past any real display, since a 4K screen at an unreadably small font is on the order of 800 columns — while capping the worst a peer can request at a few million cells. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 85ab847a3..d7413892b 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,9 +13,9 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4050, + "docs/specs/pocket-app.md": 4200, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4050, + "docs/specs/remote-api.md": 4200, "docs/specs/remote-security-model.md": 4200, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, From 9b9b68c7f43264e7f15b50b2d0e1e5f78e4ef0dd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:39:00 -0700 Subject: [PATCH 07/46] fix(remote): end a session whose peer switched onto an abandoned channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A setup deadline can fire on one end while the other's channel is opening, and then that end's `direct-switch` lands on a peer with nothing left to receive it: the Client's every request would hang unanswered, and the Burrow would hold the session to its idle deadline. Both now treat that switch the way they treat a channel that dies after one — burrow loss, and a fresh handshake to return. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/remote-api.md | 28 +++++++------- lib/src/remote/burrow/burrow-runtime.ts | 9 ++++- lib/src/remote/client/pocket-client.test.ts | 43 +++++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 18 ++++++++- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index b53b09193..6e283c75f 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -62,9 +62,9 @@ Source of truth: `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/bu ### Direct path After authorization the same Noise session moves off the Relay onto a WebRTC -data channel between phone and laptop. **The presence protocol is inherited -unchanged and the Relay is never trusted with authorization.** Which Burrows can -answer is staged ([Future](#future)). +data channel. **The presence protocol is inherited unchanged and the Relay is +never trusted with authorization.** Which Burrows answer is staged +([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 @@ -73,18 +73,17 @@ path: `direct-offer` (Client→Burrow, SDP), `direct-answer` (Burrow→Client, S `{ 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 stays relayed with no negotiation at all. +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`, after which the local description as it stands is -what travels. **An SDP over `MAX_DIRECT_SDP_LENGTH` is never sent**: the Client -skips the offer, the Burrow declines. That bound is derived from -`CONTROL_PAYLOAD_SIZE` and the characters an SDP is made of, so a maximal signal -always fits one control body. +`DIRECT_GATHER_TIMEOUT_MS`, past which what it has is what travels. **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.** `iceServers: []` at both ends, host candidates only. **Never a public STUN or TURN default** — it would hand a third party the user's @@ -111,20 +110,23 @@ non-binary channel message — disposes the session. (rationale) `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. **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, so a keepalive decrypted off the channel refreshes the deadline -the same way ([remote-security-model.md](./remote-security-model.md) → Burrow +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` — -answering `null` where a runtime has none, so neither end reaches a WebRTC -global itself. **Pocket shows which path carries the session** +`null` where a runtime has none, so neither end reaches a WebRTC global. +**Pocket shows which path carries the session** ([pocket-app.md](./pocket-app.md)). Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index 501905c61..d5d362a83 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -1697,7 +1697,14 @@ export class BurrowRuntime { return; case 'direct-switch': { const direct = established.direct; - if (!direct) return; + if (!direct) { + // **A switch onto a channel this Burrow has abandoned is the end of + // the session.** Nothing the Client sends can arrive any more, and a + // session held to its idle deadline on that is one the phone is + // waiting out for two minutes. + if (established.directAttempted) this.#disposeEstablished(clientId); + return; + } const held = direct.cutover.onSwitchDecrypted(); // In arrival order, through the same decrypt path the relay's frames // take: what was held is exactly what was sent after the switch. diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 762e623d2..d26ce62ae 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1401,6 +1401,49 @@ describe('the direct path, end to end', () => { expect(run.harness.client.connectedBurrowId).toBeNull(); }); + + /** + * The one failure a phone cannot recover from on its own: the peer that + * abandoned is deaf, and the other end has already stopped using the relay. + */ + it('ends the session when the Burrow switches onto a channel the phone abandoned', async () => { + const run = await connectedDirect({ network: { opening: 'manual' } }); + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to answer', + ); + await settleTicks(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + expect(run.clientPeers[0]!.closed).toBe(true); + // And only now does the Burrow's channel come up, so its switch lands on a + // phone that has already closed its end. + run.network.openChannels(); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('ends the session when the phone switches onto a channel the Burrow abandoned', async () => { + const run = await connectedDirect({ network: { opening: 'manual' } }); + await waitFor( + () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, + 'the Burrow to answer', + ); + await settleTicks(); + + run.burrowTimers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + expect(run.burrowPeers[0]!.closed).toBe(true); + run.network.openChannels(); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + }); + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { const run = await connectedDirect(); await run.cutover(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index f7ffb7d4f..8f5f97e37 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -375,6 +375,8 @@ export class PocketClient { #onBurrowGone: (() => void) | null = null; /** This session's direct-path attempt, or null while it is purely relayed. */ #direct: DirectAttempt | null = null; + /** Whether this session has offered one, however that attempt ended. */ + #directAttempted = false; #onTransportPath: ((path: DirectPath) => void) | null = null; /** The last path announced, so an unchanged one is not announced twice. */ #announcedPath: DirectPath = 'relay'; @@ -1197,6 +1199,9 @@ export class PocketClient { onViolation: (reason) => this.#loseBurrow(reason), }, }); + // Never two: a promotion that replaced a session leaves this holding the + // old one's peer, which nothing else would close. + this.#direct?.peer.close(); this.#direct = { peer, cutover: new DirectCutover() }; const sdp = await peer.offer(); // The session may have been replaced or disposed while the description was @@ -1216,6 +1221,7 @@ export class PocketClient { 'transport', established.session.sendControl({ ...signal }), ); + this.#directAttempted = true; } catch { this.#abandonDirect(peer); } @@ -1283,7 +1289,16 @@ export class PocketClient { // without this stack simply stay relayed. if (!isDirectSignalV1(value)) return; const direct = this.#directFor(established); - if (!direct) return; + if (!direct) { + // **A switch onto a channel this phone has abandoned is burrow loss.** + // Nothing the Burrow sends can arrive any more, and a silent session + // whose every request hangs forever is the one failure the app cannot + // recover from on its own. + if (value.t === 'direct-switch' && this.#directAttempted) { + this.#loseBurrow('the computer moved to a direct path this phone had closed'); + } + return; + } switch (value.t) { case 'direct-answer': void direct.peer.acceptAnswer(value.sdp); @@ -1665,6 +1680,7 @@ export class PocketClient { this.#direct?.peer.close(); this.#direct?.cutover.clear(); this.#direct = null; + this.#directAttempted = false; this.#connectedBurrowId = null; this.#established = null; this.#announcePath(); From 7a9103fece62fd21971e7edf3d25109bdd33b65a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:41:39 -0700 Subject: [PATCH 08/46] test(remote): spell the rejected control character, so the file stays text A raw NUL in the source made git treat the whole test as binary. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- remote-lib-common/test/direct-path.test.mjs | Bin 7373 -> 7415 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 1f5ee7a1bc53c08daf38d9e4375a5be8f0b164e0..75cfc0166327c06d24b8b7e1261916385643d353 100644 GIT binary patch delta 35 pcmX?W`Q37ZJ2!hwsR0nEPrk<@#g$T^p`K`4VHH!d*@L@P5CGcU3vd7c delta 14 VcmexvdDe1+J2xZ4<|6J^K>#ep1poj5 From e9c7fdcf44be3d446adb4938e9ee277b75af8a14 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:54:05 -0700 Subject: [PATCH 09/46] feat(remote): the standalone Burrow answers a direct offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar builds its peer from node-datachannel's W3C polyfill, opened inside the first `direct-offer` rather than at boot — a native library with its own thread pool has no business loading for the sidecar starts that never see a Client. A load that throws is warned once and declines forever after: a missing platform package is a property of the installation, not of the offer, and the answer is `direct-decline` either way. Packaging: a sidecar package's transitive dependencies do not ship, because the Tauri bundle copies only what is under `standalone/sidecar/node_modules`. So the platform packages and `detect-libc` are declared there directly, and both specifiers stay external to `burrow.cjs` — the addon resolves its `.node` relative to its own `__dirname`, which inlining would move out of the installed package. The build asserts the bare requires survived. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/host/remote/native-direct-peer.ts | 131 +++++++++++++++++++++ lib/src/host/remote/sidecar-entry.ts | 8 ++ pnpm-lock.yaml | 119 +++++++++++++++++++ standalone/scripts/build-sidecar-proxy.mjs | 35 +++++- standalone/sidecar/package.json | 10 ++ 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 lib/src/host/remote/native-direct-peer.ts diff --git a/lib/src/host/remote/native-direct-peer.ts b/lib/src/host/remote/native-direct-peer.ts new file mode 100644 index 000000000..b8d0b13a3 --- /dev/null +++ b/lib/src/host/remote/native-direct-peer.ts @@ -0,0 +1,131 @@ +/** + * The standalone Burrow's direct-path peer: `node-datachannel`'s W3C polyfill, + * running next to the PTYs (`docs/specs/remote-api.md` → Transport → "Direct + * path", `docs/specs/standalone.md` → "Burrow service"). + * + * Host code, so nothing here imports the webview library — only the structural + * `DirectPeerLike` seam, as a type. The polyfill satisfies that seam without + * adaptation; the only thing this module adds is *when* the addon is loaded and + * what happens when it will not load. + * + * **The addon is never touched before the first offer.** It is a native library + * with its own thread pool: loading it at boot would cost every sidecar start, + * including the overwhelming majority that never see a Client. So the load + * happens inside the first factory call, once per process, and a Burrow that + * gets no `direct-offer` never opens it at all. + */ + +import { createRequire } from 'node:module'; +import type { DirectPeerFactory, DirectPeerLike } from '../../remote/direct/direct-peer'; + +/** The one polyfill export a direct path needs. */ +interface DirectPolyfill { + readonly RTCPeerConnection: new (config: { iceServers: [] }) => DirectPeerLike; +} + +/** The addon's own module, for the teardown the polyfill does not expose. */ +interface DirectAddon { + readonly cleanup: () => void; +} + +interface NativeDirect { + readonly polyfill: DirectPolyfill; + readonly addon: DirectAddon; +} + +export interface NativeDirectPeerOptions { + /** + * A file to resolve the addon relative to, for a caller that does not sit + * beside the `node_modules` holding it. The sidecar bundle does — it is + * emitted into `standalone/sidecar/`, whose `package.json` declares the + * platform package — so the shipped Burrow passes nothing and the loader + * below uses the bundle's own `require`. A test running this file from source + * under `lib/` names the sidecar instead. + */ + readonly resolveFrom?: string; + /** Where a load failure is reported; `console.warn` by default. */ + readonly warn?: (message: string) => void; +} + +/** + * The addon once some factory has loaded it. Process-wide rather than + * per-factory: one native library, one thread pool, one teardown. + */ +let native: NativeDirect | null = null; +/** + * Whether the addon is off the table for the rest of the process — a load that + * threw, or a teardown that has already run. Either way the factory answers + * `null` and the Burrow declines every offer, staying relayed. + */ +let declined = false; + +/** + * Both modules, required lazily. + * + * **The specifiers must survive bundling as bare `require` calls.** The addon's + * loader resolves its platform package and `detect-libc` relative to its own + * `__dirname`, so inlining the library into `burrow.cjs` would move that + * `__dirname` out of the installed package and leave nothing to find. The + * esbuild `external` entries in `standalone/scripts/build-sidecar-proxy.mjs` + * are what keeps them bare, and that build asserts it. + */ +function requireNative(resolveFrom: string | undefined): NativeDirect { + if (resolveFrom !== undefined) { + const required = createRequire(resolveFrom); + return { + polyfill: required('node-datachannel/polyfill') as DirectPolyfill, + addon: required('node-datachannel') as DirectAddon, + }; + } + return { + polyfill: require('node-datachannel/polyfill') as DirectPolyfill, + addon: require('node-datachannel') as DirectAddon, + }; +} + +/** + * A {@link DirectPeerFactory} over the native polyfill. + * + * **A load failure is warned once and declines forever after.** A missing + * platform package or a wrong ABI is a property of the installation, not of the + * offer, so retrying it per session would warn on every connection and cost a + * native load attempt each time — and the Burrow's answer is the same either + * way: `direct-decline`, and the session stays on the relay. + */ +export function createNativeDirectPeerFactory( + options: NativeDirectPeerOptions = {}, +): DirectPeerFactory { + const warn = options.warn ?? ((message: string) => console.warn(message)); + return () => { + if (!native && !declined) { + try { + native = requireNative(options.resolveFrom); + } catch (error) { + declined = true; + warn(`[burrow] no direct path: the WebRTC addon did not load: ${String(error)}`); + } + } + // `iceServers: []` here and nowhere else: host candidates only, never a + // public STUN or TURN default (`docs/specs/remote-api.md` → "Direct path"). + return native ? new native.polyfill.RTCPeerConnection({ iceServers: [] }) : null; + }; +} + +/** + * Tear the addon down, if some factory ever loaded it. + * + * The native side runs its own threads, which outlive every peer and would keep + * a process from exiting on their own. Terminal: a peer built on a cleaned-up + * addon is not one this process can use, so the factory declines afterwards. + */ +export function disposeNativeDirectPeers(): void { + const loaded = native; + native = null; + declined = true; + if (!loaded) return; + try { + loaded.addon.cleanup(); + } catch { + // Already down; nothing here can be reported to anyone useful. + } +} diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 20af75092..42a46388d 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -25,6 +25,7 @@ import type { } from '../../remote/burrow/burrow-surface-provider'; import { createAskSurfaceProvider } from './ask-surface-provider'; import { bakedConnectSrc } from './connect-src'; +import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; import { createEphemeralBurrowStateStore, FileBurrowStateStore, @@ -348,6 +349,10 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro kind: 'standalone', sendToUi: options.send, connectSrc: bakedConnectSrc(), + // The one host that answers a `direct-offer` today. Building the factory + // loads nothing: the addon is opened inside the first offer, if one ever + // comes (`native-direct-peer.ts`). + createDirectPeer: createNativeDirectPeerFactory(), }); void service.start().catch((error: unknown) => { console.error(`[burrow] failed to start: ${String(error)}`); @@ -369,6 +374,9 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro dispose() { service.dispose(); bridge.dispose(); + // After the service, so no session is still holding a channel: the addon's + // threads are what would otherwise keep the sidecar from exiting. + disposeNativeDirectPeers(); }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 166b05e08..8619792d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,9 +306,34 @@ importers: standalone/sidecar: dependencies: + detect-libc: + specifier: 2.1.2 + version: 2.1.2 + node-datachannel: + specifier: 0.33.2 + version: 0.33.2 node-pty: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15 + optionalDependencies: + '@node-datachannel/darwin-arm64': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/darwin-x64': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/linux-arm64-gnu': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/linux-x64-gnu': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/win32-arm64-msvc': + specifier: 0.33.2 + version: 0.33.2 + '@node-datachannel/win32-x64-msvc': + specifier: 0.33.2 + version: 0.33.2 vscode-ext: dependencies: @@ -1222,6 +1247,55 @@ packages: resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} engines: {node: '>= 20.19.0'} + '@node-datachannel/android-arm64@0.33.2': + resolution: {integrity: sha512-I71a0jICUNYgh4ZI5AcINEMuRtQSVyrpp4FV3kJZMcx31xS0vKKtf06tpsUAtW5uS+uRXbZR+vlMEqS1h710wQ==} + cpu: [arm64] + os: [android] + + '@node-datachannel/darwin-arm64@0.33.2': + resolution: {integrity: sha512-EwBGiaMh3MX6zjYoZHuK02Q6hmQQIvR39335Kb/ZnP3fXZU0/VWx6ksHLM6K0XFFYRfP+yJeVgU79g++xoeQpw==} + cpu: [arm64] + os: [darwin] + + '@node-datachannel/darwin-x64@0.33.2': + resolution: {integrity: sha512-a1nvL6MiskSjV3TWTNXdVpI/2kHNIOP2AbuBZjR5eC7VSrH/6hCFfHIBW4SFKXGFm8LOGliY9Df10UnJUutjwA==} + cpu: [x64] + os: [darwin] + + '@node-datachannel/linux-arm64-gnu@0.33.2': + resolution: {integrity: sha512-YWAu3EiMl2HRi/fwWlfFTW30wFTkRVYJWRHQcI1uDSeZYG/tfyBqBy6CRy9Sgrb0xjOYZ6XhLDj8cdX0foPlpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-arm64-musl@0.33.2': + resolution: {integrity: sha512-16o3Ny5hbxTTjDmhsZog/t7p2tRP6s/Oa/V9kKzZfiGlCqvxxCE4ACOWw/MWHOhN0xI9/63IIRfrrnQnPxjK8g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-datachannel/linux-x64-gnu@0.33.2': + resolution: {integrity: sha512-grLRAbZgSIX8nmObciblyCmnevDCst0snhV+z1fvDmUlzXIVXeDmv6lEzAaWs3KRleagqwSsFg5d1td1bwMT9w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-datachannel/linux-x64-musl@0.33.2': + resolution: {integrity: sha512-PlpgvYjo+gmBhIkRFaZWIhCF335FMTJmCpl+FVa4XliEgyOqD+cvjQPWcbnlrA2Jd+QnLiZt+NxT7nv4QXpoiQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-datachannel/win32-arm64-msvc@0.33.2': + resolution: {integrity: sha512-8NpHkm7R8EY0jHGHOXdK7K0wjWY34PJ+PJY++2VDHTAtXGazNm28zcnVE/4IU0N26YGdCDSSWat3OO3W6nwoRw==} + cpu: [arm64] + os: [win32] + + '@node-datachannel/win32-x64-msvc@0.33.2': + resolution: {integrity: sha512-aLk0O/M1JNVpzxx4RVBXMJTMlpswE04Cz5baayKwuEN+qk9hO6DSJgYMR6Wq9a5zGYAmvLFlOg+LsePyXkWliA==} + cpu: [x64] + os: [win32] + '@node-rs/crc32-android-arm-eabi@1.10.7': resolution: {integrity: sha512-mWNghDkwgoc5uJhhPx/LbgLoNAxnq6Sfz4pTxi4NWG7s5l+dPe7K+L4kXb0oOfwl3Yq3Ro7CNPGLrU4cmTXhaA==} engines: {node: '>= 10'} @@ -3688,6 +3762,10 @@ packages: resolution: {integrity: sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==} engines: {node: '>=v0.6.5'} + node-datachannel@0.33.2: + resolution: {integrity: sha512-WRL+uqYG2eSvpnKuCOKueaMiyKlDjkJFd6pFH/f2SbD/EiXLMXwmYNU5z+TDQPrAv+BMgkuC40UESeRRL+4zBw==} + engines: {node: '>=18.20.0'} + node-pty@1.2.0-beta.15: resolution: {integrity: sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==} @@ -5466,6 +5544,33 @@ snapshots: '@noble/ciphers@2.4.0': {} + '@node-datachannel/android-arm64@0.33.2': + optional: true + + '@node-datachannel/darwin-arm64@0.33.2': + optional: true + + '@node-datachannel/darwin-x64@0.33.2': + optional: true + + '@node-datachannel/linux-arm64-gnu@0.33.2': + optional: true + + '@node-datachannel/linux-arm64-musl@0.33.2': + optional: true + + '@node-datachannel/linux-x64-gnu@0.33.2': + optional: true + + '@node-datachannel/linux-x64-musl@0.33.2': + optional: true + + '@node-datachannel/win32-arm64-msvc@0.33.2': + optional: true + + '@node-datachannel/win32-x64-msvc@0.33.2': + optional: true + '@node-rs/crc32-android-arm-eabi@1.10.7': optional: true @@ -7854,6 +7959,20 @@ snapshots: node-bitmap@0.0.1: {} + node-datachannel@0.33.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@node-datachannel/android-arm64': 0.33.2 + '@node-datachannel/darwin-arm64': 0.33.2 + '@node-datachannel/darwin-x64': 0.33.2 + '@node-datachannel/linux-arm64-gnu': 0.33.2 + '@node-datachannel/linux-arm64-musl': 0.33.2 + '@node-datachannel/linux-x64-gnu': 0.33.2 + '@node-datachannel/linux-x64-musl': 0.33.2 + '@node-datachannel/win32-arm64-msvc': 0.33.2 + '@node-datachannel/win32-x64-msvc': 0.33.2 + node-pty@1.2.0-beta.15: dependencies: node-addon-api: 7.1.1 diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 8b86f2b4e..012e257d3 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -6,6 +6,7 @@ // - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; +import { readFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -23,6 +24,12 @@ const sidecar = path.resolve(here, '../sidecar'); // so this is the enforcement point — there is no webview CSP in front of it. const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); +// The Burrow's direct-path peer. `node-datachannel` resolves its platform +// package and `detect-libc` relative to its own `__dirname`, so it has to stay +// an installed package under `sidecar/node_modules` and be required by name — +// inlining it here would leave that loader looking beside `burrow.cjs`. +const NATIVE_DIRECT = ['node-datachannel', 'node-datachannel/polyfill']; + const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, @@ -31,15 +38,39 @@ const bundles = [ out: 'burrow.cjs', define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, assertBaked: true, + external: NATIVE_DIRECT, + assertExternal: NATIVE_DIRECT, }, ]; +/** + * Fail the build if esbuild bundled a module that has to stay external. + * + * `native-direct-peer.ts` calls `require('')` by literal, so an + * external specifier survives verbatim and a bundled one is rewritten into the + * inlined module's own accessor. That difference is the whole check: a lost + * `external` entry produces a `burrow.cjs` that loads and then cannot find the + * addon's `.node` file, which nothing before the first `direct-offer` on a real + * machine would notice. + */ +function assertExternalRequire(bundlePath, specifiers) { + const bundle = readFileSync(bundlePath, 'utf8'); + for (const specifier of specifiers) { + if (bundle.includes(`require("${specifier}")`)) continue; + throw new Error( + `sidecar: ${bundlePath} has no bare require("${specifier}") — esbuild bundled it, and ` + + 'the addon would look for its platform package beside the bundle instead of inside ' + + 'sidecar/node_modules.', + ); + } +} + // `tauri.conf.json`'s `bundle.resources` globs this whole directory, so a // pre-rename `remote-host.cjs` left in an older checkout would ship inside the // app — a dead Burrow with its own baked connect-src allowlist. await rm(path.resolve(sidecar, 'remote-host.cjs'), { force: true }); -for (const { entry, out, define, assertBaked } of bundles) { +for (const { entry, out, define, assertBaked, external, assertExternal } of bundles) { const outfile = path.resolve(sidecar, out); await build({ entryPoints: [path.resolve(libHost, entry)], @@ -50,7 +81,9 @@ for (const { entry, out, define, assertBaked } of bundles) { target: 'node24', logLevel: 'warning', ...(define ? { define } : {}), + ...(external ? { external } : {}), }); if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); + if (assertExternal) assertExternalRequire(outfile, assertExternal); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } diff --git a/standalone/sidecar/package.json b/standalone/sidecar/package.json index 2973c8682..ac799408f 100644 --- a/standalone/sidecar/package.json +++ b/standalone/sidecar/package.json @@ -7,6 +7,16 @@ "test": "node --test" }, "dependencies": { + "detect-libc": "2.1.2", + "node-datachannel": "0.33.2", "node-pty": "1.2.0-beta.15" + }, + "optionalDependencies": { + "@node-datachannel/darwin-arm64": "0.33.2", + "@node-datachannel/darwin-x64": "0.33.2", + "@node-datachannel/linux-arm64-gnu": "0.33.2", + "@node-datachannel/linux-x64-gnu": "0.33.2", + "@node-datachannel/win32-arm64-msvc": "0.33.2", + "@node-datachannel/win32-x64-msvc": "0.33.2" } } From 0c6f8bf0c53e107798580ec6d47686fd9b4bd6f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:57:40 -0700 Subject: [PATCH 10/46] test(remote): share the end-to-end loop the ceremonies run in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makeE2eHarness` and the account-plane fakes in front of it move out of `pocket-client.test.ts` into `test-e2e-harness.ts`, beside the other shared test modules, so a second suite can run the same real client, relay, and Burrow — the reason `test-relay.ts` and `test-e2e-client.ts` are shared. No behavior moves with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/client/pocket-client.test.ts | 409 ++------------------ lib/src/remote/client/test-e2e-harness.ts | 408 +++++++++++++++++++ 2 files changed, 435 insertions(+), 382 deletions(-) create mode 100644 lib/src/remote/client/test-e2e-harness.ts diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index d26ce62ae..ee6a3d460 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1,13 +1,10 @@ /** * The Pocket client's two end-to-end ceremonies, driven against the **real** - * `BurrowRuntime` through an in-memory relay (`../test-relay.ts`). + * `BurrowRuntime` through an in-memory relay. * - * **No ceremony step is stubbed.** The Noise handshakes are the shipped suite, - * the presence proofs are real ES256 assertions over the shared challenge - * builder — verified by the same `verifyPresenceProof` a Burrow runs — and the - * outcomes are decrypted on the session that produced them. Only the browser - * and network edges are faked: `fetch`, `WebSocket`, WebAuthn's two calls, and - * the two IndexedDB stores. + * The loop itself — client, relay, Burrow, and the account plane in front of + * them — is `./test-e2e-harness.ts`, shared with the suite that runs the same + * ceremonies over the native direct path. **No ceremony step is stubbed** there. * * The account-plane half (setup, sign-in, session expiry, push) drives a mocked * `fetch` alone; the relay is not involved in any of it. @@ -22,27 +19,17 @@ import { ESTABLISHED_E2E_IDLE_TIMEOUT_MS, KEEPALIVE_BODY_SIZE, MAX_DIRECT_PENDING_FRAMES, - REMOTE_EVENTS, - REMOTE_METHODS, SELFHOST_ACCOUNT_ID, SETUP_TOKEN_INVALID_ERROR, formatPairingInvitationUrl, fromBase64Url, generateNoiseKeyPair, hashPasskeyPublicKey, - mintNoiseStaticKeyPair, parsePairingInvitationUrl, - presenceChallenge, pushEndpointFingerprint, - randomBase64Url, toBase64Url, - type BurrowAclRecord, - type NoiseStaticKeyMaterial, - type PairingInvitation, type PasskeyAssertion, - type PresenceBinding, type TerminalDataEvent, - utf8Encode, } from 'remote-lib-common'; import { @@ -59,12 +46,7 @@ import { type PocketClientDeps, type PocketStorage, } from './pocket-client'; -import type { - KnownBurrowStore, - KnownBurrowV1, - PendingDeletionStore, - PendingDeliveryDeletionV1, -} from './pocket-db'; +import type { KnownBurrowV1 } from './pocket-db'; import { FakeSocket } from '../test-fake-socket'; import { FakeDirectNetwork, @@ -73,95 +55,33 @@ import { } from '../direct/test-fake-peer'; import type { DirectPeerLike } from '../direct/direct-peer'; import type { RemoteTimer } from '../ws'; -import { createTestRelay, type TestRelay } from '../test-relay'; import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; -import { BurrowRuntime } from '../burrow/burrow-runtime'; -import type { BurrowEnrollment } from '../burrow/enrollment'; -import type { PendingPairing } from '../burrow/pairing-approval'; import { PasskeyAlreadyRegisteredError, type WebAuthnClient } from './webauthn'; +import { + AUTH_ROUTES, + BURROW_LABEL, + CREDENTIAL_ID, + ORIGIN, + PASSKEY_PUBLIC_KEY, + RP_ID, + SESSION_TOKEN, + STREAMED_CHUNK, + makeE2eHarness, + makeFetch, + memoryKnownBurrows, + memoryPendingDeletions, + memoryStorage, + secret, + waitFor, + type E2eHarness, + type FetchCall, + type MemoryKnownBurrows, + type MemoryPendingDeletions, + type RouteHandler, +} from './test-e2e-harness'; // --- Fakes ----------------------------------------------------------------- -const ORIGIN = 'https://pocket.example'; -const RP_ID = 'pocket.example'; -const BURROW_LABEL = 'Ned’s laptop'; -const SESSION_TOKEN = 'tok-abc'; -/** What the stub Burrow streams on attach: a chunk whose two projections differ. */ -const STREAMED_CHUNK: TerminalDataEvent = { - bytes: toBase64Url(utf8Encode('pre\x1b]1337;File=inline=1:AAAA\x07post')), - text: toBase64Url(utf8Encode('prepost')), -}; - -/** A base64url string usable where a real 32-byte secret goes. */ -function secret(): string { - return randomBase64Url(32); -} - -interface FetchCall { - url: string; - method: string; - headers: Record; - body: unknown; -} - -type RouteHandler = ( - body: unknown, -) => { status?: number; json?: unknown } | Promise<{ status?: number; json?: unknown }>; - -/** A router-style fake `fetch` that records every call. */ -function makeFetch( - routes: Record, - /** Answers a path no exact route claims; without one, an unknown path throws. */ - fallback?: (path: string, method: string) => { status?: number; json?: unknown } | undefined, -) { - const calls: FetchCall[] = []; - const fetch = (async (url: string, init?: RequestInit) => { - const method = init?.method ?? 'POST'; - const headers = (init?.headers ?? {}) as Record; - const body = init?.body ? JSON.parse(init.body as string) : undefined; - calls.push({ url, method, headers, body }); - const path = new URL(url, 'http://test').pathname; - const handler = routes[path]; - const answered = handler ? await handler(body) : fallback?.(path, method); - if (!answered) throw new Error(`unexpected fetch: ${path}`); - const { status = 200, json } = answered; - return { - ok: status >= 200 && status < 300, - status, - json: async () => json ?? {}, - } as Response; - }) as unknown as typeof fetch; - return { fetch, calls }; -} - -function memoryStorage(): PocketStorage { - const passkeys = new Map(); - let pushEndpoint: string | null = null; - return { - getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, - setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), - forgetPasskeyPublicKey: (id) => void passkeys.delete(id), - knownCredentialIds: () => [...passkeys.keys()], - getRegisteredPushEndpoint: () => pushEndpoint, - setRegisteredPushEndpoint: (fingerprint) => void (pushEndpoint = fingerprint), - }; -} - -interface MemoryKnownBurrows extends KnownBurrowStore { - readonly records: Map; -} - -function memoryKnownBurrows(): MemoryKnownBurrows { - const records = new Map(); - return { - records, - get: async (burrowId) => records.get(burrowId) ?? null, - put: async (record) => void records.set(record.burrowId, record), - delete: async (burrowId) => void records.delete(burrowId), - list: async () => [...records.values()], - }; -} - /** The delivery id a paired record holds; throws if the record is not paired. */ function deliveryIdOf(store: MemoryKnownBurrows, burrowId: string): string { const authorization = store.records.get(burrowId)?.authorization; @@ -169,20 +89,6 @@ function deliveryIdOf(store: MemoryKnownBurrows, burrowId: string): string { return authorization.deliveryId; } -interface MemoryPendingDeletions extends PendingDeletionStore { - readonly records: Map; -} - -function memoryPendingDeletions(): MemoryPendingDeletions { - const records = new Map(); - return { - records, - put: async (record) => void records.set(`${record.burrowId}:${record.deliveryId}`, record), - delete: async (burrowId, deliveryId) => void records.delete(`${burrowId}:${deliveryId}`), - list: async () => [...records.values()], - }; -} - /** * A clock the test can make jump a full pairing TTL on every read, so the * deadline a ceremony sets is already due by the time its waiter is @@ -209,15 +115,6 @@ async function settleTicks(): Promise { for (let i = 0; i < 8; i += 1) await new Promise((r) => setTimeout(r, 1)); } -/** Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. */ -async function waitFor(predicate: () => boolean, what = 'a condition'): Promise { - for (let i = 0; i < 400; i++) { - if (predicate()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw new Error(`timed out waiting for ${what}`); -} - // --- The account-plane harness --------------------------------------------- interface Harness { @@ -228,9 +125,6 @@ interface Harness { pendingDeletions: MemoryPendingDeletions; } -const CREDENTIAL_ID = 'cred-123'; -const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; - const assertion: PasskeyAssertion = { credentialId: CREDENTIAL_ID, clientDataJSON: 'client-data', @@ -251,31 +145,6 @@ const fakeWebAuthn: WebAuthnClient = { }, }; -const AUTH_ROUTES: Record = { - '/api/setup/begin': () => ({ - json: { - challenge: secret(), - rpId: RP_ID, - accountId: SELFHOST_ACCOUNT_ID, - existingCredentialIds: [], - }, - }), - '/api/setup/finish': () => ({ - json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, - }), - '/api/setup/retire': () => ({ status: 204 }), - '/api/signin/begin': () => ({ json: { challenge: secret(), rpId: RP_ID } }), - '/api/signin/finish': () => ({ - json: { - sessionToken: SESSION_TOKEN, - accountId: SELFHOST_ACCOUNT_ID, - expiresAt: 1, - passkeyPublicKey: PASSKEY_PUBLIC_KEY, - }, - }), - '/api/burrows': () => ({ json: { burrows: [{ burrowId: 'h1', label: 'Laptop', online: true }] } }), -}; - function makeClient( routes: Record, overrides: Partial = {}, @@ -332,230 +201,6 @@ async function seedRecord( return record; } -// --- The end-to-end harness ------------------------------------------------- - -interface E2eHarness { - client: PocketClient; - burrow: BurrowRuntime; - relay: TestRelay; - burrowId: string; - authenticator: TestAuthenticator; - noiseStatic: NoiseStaticKeyMaterial; - knownBurrows: MemoryKnownBurrows; - pendingDeletions: MemoryPendingDeletions; - approvals: PendingPairing[]; - savedAcl: BurrowAclRecord[]; - calls: FetchCall[]; - /** The harness's own `fetch`, for a second client on the same fake Relay. */ - fetch: typeof fetch; - /** The Client's relay socket, once one is open — what a keepalive lands on. */ - clientSocket(): FakeSocket; - /** One live invitation, as `setupQr` would mint it. */ - mintInvitation(): Promise; - /** Run a pairing and confirm it on the Burrow with the digits the phone showed. */ - pairAndApprove( - invitation: PairingInvitation, - options?: { code?: (shown: string) => string }, - ): Promise>>; -} - -/** - * A real Burrow, a real relay, and a real client — the whole loop in memory. - * - * `/api/reauth/*` is faked, but faithfully: `begin` derives the challenge from - * the presented binding with the shared builder, exactly as the Relay does, so - * the assertion the authenticator produces is one `verifyPresenceProof` - * accepts. Nothing else about the proof is simulated. - */ -async function makeE2eHarness( - options: { - burrowId?: string; - knownBurrows?: MemoryKnownBurrows; - pendingDeletions?: MemoryPendingDeletions; - authenticator?: TestAuthenticator; - noiseStatic?: NoiseStaticKeyMaterial; - /** - * What the Burrow *announces* as its static, when that has to differ from the - * key it actually handshakes with. Nothing on the Burrow validates this - * string, so it is how a malformed pin reaches the Client at all. - */ - announcedStatic?: string; - loadAcl?: () => BurrowAclRecord[]; - now?: () => number; - /** Make every delivery-row deletion fail, as an offline phone's would. */ - pushDeleteFails?: boolean; - /** Extra `PocketClient` deps — the keepalive timer and visibility seams. */ - deps?: Partial; - /** How this Burrow builds a peer for the direct path; absent, it declines. */ - burrowDirect?: () => DirectPeerLike | null; - /** The Burrow's timers, where a case has to fire one by hand. */ - burrowSetTimer?: RemoteTimer; - } = {}, -): Promise { - const burrowId = options.burrowId ?? randomBase64Url(16); - const authenticator = - options.authenticator ?? (await createTestAuthenticator({ rpId: RP_ID, origin: ORIGIN })); - const noiseStatic = options.noiseStatic ?? (await mintNoiseStaticKeyPair()); - const knownBurrows = options.knownBurrows ?? memoryKnownBurrows(); - const pendingDeletions = options.pendingDeletions ?? memoryPendingDeletions(); - const approvals: PendingPairing[] = []; - let savedAcl: BurrowAclRecord[] = []; - - const enrollment: BurrowEnrollment = { - relayUrl: ORIGIN, - burrowId, - burrowToken: 'burrow-tok', - origin: ORIGIN, - rpId: RP_ID, - label: BURROW_LABEL, - noiseStaticPrivateKey: noiseStatic.privateKeyPkcs8, - noiseStaticPublicKey: options.announcedStatic ?? noiseStatic.publicKey, - }; - const burrowSocket = new FakeSocket(); - const burrow = new BurrowRuntime({ - enrollment, - reconnect: false, - createWebSocket: () => burrowSocket, - ...(options.burrowDirect ? { createDirectPeer: options.burrowDirect } : {}), - ...(options.burrowSetTimer ? { setTimer: options.burrowSetTimer } : {}), - loadAcl: options.loadAcl ?? (() => []), - saveAcl: (_burrowId, records) => { - savedAcl = [...records]; - }, - requestApproval: (pending) => approvals.push(pending), - dismissApproval: () => {}, - createSession: ({ send }) => ({ - // Enough protocol-v1 to prove the byte stream: every request is answered - // with its own `requestId`, which is what `hello` correlates on. - handle: (data) => { - const request = data as { requestId?: unknown; method?: unknown }; - if (typeof request.requestId !== 'string') return; - send({ - requestId: request.requestId, - ok: true, - result: { protocolVersion: 1, burrowId, grants: { input: true, layout: false } }, - }); - // An attach opens its stream under the request's own id, so one canned - // event proves the subscription path as well as the request one. - if (request.method === REMOTE_METHODS.surfaceAttach) { - send({ - subId: request.requestId, - event: REMOTE_EVENTS.terminalData, - data: STREAMED_CHUNK, - }); - } - }, - dispose: () => {}, - }), - }); - burrow.start(); - burrowSocket.open(); - const relay = createTestRelay({ burrowId, burrowSocket }); - - // The presence routes, derived exactly as the Relay derives them. - const nonces = new Map(); - const routes: Record = { - ...AUTH_ROUTES, - // The account's real passkey, so the key the proof presents is the one the - // authenticator actually signs with. - '/api/signin/finish': () => ({ - json: { - sessionToken: SESSION_TOKEN, - accountId: SELFHOST_ACCOUNT_ID, - expiresAt: 1, - passkeyPublicKey: authenticator.publicKey, - }, - }), - '/api/reauth/begin': async (body) => { - const binding = (body as { binding: PresenceBinding }).binding; - const relayNonce = secret(); - nonces.set(relayNonce, binding); - return { - json: { - challenge: await presenceChallenge(binding, relayNonce), - rpId: RP_ID, - relayNonce, - allowCredentials: [binding.passkeyCredentialId], - }, - }; - }, - '/api/reauth/finish': (body) => { - const { relayNonce } = body as { relayNonce: string }; - if (!nonces.delete(relayNonce)) return { status: 400, json: { error: 'unknown nonce' } }; - return { json: { verifiedAt: 1 } }; - }, - }; - // The delivery ids a Burrow mints are random, so the deletion route is matched - // by shape rather than by an exact path. - const { fetch, calls } = makeFetch(routes, (path, method) => { - if (method !== 'DELETE' || !path.startsWith('/api/push/subscriptions/')) return undefined; - return options.pushDeleteFails ? { status: 503, json: { error: 'down' } } : { status: 204 }; - }); - - const storage = memoryStorage(); - const webauthn: WebAuthnClient = { - async registerPasskey() { - return { - credentialId: authenticator.credentialId, - publicKey: authenticator.publicKey, - clientDataJSON: 'create-client-data', - }; - }, - // The real thing: a signature this Burrow's own verifier accepts. - getAssertion: (challenge) => authenticator.assert(challenge, ORIGIN), - }; - let clientSocket: FakeSocket | null = null; - const client = new PocketClient({ - wsBase: 'ws://test', - fetch, - webauthn, - createWebSocket: () => (clientSocket = relay.openClientSocket()), - knownBurrows, - pendingDeletions, - storage, - ...(options.now ? { now: options.now } : {}), - ...options.deps, - }); - // Sign-in caches the asserted passkey's public key and names the credential - // every presence proof is built from, exactly as it does in the app. - await client.signin(); - - return { - client, - burrow, - relay, - burrowId, - authenticator, - noiseStatic, - knownBurrows, - pendingDeletions, - approvals, - get savedAcl() { - return savedAcl; - }, - calls, - fetch, - clientSocket: () => { - if (!clientSocket) throw new Error('the Client has not opened a relay socket'); - return clientSocket; - }, - mintInvitation: () => burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS), - async pairAndApprove(invitation, { code } = {}) { - // Counted from here: a harness that pairs twice must confirm the *new* - // request rather than re-answering the one still in the log. - const before = approvals.length; - let shown: string | null = null; - const pairing = client.pair(invitation, 'iPhone Safari', (value) => { - shown = value; - }); - await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); - const pending = approvals[approvals.length - 1]!; - pending.approve(code ? code(shown!) : shown!); - return await pairing; - }, - }; -} - // --- Pairing ---------------------------------------------------------------- describe('pairing, end to end', () => { diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts new file mode 100644 index 000000000..d94c18445 --- /dev/null +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -0,0 +1,408 @@ +/** + * The whole remote loop in memory — a real `PocketClient`, a real `TestRelay`, + * and a real `BurrowRuntime` — as a test drives it. + * + * Test-only, and shared for the reason `../test-relay.ts` and + * `../test-e2e-client.ts` are: every suite that runs a phone against a Burrow + * has to be driven against *the same* idea of what the account plane answers, + * and two copies would be two opinions about what a signed-in Client is. + * + * **No ceremony step is stubbed.** The Noise handshakes are the shipped suite, + * the presence proofs are real ES256 assertions over the shared challenge + * builder — verified by the same `verifyPresenceProof` a Burrow runs — and the + * outcomes are decrypted on the session that produced them. Only the browser + * and network edges are faked: `fetch`, `WebSocket`, WebAuthn's two calls, and + * the two IndexedDB stores. `/api/reauth/*` is faked faithfully rather than + * simulated: `begin` derives the challenge from the presented binding with the + * shared builder, exactly as the Relay does, so the assertion the authenticator + * produces is one the Burrow accepts. + */ + +import { + DEFAULT_PAIRING_TTL_MS, + REMOTE_EVENTS, + REMOTE_METHODS, + SELFHOST_ACCOUNT_ID, + mintNoiseStaticKeyPair, + presenceChallenge, + randomBase64Url, + toBase64Url, + utf8Encode, + type BurrowAclRecord, + type NoiseStaticKeyMaterial, + type PairingInvitation, + type PresenceBinding, + type TerminalDataEvent, +} from 'remote-lib-common'; + +import { PocketClient, type PocketClientDeps, type PocketStorage } from './pocket-client'; +import type { + KnownBurrowStore, + KnownBurrowV1, + PendingDeletionStore, + PendingDeliveryDeletionV1, +} from './pocket-db'; +import type { WebAuthnClient } from './webauthn'; +import { BurrowRuntime } from '../burrow/burrow-runtime'; +import type { BurrowEnrollment } from '../burrow/enrollment'; +import type { PendingPairing } from '../burrow/pairing-approval'; +import type { DirectPeerLike } from '../direct/direct-peer'; +import { FakeSocket } from '../test-fake-socket'; +import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; +import { createTestRelay, type TestRelay } from '../test-relay'; +import type { RemoteTimer } from '../ws'; + +// --- Fakes ------------------------------------------------------------------ + +export const ORIGIN = 'https://pocket.example'; +export const RP_ID = 'pocket.example'; +export const BURROW_LABEL = 'Ned’s laptop'; +export const SESSION_TOKEN = 'tok-abc'; +/** What the stub Burrow streams on attach: a chunk whose two projections differ. */ +export const STREAMED_CHUNK: TerminalDataEvent = { + bytes: toBase64Url(utf8Encode('pre\x1b]1337;File=inline=1:AAAA\x07post')), + text: toBase64Url(utf8Encode('prepost')), +}; + +/** A base64url string usable where a real 32-byte secret goes. */ +export function secret(): string { + return randomBase64Url(32); +} + +export interface FetchCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +export type RouteHandler = ( + body: unknown, +) => { status?: number; json?: unknown } | Promise<{ status?: number; json?: unknown }>; + +/** A router-style fake `fetch` that records every call. */ +export function makeFetch( + routes: Record, + /** Answers a path no exact route claims; without one, an unknown path throws. */ + fallback?: (path: string, method: string) => { status?: number; json?: unknown } | undefined, +) { + const calls: FetchCall[] = []; + const fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'POST'; + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, method, headers, body }); + const path = new URL(url, 'http://test').pathname; + const handler = routes[path]; + const answered = handler ? await handler(body) : fallback?.(path, method); + if (!answered) throw new Error(`unexpected fetch: ${path}`); + const { status = 200, json } = answered; + return { + ok: status >= 200 && status < 300, + status, + json: async () => json ?? {}, + } as Response; + }) as unknown as typeof globalThis.fetch; + return { fetch, calls }; +} + +export function memoryStorage(): PocketStorage { + const passkeys = new Map(); + let pushEndpoint: string | null = null; + return { + getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, + setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), + forgetPasskeyPublicKey: (id) => void passkeys.delete(id), + knownCredentialIds: () => [...passkeys.keys()], + getRegisteredPushEndpoint: () => pushEndpoint, + setRegisteredPushEndpoint: (fingerprint) => void (pushEndpoint = fingerprint), + }; +} + +export interface MemoryKnownBurrows extends KnownBurrowStore { + readonly records: Map; +} + +export function memoryKnownBurrows(): MemoryKnownBurrows { + const records = new Map(); + return { + records, + get: async (burrowId) => records.get(burrowId) ?? null, + put: async (record) => void records.set(record.burrowId, record), + delete: async (burrowId) => void records.delete(burrowId), + list: async () => [...records.values()], + }; +} + +export interface MemoryPendingDeletions extends PendingDeletionStore { + readonly records: Map; +} + +export function memoryPendingDeletions(): MemoryPendingDeletions { + const records = new Map(); + return { + records, + put: async (record) => void records.set(`${record.burrowId}:${record.deliveryId}`, record), + delete: async (burrowId, deliveryId) => void records.delete(`${burrowId}:${deliveryId}`), + list: async () => [...records.values()], + }; +} + +/** Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. */ +export async function waitFor(predicate: () => boolean, what = 'a condition'): Promise { + for (let i = 0; i < 400; i++) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 2)); + } + throw new Error(`timed out waiting for ${what}`); +} + +export const CREDENTIAL_ID = 'cred-123'; +export const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; + +export const AUTH_ROUTES: Record = { + '/api/setup/begin': () => ({ + json: { + challenge: secret(), + rpId: RP_ID, + accountId: SELFHOST_ACCOUNT_ID, + existingCredentialIds: [], + }, + }), + '/api/setup/finish': () => ({ + json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, + }), + '/api/setup/retire': () => ({ status: 204 }), + '/api/signin/begin': () => ({ json: { challenge: secret(), rpId: RP_ID } }), + '/api/signin/finish': () => ({ + json: { + sessionToken: SESSION_TOKEN, + accountId: SELFHOST_ACCOUNT_ID, + expiresAt: 1, + passkeyPublicKey: PASSKEY_PUBLIC_KEY, + }, + }), + '/api/burrows': () => ({ json: { burrows: [{ burrowId: 'h1', label: 'Laptop', online: true }] } }), +}; + +export interface E2eHarness { + client: PocketClient; + burrow: BurrowRuntime; + relay: TestRelay; + burrowId: string; + authenticator: TestAuthenticator; + noiseStatic: NoiseStaticKeyMaterial; + knownBurrows: MemoryKnownBurrows; + pendingDeletions: MemoryPendingDeletions; + approvals: PendingPairing[]; + savedAcl: BurrowAclRecord[]; + calls: FetchCall[]; + /** The harness's own `fetch`, for a second client on the same fake Relay. */ + fetch: typeof globalThis.fetch; + /** The Client's relay socket, once one is open — what a keepalive lands on. */ + clientSocket(): FakeSocket; + /** One live invitation, as `setupQr` would mint it. */ + mintInvitation(): Promise; + /** Run a pairing and confirm it on the Burrow with the digits the phone showed. */ + pairAndApprove( + invitation: PairingInvitation, + options?: { code?: (shown: string) => string }, + ): Promise>>; +} + +/** + * A real Burrow, a real relay, and a real client — the whole loop in memory. + * + * `/api/reauth/*` is faked, but faithfully: `begin` derives the challenge from + * the presented binding with the shared builder, exactly as the Relay does, so + * the assertion the authenticator produces is one `verifyPresenceProof` + * accepts. Nothing else about the proof is simulated. + */ +export async function makeE2eHarness( + options: { + burrowId?: string; + knownBurrows?: MemoryKnownBurrows; + pendingDeletions?: MemoryPendingDeletions; + authenticator?: TestAuthenticator; + noiseStatic?: NoiseStaticKeyMaterial; + /** + * What the Burrow *announces* as its static, when that has to differ from the + * key it actually handshakes with. Nothing on the Burrow validates this + * string, so it is how a malformed pin reaches the Client at all. + */ + announcedStatic?: string; + loadAcl?: () => BurrowAclRecord[]; + now?: () => number; + /** Make every delivery-row deletion fail, as an offline phone's would. */ + pushDeleteFails?: boolean; + /** Extra `PocketClient` deps — the keepalive timer and visibility seams. */ + deps?: Partial; + /** How this Burrow builds a peer for the direct path; absent, it declines. */ + burrowDirect?: () => DirectPeerLike | null; + /** The Burrow's timers, where a case has to fire one by hand. */ + burrowSetTimer?: RemoteTimer; + } = {}, +): Promise { + const burrowId = options.burrowId ?? randomBase64Url(16); + const authenticator = + options.authenticator ?? (await createTestAuthenticator({ rpId: RP_ID, origin: ORIGIN })); + const noiseStatic = options.noiseStatic ?? (await mintNoiseStaticKeyPair()); + const knownBurrows = options.knownBurrows ?? memoryKnownBurrows(); + const pendingDeletions = options.pendingDeletions ?? memoryPendingDeletions(); + const approvals: PendingPairing[] = []; + let savedAcl: BurrowAclRecord[] = []; + + const enrollment: BurrowEnrollment = { + relayUrl: ORIGIN, + burrowId, + burrowToken: 'burrow-tok', + origin: ORIGIN, + rpId: RP_ID, + label: BURROW_LABEL, + noiseStaticPrivateKey: noiseStatic.privateKeyPkcs8, + noiseStaticPublicKey: options.announcedStatic ?? noiseStatic.publicKey, + }; + const burrowSocket = new FakeSocket(); + const burrow = new BurrowRuntime({ + enrollment, + reconnect: false, + createWebSocket: () => burrowSocket, + ...(options.burrowDirect ? { createDirectPeer: options.burrowDirect } : {}), + ...(options.burrowSetTimer ? { setTimer: options.burrowSetTimer } : {}), + loadAcl: options.loadAcl ?? (() => []), + saveAcl: (_burrowId, records) => { + savedAcl = [...records]; + }, + requestApproval: (pending) => approvals.push(pending), + dismissApproval: () => {}, + createSession: ({ send }) => ({ + // Enough protocol-v1 to prove the byte stream: every request is answered + // with its own `requestId`, which is what `hello` correlates on. + handle: (data) => { + const request = data as { requestId?: unknown; method?: unknown }; + if (typeof request.requestId !== 'string') return; + send({ + requestId: request.requestId, + ok: true, + result: { protocolVersion: 1, burrowId, grants: { input: true, layout: false } }, + }); + // An attach opens its stream under the request's own id, so one canned + // event proves the subscription path as well as the request one. + if (request.method === REMOTE_METHODS.surfaceAttach) { + send({ + subId: request.requestId, + event: REMOTE_EVENTS.terminalData, + data: STREAMED_CHUNK, + }); + } + }, + dispose: () => {}, + }), + }); + burrow.start(); + burrowSocket.open(); + const relay = createTestRelay({ burrowId, burrowSocket }); + + // The presence routes, derived exactly as the Relay derives them. + const nonces = new Map(); + const routes: Record = { + ...AUTH_ROUTES, + // The account's real passkey, so the key the proof presents is the one the + // authenticator actually signs with. + '/api/signin/finish': () => ({ + json: { + sessionToken: SESSION_TOKEN, + accountId: SELFHOST_ACCOUNT_ID, + expiresAt: 1, + passkeyPublicKey: authenticator.publicKey, + }, + }), + '/api/reauth/begin': async (body) => { + const binding = (body as { binding: PresenceBinding }).binding; + const relayNonce = secret(); + nonces.set(relayNonce, binding); + return { + json: { + challenge: await presenceChallenge(binding, relayNonce), + rpId: RP_ID, + relayNonce, + allowCredentials: [binding.passkeyCredentialId], + }, + }; + }, + '/api/reauth/finish': (body) => { + const { relayNonce } = body as { relayNonce: string }; + if (!nonces.delete(relayNonce)) return { status: 400, json: { error: 'unknown nonce' } }; + return { json: { verifiedAt: 1 } }; + }, + }; + // The delivery ids a Burrow mints are random, so the deletion route is matched + // by shape rather than by an exact path. + const { fetch, calls } = makeFetch(routes, (path, method) => { + if (method !== 'DELETE' || !path.startsWith('/api/push/subscriptions/')) return undefined; + return options.pushDeleteFails ? { status: 503, json: { error: 'down' } } : { status: 204 }; + }); + + const storage = memoryStorage(); + const webauthn: WebAuthnClient = { + async registerPasskey() { + return { + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: 'create-client-data', + }; + }, + // The real thing: a signature this Burrow's own verifier accepts. + getAssertion: (challenge) => authenticator.assert(challenge, ORIGIN), + }; + let clientSocket: FakeSocket | null = null; + const client = new PocketClient({ + wsBase: 'ws://test', + fetch, + webauthn, + createWebSocket: () => (clientSocket = relay.openClientSocket()), + knownBurrows, + pendingDeletions, + storage, + ...(options.now ? { now: options.now } : {}), + ...options.deps, + }); + // Sign-in caches the asserted passkey's public key and names the credential + // every presence proof is built from, exactly as it does in the app. + await client.signin(); + + return { + client, + burrow, + relay, + burrowId, + authenticator, + noiseStatic, + knownBurrows, + pendingDeletions, + approvals, + get savedAcl() { + return savedAcl; + }, + calls, + fetch, + clientSocket: () => { + if (!clientSocket) throw new Error('the Client has not opened a relay socket'); + return clientSocket; + }, + mintInvitation: () => burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS), + async pairAndApprove(invitation, { code } = {}) { + // Counted from here: a harness that pairs twice must confirm the *new* + // request rather than re-answering the one still in the log. + const before = approvals.length; + let shown: string | null = null; + const pairing = client.pair(invitation, 'iPhone Safari', (value) => { + shown = value; + }); + await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); + const pending = approvals[approvals.length - 1]!; + pending.approve(code ? code(shown!) : shown!); + return await pairing; + }, + }; +} From f0373a0609a412400b8560e9ad400763a075a77b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:08:41 -0700 Subject: [PATCH 11/46] test(remote): run the direct path over the real addon, end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Node↔Node case with the shipped node-datachannel polyfill on both ends: real host candidates, real DTLS/SCTP, and the real Noise session on top — the whole loop from `test-e2e-harness.ts` with only the peer swapped. It pins the two things the in-memory pair cannot say anything about: that the polyfill satisfies `DirectPeerLike` as written, and that one channel frame carries a full `NOISE_MAX_MESSAGE_LENGTH` message intact. A lost negotiation is retried rather than failed. Two agents in one process occasionally settle on a candidate pair that answers ICE and then swallows DTLS — every failure measured picked the same stray ULA IPv6 address, ~2% of attempts — and staying relayed is what the protocol does about that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- .../host/remote/native-direct-peer.test.ts | 266 ++++++++++++++++++ lib/src/remote/client/test-e2e-harness.ts | 19 +- 2 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 lib/src/host/remote/native-direct-peer.test.ts diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts new file mode 100644 index 000000000..2ad824906 --- /dev/null +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -0,0 +1,266 @@ +// @vitest-environment node +/** + * The direct path over the **real** addon: two Node peers negotiating a live + * WebRTC data channel, with `node-datachannel`'s polyfill on both ends. + * + * Everything else in the direct path's suites runs on the in-memory pair in + * `remote/direct/test-fake-peer.ts`, which answers descriptions rather than + * parsing them and opens a channel because a test said to. That is the right + * shape for the cutover rules and the wrong one for the two questions this file + * asks: does the shipped `RTCPeerConnection` satisfy the `DirectPeerLike` seam + * as written, and does a real SCTP channel carry what the protocol puts on it. + * So the SDP here is a real description of real host candidates, the channel is + * real DTLS/SCTP, and the session riding it is the real Noise session — the + * whole loop from `test-e2e-harness.ts`, with only the addon swapped in. + * + * The addon is resolved from `standalone/sidecar`, which is where it is + * installed and where the shipped bundle finds it; `lib` must not depend on it, + * because `lib` is a browser bundle root. + */ + +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { NOISE_MAX_MESSAGE_LENGTH, type TerminalDataEvent } from 'remote-lib-common'; +import { DirectPeer, type DirectPeerLike } from '../../remote/direct/direct-peer'; +import { STREAMED_CHUNK, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; +import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; + +/** + * A file inside the package that declares the addon. The sidecar bundle sits + * beside its own `node_modules` and needs no such hint; this file, running from + * source under `lib/`, does. + */ +const SIDECAR = fileURLToPath( + new URL('../../../../standalone/sidecar/package.json', import.meta.url), +); + +/** + * What one negotiation is given before it is written off. A local pair settles + * in single-digit milliseconds; the slack is for a machine whose interfaces are + * slow to answer, where each end waits out `DIRECT_GATHER_TIMEOUT_MS`. + */ +const ATTEMPT_BUDGET_MS = 5_000; +/** How many negotiations a case will spend before it fails; see {@link untilOpen}. */ +const NEGOTIATION_ATTEMPTS = 4; +/** Every attempt, plus the ceremonies in front of them — never vitest's default. */ +const CASE_BUDGET_MS = 45_000; + +const warnings: string[] = []; +const buildPeer = createNativeDirectPeerFactory({ + resolveFrom: SIDECAR, + warn: (message) => warnings.push(message), +}); + +afterAll(() => { + // The addon runs its own threads, which outlive every peer and would hold + // this worker open after the last assertion. + disposeNativeDirectPeers(); +}); + +interface Negotiation { + /** Whether the channel this attempt describes has come up. */ + open(): boolean; + /** Drop this attempt's peers, so a retry does not run beside them. */ + abandon(): void; +} + +/** + * Run `start` until the channel it negotiates comes up, abandoning an attempt + * that does not. + * + * **Retried on purpose.** Two agents in one process occasionally settle on a + * candidate pair that answers ICE and then swallows DTLS — every failure + * measured picked the same stray ULA IPv6 address, ~2% of attempts on macOS + * 26.0, 2026-09 — and staying relayed is precisely what the protocol does about + * that. The claim under test is that the addon carries the session when a + * channel comes up, not that ICE never loses one, so a lost attempt is retried + * on a fresh session rather than reported as a broken addon. + */ +async function untilOpen(start: () => Promise, what: string): Promise { + let lost: unknown; + for (let attempt = 1; attempt <= NEGOTIATION_ATTEMPTS; attempt += 1) { + const run = await start(); + try { + await waitFor(() => run.open(), what, ATTEMPT_BUDGET_MS); + return run; + } catch (error) { + lost = error; + run.abandon(); + } + } + throw lost; +} + +/** Build a peer, keeping it so a case can close the far end by hand. */ +function collect(into: DirectPeerLike[]): () => DirectPeerLike | null { + return () => { + const peer = buildPeer(); + if (peer) into.push(peer); + return peer; + }; +} + +/** + * The whole loop — phone, relay, Burrow — with both ends on the native addon, + * paired, connected, and offered a direct path. + */ +async function startConnected() { + const clientPeers: DirectPeerLike[] = []; + const burrowPeers: DirectPeerLike[] = []; + const harness = await makeE2eHarness({ + deps: { createDirectPeer: collect(clientPeers) }, + burrowDirect: collect(burrowPeers), + }); + await harness.pairAndApprove(await harness.mintInvitation()); + expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); + + const transportFrames = (frames: Array>) => + frames.filter((frame) => frame.kind === 'connection' && frame.step === 'transport'); + return { + harness, + clientPeers, + burrowPeers, + /** Client→relay transport frames on the connection, which stop at the switch. */ + clientFrames: () => transportFrames(harness.clientSocket().frames('e2e')), + /** Burrow→relay transport frames on the connection, which stop at its own switch. */ + burrowFrames: () => transportFrames(harness.relay.burrowSocket.frames('e2e')), + open: () => harness.client.transportPath === 'direct', + abandon: () => { + for (const peer of [...clientPeers, ...burrowPeers]) peer.close(); + }, + }; +} + +const connectedDirect = () => untilOpen(startConnected, 'the session to go direct'); + +describe('the direct path over the native addon', () => { + it( + 'negotiates a channel and carries protocol-v1 on it, the relay silent after', + async () => { + const run = await connectedDirect(); + + // Both ends built a peer, and nothing warned — a machine that cannot load + // the addon would have declined and stayed relayed instead. + expect(warnings).toEqual([]); + expect(run.clientPeers).toHaveLength(1); + expect(run.burrowPeers).toHaveLength(1); + expect(run.harness.client.transportPath).toBe('direct'); + // Three Client→Burrow transport frames on this connection: the connection + // request the ceremony ended with, the offer, and the switch. Three back: + // the outcome, the answer, and the Burrow's own switch. The SDPs crossed + // inside the session, so the Relay saw only padded control bodies. + expect(run.clientFrames()).toHaveLength(3); + expect(run.burrowFrames()).toHaveLength(3); + + const clientBefore = run.clientFrames().length; + const burrowBefore = run.burrowFrames().length; + const chunks: TerminalDataEvent[] = []; + + expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + await run.harness.client.watchDirectory(() => {}); + await run.harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); + await run.harness.client.write('surface-1', 'ls\n'); + + // Requests, answers, and the burrow→client stream all crossed the channel; + // the relay carried none of it, in either direction. + expect(run.clientFrames()).toHaveLength(clientBefore); + expect(run.burrowFrames()).toHaveLength(burrowBefore); + expect(chunks).toEqual([STREAMED_CHUNK]); + }, + CASE_BUDGET_MS, + ); + + /** + * The bound the protocol actually needs from the channel. One channel frame + * is one Noise transport message, so a real SCTP association has to carry + * `NOISE_MAX_MESSAGE_LENGTH` bytes in a single message — well past the 16 KB + * a data channel fragments at without a negotiated maximum, and the one + * property the in-memory pair cannot say anything about. + * + * Driven as a bare pair rather than through a session: what is being measured + * is the bytes, and a Noise message that large is not something the shipped + * protocol-v1 has a request for. + */ + it( + 'carries a full-size Noise transport message in one frame, intact', + async () => { + const run = await untilOpen(async () => { + let opened = 0; + const inbound: Uint8Array[] = []; + const lost: string[] = []; + const handlers = (onFrame: (frame: Uint8Array) => void) => ({ + onOpen: () => (opened += 1), + onFrame, + onClosed: (reason: string) => lost.push(reason), + onViolation: (reason: string) => lost.push(reason), + }); + const offererPeer = buildPeer(); + const answererPeer = buildPeer(); + expect(offererPeer).not.toBeNull(); + expect(answererPeer).not.toBeNull(); + const offerer = new DirectPeer({ peer: offererPeer!, handlers: handlers(() => {}) }); + const answerer = new DirectPeer({ + peer: answererPeer!, + handlers: handlers((frame) => inbound.push(frame)), + }); + const offer = await offerer.offer(); + expect(offer).not.toBeNull(); + const answer = await answerer.answer(offer!); + expect(answer).not.toBeNull(); + await offerer.acceptAnswer(answer!); + return { + offerer, + answerer, + inbound, + lost, + open: () => opened === 2, + abandon: () => { + offerer.close(); + answerer.close(); + }, + }; + }, 'both ends of the channel to open'); + + try { + const payload = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); + crypto.getRandomValues(payload); + run.offerer.send(payload); + + await waitFor(() => run.inbound.length === 1, 'the frame to arrive', ATTEMPT_BUDGET_MS); + expect(run.inbound[0]).toEqual(payload); + expect(run.lost).toEqual([]); + } finally { + run.abandon(); + } + }, + CASE_BUDGET_MS, + ); + + /** + * The channel is the session once both ends have switched, so losing it is + * losing the Burrow — there is no relay left to fall back to. + */ + it( + 'ends the session at both ends when the Burrow closes its peer', + async () => { + const run = await connectedDirect(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + run.burrowPeers[0]!.close(); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ATTEMPT_BUDGET_MS, + ); + await waitFor( + () => run.harness.client.connectedBurrowId === null, + 'the phone to report the Burrow gone', + ATTEMPT_BUDGET_MS, + ); + expect(gone).toHaveBeenCalledOnce(); + }, + CASE_BUDGET_MS, + ); +}); diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index d94c18445..a17c9fa73 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -148,13 +148,24 @@ export function memoryPendingDeletions(): MemoryPendingDeletions { }; } -/** Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. */ -export async function waitFor(predicate: () => boolean, what = 'a condition'): Promise { - for (let i = 0; i < 400; i++) { +/** + * Poll until `predicate` holds, so a Burrow awaiting WebCrypto can catch up. + * + * The default budget covers a ceremony step, which is a run of awaited + * WebCrypto calls and nothing else. A caller waiting on something with a + * network in it — a real ICE negotiation — names its own. + */ +export async function waitFor( + predicate: () => boolean, + what = 'a condition', + timeoutMs = 800, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { if (predicate()) return; + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`); await new Promise((r) => setTimeout(r, 2)); } - throw new Error(`timed out waiting for ${what}`); } export const CREDENTIAL_ID = 'cred-123'; From 30c98a9c8188eec331205ac70df51c5d600c7d1a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:12:07 -0700 Subject: [PATCH 12/46] chore(website): disclose the WebRTC addon and its per-platform prebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native addon publishes one prebuilt package per platform and pnpm installs only the host's, so the generator's walk of real directories threw on the other five — the first dependency it has met that no single machine can resolve whole. It now describes a platform package a *root* declares from its installed sibling, which the family publishes in lockstep, so the disclosure names every platform's package and reads the same wherever it is generated. An unresolvable dependency outside that family still throws, and a prebuild only the addon declares (android, musl) reaches no bundle and is not listed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- website/scripts/generate-deps.js | 57 ++++++++++++++++++++++++++ website/src/data/dependencies-npm.json | 56 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/website/scripts/generate-deps.js b/website/scripts/generate-deps.js index 5dc5f6371..56c114535 100644 --- a/website/scripts/generate-deps.js +++ b/website/scripts/generate-deps.js @@ -30,6 +30,12 @@ const productDependencyFilters = [ // Neither package installs an artifact on a user's disk. Any new workspace // requires classification here or a runtime edge from a product root. const excludedWorkspacePackages = ["canopy", "dormouse-website"]; +// A native addon publishes one prebuilt package per platform and pnpm installs +// only the host's, so a walk of real directories can never see the rest — +// while each of them is exactly what reaches a user on that platform. These +// scopes mark that family. See docs/specs/security-supply-chain.md -> +// "Disclosure". +const platformPackageScopes = ["@node-datachannel/"]; function readJson(path) { return JSON.parse(readFileSync(path, "utf-8")); @@ -110,6 +116,26 @@ const externalPackages = new Map(); const visitedExternalPackagePaths = new Set(); const visitedWorkspacePackageNames = new Set(); +const platformScopeOf = (packageName) => + platformPackageScopes.find((scope) => packageName.startsWith(scope)) ?? null; + +/** + * The prebuilt packages that actually ship. The Tauri bundle copies + * `standalone/sidecar/node_modules`, and pnpm puts a package there only if that + * manifest declares it — so a root's own optional list is the shipped set. The + * addon's list also names builds this project never releases (android, musl), + * and those reach no bundle. + */ +const shippedPlatformPackages = new Set( + productDependencyFilters.flatMap((name) => + Object.keys(workspacePackagesByName.get(name).pkg.optionalDependencies ?? {}).filter( + platformScopeOf, + ), + ), +); +/** Shipped platform packages this machine cannot install; described from a sibling. */ +const absentPlatformPackages = new Set(); + function addExternalPackage(pkg) { const key = [ pkg.name, @@ -151,6 +177,13 @@ function scanDependency(fromDir, packageName) { const packageJsonPath = getPackageJsonPath(fromDir, packageName); if (!packageJsonPath) { + // A prebuilt package for a platform that is not this one: absent by design + // rather than under-reported, so it is described from its installed sibling + // below. Anything else missing is still a hard error. + if (platformScopeOf(packageName)) { + if (shippedPlatformPackages.has(packageName)) absentPlatformPackages.add(packageName); + return; + } throw new Error(`Could not resolve package.json for "${packageName}" from ${fromDir}`); } @@ -173,6 +206,21 @@ for (const packageName of productDependencyFilters) { scanWorkspacePackage(packageName); } +// The family is published in lockstep from one repository, so the host's +// package describes its siblings exactly — which is also what keeps this +// disclosure identical on every machine that generates it. +for (const packageName of absentPlatformPackages) { + const scope = platformScopeOf(packageName); + const sibling = [...externalPackages.values()].find((pkg) => pkg.name.startsWith(scope)); + if (!sibling) { + throw new Error(`No ${scope}* package is installed, so "${packageName}" cannot be described`); + } + const key = [packageName, sibling.license ?? "", sibling.author ?? "", sibling.homepage ?? ""].join( + "\0", + ); + externalPackages.set(key, { ...sibling, name: packageName, versions: new Set(sibling.versions) }); +} + // Within a single "A OR B OR ..." choice, move MIT to the front so the // listing reads consistently (MIT is the license we expect most often). function moveMitFirstInOrGroup(orExpression) { @@ -247,6 +295,15 @@ const missingLicense = { }; const missingAuthor = { "@hono/node-ws": "Hono middleware contributors", + // The addon ships a `contributors` array rather than npm's singular `author` + // field, and its prebuilt platform packages carry neither. + "@node-datachannel/darwin-arm64": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/darwin-x64": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/linux-arm64-gnu": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/linux-x64-gnu": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/win32-arm64-msvc": "Murat Doğan, Paul-Louis Ageneau", + "@node-datachannel/win32-x64-msvc": "Murat Doğan, Paul-Louis Ageneau", + "node-datachannel": "Murat Doğan, Paul-Louis Ageneau", "@tauri-apps/api": "Tauri Apps Contributors", "@tauri-apps/plugin-shell": "Tauri Apps Contributors", "@tauri-apps/plugin-updater": "Tauri Apps Contributors", diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 60efdbdf2..1a3b78122 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -20,6 +20,48 @@ "author": "Paul Miller (https://paulmillr.com)", "homepage": "https://paulmillr.com/noble/" }, + { + "name": "@node-datachannel/darwin-arm64", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/darwin-x64", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/linux-arm64-gnu", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/linux-x64-gnu", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/win32-arm64-msvc", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, + { + "name": "@node-datachannel/win32-x64-msvc", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel" + }, { "name": "@phosphor-icons/react", "version": "2.1.10", @@ -160,6 +202,13 @@ "author": "Josh Junon (https://github.com/qix-)", "homepage": "https://github.com/debug-js/debug" }, + { + "name": "detect-libc", + "version": "2.1.2", + "license": "Apache-2.0", + "author": "Lovell Fuller ", + "homepage": "https://github.com/lovell/detect-libc" + }, { "name": "ecdsa-sig-formatter", "version": "1.0.11", @@ -258,6 +307,13 @@ "author": "Node.js API collaborators", "homepage": "https://github.com/nodejs/node-addon-api" }, + { + "name": "node-datachannel", + "version": "0.33.2", + "license": "MPL 2.0", + "author": "Murat Doğan, Paul-Louis Ageneau", + "homepage": "https://github.com/murat-dogan/node-datachannel#readme" + }, { "name": "node-pty", "version": "1.2.0-beta.15", From 4c8dd560f701dd5312727adb4b030af97640f909 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:13:54 -0700 Subject: [PATCH 13/46] docs: promote the standalone Burrow's direct-path answer remote-api.md names who answers instead of staging it, and the ledger loses the item. standalone.md gains the packaging invariant the sidecar's Burrow now depends on: a sidecar package's transitive dependencies do not ship, so the addon's platform package and detect-libc are declared there directly and both specifiers stay external to burrow.cjs. security-supply-chain.md states how a prebuild for another platform is disclosed at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/remote-api.md | 16 +++++++++------- docs/specs/security-supply-chain.md | 2 ++ docs/specs/standalone.md | 14 ++++++++++++++ scripts/spec-word-budgets.json | 4 ++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 6e283c75f..5bb18f7f9 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -63,8 +63,11 @@ Source of truth: `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/bu 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.** Which Burrows answer is staged -([Future](#future)). +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 @@ -359,12 +362,11 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ ### 8. Direct path (WebRTC) -**Scope: direct-path** — latency. The shipped half is [Transport → Direct path](#direct-path), which Pocket offers and answers today. What remains, in staged order: +**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. **The standalone Burrow answers** — the sidecar over `node-datachannel`'s W3C polyfill, a native addon declared in `standalone/sidecar/package.json` beside `node-pty`, loaded lazily at the first offer, a load failure answering `direct-decline`. -2. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, the supply-chain disclosure regenerated, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. -3. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). -4. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. +1. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. +2. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). +3. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. Relay-supplied ICE servers are unstaged (SaaS), as is a session surviving relay loss. diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index e23f963bf..c77c9dcbe 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -37,6 +37,8 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. **Must reject unclassified workspaces and exclusions reachable from a product root before generating disclosure.** Runtime and optional edges count; development edges do not. `website/scripts/dependency-workspaces.test.js` pins coverage. +**A prebuilt package for another platform is described rather than resolved.** `node-datachannel` — the sidecar's second native addon, beside `node-pty` — publishes one package per platform, of which pnpm installs only the host's. Each absent one is listed from its installed sibling, published in lockstep, so the disclosure reads the same wherever it is generated. **Only a prebuild a product root declares itself is listed**: the bundle copies `standalone/sidecar/node_modules`, so one the addon alone declares reaches nobody. Any other unresolvable dependency still throws. + **Bundled themes are disclosed outside that lockfile walk.** The themes compiled into every build (`lib/src/lib/themes/bundled.json`) come from OpenVSX extensions, not npm, so `website/scripts/generate-deps.js` appends the checked-in `lib/src/lib/themes/bundled-extensions.json` to the npm table instead. The two come from one run of `lib/scripts/bundle-themes.mjs` but both are committed and can drift, which the CI gate below cannot see (rationale). `lib/src/lib/themes/bundled-extensions.test.ts` pins them, joining on the `extensionId` each disclosure record carries: a bundled theme whose extension has no record, or a record with no bundled theme left, fails. **The join is on the extension set only** — `bundled.json` carries no version or license, so nothing pins a hand-edit to those published fields. - **FAIL IF** `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run against a clean working tree after `pnpm install --frozen-lockfile`. The install is a precondition: the generator walks real `node_modules` directories and throws rather than under-reporting if they are absent. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index b4e7bf4de..8cc9babc2 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -137,6 +137,20 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si browser dev harness is *not* this case: its per-run temp directory makes a dev enrollment live and die with the run. +**The direct path.** The sidecar is the one Burrow that answers a `direct-offer` +(`docs/specs/remote-api.md` → Transport → "Direct path"), over +`node-datachannel`'s W3C polyfill. **A sidecar package's transitive dependencies +do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and +nothing else — so the addon's platform package and `detect-libc` are declared in +`standalone/sidecar/package.json` directly. **Both specifiers stay `external` to +`burrow.cjs`**, which the build asserts: the addon resolves its `.node` relative +to its own `__dirname`, and inlining would move that out of the installed +package. + +Source of truth: `standalone/sidecar/package.json`, +`lib/src/host/remote/native-direct-peer.ts`, `assertExternalRequire` in +`standalone/scripts/build-sidecar-proxy.mjs`. + **The bridge.** Webview → sidecar is one generic passthrough invoke, `burrow_command(payload)`, writing `{"event":"burrow:command", "data":payload}` to stdin for the dispatch table's `handleCommand`. Sidecar → diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d7413892b..d75abde28 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -21,10 +21,10 @@ "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, "docs/specs/security-remote.md": 4900, - "docs/specs/security-supply-chain.md": 1150, + "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, - "docs/specs/standalone.md": 4300, + "docs/specs/standalone.md": 4400, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, From b32f8c81b888cd9c6218fdfab8bd417bdf684577 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:59:27 -0700 Subject: [PATCH 14/46] docs(security): state the direct path's place in the remote trust model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct path is a transport change under an already-authorized session, so the model spec now says so in one place instead of leaving a reader to infer it from remote-api.md: same session and counters, signaling only inside the ciphertext, offered only after promotion, DTLS beneath that nothing relies on, never an ICE server, and a peer connection that outlives nothing. The UDP socket ICE binds is named honestly — it is the one listener on every interface, and its two parsers are attack surface — and Residual metadata now records that a switched session stops exposing traffic timing to the Relay at all. security-remote.md gains the eight audited rows, the network-posture prose for that UDP socket (the loopback lint reads TCP bind spellings in our own source and reaches nothing the addon binds), and a Relay-compromise row that says what a switch takes away from it. Two of the rows are the prose the new e2e-lint rules pin. The direct-path bounds join the Burrow bounds table, evidence goes to the rationale under `## Direct path`, and the ledger item this closes is deleted — the supply-chain disclosure moves onto the item that declares the addon, where CI already gates it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- .github/audit/application-security.md | 4 +- docs/specs/remote-api.md | 5 +- docs/specs/remote-security-model.md | 66 ++++++++++++++++++- docs/specs/remote-security-model.rationale.md | 50 ++++++++++++++ docs/specs/security-remote.md | 28 +++++++- scripts/spec-word-budgets.json | 4 +- 6 files changed, 147 insertions(+), 10 deletions(-) diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 71a69abc6..a944d2aa2 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -25,8 +25,10 @@ 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-peer.ts` (the data channel the same session may +move onto — `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); diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 5bb18f7f9..f6f5daff9 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -364,9 +364,8 @@ These are the methods the dor CLI speaks today; the remote API reuses their requ **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. **Security** — `docs/specs/security-remote.md` rows, `scripts/e2e-lint.mjs` rules with self-tests, and a section of `docs/specs/remote-security-model.md` stating the path adds no layer to the trust model. -2. **VS Code Burrow** — platform-targeted VSIX builds carrying the addon per target (`docs/specs/deploy.md`). -3. **Dogfood** across a tailnet, keystroke round-trip measured relayed and direct into the rationale. +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. Relay-supplied ICE servers are unstaged (SaaS), as is a session surviving relay loss. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 8dace5c46..5e8920ec5 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -340,6 +340,9 @@ omits `client-gone`, invents client IDs, or reorders frames. | `MAX_ESTABLISHED_E2E_SESSIONS` | 16 | `remote-lib-common/src/security/e2e-bounds.ts` | | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | +| `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | +| `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | +| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 64 frames / 1 MiB | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative received-string length; both `e2e` and `client-gone` share one FIFO and one @@ -403,6 +406,58 @@ admits Burrow enrollment with ([relay.md](./relay.md#http-api)). Pinned by `relay/test/malicious-relay.test.mjs` and `remote-lib-common/test/token-bucket.test.mjs`. +## Direct path + +**The direct path adds no layer to this model.** A WebRTC data channel replaces +the Relay as the carrier of an already-authorized session; every rule above +holds unchanged, because nothing about *what* is carried changes. +[remote-api.md](./remote-api.md) -> "Direct path" owns the design and is not +restated here. + +- **Same session, same counters.** The channel carries transport messages of the + session promoted at [Connection](#connection), on the two `CipherState`s from + that `Split`. **Never a second handshake, a rekey, or a byte of plaintext.** +- **Signaling never leaves the ciphertext**, which is what makes it trustworthy: + a description the Relay could have written would be one it could point at + itself. +- **Offered only after promotion.** A peer connection built before the + connection outcome would be one an unauthorized party had steered. +- **DTLS beneath is transport hygiene this model does not rely on.** It protects + nothing the Noise session does not already protect, and **the fingerprints in + an SDP are authentic for exactly one reason — that SDP arrived inside the + session**. A DTLS peer is never an authenticated one. +- **Never an ICE server.** A public STUN or TURN default hands a third party the + user's address, and a TURN relay hands it the traffic; both ends pass an empty + list (rationale). +- **One peer connection per session, and never a longer-lived one.** Created at + the offer and closed by every path that ends the session — outcome, expiry, + `client-gone`, a lost relay socket, `stop()`. + +**The listener is a UDP socket per local address, for the life of an attempt.** +ICE gathering binds one on every interface it gathers a candidate on, so the +host answers UDP from anyone who can route to it on any of those networks. +**Two parsers sit behind it and both are attack surface**: before DTLS, ICE's +own STUN parser, which answers a binding request only under this attempt's +ufrag and password (RFC 8445 requires 24 and 128 bits of randomness), both +freshly generated and reaching the peer only inside the session; after DTLS, +the peer implementation's TLS stack — the browser's on the phone, the addon's +on a standalone Burrow (`docs/specs/security-supply-chain.md`). A memory-safety +bug in either is reachable by any stranger on those networks, and nothing above +it mitigates that. + +**A channel lost after cutover is burrow loss, and that is accepted**: the +counters have moved, a stream cipher has no resynchronization point, and both +ends end the session rather than resume on the Relay (rationale). The Relay +still sees that the session exists and whether each end is online; it no longer +sees the traffic ([Residual metadata](#residual-metadata)). + +Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, +their guard, and `DirectCutover`), `lib/src/remote/direct/direct-peer.ts` +(`DirectPeer`), `BurrowRuntime.#answerDirect` in +`lib/src/remote/burrow/burrow-runtime.ts`, `PocketClient.#offerDirect` in +`lib/src/remote/client/pocket-client.ts`. The audited rows are +`docs/specs/security-remote.md` -> "Direct path". + ## Noise suite - **Exactly one suite: `Noise_IK_25519_ChaChaPoly_SHA256`, Noise revision 34.** @@ -525,9 +580,14 @@ such claim. **No traffic-analysis resistance, per-Burrow unlinkability, or metadata anonymity is claimed.** The Relay still observes account and passkey authentication data, IPs, Burrow IDs and online state, routing relationships, every session's reauth -exchange, push endpoints, timing, ciphertext sizes, and volume. Two leaks follow -and are accepted rather than closed (rationale): Client→Burrow timing exposes -inter-keystroke timing while keystroke *values* stay encrypted, and one +exchange, push endpoints, timing, ciphertext sizes, and volume — **the last +three only while the Relay is carrying the session**, since a session that has +switched to the [direct path](#direct-path) leaves it the fact of the session +and each end's liveness and nothing else. Two leaks follow and are accepted +rather than closed (rationale): Client→Burrow timing exposes inter-keystroke +timing on a relayed session while keystroke *values* stay encrypted, ending at +the switch — which in exchange shows each paired peer the other's addresses, +until then known only to the Relay — and one `PushSubscription` per worker scope lets a shared endpoint correlate every `deliveryId` one Pocket profile registers across Burrows. A push carries no counter, so a Relay that kept an envelope can re-deliver it diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index f81fafa7f..bb548ef79 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -239,6 +239,56 @@ it arrives on a *new* socket, against a Burrow that no longer holds the private half, so no path completes. Keeping the entry leaves the same dead code on screen as the mint-straddle case under [Pairing](#pairing). +## Direct path + +*(2026-09; the path shipped on the Pocket side and the standalone Burrow.)* + +**Why the path needed no new trust layer, and how that was checked.** The +question asked of the design was which of the five layers a WebRTC channel +touches. The answer is none: the channel is a byte pipe under an already +promoted Noise session, so peer authenticity still comes from IK, presence from +the assertion the promotion consumed, authorization from the ACL conjunction, +and the final decision from the Burrow. Three things had to hold for that to be +true rather than merely plausible, and each is a rule above — the SDP travels +as a `control` message inside the session (so nothing about the channel is +Relay-supplied), the offer happens strictly after the outcome (so nothing is +built for an unauthorized party), and the bytes on the channel are the same +transport messages on the same counters (so no second cipher exists to get +wrong). A design that trickled candidates, or that negotiated on a side +channel, would have broken all three at once. + +**Why no ICE servers, stated as a hard rule rather than a default.** A STUN +server learns the client's public address and the fact of a session, from a +party neither endpoint chose; a TURN server learns the traffic pattern and +carries the ciphertext, which is exactly the position the Relay already holds +and the whole model treats as untrusted. The shipped deployment is a tailnet, +where host candidates reach on both ends, so the servers buy connectivity that +is already there. Relay-supplied ICE servers stay in `remote-api.md`'s +`## Future` for a SaaS deployment, where the trade is real and would need its +own analysis. + +**Why the UDP listener is named in the spec at all.** Everything else the +product opens to a network is TCP behind loopback or behind the Relay's HTTPS +origin, and the audit's listener sweep is written for that shape +(`docs/specs/security-local.md` -> "Loopback Listeners" and +`scripts/loopback-lint.mjs` both read bind spellings on loopback TCP). An ICE +socket matches neither, is bound on every interface, and appears in no source +file of ours — the addon and the browser bind it. Left unnamed it would be the +one network-facing thing no page of this suite mentions. What is behind it is +small but not nothing: an unauthenticated STUN parser gated only by a +per-attempt credential, and a DTLS stack. It exists between the offer and the +session's disposal, which is minutes, not the process's lifetime. + +**Why a lost channel after cutover ends the session instead of falling back.** +Falling back would mean resuming a `CipherState` at the counter the peer +believes it is on, across a gap of unknown length in an ordered stream — a +resynchronization the Noise transport deliberately has no construction for +([Noise suite](#noise-suite): any gap ends the session). Holding the relay path +warm as a standby would also mean the sender deciding per message which path a +frame took, which is the ordering bug the single `direct-switch` boundary +exists to make impossible. The cost of ending instead is one reconnect on the +phone, which the app already does for every other burrow loss. + ## Noise suite **Why X25519 is WebCrypto and ChaChaPoly is bundled.** Measured 2026-06 across diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index a471af08a..a67d2d0c0 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -30,7 +30,7 @@ no plaintext relay route, and no reader for any of the pre-cutover frames. | Compromise | Buys | What still stands | | --- | --- | --- | -| Relay | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale) | +| Relay | account state, routing metadata | **no new authorization and no plaintext**. On an established session, availability only — drop, delay, reorder, or refuse, never read and never inject — and the first invalid ciphertext destroys the session. Web Push holds **confidentiality**, not **freshness**: a kept envelope re-delivers as current, accepted residual (rationale). A session switched to the [direct path](#direct-path) leaves it the lifecycle levers alone — it can still end that session by dropping a socket, but sees, delays, and reorders none of its traffic | | Setup password | one endpoint, `/api/burrow/enroll`, and thence a `burrowToken` | it registers **no** passkey — `/api/setup/*` takes a Burrow-minted setup token and nothing else — so it reaches an owner passkey only via the next row. `/api/burrow/enroll` accepts one other credential, the installer's enrollment offer: owner-only *at rest*, the whole of what the file mode protects, checked by possession over HTTPS rather than local identity, so a leaked token redeems remotely — bounded single-use, 24-hour expiry, permanently disabled by the first Burrow enrollment. Still **no Burrow access** | | `burrowToken` | the Burrow's own relay traffic and, transitively, **account takeover**: it mints setup tokens at `/api/burrow/setup-token`, the only thing that registers an owner passkey | bounded three ways — single-use and dead 5 minutes after minting; revoking the Burrow (deleting its row from `burrows.json`) stops minting immediately *and* kills already-minted tokens, re-checked at both setup gates; a signed-in phone retires an unused token at `/api/setup/retire`. Still **no Burrow access**: pairing runs Noise IK against an invitation keypair the Burrow never sent anywhere (rationale) | | Synced or stolen passkey | sign-in, and the ability to *ask* | the paired Client static is missing, so `BurrowAcl` answers `client-not-paired` | @@ -161,6 +161,15 @@ Funnel publishes the same TLS origin and stays inside this analysis: public admi is owned by [The setup password](#the-setup-password), and a Client still reaches no Burrow without the Burrow-local authorization above. +**A direct path opens the one listener no loopback rule covers.** ICE gathering +binds a UDP socket per local address, so while an attempt is live the machine +answers UDP from anyone routing to it on any of those networks. +`docs/specs/security-local.md` -> "Loopback Listeners" is about loopback TCP and +`scripts/loopback-lint.mjs` reads bind spellings in our own source, so neither +reaches a socket the browser or the addon binds. It exists only between an +offer and that session's disposal, and what answers on it is +`docs/specs/remote-security-model.md` -> "Direct path". + **Must not make Funnel state an install or health verdict.** The installers configure Serve but neither inspect, warn about, enable, nor disable Funnel; `manage verify` checks the local TLS-to-loopback path, while CI and this audit check application @@ -201,6 +210,23 @@ a second unchecked resolution. - **FAIL IF** push text stops being bounded with the shared `boundedPushText` on the Burrow before sealing, or re-bounded with it in `lib/src/remote/pocket-app/sw.ts` before `showNotification`. The worker is the sanitization sink: a worker that renders what it decrypted without re-bounding it leaves the property with one enforcer instead of two (rationale). - **FAIL IF** the relay routes a Burrow-originated frame from a socket that is not the Client's current Burrow binding, or begins decoding, remembering, or acting on an `e2e` ciphertext. `relay/src/relay.ts` must route the `e2e` envelope and nothing else: it holds no gate, no challenge memory, and no notion of an authorized session (rationale). A Relay-side type import from the protocol-v1 half of `remote-lib-common/src/remote/wire.ts` is the leading indicator and fails the same way. +### Direct path + +**An authorized session may leave the Relay for a WebRTC data channel, carrying +what it already carried**: the same Noise session, the same counters, the same +bounds. `docs/specs/remote-api.md` -> "Direct path" owns the design and +`docs/specs/remote-security-model.md` -> "Direct path" owns why it adds no trust +layer; neither is restated below. + +- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. `BurrowRuntime.#answerDirect` in `lib/src/remote/burrow/burrow-runtime.ts` must return on the session's `directAttempted` flag and set it before its first `await`; `PocketClient.#offerDirect` in `lib/src/remote/client/pocket-client.ts` must be reachable only from the `ok: true` branch of a connection outcome, since a peer connection built earlier is one an unauthorized party steered. +- **FAIL IF** a byte crosses the channel that is not a Noise transport message of the promoted session: one message per frame, raw bytes, no second handshake, no plaintext, and no framing of ours beside it. **Every inbound frame is bounded at `NOISE_MAX_MESSAGE_LENGTH` before it reaches a cipher** — `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse an over-cap frame and a non-binary message as violations rather than parse either. +- **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. +- **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. +- **FAIL IF** a peer connection can outlive its session or precede promotion. `#disposeDirect` must run on every path that ends an established session in `lib/src/remote/burrow/burrow-runtime.ts` — `#disposeEstablished`, and through `#disposeClient` the `client-gone`, socket-loss and `stop()` paths — and `#disposeCeremony` in `lib/src/remote/client/pocket-client.ts` must close the peer on every teardown, an intentional `close()` and a dropped relay socket included. +- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three and both endpoints must act on every outcome it returns. Pinned by `remote-lib-common/test/direct-path.test.mjs` and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through an import performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. +- **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). + ### Revocation and the audit trail These are the two real gaps in the shipped model, and they are gaps rather than diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d75abde28..5a0a8b073 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -16,11 +16,11 @@ "docs/specs/pocket-app.md": 4200, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 4200, - "docs/specs/remote-security-model.md": 4200, + "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 4900, + "docs/specs/security-remote.md": 5600, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, From 19d534fc1cc30c7fef56211dff7d51851aa766d9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:59:36 -0700 Subject: [PATCH 15/46] lint(e2e): refuse an ICE server, and a Relay that names an SDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rules, each with the self-test case that makes it load-bearing: - No STUN or TURN URL in shipped source. Anchored on the quote that opens the literal, because `// early return:` and `Saturn:` both contain `turn:` and a rule that reddened on those would be deleted. - No non-empty `iceServers` list. The empty array is the shipped value at both ends, so the pattern demands a first element. - The Relay never names a direct-path signal or an SDP. Same reasoning as the protocol-v1 rule beside it: routing an opaque envelope needs none of these words, so one appearing is the leading indicator that a route has started to care what it carries. `direct-path.ts` and `direct-peer.ts` join `E2E_MODULES`, so the curve rule covers them. `direct-peer.ts` stays out of the optional-field rule's own list on purpose — its `readonly sdp?: string` mirrors `RTCSessionDescriptionInit`, whose optionality is the W3C API's; the signal that carries an SDP over the wire keeps it required. Verified by breaking each new pattern in turn: the self-test reports all three as staying green when they should redden. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- scripts/e2e-lint.mjs | 55 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index f4fa4ef03..51b11e8ce 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -9,7 +9,8 @@ * Why this exists: the properties the trust boundary rests on are *absences* — * one Noise suite and no way to select another, no JavaScript curve, no * plaintext relay route, no legacy frame discriminant left to answer, no - * Relay-side view of protocol-v1, no checked-in service worker shadowing the + * Relay-side view of protocol-v1 or of the direct path's signaling, no ICE + * server, no checked-in service worker shadowing the * built one. An absence is exactly what a reviewer stops noticing: nothing in a * diff says "a second cipher suite is now reachable", and the nightly audit is * thorough but probabilistic. This makes the cheap half deterministic, so @@ -74,6 +75,11 @@ const E2E_MODULES = [ // `passkey.ts` does, which is why that one is out of scope and this one is in. 'remote-lib-common/src/security/presence.ts', 'remote-lib-common/src/security/acl.ts', + // The direct path carries the same promoted session onto a data channel, so + // it is inside the boundary for the same reasons the transport is: one key + // agreement, one AEAD, and no second construction reachable from either. + 'remote-lib-common/src/security/direct-path.ts', + 'lib/src/remote/direct/direct-peer.ts', 'remote-lib-common/src/remote/wire.ts', 'lib/src/remote/burrow/burrow-runtime.ts', 'lib/src/remote/burrow/push-delivery.ts', @@ -223,6 +229,45 @@ export const RULES = [ violationFile: 'relay/src/relay.ts', violation: "\nimport type { DirectoryEntry } from 'remote-lib-common';\n", }, + { + rule: 'No STUN or TURN URL in shipped source', + security: 'any ICE server reaches shipped source', + kind: 'forbid', + trees: SOURCE_TREES, + // Anchored on the quote that opens the literal, because the bare scheme is + // a substring of ordinary prose: `// ... early return:` and `Saturn:` both + // contain `turn:`, and a rule that reddened on those would be deleted. + pattern: /['"`](?:stuns?|turns?):/g, + violationFile: 'lib/src/remote/pocket-app/App.tsx', + violation: "\nconst __selftest = 'stun:stun.example.net:19302';\n", + }, + { + rule: 'No non-empty `iceServers` list anywhere', + security: 'any ICE server reaches shipped source', + kind: 'forbid', + trees: SOURCE_TREES, + // The empty array is the shipped value at both ends and must keep passing, + // so the pattern demands a first element: anything after `[` that is + // neither whitespace nor the closing bracket. A server assembled at runtime + // is past what a regex can see, which is why the spec row is read by hand + // as well. + pattern: /iceServers\s*:\s*\[\s*[^\]\s]/g, + violationFile: 'lib/src/remote/pocket-app/App.tsx', + violation: '\nconst __selftest = { iceServers: [{ urls: [] }] };\n', + }, + { + rule: 'The Relay never names a direct-path signal or an SDP', + security: 'any signaling leaves the ciphertext', + kind: 'forbid', + trees: ['relay/src/'], + // The signals ride as `control` messages inside the session, so the Relay + // routes them without knowing they exist. Naming one is the leading + // indicator that a route, a guard, or a frame type has started to care — + // the same reasoning as the protocol-v1 rule above. + pattern: /\b(?:direct-offer|direct-answer|direct-decline|direct-switch|RTCPeerConnection|sdp)\b/gi, + violationFile: 'relay/src/relay.ts', + violation: "\nconst __selftest = { sdp: '' };\n", + }, { rule: 'No checked-in service worker beside the built one', security: 'the worker in `lib/src/remote/pocket-app/sw.ts` is the only thing that opens one', @@ -257,6 +302,14 @@ export const RULES = [ // Every one of these is load-bearing on every message that carries it, so // an optional spelling is a shape where a peer can simply omit the // authentication and have the type still check. + // + // `lib/src/remote/direct/direct-peer.ts` is deliberately not in this list, + // even though it is an `E2E_MODULES` entry: its `readonly sdp?: string` + // mirrors `RTCSessionDescriptionInit`, whose optionality is the W3C API's, + // not ours. The signal that *does* carry an SDP over the wire keeps it + // required — `DirectSignalV1` in + // `remote-lib-common/src/security/direct-path.ts`, whose guard demands the + // exact key set. pattern: /\b(?:ct|salt|sealed|handshakeHash|key|ciphertext|plaintext|proof|assertion)[ \t]*\?[ \t]*:/g, violationFile: 'remote-lib-common/src/security/push-seal.ts', violation: '\nexport interface SelftestSeal {\n readonly ct?: string;\n}\n', From 91909c9d225e33fe22295d5071449faf7d75c38d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:59:43 -0700 Subject: [PATCH 16/46] docs(security): say publicly that a session may leave the Relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Traffic analysis entry enumerated what the Relay sees, and that list is now conditional: an authorized session may move onto a direct connection between the two devices, after which the Relay sees the session exists and nothing about its traffic. The staged-WebRTC check keeps its condition — `## Future` in remote-api.md still names WebRTC, since the VS Code Burrow and relay-supplied ICE servers are unbuilt — but its comment claimed the whole feature was below the fold. It is half promoted, and the sentence the rule is still here to stop is one that would be false for a VS Code user. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/security.md | 5 ++++- scripts/public-docs-lint.mjs | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/specs/security.md b/docs/specs/security.md index c29377532..23aae10a7 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -74,7 +74,10 @@ run this knows what they are taking on. artifact the origin serves ([Trust Model](./remote-security-model.md#trust-model)). - **Traffic analysis.** The Relay sees who talks to whom, when, how often, and how large each ciphertext is, and keystroke timing, never keystroke values - ([Residual metadata](./remote-security-model.md#residual-metadata)). + ([Residual metadata](./remote-security-model.md#residual-metadata)). An + authorized session may move onto a direct connection between the two devices, + after which the Relay sees that the session exists and nothing about its + traffic ([Direct path](./remote-security-model.md#direct-path)). - **Push replay, when push is enabled.** A push proves confidentiality, not freshness: a Relay that kept an envelope can re-deliver it ([Push sealing](./remote-security-model.md#push-sealing)). - **Per-Burrow unlinkability, when push is enabled.** One push endpoint per browser lets the Relay see diff --git a/scripts/public-docs-lint.mjs b/scripts/public-docs-lint.mjs index cff78169e..2adcefeff 100644 --- a/scripts/public-docs-lint.mjs +++ b/scripts/public-docs-lint.mjs @@ -495,9 +495,13 @@ async function checkGenerated() { /** * Public copy must not present staged remote transports as shipped. * - * Scoped by the spec that stages them: the ban applies only while WebRTC is - * still below docs/specs/remote-api.md's `## Future` fold, so promoting it - * retires this rule in the same commit that ships it. + * Scoped by the spec that stages them: the ban holds while docs/specs/ + * remote-api.md's `## Future` still names WebRTC, and the last item that does + * retires this rule in the commit that ships it. The direct path is half + * promoted — the phone offers one and the standalone Burrow answers, while the + * VS Code Burrow and relay-supplied ICE servers are still staged — so "Dormouse + * connects your phone directly" is exactly the sentence this rule is still here + * to stop, since it would be false for a VS Code user reading it. */ function checkNoStagedClaims() { const api = readRepoFile('docs/specs/remote-api.md'); From 9f86c30114e51f560a51ab02ee6fa259c1e9bbf3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:20:16 -0700 Subject: [PATCH 17/46] fix(remote): copy a held direct frame out of the event's buffer A typed-array channel message was framed as a view over the event's buffer, and the cutover may hold that frame until the peer's switch decrypts; the send side already copied. Same shape on both sides now. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/direct/direct-peer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 124d63282..e828a38a7 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -228,7 +228,10 @@ export class DirectPeer { if (data instanceof ArrayBuffer) { frame = new Uint8Array(data); } else if (ArrayBuffer.isView(data)) { - frame = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + // Copied, not viewed: the cutover may hold this frame until the peer's + // switch decrypts, and a view over a pooled or reused buffer (the Node + // polyfill hands over a `Buffer`) would read whatever landed there next. + frame = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); } else { this.#handlers.onViolation('a direct channel message was not binary'); return; From 56768657ffc85fd256ccb376edfd52f8e6ea835d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:20:55 -0700 Subject: [PATCH 18/46] docs(security): describe the ICE listener as measured Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/remote-security-model.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 5e8920ec5..e5fbe0b1d 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -433,9 +433,12 @@ restated here. the offer and closed by every path that ends the session — outcome, expiry, `client-gone`, a lost relay socket, `stop()`. -**The listener is a UDP socket per local address, for the life of an attempt.** -ICE gathering binds one on every interface it gathers a candidate on, so the -host answers UDP from anyone who can route to it on any of those networks. +**The listener is UDP on every interface a candidate names, for the life of an +attempt.** The standalone Burrow's addon binds one socket on the unspecified +address and advertises each routable interface at that port (measured +2026-09: four host candidates, one port, no loopback or link-local); a browser +binds per interface. Either way the host answers UDP from anyone who can route +to it on any of those networks. **Two parsers sit behind it and both are attack surface**: before DTLS, ICE's own STUN parser, which answers a binding request only under this attempt's ufrag and password (RFC 8445 requires 24 and 128 bits of randomness), both From 9bbee8e1ee9186a7acbda52a90134cc9ece1f114 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:39:45 -0700 Subject: [PATCH 19/46] build(standalone): assert sidecar externals from the metafile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `assertExternal` config key — it duplicated `external` (the same `NATIVE_DIRECT` array), so it could never disagree with it. Assert from the `external` list itself. Check esbuild's metafile instead of grepping the emitted text: each external specifier must appear in `metafile.outputs[].imports` as `{ kind: 'require-call', external: true }`. The old text grep matched `require("…")` anywhere in the bundle, including inside an inlined module. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/standalone.md | 9 ++--- standalone/scripts/build-sidecar-proxy.mjs | 40 ++++++++++++++-------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 8cc9babc2..ac82c58c9 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -143,12 +143,13 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and nothing else — so the addon's platform package and `detect-libc` are declared in `standalone/sidecar/package.json` directly. **Both specifiers stay `external` to -`burrow.cjs`**, which the build asserts: the addon resolves its `.node` relative -to its own `__dirname`, and inlining would move that out of the installed -package. +`burrow.cjs`**, which the build asserts from esbuild's metafile — each has to +leave the bundle as an external `require-call` edge: the addon resolves its +`.node` relative to its own `__dirname`, and inlining would move that out of the +installed package. Source of truth: `standalone/sidecar/package.json`, -`lib/src/host/remote/native-direct-peer.ts`, `assertExternalRequire` in +`lib/src/host/remote/native-direct-peer.ts`, `assertExternalImports` in `standalone/scripts/build-sidecar-proxy.mjs`. **The bridge.** Webview → sidecar is one generic passthrough invoke, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 012e257d3..c21fc22cb 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -6,7 +6,6 @@ // - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; -import { readFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -39,26 +38,36 @@ const bundles = [ define: { [CONNECT_SRC_PLACEHOLDER]: JSON.stringify(remoteSrc) }, assertBaked: true, external: NATIVE_DIRECT, - assertExternal: NATIVE_DIRECT, }, ]; /** - * Fail the build if esbuild bundled a module that has to stay external. + * Fail the build if esbuild bundled a module the `external` list has to keep out. * * `native-direct-peer.ts` calls `require('')` by literal, so an - * external specifier survives verbatim and a bundled one is rewritten into the - * inlined module's own accessor. That difference is the whole check: a lost - * `external` entry produces a `burrow.cjs` that loads and then cannot find the - * addon's `.node` file, which nothing before the first `direct-offer` on a real - * machine would notice. + * external specifier stays a `require-call` edge out of the bundle and a bundled + * one becomes an inlined module with no edge at all. That difference is the + * whole check: a lost `external` entry produces a `burrow.cjs` that loads and + * then cannot find the addon's `.node` file, which nothing before the first + * `direct-offer` on a real machine would notice. */ -function assertExternalRequire(bundlePath, specifiers) { - const bundle = readFileSync(bundlePath, 'utf8'); +function assertExternalImports(metafile, outfile, specifiers) { + // esbuild keys `metafile.outputs` by path relative to the process cwd, with + // `/` separators on every platform. + const outputKey = path.relative(process.cwd(), outfile).split(path.sep).join('/'); + const imports = metafile.outputs[outputKey]?.imports; + if (!imports) { + throw new Error( + `sidecar: esbuild metafile has no output for "${outputKey}" — cannot check external imports.`, + ); + } for (const specifier of specifiers) { - if (bundle.includes(`require("${specifier}")`)) continue; + const kept = imports.some( + (edge) => edge.path === specifier && edge.kind === 'require-call' && edge.external === true, + ); + if (kept) continue; throw new Error( - `sidecar: ${bundlePath} has no bare require("${specifier}") — esbuild bundled it, and ` + + `sidecar: ${outputKey} has no external require("${specifier}") — esbuild bundled it, and ` + 'the addon would look for its platform package beside the bundle instead of inside ' + 'sidecar/node_modules.', ); @@ -70,9 +79,9 @@ function assertExternalRequire(bundlePath, specifiers) { // app — a dead Burrow with its own baked connect-src allowlist. await rm(path.resolve(sidecar, 'remote-host.cjs'), { force: true }); -for (const { entry, out, define, assertBaked, external, assertExternal } of bundles) { +for (const { entry, out, define, assertBaked, external } of bundles) { const outfile = path.resolve(sidecar, out); - await build({ + const result = await build({ entryPoints: [path.resolve(libHost, entry)], outfile, bundle: true, @@ -80,10 +89,11 @@ for (const { entry, out, define, assertBaked, external, assertExternal } of bund format: 'cjs', target: 'node24', logLevel: 'warning', + metafile: true, ...(define ? { define } : {}), ...(external ? { external } : {}), }); if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); - if (assertExternal) assertExternalRequire(outfile, assertExternal); + if (external) assertExternalImports(result.metafile, outfile, external); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } From 3134f06fe550a7ac8b6460b762a58203281ca293 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:41:42 -0700 Subject: [PATCH 20/46] refactor(remote): put the direct attempt's lifecycle in DirectCutover `begin()` is the one-attempt-per-session gate and `abandon()` the one way to give one up, so `onSwitchDecrypted()` can answer `fatal` for a switch onto a channel this end abandoned rather than each endpoint tracking its own `directAttempted` boolean beside the shared state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- remote-lib-common/src/security/direct-path.ts | 64 +++++++++++++++++-- remote-lib-common/test/direct-path.test.mjs | 51 ++++++++++++++- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index c07ca1b2e..5a7c52599 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -127,6 +127,19 @@ export type DirectRelayOutcome = 'process' | 'violation'; /** What one inbound channel frame turned out to be. */ export type DirectChannelOutcome = 'process' | 'held' | 'overflow'; +/** + * How far this end's one attempt has got: `idle` before it starts, `attempting` + * from {@link DirectCutover.begin} until {@link DirectCutover.abandon}, and + * `abandoned` forever after. There is no way back to `idle`, which is what makes + * the attempt once-per-session. + */ +export type DirectAttemptState = 'idle' | 'attempting' | 'abandoned'; + +/** What the peer's `direct-switch` turned out to mean; see {@link DirectCutover.onSwitchDecrypted}. */ +export type DirectSwitchOutcome = + | { readonly kind: 'drain'; readonly frames: Uint8Array[] } + | { readonly kind: 'fatal' }; + /** * One end's view of the cutover, which is per direction and never negotiated: * each side switches its own sends and learns about the peer's when the peer's @@ -138,13 +151,22 @@ export type DirectChannelOutcome = 'process' | 'held' | 'overflow'; * before it. The holding queue is bounded in both frames and bytes; a peer that * overruns it is one this end cannot keep in order, which is a dead session * rather than a dropped frame. + * + * The attempt's lifecycle lives here too, so the two ends cannot disagree about + * when one may start or what a switch means after one has been given up. */ export class DirectCutover { + #state: DirectAttemptState = 'idle'; #outbound: DirectPath = 'relay'; #inbound: DirectPath = 'relay'; readonly #held: Uint8Array[] = []; #heldBytes = 0; + /** How far this end's one attempt has got. */ + get state(): DirectAttemptState { + return this.#state; + } + /** Where this end's own messages go. */ get outbound(): DirectPath { return this.#outbound; @@ -173,6 +195,31 @@ export class DirectCutover { return this.#heldBytes; } + /** + * Claim this session's one attempt. **The one-attempt-per-session gate**: a + * second call answers `false` and allocates nothing, whatever the first + * attempt did or is still doing. + */ + begin(): boolean { + if (this.#state !== 'idle') return false; + this.#state = 'attempting'; + return true; + } + + /** + * Give the attempt up, leaving the session exactly as relayed as it was, and + * release what it was holding. + * + * **Only legal before either direction has switched** — after that there is no + * relay left to fall back to, so a caller reaching here has confused an + * abandoned attempt with burrow loss. + */ + abandon(): void { + if (this.switched) throw new Error('a switched direct path cannot be abandoned'); + this.#state = 'abandoned'; + this.clear(); + } + /** * Move this end's sends onto the channel. The caller sends its * `direct-switch` on the relay *first*: this is the line after which nothing @@ -198,14 +245,19 @@ export class DirectCutover { /** * The peer's `direct-switch` decrypted: everything held is now known to come - * after it. Returns the held frames in arrival order, and empties the queue. + * after it, so `drain` carries the held frames in arrival order and empties + * the queue. + * + * **A switch onto a channel this end has abandoned is `fatal`**: nothing that + * peer sends can arrive any more, and the alternative is a session whose every + * request hangs unanswered. */ - onSwitchDecrypted(): Uint8Array[] { + onSwitchDecrypted(): DirectSwitchOutcome { + if (this.#state === 'abandoned') return { kind: 'fatal' }; this.#inbound = 'direct'; - const drained = [...this.#held]; - this.#held.length = 0; - this.#heldBytes = 0; - return drained; + const frames = [...this.#held]; + this.clear(); + return { kind: 'drain', frames }; } /** diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 75cfc0166..c7b745884 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -116,8 +116,57 @@ test('starts relayed in both directions', () => { assert.equal(cutover.onRelayTransport(), 'process'); }); +test('claims the session’s one attempt, and never a second', () => { + const cutover = new DirectCutover(); + assert.equal(cutover.state, 'idle'); + assert.equal(cutover.begin(), true); + assert.equal(cutover.state, 'attempting'); + // The one-attempt-per-session gate: a second offer allocates nothing. + assert.equal(cutover.begin(), false); + cutover.abandon(); + assert.equal(cutover.state, 'abandoned'); + // And an attempt that was given up cannot be restarted either. + assert.equal(cutover.begin(), false); +}); + +test('abandoning releases what it held, and is refused once switched', () => { + const cutover = new DirectCutover(); + cutover.begin(); + cutover.onChannelFrame(frame(1)); + cutover.abandon(); + assert.equal(cutover.pendingFrames, 0); + assert.equal(cutover.pendingBytes, 0); + + // After a switch there is no relay to fall back to, so a caller reaching here + // has confused an abandoned attempt with burrow loss. + const switched = new DirectCutover(); + switched.begin(); + switched.switchOutbound(); + assert.throws(() => switched.abandon(), /cannot be abandoned/); +}); + +test('a switch onto an abandoned channel is fatal, and a switch while attempting drains', () => { + const abandoned = new DirectCutover(); + abandoned.begin(); + abandoned.abandon(); + assert.deepEqual(abandoned.onSwitchDecrypted(), { kind: 'fatal' }); + // Refused before anything moved: the session is over, not half switched. + assert.equal(abandoned.inbound, 'relay'); + + const attempting = new DirectCutover(); + attempting.begin(); + attempting.onChannelFrame(frame(1)); + attempting.onChannelFrame(frame(2)); + assert.deepEqual(attempting.onSwitchDecrypted(), { + kind: 'drain', + frames: [frame(1), frame(2)], + }); + assert.equal(attempting.inbound, 'direct'); +}); + test('switches each direction on its own, and only both make it direct', () => { const cutover = new DirectCutover(); + cutover.begin(); assert.equal(cutover.switchOutbound(), true); // A second open must not put a second switch on the relay. assert.equal(cutover.switchOutbound(), false); @@ -139,7 +188,7 @@ test('holds channel frames until the peer’s switch, then drains them in order' assert.equal(cutover.pendingBytes, 8); const drained = cutover.onSwitchDecrypted(); - assert.deepEqual(drained, [frame(1), frame(2)]); + assert.deepEqual(drained.frames, [frame(1), frame(2)]); assert.equal(cutover.pendingFrames, 0); assert.equal(cutover.pendingBytes, 0); // Everything after the switch is read straight through. From ccd9af362cc01ae746ded34934398f585c93d37e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:46:31 -0700 Subject: [PATCH 21/46] build(website): describe an absent prebuild from its declared sibling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `@node-datachannel/` scope allowlist with the property it was standing in for. `getDependencyNames` now yields `{ name, optional }`, and an unresolvable dependency is resolved by the edge that declares it: - optional, from an external package — skipped, it ships to nobody - optional, from a product root — described from a sibling declared in the same `optionalDependencies` block at the same exact version string - anything else — still a hard error The old rule described an absent prebuild from any installed package sharing a scope prefix, which is a weaker guarantee than the lockstep publishing it relies on. Also fold both `externalPackages` key sites into `externalPackageKey`. The generated disclosure is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- docs/specs/security-supply-chain.md | 7 +- website/scripts/dependency-workspaces.js | 10 +- website/scripts/dependency-workspaces.test.js | 19 ++- website/scripts/generate-deps.js | 122 ++++++++++-------- 4 files changed, 98 insertions(+), 60 deletions(-) diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index c77c9dcbe..30cb0bb1a 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -37,7 +37,10 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. **Must reject unclassified workspaces and exclusions reachable from a product root before generating disclosure.** Runtime and optional edges count; development edges do not. `website/scripts/dependency-workspaces.test.js` pins coverage. -**A prebuilt package for another platform is described rather than resolved.** `node-datachannel` — the sidecar's second native addon, beside `node-pty` — publishes one package per platform, of which pnpm installs only the host's. Each absent one is listed from its installed sibling, published in lockstep, so the disclosure reads the same wherever it is generated. **Only a prebuild a product root declares itself is listed**: the bundle copies `standalone/sidecar/node_modules`, so one the addon alone declares reaches nobody. Any other unresolvable dependency still throws. +**An unresolvable dependency throws unless an optional-edge rule covers it.** `node-datachannel` — the sidecar's second native addon, beside `node-pty` — publishes one prebuilt package per platform, and pnpm installs only the host's. + +- **Optional, declared by an external package: skipped.** The bundle copies `standalone/sidecar/node_modules`, so a prebuild the addon alone declares (android, musl) reaches nobody. +- **Optional, declared by a product root: described from a sibling in the same `optionalDependencies` block at the same exact version string** — published in lockstep, so the disclosure is identical on every machine. No such sibling installed throws. **Bundled themes are disclosed outside that lockfile walk.** The themes compiled into every build (`lib/src/lib/themes/bundled.json`) come from OpenVSX extensions, not npm, so `website/scripts/generate-deps.js` appends the checked-in `lib/src/lib/themes/bundled-extensions.json` to the npm table instead. The two come from one run of `lib/scripts/bundle-themes.mjs` but both are committed and can drift, which the CI gate below cannot see (rationale). `lib/src/lib/themes/bundled-extensions.test.ts` pins them, joining on the `extensionId` each disclosure record carries: a bundled theme whose extension has no record, or a record with no bundled theme left, fails. **The join is on the extension set only** — `bundled.json` carries no version or license, so nothing pins a hand-edit to those published fields. @@ -45,7 +48,7 @@ The roots are `productDependencyFilters` in `website/scripts/generate-deps.js`. - **FAIL IF** `.github/workflows/ci.yml` stops running that generator under that same install precondition, or stops failing on a diff (rationale). - **FAIL IF** the disclosure omits a shipped workspace's graph or excludes a shipped package. Derive shipping routes from `pnpm-workspace.yaml` and the builds, not the enumeration above; the generator enforces classification, but cannot establish whether an exclusion is justified (rationale). -Source of truth: `productDependencyFilters` / `excludedWorkspacePackages` in `website/scripts/generate-deps.js`; `assertWorkspaceCoverage` in `website/scripts/dependency-workspaces.js`. +Source of truth: `productDependencyFilters` / `excludedWorkspacePackages` / `optionalSiblingsAtSameVersion` in `website/scripts/generate-deps.js`; `assertWorkspaceCoverage` in `website/scripts/dependency-workspaces.js`. ## Bundled runtime diff --git a/website/scripts/dependency-workspaces.js b/website/scripts/dependency-workspaces.js index 3904855fa..2e017c855 100644 --- a/website/scripts/dependency-workspaces.js +++ b/website/scripts/dependency-workspaces.js @@ -14,7 +14,7 @@ export function assertWorkspaceCoverage(workspacePackages, roots, exclusions) { if (covered.has(name)) return; covered.add(name); const pkg = byName.get(name); - for (const dependency of getDependencyNames(pkg)) { + for (const { name: dependency } of getDependencyNames(pkg)) { if (byName.has(dependency)) visit(dependency); } } @@ -29,6 +29,12 @@ export function assertWorkspaceCoverage(workspacePackages, roots, exclusions) { } } +// `optional` rides along because an unresolvable dependency means something +// different on each edge: a required one is a broken install, an optional one is +// a package this machine cannot hold. export function getDependencyNames(pkg) { - return [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {})]; + return [ + ...Object.keys(pkg.dependencies ?? {}).map((name) => ({ name, optional: false })), + ...Object.keys(pkg.optionalDependencies ?? {}).map((name) => ({ name, optional: true })), + ]; } diff --git a/website/scripts/dependency-workspaces.test.js b/website/scripts/dependency-workspaces.test.js index be0233366..8ccb35381 100644 --- a/website/scripts/dependency-workspaces.test.js +++ b/website/scripts/dependency-workspaces.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { assertWorkspaceCoverage } from './dependency-workspaces.js'; +import { assertWorkspaceCoverage, getDependencyNames } from './dependency-workspaces.js'; const workspace = (name, fields = {}) => ({ pkg: { name, ...fields } }); @@ -35,3 +35,20 @@ describe('dependency disclosure workspace coverage', () => { .toThrow('Workspace package names must be unique'); }); }); + +describe('dependency edges', () => { + it('flags which edges are optional and drops development ones', () => { + expect(getDependencyNames({ + dependencies: { runtime: '1.0.0' }, + optionalDependencies: { 'native-darwin': '2.0.0' }, + devDependencies: { builder: '3.0.0' }, + })).toEqual([ + { name: 'runtime', optional: false }, + { name: 'native-darwin', optional: true }, + ]); + }); + + it('accepts a manifest declaring neither block', () => { + expect(getDependencyNames({})).toEqual([]); + }); +}); diff --git a/website/scripts/generate-deps.js b/website/scripts/generate-deps.js index 56c114535..557c282da 100644 --- a/website/scripts/generate-deps.js +++ b/website/scripts/generate-deps.js @@ -30,12 +30,6 @@ const productDependencyFilters = [ // Neither package installs an artifact on a user's disk. Any new workspace // requires classification here or a runtime edge from a product root. const excludedWorkspacePackages = ["canopy", "dormouse-website"]; -// A native addon publishes one prebuilt package per platform and pnpm installs -// only the host's, so a walk of real directories can never see the rest — -// while each of them is exactly what reaches a user on that platform. These -// scopes mark that family. See docs/specs/security-supply-chain.md -> -// "Disclosure". -const platformPackageScopes = ["@node-datachannel/"]; function readJson(path) { return JSON.parse(readFileSync(path, "utf-8")); @@ -112,50 +106,55 @@ const workspacePackagesByName = new Map(workspacePackages.map((workspacePackage) workspacePackage.pkg.name, workspacePackage, ])); +const productRoots = new Set(productDependencyFilters); const externalPackages = new Map(); const visitedExternalPackagePaths = new Set(); const visitedWorkspacePackageNames = new Set(); - -const platformScopeOf = (packageName) => - platformPackageScopes.find((scope) => packageName.startsWith(scope)) ?? null; +/** + * Optional dependencies of a product root this machine cannot install, mapped to + * the siblings they may be described from. See + * docs/specs/security-supply-chain.md -> "Disclosure". + */ +const undescribedPackages = new Map(); /** - * The prebuilt packages that actually ship. The Tauri bundle copies - * `standalone/sidecar/node_modules`, and pnpm puts a package there only if that - * manifest declares it — so a root's own optional list is the shipped set. The - * addon's list also names builds this project never releases (android, musl), - * and those reach no bundle. + * Identity of a disclosed package. Two installs of one name that agree on all + * four fields are one row with two versions; a disagreement is two rows. */ -const shippedPlatformPackages = new Set( - productDependencyFilters.flatMap((name) => - Object.keys(workspacePackagesByName.get(name).pkg.optionalDependencies ?? {}).filter( - platformScopeOf, - ), - ), -); -/** Shipped platform packages this machine cannot install; described from a sibling. */ -const absentPlatformPackages = new Set(); +function externalPackageKey({ name, license, author, homepage }) { + return [name, license ?? "", author ?? "", homepage ?? ""].join("\0"); +} function addExternalPackage(pkg) { - const key = [ - pkg.name, - pkg.license ?? "", - formatAuthor(pkg.author) ?? "", - getHomepage(pkg) ?? "", - ].join("\0"); + const identity = { + name: pkg.name, + license: pkg.license ?? null, + author: formatAuthor(pkg.author), + homepage: getHomepage(pkg), + }; + const key = externalPackageKey(identity); const existing = externalPackages.get(key); if (existing) { existing.versions.add(pkg.version); return; } - externalPackages.set(key, { - name: pkg.name, - versions: new Set([pkg.version]), - license: pkg.license ?? null, - author: formatAuthor(pkg.author), - homepage: getHomepage(pkg), - }); + externalPackages.set(key, { ...identity, versions: new Set([pkg.version]) }); +} + +/** + * The names declared beside `packageName` in the same `optionalDependencies` + * block at the same exact version string. A prebuilt family is published in + * lockstep from one repository under one pinned version, so any of these + * describes the absent one exactly — which is also what keeps this disclosure + * identical on every machine that generates it. + */ +function optionalSiblingsAtSameVersion(pkg, packageName) { + const optionalDependencies = pkg.optionalDependencies ?? {}; + const version = optionalDependencies[packageName]; + return Object.keys(optionalDependencies).filter( + (name) => name !== packageName && optionalDependencies[name] === version, + ); } function scanWorkspacePackage(name) { @@ -169,7 +168,7 @@ function scanWorkspacePackage(name) { scanDependencies(workspacePackage.pkg, workspacePackage.dir); } -function scanDependency(fromDir, packageName) { +function scanDependency(fromDir, packageName, declaredBy) { if (workspacePackagesByName.has(packageName)) { scanWorkspacePackage(packageName); return; @@ -177,11 +176,21 @@ function scanDependency(fromDir, packageName) { const packageJsonPath = getPackageJsonPath(fromDir, packageName); if (!packageJsonPath) { - // A prebuilt package for a platform that is not this one: absent by design - // rather than under-reported, so it is described from its installed sibling - // below. Anything else missing is still a hard error. - if (platformScopeOf(packageName)) { - if (shippedPlatformPackages.has(packageName)) absentPlatformPackages.add(packageName); + // Absent by design rather than under-reported, in one of two ways. An + // optional dependency of an external package ships to nobody: the Tauri + // bundle copies `standalone/sidecar/node_modules`, and pnpm puts a package + // there only if that manifest declares it — the addon's own list also names + // builds this project never releases (android, musl). An optional + // dependency a product root declares itself does ship, on the platform that + // can hold it, so it is described from a sibling below. Anything else + // missing is still a hard error. + if (declaredBy.optional) { + if (declaredBy.isProductRoot) { + undescribedPackages.set( + packageName, + optionalSiblingsAtSameVersion(declaredBy.pkg, packageName), + ); + } return; } throw new Error(`Could not resolve package.json for "${packageName}" from ${fromDir}`); @@ -197,8 +206,9 @@ function scanDependency(fromDir, packageName) { } function scanDependencies(pkg, fromDir) { - for (const packageName of getDependencyNames(pkg)) { - scanDependency(fromDir, packageName); + const isProductRoot = productRoots.has(pkg.name); + for (const { name, optional } of getDependencyNames(pkg)) { + scanDependency(fromDir, name, { pkg, optional, isProductRoot }); } } @@ -206,19 +216,21 @@ for (const packageName of productDependencyFilters) { scanWorkspacePackage(packageName); } -// The family is published in lockstep from one repository, so the host's -// package describes its siblings exactly — which is also what keeps this -// disclosure identical on every machine that generates it. -for (const packageName of absentPlatformPackages) { - const scope = platformScopeOf(packageName); - const sibling = [...externalPackages.values()].find((pkg) => pkg.name.startsWith(scope)); +// Snapshotted before the loop writes to `externalPackages`, so nothing is ever +// described from something that was itself described rather than read. +const describedPackagesByName = new Map(); +for (const pkg of externalPackages.values()) { + if (!describedPackagesByName.has(pkg.name)) describedPackagesByName.set(pkg.name, pkg); +} +for (const [packageName, siblings] of undescribedPackages) { + const sibling = siblings.map((name) => describedPackagesByName.get(name)).find(Boolean); if (!sibling) { - throw new Error(`No ${scope}* package is installed, so "${packageName}" cannot be described`); + throw new Error( + `"${packageName}" is not installed and neither is any sibling declared beside it at the same version, so it cannot be described`, + ); } - const key = [packageName, sibling.license ?? "", sibling.author ?? "", sibling.homepage ?? ""].join( - "\0", - ); - externalPackages.set(key, { ...sibling, name: packageName, versions: new Set(sibling.versions) }); + const described = { ...sibling, name: packageName, versions: new Set(sibling.versions) }; + externalPackages.set(externalPackageKey(described), described); } // Within a single "A OR B OR ..." choice, move MIT to the front so the From b686fe3843f25ce27df884c6cebe78bfc8e28bbd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:50:44 -0700 Subject: [PATCH 22/46] refactor(remote): one DirectEndpoint, two ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both endpoints re-implemented the same ~180 lines of cutover policy on top of the shared state: the four channel handlers, the post-await liveness re-check, abandon, the outbound fork, the drain loop. All of it moves to `DirectEndpoint`, one per authorized session, constructed with the four things the two ends actually differ in — who offers, how a signal reaches the relay, how a ciphertext is decrypted, and what ends the session. `DirectPeer.send` answers false instead of throwing, so a refused send on a switched session is burrow loss rather than an error thrown into the relay path's contract. The Burrow's `#sendControl` carries a signal and `#sendDirectSignal` is gone; the Client gets one of its own instead of two inline try/catch blocks. The handler cases move to `direct-endpoint.test.ts` over the fake peer pair; the two endpoint suites keep the cases that prove the wiring. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/burrow/burrow-runtime.ts | 287 +++----------- lib/src/remote/client/pocket-client.test.ts | 133 +------ lib/src/remote/client/pocket-client.ts | 246 ++---------- lib/src/remote/client/test-e2e-harness.ts | 4 - lib/src/remote/direct/direct-endpoint.test.ts | 370 ++++++++++++++++++ lib/src/remote/direct/direct-endpoint.ts | 298 ++++++++++++++ lib/src/remote/direct/direct-peer.test.ts | 47 +-- lib/src/remote/direct/direct-peer.ts | 20 +- lib/src/remote/direct/test-fake-peer.ts | 9 +- lib/src/remote/test-timers.ts | 50 +++ 10 files changed, 841 insertions(+), 623 deletions(-) create mode 100644 lib/src/remote/direct/direct-endpoint.test.ts create mode 100644 lib/src/remote/direct/direct-endpoint.ts create mode 100644 lib/src/remote/test-timers.ts diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index d5d362a83..267185aae 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -13,7 +13,6 @@ import { ESTABLISHED_E2E_IDLE_TIMEOUT_MS, BurrowAcl, ChallengeIssuer, - DirectCutover, MAX_ESTABLISHED_E2E_SESSIONS, MAX_PENDING_PAIRINGS, MAX_TOKENS_PER_BURROW, @@ -33,7 +32,6 @@ import { importNoiseStaticPrivateKey, isBoundedString, isConnectionRequestV1, - isDirectSignalV1, isE2eRelayToBurrowFrame, isPairingRequestV1, MAX_CLIENT_ID_LENGTH, @@ -63,7 +61,8 @@ import { } from 'remote-lib-common'; import type { BurrowEnrollment } from './enrollment'; import { createSerialQueue } from '../../host/remote/serial-queue'; -import { DirectPeer, type DirectPeerFactory } from '../direct/direct-peer'; +import { DirectEndpoint } from '../direct/direct-endpoint'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; import { loadBurrowAcl } from './acl'; import type { PendingPairing } from './pairing-approval'; @@ -209,16 +208,6 @@ interface PendingConnectionSession { readonly expiresAt: number; } -/** - * One session's direct-path attempt: the peer connection and where each - * direction currently sends (`docs/specs/remote-api.md` → Transport → "Direct - * path"). - */ -interface DirectAttempt { - readonly peer: DirectPeer; - readonly cutover: DirectCutover; -} - /** An authorized session: the two cipher states plus the remote-api handler. */ interface EstablishedSession { readonly connectionId: string; @@ -231,10 +220,12 @@ interface EstablishedSession { * on either path: the idle deadline is path-agnostic. */ lastClientActivityAt: number; - /** The direct path this session took, or null while it is purely relayed. */ - direct: DirectAttempt | null; - /** **One attempt per session**: a second `direct-offer` allocates nothing. */ - directAttempted: boolean; + /** + * This session's direct path, as the answerer runs it. Created with the + * session and disposed with it, so a peer connection can neither precede + * authorization nor outlive it. + */ + readonly direct: DirectEndpoint; } /** Per-client lifecycle state tracked by the Burrow, keyed by clientId. */ @@ -1443,9 +1434,11 @@ export class BurrowRuntime { // Cleared with the dispose, not merely overwritten below: without a session // factory there is no replacement, and a leftover reference would route the // next frame on the old id into a handler that has already been disposed. - if (state.established) this.#disposeDirect(state.established); - state.established?.api.dispose(); - state.established = undefined; + if (state.established) { + state.established.direct.dispose(); + state.established.api.dispose(); + state.established = undefined; + } this.#sendControl(clientId, 'connection', pending.connectionId, pending.session, { ok: true, burrowLabel: boundedBurrowLabel(this.#enrollment.label), @@ -1466,17 +1459,29 @@ export class BurrowRuntime { this.#sendApp(clientId, connectionId, session, payload); }, }); - state.established = { + // Declared first so the endpoint's liveness check can name the session it + // belongs to; assigned before any frame can reach it. + let established: EstablishedSession; + const direct = new DirectEndpoint('answerer', { + createPeer: this.#createDirectPeer, + sendSignal: (signal) => this.#sendControl(clientId, 'connection', connectionId, session, signal), + receive: (ciphertext) => this.#receiveOnSession(clientId, established, ciphertext), + fatal: (reason) => { + console.warn(`[burrow] the direct path ended this session: ${reason}`); + this.#disposeEstablished(clientId); + }, + isCurrent: () => this.#clients.get(clientId)?.established === established, + setTimer: this.#setTimer, + }); + established = { connectionId, session, api, clientStaticPublicKey, lastClientActivityAt: this.#now(), - // The Client offers the direct path, and only after this outcome: nothing - // here exists before a session is authorized. - direct: null, - directAttempted: false, + direct, }; + state.established = established; this.#armReaper(); } @@ -1574,7 +1579,7 @@ export class BurrowRuntime { * path"). */ #onEstablishedFrame(clientId: string, established: EstablishedSession, ct: string): void { - if (established.direct?.cutover.onRelayTransport() === 'violation') { + if (!established.direct.onRelayTransport()) { this.#disposeEstablished(clientId); return; } @@ -1604,7 +1609,7 @@ export class BurrowRuntime { // (`docs/specs/remote-security-model.md` → Burrow bounds). established.lastClientActivityAt = this.#now(); if (receipt.kind === 'control') { - this.#onDirectSignal(clientId, established, receipt.value); + established.direct.onSignal(receipt.value); return; } if (receipt.kind !== 'app') return; @@ -1634,9 +1639,13 @@ export class BurrowRuntime { session: NoiseTransportSession, payload: unknown, ): void { + // Resolved once for the whole message: every chunk of it takes the path the + // first one did, and "after the switch, nothing on the relay" is this line. + const direct = this.#directFor(clientId, connectionId); try { for (const ciphertext of session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#deliver(clientId, connectionId, ciphertext); + if (direct?.send(ciphertext)) continue; + this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); } } catch { // **Only a poisoned session is burrow loss.** An over-cap message is @@ -1652,241 +1661,47 @@ export class BurrowRuntime { } /** - * One transport ciphertext on whichever path this Burrow has switched to. - * Every byte of an established session goes through here, so "after the - * switch, nothing on the relay" is one line rather than a rule each caller - * keeps. + * The endpoint carrying one connection, or null once that connection is no + * longer this client's live session. */ - #deliver(clientId: string, connectionId: string, ciphertext: Uint8Array): void { + #directFor(clientId: string, connectionId: string): DirectEndpoint | null { const established = this.#clients.get(clientId)?.established; - const direct = established?.connectionId === connectionId ? established.direct : null; - if (direct && direct.cutover.outbound === 'direct') { - direct.peer.send(ciphertext); - return; - } - this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); + return established?.connectionId === connectionId ? established.direct : null; } #disposeEstablished(clientId: string): void { const state = this.#clients.get(clientId); if (!state?.established) return; - this.#disposeDirect(state.established); + state.established.direct.dispose(); state.established.api.dispose(); state.established = undefined; this.#pruneClient(clientId); } - // --- The direct path ----------------------------------------------------- - - /** - * One decrypted signal on an established session - * (`docs/specs/remote-api.md` → Transport → "Direct path"). An unknown - * control shape says nothing this Burrow can act on and is never a session - * failure — which is what lets a Pocket without this stack simply stay - * relayed. - */ - #onDirectSignal( - clientId: string, - established: EstablishedSession, - value: Record, - ): void { - if (!isDirectSignalV1(value)) return; - switch (value.t) { - case 'direct-offer': - void this.#answerDirect(clientId, established, value.sdp); - return; - case 'direct-switch': { - const direct = established.direct; - if (!direct) { - // **A switch onto a channel this Burrow has abandoned is the end of - // the session.** Nothing the Client sends can arrive any more, and a - // session held to its idle deadline on that is one the phone is - // waiting out for two minutes. - if (established.directAttempted) this.#disposeEstablished(clientId); - return; - } - const held = direct.cutover.onSwitchDecrypted(); - // In arrival order, through the same decrypt path the relay's frames - // take: what was held is exactly what was sent after the switch. - for (const frame of held) { - if (this.#clients.get(clientId)?.established !== established) return; - this.#receiveOnSession(clientId, established, frame); - } - return; - } - default: - // `direct-answer` and `direct-decline` are this Burrow's own to send. - return; - } - } - - /** - * Answer one offer, or decline it. **At most one attempt per session**, so a - * second offer allocates nothing whatever the first did, and a Burrow with no - * peer factory — or one whose answer would not fit a signal — declines rather - * than leaving the Client waiting on the setup deadline. - */ - async #answerDirect( - clientId: string, - established: EstablishedSession, - offerSdp: string, - ): Promise { - if (established.directAttempted) return; - established.directAttempted = true; - const epoch = this.#epoch; - let connection; - try { - connection = this.#createDirectPeer?.() ?? null; - } catch (error) { - console.warn('[burrow] could not build a direct peer', error); - connection = null; - } - if (!connection) { - this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-decline' }); - return; - } - const peer = new DirectPeer({ - peer: connection, - setTimer: this.#setTimer, - handlers: { - onOpen: () => this.#onDirectOpen(clientId, established), - onFrame: (frame) => this.#onDirectFrame(clientId, established, frame), - onClosed: () => this.#onDirectClosed(clientId, established), - // A peer speaking something else on the channel is not one this - // session's counters can stay synchronized with, switched or not. - onViolation: (reason) => { - console.warn(`[burrow] direct channel violation: ${reason}`); - this.#disposeEstablished(clientId); - }, - }, - }); - established.direct = { peer, cutover: new DirectCutover() }; - const sdp = await peer.answer(offerSdp); - // A teardown, a replacement promotion, or a channel that already failed - // while the description was being built: this peer is no longer the one. - if ( - this.#epoch !== epoch || - this.#clients.get(clientId)?.established !== established || - established.direct?.peer !== peer - ) { - peer.close(); - return; - } - if (sdp === null) { - this.#abandonDirect(established, peer); - this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-decline' }); - return; - } - this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-answer', sdp }); - } - - /** - * The channel is open. **The `direct-switch` is this end's last message on - * the relay** — everything after it goes on the channel, which is what keeps - * order per direction. - */ - #onDirectOpen(clientId: string, established: EstablishedSession): void { - const direct = this.#directFor(clientId, established); - if (!direct) return; - this.#sendDirectSignal(clientId, established, { v: 1, t: 'direct-switch' }); - direct.cutover.switchOutbound(); - } - - /** One frame off the channel: processed, held until the peer's switch, or fatal. */ - #onDirectFrame(clientId: string, established: EstablishedSession, frame: Uint8Array): void { - const direct = this.#directFor(clientId, established); - if (!direct) return; - switch (direct.cutover.onChannelFrame(frame)) { - case 'process': - this.#receiveOnSession(clientId, established, frame); - return; - case 'held': - return; - case 'overflow': - console.warn('[burrow] a direct path outran what can be held in order'); - this.#disposeEstablished(clientId); - return; - } - } - - /** - * The channel went away. **Before either direction has switched that is - * merely an abandoned attempt**, and the session carries on relayed; - * afterwards the session is over, because what was riding the channel is gone - * and a stream cipher has no resynchronization point. - */ - #onDirectClosed(clientId: string, established: EstablishedSession): void { - const direct = this.#directFor(clientId, established); - if (!direct) return; - if (!direct.cutover.switched) { - this.#abandonDirect(established, direct.peer); - return; - } - this.#disposeEstablished(clientId); - } - - /** This session's attempt, or null once it has been replaced or disposed. */ - #directFor(clientId: string, established: EstablishedSession): DirectAttempt | null { - return this.#clients.get(clientId)?.established === established ? established.direct : null; - } - - /** Give up on the channel, leaving the session exactly as relayed as it was. */ - #abandonDirect(established: EstablishedSession, peer: DirectPeer): void { - peer.close(); - if (established.direct?.peer !== peer) return; - established.direct.cutover.clear(); - established.direct = null; - } - - /** - * The peer connection is this session's: every disposal path closes it, so - * none can outlive the session that authorized it. - */ - #disposeDirect(established: EstablishedSession): void { - established.direct?.peer.close(); - established.direct?.cutover.clear(); - established.direct = null; - } - - /** - * One signal, on the relay — the path that carries them until the switch. A - * poisoned session has nothing to say; whatever poisoned it disposes it. - */ - #sendDirectSignal( - clientId: string, - established: EstablishedSession, - signal: DirectSignalV1, - ): void { - let ciphertext: Uint8Array; - try { - ciphertext = established.session.sendControl({ ...signal }); - } catch { - return; - } - this.#sendE2e(clientId, 'connection', established.connectionId, 'transport', ciphertext); - } - // --- Shared plumbing ----------------------------------------------------- /** - * One control message on a ceremony session; the transport pads every one to - * the same size (`docs/specs/relay.md` → E2E framing). + * One control message on a ceremony or established session — an outcome, or + * one of the direct path's signals, which ride the relay until the switch. + * The transport pads every one to the same size (`docs/specs/relay.md` → E2E + * framing). Answers `false` for a poisoned session, which has nothing to say. */ #sendControl( clientId: string, kind: 'pairing' | 'connection', id: string, session: NoiseTransportSession, - value: PairingOutcomeV1 | ConnectionOutcomeV1, - ): void { + value: PairingOutcomeV1 | ConnectionOutcomeV1 | DirectSignalV1, + ): boolean { let ciphertext: Uint8Array; try { ciphertext = session.sendControl({ ...value }); } catch { - // A poisoned session has nothing to say; the caller disposes it anyway. - return; + // Whatever poisoned it disposes it; the caller need not. + return false; } this.#sendE2e(clientId, kind, id, 'transport', ciphertext); + return true; } /** Forget a client that holds nothing, so a relay-chosen key cannot accumulate. */ diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index ee6a3d460..05276d38d 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -14,11 +14,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { CONTROL_PAYLOAD_SIZE, DEFAULT_PAIRING_TTL_MS, - DIRECT_SETUP_TIMEOUT_MS, E2E_KEEPALIVE_INTERVAL_MS, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, KEEPALIVE_BODY_SIZE, - MAX_DIRECT_PENDING_FRAMES, SELFHOST_ACCOUNT_ID, SETUP_TOKEN_INVALID_ERROR, formatPairingInvitationUrl, @@ -48,6 +46,7 @@ import { } from './pocket-client'; import type { KnownBurrowV1 } from './pocket-db'; import { FakeSocket } from '../test-fake-socket'; +import { fakeTimers } from '../test-timers'; import { FakeDirectNetwork, type FakeDirectNetworkOptions, @@ -574,37 +573,6 @@ describe('connecting, end to end', () => { // --- Keepalives ------------------------------------------------------------- -/** One armed timer at a time, fired by hand — no test waits thirty seconds. */ -function fakeTimers() { - const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; - return { - setTimer(run: () => void, delayMs: number): () => void { - const timer = { run, delayMs, cancelled: false }; - armed.push(timer); - return () => { - timer.cancelled = true; - }; - }, - get live() { - return armed.filter((timer) => !timer.cancelled); - }, - /** Fire the armed timer, as its delay elapsing would. */ - fire(): void { - const timer = this.live.at(-1); - if (!timer) throw new Error('no keepalive timer is armed'); - timer.cancelled = true; - timer.run(); - }, - /** Fire the one armed for `delayMs`, where more than one deadline is live. */ - fireAt(delayMs: number): void { - const timer = this.live.find((entry) => entry.delayMs === delayMs); - if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); - timer.cancelled = true; - timer.run(); - }, - }; -} - /** `document.visibilityState`, as a seam a test can flip. */ function fakeVisibility() { let visible = true; @@ -777,7 +745,6 @@ describe('the direct path, end to end', () => { ) { const network = new FakeDirectNetwork(options.network); const timers = fakeTimers(); - const burrowTimers = fakeTimers(); const clientPeers: FakePeer[] = []; const burrowPeers: FakePeer[] = []; const harness = await makeE2eHarness({ @@ -802,7 +769,6 @@ describe('the direct path, end to end', () => { return peer; }, }), - burrowSetTimer: burrowTimers.setTimer, }); await harness.pairAndApprove(await harness.mintInvitation()); expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); @@ -810,7 +776,6 @@ describe('the direct path, end to end', () => { harness, network, timers, - burrowTimers, clientPeers, burrowPeers, /** This session's routing id, read off the envelope the Client addressed. */ @@ -905,34 +870,6 @@ describe('the direct path, end to end', () => { expect(run.clientFrames().length).toBeGreaterThan(before); }); - it('never offers from a runtime with no peer connection', async () => { - const run = await connectedDirect({ clientHasPeer: false }); - await settleTicks(); - - // One transport frame each way: the connection request and its outcome. - expect(run.clientFrames()).toHaveLength(1); - expect(run.burrowFrames().filter((frame) => frame.step === 'transport')).toHaveLength(1); - expect(run.burrowPeers).toEqual([]); - expect(run.harness.client.transportPath).toBe('relay'); - expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); - }); - - it('leaves the session relayed when the channel never opens', async () => { - const run = await connectedDirect({ network: { opening: 'never' } }); - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to answer', - ); - - run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); - - expect(run.harness.client.transportPath).toBe('relay'); - expect(run.clientPeers[0]!.closed).toBe(true); - // Never switched, so this is an abandoned attempt rather than burrow loss. - expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); - expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); - }); - it('ends both ends when the channel dies after the switch', async () => { const run = await connectedDirect(); await run.cutover(); @@ -1021,74 +958,6 @@ describe('the direct path, end to end', () => { expect(run.harness.client.transportPath).toBe('direct'); }); - it('ends the session when held frames outrun the queue', async () => { - const run = await connectedDirect({ network: { opening: 'manual' } }); - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to answer', - ); - await settleTicks(); - const gone = vi.fn(); - run.harness.client.setOnBurrowGone(gone); - - run.harness.relay.holdToClient(); - run.network.openChannels(); - - // One answer per request, all of them held: the cap is what stops a peer - // that never sends the switch from growing this without bound. - const pending = []; - for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) { - pending.push(run.harness.client.request('hello', {})); - } - await Promise.allSettled(pending); - - expect(gone).toHaveBeenCalledOnce(); - expect(run.harness.client.connectedBurrowId).toBeNull(); - }); - - - /** - * The one failure a phone cannot recover from on its own: the peer that - * abandoned is deaf, and the other end has already stopped using the relay. - */ - it('ends the session when the Burrow switches onto a channel the phone abandoned', async () => { - const run = await connectedDirect({ network: { opening: 'manual' } }); - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to answer', - ); - await settleTicks(); - const gone = vi.fn(); - run.harness.client.setOnBurrowGone(gone); - - run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); - expect(run.clientPeers[0]!.closed).toBe(true); - // And only now does the Burrow's channel come up, so its switch lands on a - // phone that has already closed its end. - run.network.openChannels(); - - expect(gone).toHaveBeenCalledOnce(); - expect(run.harness.client.connectedBurrowId).toBeNull(); - }); - - it('ends the session when the phone switches onto a channel the Burrow abandoned', async () => { - const run = await connectedDirect({ network: { opening: 'manual' } }); - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to answer', - ); - await settleTicks(); - - run.burrowTimers.fireAt(DIRECT_SETUP_TIMEOUT_MS); - expect(run.burrowPeers[0]!.closed).toBe(true); - run.network.openChannels(); - - await waitFor( - () => run.harness.burrow.establishedSessionCount === 0, - 'the Burrow to drop the session', - ); - }); - it('closes the Burrow’s peer with the client the Relay says is gone', async () => { const run = await connectedDirect(); await run.cutover(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 8f5f97e37..38d16ec84 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -13,7 +13,6 @@ import { API_ROUTES, DEFAULT_CHALLENGE_TTL_MS, DEFAULT_PAIRING_TTL_MS, - DirectCutover, E2E_ID_BYTE_LENGTH, E2E_KEEPALIVE_INTERVAL_MS, ESTABLISHED_E2E_IDLE_TIMEOUT_MS, @@ -32,7 +31,6 @@ import { generateNoiseKeyPair, hashPasskeyPublicKey, isConnectionOutcomeV1, - isDirectSignalV1, isE2eRelayToClientFrame, isNoisePublicKey, isPairingOutcomeV1, @@ -89,7 +87,8 @@ import { type KnownBurrowV1, type PendingDeletionStore, } from './pocket-db'; -import { DirectPeer, type DirectPeerFactory } from '../direct/direct-peer'; +import { DirectEndpoint } from '../direct/direct-endpoint'; +import type { DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; /** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ @@ -342,16 +341,6 @@ interface EstablishedSession { lastSentAt: number; } -/** - * One session's direct-path attempt: the peer connection and where each - * direction currently sends (`docs/specs/remote-api.md` → Transport → "Direct - * path"). At most one per session, created at the offer and closed with it. - */ -interface DirectAttempt { - readonly peer: DirectPeer; - readonly cutover: DirectCutover; -} - export class PocketClient { readonly #baseUrl: string; readonly #wsBase: string; @@ -373,13 +362,9 @@ export class PocketClient { #established: EstablishedSession | null = null; #connectedBurrowId: string | null = null; #onBurrowGone: (() => void) | null = null; - /** This session's direct-path attempt, or null while it is purely relayed. */ - #direct: DirectAttempt | null = null; - /** Whether this session has offered one, however that attempt ended. */ - #directAttempted = false; + /** This session's direct path, or null while there is no authorized session. */ + #direct: DirectEndpoint | null = null; #onTransportPath: ((path: DirectPath) => void) | null = null; - /** The last path announced, so an unchanged one is not announced twice. */ - #announcedPath: DirectPath = 'relay'; /** Cancels the armed keepalive, and the visibility subscription behind it. */ #cancelKeepalive: (() => void) | null = null; #cancelVisibility: (() => void) | null = null; @@ -426,7 +411,7 @@ export class PocketClient { * have left the relay — before that the relay is still carrying half of it. */ get transportPath(): DirectPath { - return this.#direct?.cutover.path ?? 'relay'; + return this.#direct?.path ?? 'relay'; } /** Notified whenever {@link transportPath} changes. */ @@ -919,7 +904,8 @@ export class PocketClient { this.#startKeepalives(); // After the outcome and never before: a peer connection that existed // ahead of authorization would be one an unauthorized party had steered. - void this.#offerDirect(this.#established, burrowId); + this.#direct = this.#directEndpoint(this.#established, burrowId); + void this.#direct.offer(); return { ok: true, burrowLabel: outcome.burrowLabel }; } if (outcome.code === 'pairing-required') { @@ -1167,184 +1153,47 @@ export class PocketClient { // --- The direct path ----------------------------------------------------- /** - * Offer a direct path on a session that has just been authorized: **once per - * session, never retried** (`docs/specs/remote-api.md` → Transport → "Direct - * path"). The Client is always the offerer, and the whole description travels - * inside the session — the Relay never sees an SDP, a candidate, or that a - * direct path exists. - * - * Every failure here is silent and terminal for the attempt alone: a runtime - * with no factory, a description too large to fit one control message, a - * negotiation that threw. The session keeps running on the relay. + * This session's direct path, as the offerer runs it + * (`docs/specs/remote-api.md` → Transport → "Direct path"). Every rule about + * the attempt, the channel, and the cutover is + * {@link DirectEndpoint}'s; what is injected here is how this Client puts a + * signal on the relay, decrypts a channel frame, and reports the session gone. */ - async #offerDirect(established: EstablishedSession, burrowId: string): Promise { - const factory = this.#createDirectPeer; - if (!factory) return; - let connection; - try { - connection = factory(); - } catch { - return; - } - if (!connection) return; - const peer = new DirectPeer({ - peer: connection, + #directEndpoint(established: EstablishedSession, burrowId: string): DirectEndpoint { + return new DirectEndpoint('offerer', { + createPeer: this.#createDirectPeer, + sendSignal: (signal) => this.#sendDirectSignal(established, burrowId, signal), + receive: (ciphertext) => this.#receiveOnSession(established, ciphertext), + // Reported exactly as a `burrow-gone` frame is: from here they are the + // same event, and the app must leave the wall either way. + fatal: (reason) => this.#loseBurrow(reason), + isCurrent: () => this.#established === established, + onPathChanged: (path) => this.#onTransportPath?.(path), setTimer: this.#setTimer, - handlers: { - onOpen: () => this.#onDirectOpen(established, burrowId), - onFrame: (frame) => this.#onDirectFrame(established, frame), - onClosed: (reason) => this.#onDirectClosed(established, reason), - // A peer speaking something else on the channel is not one this - // session's counters can stay synchronized with, switched or not. - onViolation: (reason) => this.#loseBurrow(reason), - }, }); - // Never two: a promotion that replaced a session leaves this holding the - // old one's peer, which nothing else would close. - this.#direct?.peer.close(); - this.#direct = { peer, cutover: new DirectCutover() }; - const sdp = await peer.offer(); - // The session may have been replaced or disposed while the description was - // being built; a peer left over from one is not this one's. - if (this.#established !== established || this.#direct?.peer !== peer) { - peer.close(); - return; - } - if (sdp === null) { - this.#abandonDirect(peer); - return; - } - const signal: DirectSignalV1 = { v: 1, t: 'direct-offer', sdp }; - try { - this.#sendE2e( - this.#route(established, burrowId), - 'transport', - established.session.sendControl({ ...signal }), - ); - this.#directAttempted = true; - } catch { - this.#abandonDirect(peer); - } } /** - * The channel is open. **The `direct-switch` is this end's last message on - * the relay** — everything after it goes on the channel, which is what keeps - * order per direction. + * One signal on the relay — the path that carries them until the switch. A + * poisoned session has nothing to say; whatever poisoned it ends it. */ - #onDirectOpen(established: EstablishedSession, burrowId: string): void { - const direct = this.#directFor(established); - if (!direct) return; - const signal: DirectSignalV1 = { v: 1, t: 'direct-switch' }; + #sendDirectSignal( + established: EstablishedSession, + burrowId: string, + signal: DirectSignalV1, + ): boolean { try { this.#sendE2e( this.#route(established, burrowId), 'transport', established.session.sendControl({ ...signal }), ); + return true; } catch { - this.#abandonDirect(direct.peer); - return; - } - direct.cutover.switchOutbound(); - this.#announcePath(); - } - - /** One frame off the channel: processed, held until the peer's switch, or fatal. */ - #onDirectFrame(established: EstablishedSession, frame: Uint8Array): void { - const direct = this.#directFor(established); - if (!direct) return; - switch (direct.cutover.onChannelFrame(frame)) { - case 'process': - this.#receiveOnSession(established, frame); - return; - case 'held': - return; - case 'overflow': - this.#loseBurrow('the direct path outran what this phone can hold in order'); - return; - } - } - - /** - * The channel went away. **Before either direction has switched that is - * merely an abandoned attempt**; afterwards it is burrow loss, reported - * exactly as a `burrow-gone` frame is, because the messages that were riding - * it are gone and a stream cipher has no resynchronization point. - */ - #onDirectClosed(established: EstablishedSession, reason: string): void { - const direct = this.#directFor(established); - if (!direct) return; - if (!direct.cutover.switched) { - this.#abandonDirect(direct.peer); - return; - } - this.#loseBurrow(reason); - } - - /** One decrypted signal; anything else on the control channel is ignored. */ - #onDirectSignal(established: EstablishedSession, value: Record): void { - // An unknown control shape on an established session says nothing this can - // act on and is never a session failure — which is what lets a Burrow - // without this stack simply stay relayed. - if (!isDirectSignalV1(value)) return; - const direct = this.#directFor(established); - if (!direct) { - // **A switch onto a channel this phone has abandoned is burrow loss.** - // Nothing the Burrow sends can arrive any more, and a silent session - // whose every request hangs forever is the one failure the app cannot - // recover from on its own. - if (value.t === 'direct-switch' && this.#directAttempted) { - this.#loseBurrow('the computer moved to a direct path this phone had closed'); - } - return; - } - switch (value.t) { - case 'direct-answer': - void direct.peer.acceptAnswer(value.sdp); - return; - case 'direct-decline': - this.#abandonDirect(direct.peer); - return; - case 'direct-switch': { - const held = direct.cutover.onSwitchDecrypted(); - this.#announcePath(); - // In arrival order, through the same decrypt path the relay's frames - // take: what was held is exactly what was sent after the switch. - for (const frame of held) { - if (this.#established !== established) return; - this.#receiveOnSession(established, frame); - } - return; - } - default: - // `direct-offer` is the Client's own to send; a Burrow offering one is - // ignored rather than answered. - return; + return false; } } - /** This session's attempt, or null once it has been replaced or disposed. */ - #directFor(established: EstablishedSession): DirectAttempt | null { - return this.#established === established ? this.#direct : null; - } - - /** Give up on the channel, leaving the session exactly as relayed as it was. */ - #abandonDirect(peer: DirectPeer): void { - peer.close(); - if (this.#direct?.peer !== peer) return; - this.#direct.cutover.clear(); - this.#direct = null; - this.#announcePath(); - } - - #announcePath(): void { - const path = this.transportPath; - if (path === this.#announcedPath) return; - this.#announcedPath = path; - this.#onTransportPath?.(path); - } - /** * The end-to-end session is over while the relay socket is not: dispose it, * fail everything in flight, and tell the app — the same three steps a @@ -1376,7 +1225,7 @@ export class PocketClient { try { // Path-agnostic: a keepalive off the channel refreshes the Burrow's idle // deadline exactly as one off the relay does. - this.#deliver(this.#route(established, burrowId), established.session.sendKeepalive()); + this.#deliver(established, burrowId, established.session.sendKeepalive()); established.lastSentAt = this.#now(); } catch { // A closed socket or a poisoned session; both have their own teardown, @@ -1404,9 +1253,7 @@ export class PocketClient { */ #reapedByBurrow(established: EstablishedSession): boolean { if (this.#now() - established.lastSentAt < ESTABLISHED_E2E_IDLE_TIMEOUT_MS) return false; - this.#disposeCeremony(); - this.#rejectAll(new Error(BURROW_SESSION_REAPED_MESSAGE)); - this.#onBurrowGone?.(); + this.#loseBurrow(BURROW_SESSION_REAPED_MESSAGE); return true; } @@ -1466,9 +1313,8 @@ export class PocketClient { const burrowId = this.#connectedBurrowId; if (burrowId === null) throw new Error('not connected to a burrow'); if (this.#reapedByBurrow(established)) throw new Error(BURROW_SESSION_REAPED_MESSAGE); - const route = this.#route(established, burrowId); for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#deliver(route, ciphertext); + this.#deliver(established, burrowId, ciphertext); } established.lastSentAt = this.#now(); } @@ -1478,13 +1324,9 @@ export class PocketClient { * byte of an established session goes through here, so "after the switch, * nothing on the relay" is one line rather than a rule each caller keeps. */ - #deliver(route: E2eRoute, ciphertext: Uint8Array): void { - const direct = this.#direct; - if (direct && direct.cutover.outbound === 'direct') { - direct.peer.send(ciphertext); - return; - } - this.#sendE2e(route, 'transport', ciphertext); + #deliver(established: EstablishedSession, burrowId: string, ciphertext: Uint8Array): void { + if (this.#direct?.send(ciphertext)) return; + this.#sendE2e(this.#route(established, burrowId), 'transport', ciphertext); } #send(frame: E2eClientFrame): void { @@ -1545,9 +1387,7 @@ export class PocketClient { if (isE2eRelayToClientFrame(frame)) this.#onE2e(frame); return; case 'burrow-gone': - this.#disposeCeremony(); - this.#rejectAll(new Error('burrow disconnected')); - this.#onBurrowGone?.(); + this.#loseBurrow('burrow disconnected'); return; case 'error': // Fixed copy, for the reason the denial tables are: the text is the @@ -1591,8 +1431,7 @@ export class PocketClient { * path"). */ #onEstablishedFrame(established: EstablishedSession, ct: string): void { - const direct = this.#directFor(established); - if (direct && direct.cutover.onRelayTransport() === 'violation') { + if (this.#direct && !this.#direct.onRelayTransport()) { this.#loseBurrow('a relay frame arrived after the direct switch'); return; } @@ -1616,7 +1455,7 @@ export class PocketClient { // A keepalive is accepted and ignored; the only control messages on an // established session are the direct path's signals. if (receipt.kind === 'control') { - this.#onDirectSignal(established, receipt.value); + this.#direct?.onSignal(receipt.value); return; } if (receipt.kind !== 'app') return; @@ -1677,13 +1516,10 @@ export class PocketClient { this.#stopKeepalives(); // The peer connection is this session's: every disposal path closes it, so // none can outlive the session that authorized it. - this.#direct?.peer.close(); - this.#direct?.cutover.clear(); + this.#direct?.dispose(); this.#direct = null; - this.#directAttempted = false; this.#connectedBurrowId = null; this.#established = null; - this.#announcePath(); } /** Fail every awaited ceremony frame and in-flight request (avoids hangs). */ diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index a17c9fa73..abb67c6ba 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -50,7 +50,6 @@ import type { DirectPeerLike } from '../direct/direct-peer'; import { FakeSocket } from '../test-fake-socket'; import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; import { createTestRelay, type TestRelay } from '../test-relay'; -import type { RemoteTimer } from '../ws'; // --- Fakes ------------------------------------------------------------------ @@ -250,8 +249,6 @@ export async function makeE2eHarness( deps?: Partial; /** How this Burrow builds a peer for the direct path; absent, it declines. */ burrowDirect?: () => DirectPeerLike | null; - /** The Burrow's timers, where a case has to fire one by hand. */ - burrowSetTimer?: RemoteTimer; } = {}, ): Promise { const burrowId = options.burrowId ?? randomBase64Url(16); @@ -279,7 +276,6 @@ export async function makeE2eHarness( reconnect: false, createWebSocket: () => burrowSocket, ...(options.burrowDirect ? { createDirectPeer: options.burrowDirect } : {}), - ...(options.burrowSetTimer ? { setTimer: options.burrowSetTimer } : {}), loadAcl: options.loadAcl ?? (() => []), saveAcl: (_burrowId, records) => { savedAcl = [...records]; diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts new file mode 100644 index 000000000..f9b4b5901 --- /dev/null +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -0,0 +1,370 @@ +/** + * The cutover policy both ends run (`docs/specs/remote-api.md` -> Transport -> + * "Direct path"), driven as two endpoints over the linked fake pair. + * + * This is where the rules live now: what an attempt may do, what each signal + * means to each role, and what a channel event costs the session. The suites in + * `../client/pocket-client.test.ts` and `../burrow/burrow-runtime.test.ts` keep + * only the cases that prove the wiring — that the signals really are control + * messages on a real session, and that a loss really disposes what the runtime + * holds. + * + * Signals ride a stand-in for the relay here: they are plain objects, delivered + * to the far endpoint, with one direction holdable so a case can reproduce the + * race the holding queue exists for. + */ + +import { describe, expect, it } from 'vitest'; +import { + DIRECT_SETUP_TIMEOUT_MS, + MAX_DIRECT_PENDING_FRAMES, + type DirectPath, + type DirectSignalV1, +} from 'remote-lib-common'; + +import { DirectEndpoint } from './direct-endpoint'; +import { FakeDirectNetwork, type FakeDirectNetworkOptions, type FakePeer } from './test-fake-peer'; +import { fakeTimers } from '../test-timers'; + +interface Side { + endpoint: DirectEndpoint; + /** Every peer connection this side's factory built. */ + readonly peers: FakePeer[]; + /** Every ciphertext this side decrypted, in the order it did. */ + readonly received: Uint8Array[]; + /** Every reason this side's session was declared unrecoverable. */ + readonly fatals: string[]; + /** Every path change announced. */ + readonly paths: DirectPath[]; + /** Every signal this side put on the relay. */ + readonly sent: DirectSignalV1[]; + /** What `isCurrent()` answers; a promotion or teardown flips it. */ + live: boolean; + /** Whether the session can still encrypt a signal. */ + sendable: boolean; + /** Hold this side's outbound signals instead of delivering them. */ + hold(): void; + /** Deliver everything held, in order. */ + release(): void; +} + +interface Options extends FakeDirectNetworkOptions { + /** Give the offerer no peer factory, as a browser without WebRTC has. */ + offererHasPeer?: boolean; + /** Give the answerer none, as the VS Code host has. */ + answererHasPeer?: boolean; +} + +/** Both endpoints of one session, linked by a relay a case can hold. */ +function pair(options: Options = {}) { + const { offererHasPeer = true, answererHasPeer = true, ...network } = options; + const fake = new FakeDirectNetwork(network); + const timers = fakeTimers(); + const sides = new Map<'offerer' | 'answerer', Side>(); + + const build = (role: 'offerer' | 'answerer', hasPeer: boolean): Side => { + const peers: FakePeer[] = []; + const queued: DirectSignalV1[] = []; + const side: Side = { + peers, + received: [], + fatals: [], + paths: [], + sent: [], + live: true, + sendable: true, + hold: () => void (holding = true), + release: () => { + holding = false; + for (const signal of queued.splice(0)) deliver(signal); + }, + endpoint: undefined as unknown as DirectEndpoint, + }; + let holding = false; + const deliver = (signal: DirectSignalV1): void => { + sides.get(role === 'offerer' ? 'answerer' : 'offerer')?.endpoint.onSignal({ ...signal }); + }; + side.endpoint = new DirectEndpoint(role, { + createPeer: hasPeer + ? () => { + const peer = role === 'offerer' ? fake.createOfferer() : fake.createAnswerer(); + peers.push(peer); + return peer; + } + : null, + sendSignal: (signal) => { + if (!side.sendable) return false; + side.sent.push(signal); + if (holding) queued.push(signal); + else deliver(signal); + return true; + }, + receive: (ciphertext) => void side.received.push(ciphertext), + fatal: (reason) => void side.fatals.push(reason), + isCurrent: () => side.live, + onPathChanged: (path) => void side.paths.push(path), + setTimer: timers.setTimer, + }); + sides.set(role, side); + return side; + }; + + const offerer = build('offerer', offererHasPeer); + const answerer = build('answerer', answererHasPeer); + return { fake, timers, offerer, answerer }; +} + +/** Let the fake network's queued microtasks run. */ +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** Offer, answer, and let the channel open at both ends. */ +async function cutover(run: ReturnType): Promise { + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + await flushMicrotasks(); +} + +const frame = (n: number, size = 4) => new Uint8Array(size).fill(n); + +describe('DirectEndpoint', () => { + it('offers, answers, and switches both directions onto the channel', async () => { + const run = pair(); + + await cutover(run); + + expect(run.offerer.sent.map((s) => s.t)).toEqual(['direct-offer', 'direct-switch']); + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-answer', 'direct-switch']); + expect(run.offerer.endpoint.path).toBe('direct'); + expect(run.answerer.endpoint.path).toBe('direct'); + // Announced once, and only once both directions had left the relay. + expect(run.offerer.paths).toEqual(['direct']); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + }); + + it('spends the session’s one attempt, whichever end asks twice', async () => { + const run = pair(); + + await cutover(run); + const offer = run.offerer.sent[0]!; + if (offer.t !== 'direct-offer') throw new Error(`expected an offer, got ${offer.t}`); + await run.offerer.endpoint.offer(); + run.answerer.endpoint.onSignal({ ...offer }); + + // A second offer allocates nothing and is not answered — not even with a + // decline — at either end. + expect(run.offerer.peers).toHaveLength(1); + expect(run.answerer.peers).toHaveLength(1); + expect(run.offerer.sent.map((s) => s.t)).toEqual(['direct-offer', 'direct-switch']); + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-answer', 'direct-switch']); + }); + + it('declines an offer it has no way to answer, and stays relayed', async () => { + const run = pair({ answererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + // The decline abandons the offerer's attempt: its peer is closed and the + // session is exactly as relayed as it was. + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.fatals).toEqual([]); + }); + + it('never offers from a runtime with no peer connection', async () => { + const run = pair({ offererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.sent).toEqual([]); + expect(run.answerer.peers).toEqual([]); + // And the attempt is spent: a signal arriving later cannot start one. + expect(run.offerer.endpoint.path).toBe('relay'); + }); + + it('skips a description too large to travel inside the session', async () => { + const offering = pair({ oversize: 'offer' }); + await offering.offerer.endpoint.offer(); + expect(offering.offerer.sent).toEqual([]); + + const answering = pair({ oversize: 'answer' }); + await answering.offerer.endpoint.offer(); + await flushMicrotasks(); + expect(answering.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + }); + + it('abandons the attempt when the session cannot carry a signal', async () => { + const run = pair(); + run.offerer.sendable = false; + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.answerer.peers).toEqual([]); + expect(run.offerer.fatals).toEqual([]); + }); + + it('closes a peer whose session was replaced while it described itself', async () => { + const run = pair(); + // Flipped while `offer()` is awaiting its description, which is exactly what + // a replacement promotion or a teardown does to the session under it. + const offering = run.offerer.endpoint.offer(); + run.offerer.live = false; + await offering; + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.sent).toEqual([]); + }); + + it('ignores every signal that is the other role’s to send, and every unknown shape', async () => { + const run = pair(); + await cutover(run); + const offererSent = run.offerer.sent.length; + + // The compatibility rule the whole staging rests on: a control shape this + // peer does not know leaves the session up. + run.offerer.endpoint.onSignal({ v: 2, t: 'direct-decline' }); + run.offerer.endpoint.onSignal({ t: 'something-else' }); + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-offer', sdp: 'v=0\r\n' }); + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-answer', sdp: 'v=0\r\n' }); + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-decline' }); + + expect(run.offerer.sent).toHaveLength(offererSent); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + }); + + it('stays relayed when the channel never opens', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + // Never switched, so this is an abandoned attempt rather than burrow loss. + expect(run.offerer.fatals).toEqual([]); + expect(run.offerer.endpoint.send(frame(1))).toBe(false); + }); + + it('ends the session when the channel dies after the switch', async () => { + const run = pair(); + await cutover(run); + + run.fake.dropChannels(); + + expect(run.offerer.fatals).toEqual(['the direct channel closed']); + expect(run.answerer.fatals).toEqual(['the direct channel closed']); + }); + + it('ends the session on a channel message this protocol has no reading for', async () => { + const run = pair(); + await cutover(run); + + run.fake.offererChannel!.receiveRaw('a text frame'); + + expect(run.offerer.fatals).toEqual(['a direct channel message was not binary']); + }); + + it('holds channel frames until the peer’s switch, then drains them in order', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // The race the queue exists for: the answerer's frames overtake the + // `direct-switch` that precedes them on the relay. + run.answerer.hold(); + run.fake.openChannels(); + expect(run.offerer.endpoint.path).toBe('relay'); + + run.answerer.endpoint.send(frame(1)); + run.answerer.endpoint.send(frame(2)); + await flushMicrotasks(); + expect(run.offerer.received).toEqual([]); + + run.answerer.release(); + + expect(run.offerer.received).toEqual([frame(1), frame(2)]); + expect(run.offerer.endpoint.path).toBe('direct'); + }); + + it('ends the session when held frames outrun the queue', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + run.answerer.hold(); + run.fake.openChannels(); + + for (let i = 0; i <= MAX_DIRECT_PENDING_FRAMES; i += 1) run.answerer.endpoint.send(frame(i)); + await flushMicrotasks(); + + expect(run.offerer.fatals).toEqual(['the direct path outran what can be held in order']); + expect(run.offerer.received).toEqual([]); + }); + + it('ends the session when the peer switches onto a channel this end abandoned', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + run.timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); + expect(run.offerer.fatals).toEqual([]); + // And only now does the peer's channel come up, so its switch lands on an + // end that has already closed its own. + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + + expect(run.offerer.fatals).toEqual([ + 'the peer moved to a direct path this end had abandoned', + ]); + }); + + it('refuses a relay transport frame once the peer has switched', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // Before the peer's switch the relay is still the path it sends on. + expect(run.offerer.endpoint.onRelayTransport()).toBe(true); + run.fake.openChannels(); + + expect(run.offerer.endpoint.onRelayTransport()).toBe(false); + }); + + it('routes a ciphertext onto the channel only after this end has switched', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + // Nothing on the channel yet: the caller puts it on the relay. + expect(run.offerer.endpoint.send(frame(1))).toBe(false); + + run.fake.openChannels(); + expect(run.offerer.endpoint.send(frame(2))).toBe(true); + await flushMicrotasks(); + expect(run.answerer.received).toEqual([frame(2)]); + }); + + it('ends the session when a switched channel refuses a send', async () => { + const run = pair(); + await cutover(run); + // Closed under the endpoint, which a radio gap does between two sends. + run.fake.offererChannel!.close(); + + expect(run.offerer.endpoint.send(frame(1))).toBe(true); + expect(run.offerer.fatals).toEqual(['the direct channel refused a message']); + }); + + it('disposes idempotently, closing the peer and reporting the relay', async () => { + const run = pair(); + await cutover(run); + + run.offerer.endpoint.dispose(); + run.offerer.endpoint.dispose(); + + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.paths).toEqual(['direct', 'relay']); + // Inert afterwards: nothing it is told does anything to a dead session. + expect(run.offerer.endpoint.send(frame(1))).toBe(false); + run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + expect(run.offerer.fatals).toEqual([]); + }); +}); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts new file mode 100644 index 000000000..95897485f --- /dev/null +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -0,0 +1,298 @@ +/** + * One authorized session's direct path, as both ends run it + * (`docs/specs/remote-api.md` -> Transport -> "Direct path"). + * + * **One policy, two ends.** The Client and the Burrow differ in exactly four + * things — who offers, how a control message is put on the relay, how a + * ciphertext is decrypted, and what "the session is over" does — so those are + * injected ({@link DirectEndpointDeps}) and everything else is here: the + * attempt, the peer, the cutover, and every rule about what a channel event + * means. Two copies of that were two chances to disagree about a switch. + * + * One per authorized session, created at promotion and disposed with it, so a + * peer connection can neither precede authorization nor outlive it. + */ + +import { + DirectCutover, + isDirectSignalV1, + type DirectPath, + type DirectSignalV1, +} from 'remote-lib-common'; + +import { DirectPeer, type DirectPeerFactory } from './direct-peer'; +import type { RemoteTimer } from '../ws'; + +/** + * Which half of the negotiation this end plays. The Client offers and the + * Burrow answers, and each ignores the signals that are the other's to send. + */ +export type DirectRole = 'offerer' | 'answerer'; + +export interface DirectEndpointDeps { + /** How this runtime builds a peer connection, or `null` where it has none. */ + readonly createPeer: DirectPeerFactory | null; + /** + * Encrypt one signal as a control message and put it on the relay path; + * `false` if the session could not send it. + */ + sendSignal(signal: DirectSignalV1): boolean; + /** + * Decrypt one transport ciphertext that arrived on the channel — the same + * path a relay ciphertext takes, because it is the same session. + */ + receive(ciphertext: Uint8Array): void; + /** + * The session is unrecoverable: the endpoint's owner disposes it (the Burrow + * through `#disposeEstablished`, the Client through `#loseBurrow`). + */ + fatal(reason: string): void; + /** + * Whether this endpoint's session is still the live one. Both ends re-check + * it after every await, because a promotion or a teardown can replace the + * session while a description is being built. + */ + isCurrent(): boolean; + /** Notified whenever {@link DirectEndpoint.path} changes; the Client's indicator. */ + onPathChanged?(path: DirectPath): void; + /** Every deadline the peer arms; see {@link RemoteTimer}. */ + readonly setTimer?: RemoteTimer; +} + +export class DirectEndpoint { + readonly #role: DirectRole; + readonly #deps: DirectEndpointDeps; + readonly #cutover = new DirectCutover(); + #peer: DirectPeer | null = null; + #disposed = false; + /** The last path announced, so an unchanged one is not announced twice. */ + #announced: DirectPath = 'relay'; + + constructor(role: DirectRole, deps: DirectEndpointDeps) { + this.#role = role; + this.#deps = deps; + } + + /** What carries this session; `direct` only once **both** directions have switched. */ + get path(): DirectPath { + return this.#disposed ? 'relay' : this.#cutover.path; + } + + /** + * Offer a direct path, which is the offerer's alone to do: **once per session, + * never retried**. The whole description travels inside the session, so the + * Relay never sees an SDP, a candidate, or that a direct path exists. + * + * Every failure is silent and terminal for the attempt alone — no factory, a + * description too large to fit one control message, a negotiation that threw — + * and the session keeps running on the relay. + */ + async offer(): Promise { + if (this.#role !== 'offerer' || !this.#cutover.begin()) return; + const peer = this.#build(); + if (!peer) { + this.#giveUp(); + return; + } + const sdp = await peer.offer(); + // The session may have been replaced or disposed while the description was + // being built; a peer left over from one is not this one's. + if (!this.#alive() || this.#peer !== peer) { + peer.close(); + return; + } + if (sdp === null || !this.#deps.sendSignal({ v: 1, t: 'direct-offer', sdp })) this.#giveUp(); + } + + /** + * One decrypted control message on this session. **An unknown control shape is + * ignored, never a session failure**, which is what lets a peer without this + * stack simply stay relayed — as is a signal that is the other role's to send. + */ + onSignal(value: Record): void { + if (!isDirectSignalV1(value) || !this.#alive()) return; + switch (value.t) { + case 'direct-offer': + if (this.#role === 'answerer') void this.#answer(value.sdp); + return; + case 'direct-answer': + if (this.#role === 'offerer') void this.#peer?.acceptAnswer(value.sdp); + return; + case 'direct-decline': + if (this.#role === 'offerer') this.#giveUp('the peer declined a direct path'); + return; + case 'direct-switch': { + const outcome = this.#cutover.onSwitchDecrypted(); + if (outcome.kind === 'fatal') { + this.#deps.fatal('the peer moved to a direct path this end had abandoned'); + return; + } + this.#announce(); + // In arrival order, through the same decrypt path the relay's frames + // take: what was held is exactly what was sent after the switch. + for (const frame of outcome.frames) { + if (!this.#alive()) return; + this.#deps.receive(frame); + } + return; + } + } + } + + /** + * One transport frame arriving on the relay; `false` is a violation. + * + * **After the peer has switched there is nothing left for it to send there**, + * so a frame that arrives anyway is a peer whose two paths this end can no + * longer keep in order. + */ + onRelayTransport(): boolean { + return this.#cutover.onRelayTransport() === 'process'; + } + + /** + * One transport ciphertext, `true` once it is on the channel. The caller puts + * it on the relay when this answers `false`, so "after the switch, nothing on + * the relay" is one line rather than a rule each caller keeps. + */ + send(ciphertext: Uint8Array): boolean { + if (this.#disposed || this.#cutover.outbound !== 'direct') return false; + if (this.#peer?.send(ciphertext)) return true; + // Switched, and the channel will not take it: there is no relay to fall + // back to, so this is burrow loss rather than a message to re-route. + this.#deps.fatal('the direct channel refused a message'); + return true; + } + + /** + * Close the peer and release what the cutover held. Idempotent, and called on + * every path that ends the session, so no peer connection outlives the session + * that authorized it. + */ + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#peer?.close(); + this.#peer = null; + this.#cutover.clear(); + this.#announce(); + } + + // --- Internals ------------------------------------------------------------- + + /** + * Answer one offer, or decline it. A runtime with no peer factory — or one + * whose answer would not fit a signal — declines rather than leaving the + * offerer waiting out the setup deadline. + */ + async #answer(offerSdp: string): Promise { + if (!this.#cutover.begin()) return; + const peer = this.#build(); + if (!peer) { + this.#giveUp(); + this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + return; + } + const sdp = await peer.answer(offerSdp); + if (!this.#alive() || this.#peer !== peer) { + peer.close(); + return; + } + if (sdp === null) { + this.#giveUp(); + this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + return; + } + if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); + } + + /** This attempt's peer, wired to the four channel events, or null if there is none. */ + #build(): DirectPeer | null { + const factory = this.#deps.createPeer; + if (!factory) return null; + let connection; + try { + connection = factory(); + } catch (error) { + console.warn('[direct] could not build a peer connection', error); + return null; + } + if (!connection) return null; + this.#peer = new DirectPeer({ + peer: connection, + ...(this.#deps.setTimer ? { setTimer: this.#deps.setTimer } : {}), + handlers: { + onOpen: () => this.#onOpen(), + onFrame: (frame) => this.#onFrame(frame), + onClosed: (reason) => this.#onClosed(reason), + // A peer speaking something else on the channel is not one this + // session's counters can stay synchronized with, switched or not. + onViolation: (reason) => this.#deps.fatal(reason), + }, + }); + return this.#peer; + } + + /** + * The channel is open. **The `direct-switch` is this end's last message on the + * relay** — everything after it goes on the channel, which is what keeps order + * per direction. + */ + #onOpen(): void { + if (!this.#alive()) return; + if (!this.#deps.sendSignal({ v: 1, t: 'direct-switch' })) { + this.#giveUp(); + return; + } + this.#cutover.switchOutbound(); + this.#announce(); + } + + /** One frame off the channel: processed, held until the peer's switch, or fatal. */ + #onFrame(frame: Uint8Array): void { + if (!this.#alive()) return; + switch (this.#cutover.onChannelFrame(frame)) { + case 'process': + this.#deps.receive(frame); + return; + case 'held': + return; + case 'overflow': + this.#deps.fatal('the direct path outran what can be held in order'); + return; + } + } + + #onClosed(reason: string): void { + if (!this.#alive()) return; + this.#giveUp(reason); + } + + /** + * The attempt is over. **Before either direction has switched that is merely an + * abandoned attempt** and the session carries on relayed; afterwards what was + * riding the channel is gone and a stream cipher has no resynchronization + * point, so the session is over. + */ + #giveUp(reason = 'the direct path was abandoned'): void { + if (this.#cutover.switched) { + this.#deps.fatal(reason); + return; + } + this.#peer?.close(); + this.#peer = null; + this.#cutover.abandon(); + } + + /** Whether this endpoint still belongs to the session the caller is serving. */ + #alive(): boolean { + return !this.#disposed && this.#deps.isCurrent(); + } + + #announce(): void { + const path = this.path; + if (path === this.#announced) return; + this.#announced = path; + this.#deps.onPathChanged?.(path); + } +} diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index 2c1950300..79ecdd0e1 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -10,30 +10,7 @@ import { DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, NOISE_MAX_MESSAGE_LE import { DIRECT_CHANNEL_LABEL, DirectPeer, type DirectPeerHandlers } from './direct-peer'; import { FakeDirectNetwork, type FakeDirectNetworkOptions } from './test-fake-peer'; - -/** Armed timers a test fires by hand, so no case waits fifteen seconds. */ -function fakeTimers() { - const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; - return { - setTimer(run: () => void, delayMs: number): () => void { - const timer = { run, delayMs, cancelled: false }; - armed.push(timer); - return () => { - timer.cancelled = true; - }; - }, - get live() { - return armed.filter((timer) => !timer.cancelled); - }, - /** Fire the one armed timer whose delay is `delayMs`. */ - fire(delayMs: number): void { - const timer = this.live.find((entry) => entry.delayMs === delayMs); - if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); - timer.cancelled = true; - timer.run(); - }, - }; -} +import { fakeTimers } from '../test-timers'; function handlers(): DirectPeerHandlers & { frames: Uint8Array[]; @@ -79,7 +56,7 @@ function pair(options: FakeDirectNetworkOptions = {}) { } /** Let the fake network's queued microtasks run. */ -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); describe('DirectPeer', () => { it('negotiates one ordered channel and carries raw bytes both ways', async () => { @@ -90,7 +67,7 @@ describe('DirectPeer', () => { const answer = await burrowPeer.answer(offer!); expect(answer).toContain('a=setup:active'); await clientPeer.acceptAnswer(answer!); - await settle(); + await flushMicrotasks(); expect(client.opens).toBe(1); expect(burrow.opens).toBe(1); @@ -102,7 +79,7 @@ describe('DirectPeer', () => { clientPeer.send(Uint8Array.of(1, 2, 3)); burrowPeer.send(Uint8Array.of(4, 5)); - await settle(); + await flushMicrotasks(); expect(burrow.frames).toEqual([Uint8Array.of(1, 2, 3)]); expect(client.frames).toEqual([Uint8Array.of(4, 5)]); @@ -114,11 +91,11 @@ describe('DirectPeer', () => { const offering = clientPeer.offer(); let settled = false; void offering.then(() => (settled = true)); - await settle(); + await flushMicrotasks(); // Nothing to send yet: there is no trickle path, so the SDP waits. expect(settled).toBe(false); - timers.fire(DIRECT_GATHER_TIMEOUT_MS); + timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); expect(await offering).toContain('m=application'); }); @@ -126,7 +103,7 @@ describe('DirectPeer', () => { const { network, clientPeer, timers } = pair({ gathering: 'pending' }); const offering = clientPeer.offer(); - await settle(); + await flushMicrotasks(); network.completeGathering(); expect(await offering).toContain('m=application'); @@ -139,22 +116,22 @@ describe('DirectPeer', () => { const offer = await clientPeer.offer(); await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); - await settle(); + await flushMicrotasks(); expect(client.opens).toBe(0); - timers.fire(DIRECT_SETUP_TIMEOUT_MS); + timers.fireAt(DIRECT_SETUP_TIMEOUT_MS); expect(client.closes).toEqual(['the direct channel did not open in time']); expect(clientPeer.isOpen).toBe(false); // And nothing may be sent on it afterwards. - expect(() => clientPeer.send(Uint8Array.of(1))).toThrow(); + expect(clientPeer.send(Uint8Array.of(1))).toBe(false); }); it('reports a channel that dies under a live session, at both ends', async () => { const { network, clientPeer, burrowPeer, client, burrow } = pair(); const offer = await clientPeer.offer(); await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); - await settle(); + await flushMicrotasks(); network.dropChannels(); @@ -166,7 +143,7 @@ describe('DirectPeer', () => { const { network, clientPeer, burrowPeer, client } = pair(); const offer = await clientPeer.offer(); await clientPeer.acceptAnswer((await burrowPeer.answer(offer!))!); - await settle(); + await flushMicrotasks(); network.offererChannel!.receiveRaw('a text frame'); // Bounded before it reaches a cipher, exactly as a relay ciphertext is. diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index e828a38a7..fc667ce4f 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -39,7 +39,7 @@ export interface DirectSessionDescription { export interface DirectChannelLike { binaryType: string; readonly readyState: string; - send(data: ArrayBuffer): void; + send(data: ArrayBuffer | ArrayBufferView): void; close(): void; addEventListener(type: string, handler: (ev: unknown) => void): void; } @@ -174,15 +174,19 @@ export class DirectPeer { * One Noise transport message as one channel frame — raw bytes, never base64 * or JSON, so the channel carries exactly what the relay would have. * - * Throws if the channel cannot take it, which is the same signal a refused - * relay send is: the caller's session is dead either way. + * Answers `false` where the channel cannot take it rather than throwing: the + * endpoint decides what a refused send means, and on a session that has + * already switched it is burrow loss rather than an error for the caller. */ - send(ciphertext: Uint8Array): void { + send(ciphertext: Uint8Array): boolean { const channel = this.#channel; - if (!channel || !this.isOpen) throw new Error('the direct channel is not open'); - // Copied into its own buffer: `send` takes an `ArrayBuffer`, and a view's - // backing buffer is the transport's reused one. - channel.send(ciphertext.slice().buffer as ArrayBuffer); + if (!channel || !this.isOpen) return false; + try { + channel.send(ciphertext); + return true; + } catch { + return false; + } } /** Close the channel and the connection. Idempotent, and reports nothing. */ diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index d3d3d87d3..3cc5afd5f 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -235,10 +235,13 @@ export class FakeChannel implements DirectChannelLike { this.#listeners.set(type, list); } - send(data: ArrayBuffer): void { + send(data: ArrayBuffer | ArrayBufferView): void { if (this.readyState !== 'open') throw new Error('the channel is not open'); - // Copied on the way out: the caller's buffer is the transport's reused one. - const bytes = new Uint8Array(data.slice(0)); + // Copied on the way out, so what a case reads back is what was sent rather + // than whatever the caller's buffer holds by the time it looks. + const bytes = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) + : new Uint8Array(data.slice(0)); this.sent.push(bytes); const peer = this.#peer; if (!peer) return; diff --git a/lib/src/remote/test-timers.ts b/lib/src/remote/test-timers.ts new file mode 100644 index 000000000..426de0063 --- /dev/null +++ b/lib/src/remote/test-timers.ts @@ -0,0 +1,50 @@ +/** + * The {@link RemoteTimer} the remote stack's suites arm their deadlines on. + * + * Test-only, and shared for the reason `./test-fake-socket.ts` is: a keepalive + * interval, a gathering deadline, and a channel setup deadline are all the same + * seam, and no case can afford to wait one out. Two private copies were two + * names for the same firing rule. + */ + +import type { RemoteTimer } from './ws'; + +export interface FakeTimers { + /** Pass as `setTimer`; every armed deadline lands in {@link live}. */ + setTimer: RemoteTimer; + /** Every deadline armed and not yet cancelled or fired. */ + readonly live: Array<{ run: () => void; delayMs: number; cancelled: boolean }>; + /** Fire the most recently armed deadline, as its elapsing would. */ + fire(): void; + /** Fire the one armed for `delayMs`, where more than one deadline is live. */ + fireAt(delayMs: number): void; +} + +export function fakeTimers(): FakeTimers { + const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; + return { + setTimer(run: () => void, delayMs: number): () => void { + const timer = { run, delayMs, cancelled: false }; + armed.push(timer); + return () => { + timer.cancelled = true; + }; + }, + get live() { + return armed.filter((timer) => !timer.cancelled); + }, + fire(): void { + const live = this.live; + const timer = live[live.length - 1]; + if (!timer) throw new Error('no timer is armed'); + timer.cancelled = true; + timer.run(); + }, + fireAt(delayMs: number): void { + const timer = this.live.find((entry) => entry.delayMs === delayMs); + if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); + timer.cancelled = true; + timer.run(); + }, + }; +} From 45dda53b869963da2f7677ddc8d491a67b283188 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:53:01 -0700 Subject: [PATCH 23/46] refactor(remote): let a closed peer retain nothing, and drop dead exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DirectChannelLike.readyState` was never read and `DirectSignalType` had no consumer. `DirectPeer.close` now drops the channel and swaps its handlers for no-ops, so a closed peer holds neither the endpoint that owned them nor whatever the channel was still holding — `#fail` reads them first, since that is the one report a close owes. The path indicator's labels gain the consumer that justifies the export: one case that connects and asserts the label follows the client's report. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/direct/direct-peer.ts | 20 ++++++++++-- lib/src/remote/pocket-app/App.scan.test.tsx | 31 ++++++++++++++++++- remote-lib-common/src/security/direct-path.ts | 3 -- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index fc667ce4f..768f7a63b 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -38,7 +38,6 @@ export interface DirectSessionDescription { /** The subset of `RTCDataChannel` a Noise transport rides on. */ export interface DirectChannelLike { binaryType: string; - readonly readyState: string; send(data: ArrayBuffer | ArrayBufferView): void; close(): void; addEventListener(type: string, handler: (ev: unknown) => void): void; @@ -80,6 +79,14 @@ export interface DirectPeerHandlers { onViolation(reason: string): void; } +/** What a closed peer reports to: nothing, so it retains nothing either. */ +const SILENT_HANDLERS: DirectPeerHandlers = { + onOpen: () => {}, + onFrame: () => {}, + onClosed: () => {}, + onViolation: () => {}, +}; + export interface DirectPeerDeps { readonly peer: DirectPeerLike; readonly handlers: DirectPeerHandlers; @@ -98,7 +105,7 @@ export interface DirectPeerDeps { */ export class DirectPeer { readonly #peer: DirectPeerLike; - readonly #handlers: DirectPeerHandlers; + #handlers: DirectPeerHandlers; readonly #setTimer: RemoteTimer; #channel: DirectChannelLike | null = null; #cancelSetup: (() => void) | null = null; @@ -203,6 +210,11 @@ export class DirectPeer { } catch { // Already closed. } + // Dropped rather than merely flagged: the channel's own listeners still + // point here, and a closed peer must retain neither the endpoint that owned + // these handlers nor whatever the channel is still holding. + this.#channel = null; + this.#handlers = SILENT_HANDLERS; } // --- Internals ------------------------------------------------------------- @@ -301,7 +313,9 @@ export class DirectPeer { /** Report the channel gone, once, and take the connection down with it. */ #fail(reason: string): void { if (this.#closed) return; + // Read before the close silences them: this is the one report a close owes. + const handlers = this.#handlers; this.close(); - this.#handlers.onClosed(reason); + handlers.onClosed(reason); } } diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index ef872913a..dcee08641 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -20,6 +20,7 @@ import App, { BURROWS_EMPTY, BURROWS_TITLE, SCAN_LABEL, + TRANSPORT_PATH_LABELS, UNSUPPORTED_BROWSER_TITLE, } from './App'; import type { ConnectResult, PairingResult } from '../client/pocket-client'; @@ -44,6 +45,8 @@ import { setNativeFieldValue } from '../../lib/dom'; const fake = vi.hoisted(() => ({ noiseSupported: true as boolean, + /** The path callback `App` registered, so a case can report a cutover. */ + onTransportPath: null as ((path: 'relay' | 'direct') => void) | null, hasPriorUse: false, sessionToken: null as string | null, setup: vi.fn<(credential: { setupToken: string }, label: string) => Promise>(), @@ -95,7 +98,9 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => fake.hasPriorUse; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; - setOnTransportPathChanged = () => undefined; + setOnTransportPathChanged = (callback: ((path: 'relay' | 'direct') => void) | null) => { + fake.onTransportPath = callback; + }; close = () => fake.clientClose(); openSocket = async () => undefined; setup = (credential: { setupToken: string }, label: string) => fake.setup(credential, label); @@ -191,6 +196,7 @@ beforeEach(() => { fake.hello.mockReset().mockResolvedValue({}); fake.adapterInit.mockReset().mockResolvedValue(undefined); fake.adapterDispose.mockReset(); + fake.onTransportPath = null; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -482,6 +488,29 @@ describe('the Burrows list', () => { expect(rowFor(container, 'Second laptop').textContent).toContain('Offline'); }); + /** + * **Which path carries the session is shown, never inferred**, so a relayed + * fallback is visible rather than silent (`docs/specs/pocket-app.md`). + */ + it('says which path carries the session, and re-reads it at the cutover', async () => { + fake.hasPriorUse = true; + fake.listKnownBurrows.mockResolvedValue([await knownBurrow('burrow-1', 'First laptop')]); + fake.listBurrows.mockResolvedValue([{ burrowId: 'burrow-1', label: '', online: true }]); + await boot(); + await click(container, 'Sign in with passkey'); + + await click(container, 'Connect'); + + // Every session starts on the relay and says so. + expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.relay.label); + + act(() => fake.onTransportPath?.('direct')); + await settle(); + + expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.direct.label); + expect(container.textContent).not.toContain(TRANSPORT_PATH_LABELS.relay.label); + }); + /** * An authenticated `pairing-required` removes local authorization without * discarding the pin, so the row offers *Pair again* — which starts at the diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 5a7c52599..d3f44f7b2 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -72,9 +72,6 @@ export type DirectSignalV1 = | { readonly v: 1; readonly t: 'direct-decline' } | { readonly v: 1; readonly t: 'direct-switch' }; -/** The `t` of every signal, so a dispatcher cannot invent a fifth. */ -export type DirectSignalType = DirectSignalV1['t']; - /** * The characters an SDP may be made of: printable US-ASCII plus CR and LF. * From 5d5881bc666323a00053b552aca0f7df17b589dc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:59:19 -0700 Subject: [PATCH 24/46] test(remote): share the fakes the remote suites had each copied One `FakeEventTarget` behind the fake socket, peer, and channel; one `settle` from `test-e2e-client.ts` instead of a local tick loop; and the frame accessors and the pair-approve-connect opening move onto `E2eHarness`, where the native suite can reach them too. The direct-path run gains an `answered()` waiter, so six copies of the same predicate say what they are waiting for once. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- lib/src/remote/client/pocket-client.test.ts | 42 +++++---------- lib/src/remote/client/test-e2e-harness.ts | 60 +++++++++++++++------ lib/src/remote/direct/test-fake-peer.ts | 17 +++--- lib/src/remote/test-fake-socket.ts | 28 ++++++++-- 4 files changed, 86 insertions(+), 61 deletions(-) diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 05276d38d..da2f06fb4 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -54,7 +54,7 @@ import { } from '../direct/test-fake-peer'; import type { DirectPeerLike } from '../direct/direct-peer'; import type { RemoteTimer } from '../ws'; -import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; +import { createTestAuthenticator, settle, type TestAuthenticator } from '../test-e2e-client'; import { PasskeyAlreadyRegisteredError, type WebAuthnClient } from './webauthn'; import { AUTH_ROUTES, @@ -109,11 +109,6 @@ function expiringClock(): { now: () => number; expire: () => void } { }; } -/** Let everything already queued run, for a case asserting something did not happen. */ -async function settleTicks(): Promise { - for (let i = 0; i < 8; i += 1) await new Promise((r) => setTimeout(r, 1)); -} - // --- The account-plane harness --------------------------------------------- interface Harness { @@ -770,8 +765,7 @@ describe('the direct path, end to end', () => { }, }), }); - await harness.pairAndApprove(await harness.mintInvitation()); - expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); + await harness.connectPaired(); return { harness, network, @@ -784,16 +778,12 @@ describe('the direct path, end to end', () => { .frames('e2e') .find((frame) => frame.kind === 'connection')!.id as string, /** Client→relay transport frames on the connection, which stop at the switch. */ - clientFrames: () => - harness - .clientSocket() - .frames('e2e') - .filter((frame) => frame.kind === 'connection' && frame.step === 'transport'), - /** Burrow→relay frames on this connection, which stop at its own switch. */ - burrowFrames: () => - harness.relay.burrowSocket - .frames('e2e') - .filter((frame) => frame.kind === 'connection'), + clientFrames: harness.clientTransportFrames, + /** Burrow→relay transport frames on this connection, which stop at its own switch. */ + burrowFrames: harness.burrowTransportFrames, + /** Wait for the Burrow's second transport frame: its answer, or its decline. */ + answered: () => + waitFor(() => harness.burrowTransportFrames().length === 2, 'the Burrow to answer'), /** Wait until both directions have left the relay. */ cutover: () => waitFor(() => harness.client.transportPath === 'direct', 'the session to go direct'), @@ -813,7 +803,7 @@ describe('the direct path, end to end', () => { expect(fromBase64Url(frame.ct as string).length).toBe(1 + CONTROL_PAYLOAD_SIZE + 16); } // And three the other way: the outcome, the answer, and the Burrow's switch. - expect(run.burrowFrames().filter((frame) => frame.step === 'transport')).toHaveLength(3); + expect(run.burrowFrames()).toHaveLength(3); // Signaling only so far: the channel has carried nothing. expect(run.network.offererChannel!.sent).toEqual([]); }); @@ -858,10 +848,7 @@ describe('the direct path, end to end', () => { const run = await connectedDirect({ burrowHasPeer: false }); // The decline is the Burrow's second transport frame, after the outcome. - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to decline', - ); + await run.answered(); await waitFor(() => run.clientPeers[0]!.closed, 'the Client to close its peer'); expect(run.harness.client.transportPath).toBe('relay'); @@ -934,11 +921,8 @@ describe('the direct path, end to end', () => { */ it('holds channel frames until the peer’s switch, then drains them in order', async () => { const run = await connectedDirect({ network: { opening: 'manual' } }); - await waitFor( - () => run.burrowFrames().filter((frame) => frame.step === 'transport').length === 2, - 'the Burrow to answer', - ); - await settleTicks(); + await run.answered(); + await settle(); run.harness.relay.holdToClient(); run.network.openChannels(); @@ -948,7 +932,7 @@ describe('the direct path, end to end', () => { const order: string[] = []; const first = run.harness.client.hello().then(() => order.push('first')); const second = run.harness.client.write('surface-1', 'ls').then(() => order.push('second')); - await settleTicks(); + await settle(); expect(order).toEqual([]); run.harness.relay.releaseToClient(); diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index abb67c6ba..3f0ceffd4 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -195,6 +195,11 @@ export const AUTH_ROUTES: Record = { '/api/burrows': () => ({ json: { burrows: [{ burrowId: 'h1', label: 'Laptop', online: true }] } }), }; +/** One socket's `e2e` frames on an established connection, in order. */ +function transportFrames(frames: Array>): Array> { + return frames.filter((frame) => frame.kind === 'connection' && frame.step === 'transport'); +} + export interface E2eHarness { client: PocketClient; burrow: BurrowRuntime; @@ -211,8 +216,17 @@ export interface E2eHarness { fetch: typeof globalThis.fetch; /** The Client's relay socket, once one is open — what a keepalive lands on. */ clientSocket(): FakeSocket; + /** + * Client→relay `transport` frames on the established connection, which stop + * at this end's `direct-switch`. + */ + clientTransportFrames(): Array>; + /** The same, the other way: the Burrow's, which stop at its own switch. */ + burrowTransportFrames(): Array>; /** One live invitation, as `setupQr` would mint it. */ mintInvitation(): Promise; + /** Pair, approve, and connect — the whole ceremony every session case starts with. */ + connectPaired(): Promise; /** Run a pairing and confirm it on the Burrow with the digits the phone showed. */ pairAndApprove( invitation: PairingInvitation, @@ -363,6 +377,10 @@ export async function makeE2eHarness( getAssertion: (challenge) => authenticator.assert(challenge, ORIGIN), }; let clientSocket: FakeSocket | null = null; + const requireClientSocket = (): FakeSocket => { + if (!clientSocket) throw new Error('the Client has not opened a relay socket'); + return clientSocket; + }; const client = new PocketClient({ wsBase: 'ws://test', fetch, @@ -378,6 +396,22 @@ export async function makeE2eHarness( // every presence proof is built from, exactly as it does in the app. await client.signin(); + const mintInvitation: E2eHarness['mintInvitation'] = () => + burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS); + const pairAndApprove: E2eHarness['pairAndApprove'] = async (invitation, { code } = {}) => { + // Counted from here: a harness that pairs twice must confirm the *new* + // request rather than re-answering the one still in the log. + const before = approvals.length; + let shown: string | null = null; + const pairing = client.pair(invitation, 'iPhone Safari', (value) => { + shown = value; + }); + await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); + const pending = approvals[approvals.length - 1]!; + pending.approve(code ? code(shown!) : shown!); + return await pairing; + }; + return { client, burrow, @@ -393,23 +427,15 @@ export async function makeE2eHarness( }, calls, fetch, - clientSocket: () => { - if (!clientSocket) throw new Error('the Client has not opened a relay socket'); - return clientSocket; - }, - mintInvitation: () => burrow.mintInvitation(secret(), Date.now() + DEFAULT_PAIRING_TTL_MS), - async pairAndApprove(invitation, { code } = {}) { - // Counted from here: a harness that pairs twice must confirm the *new* - // request rather than re-answering the one still in the log. - const before = approvals.length; - let shown: string | null = null; - const pairing = client.pair(invitation, 'iPhone Safari', (value) => { - shown = value; - }); - await waitFor(() => approvals.length > before, 'the Burrow to surface an approval'); - const pending = approvals[approvals.length - 1]!; - pending.approve(code ? code(shown!) : shown!); - return await pairing; + clientSocket: requireClientSocket, + clientTransportFrames: () => transportFrames(requireClientSocket().frames('e2e')), + burrowTransportFrames: () => transportFrames(relay.burrowSocket.frames('e2e')), + mintInvitation, + pairAndApprove, + async connectPaired() { + await pairAndApprove(await mintInvitation()); + const outcome = await client.connect(burrowId); + if (!outcome.ok) throw new Error(`the connect was denied: ${outcome.message}`); }, }; } diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index 3cc5afd5f..b40c5ff87 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -22,6 +22,7 @@ import { type DirectPeerLike, type DirectSessionDescription, } from './direct-peer'; +import { FakeEventTarget } from '../test-fake-socket'; /** When the linked channel reports itself open. */ export type FakeChannelOpening = @@ -124,7 +125,7 @@ export class FakePeer implements DirectPeerLike { readonly #role: FakePeerRole; readonly #options: FakeDirectNetworkOptions; readonly #network: FakeDirectNetwork; - readonly #listeners = new Map void>>(); + readonly #events = new FakeEventTarget(); #local: DirectSessionDescription | null = null; #gathering: string; closed = false; @@ -178,9 +179,7 @@ export class FakePeer implements DirectPeerLike { } addEventListener(type: string, handler: (ev: unknown) => void): void { - const list = this.#listeners.get(type) ?? []; - list.push(handler); - this.#listeners.set(type, list); + this.#events.addEventListener(type, handler); } close(): void { @@ -205,7 +204,7 @@ export class FakePeer implements DirectPeerLike { } #emit(type: string, ev: unknown): void { - for (const handler of this.#listeners.get(type) ?? []) handler(ev); + this.#events.emit(type, ev); } } @@ -219,7 +218,7 @@ export class FakeChannel implements DirectChannelLike { #peer: FakeChannel | null = null; /** Frames delivered before this end opened, held as a real one would. */ readonly #inbox: Uint8Array[] = []; - readonly #listeners = new Map void>>(); + readonly #events = new FakeEventTarget(); constructor(label: string) { this.label = label; @@ -230,9 +229,7 @@ export class FakeChannel implements DirectChannelLike { } addEventListener(type: string, handler: (ev: unknown) => void): void { - const list = this.#listeners.get(type) ?? []; - list.push(handler); - this.#listeners.set(type, list); + this.#events.addEventListener(type, handler); } send(data: ArrayBuffer | ArrayBufferView): void { @@ -284,6 +281,6 @@ export class FakeChannel implements DirectChannelLike { } #emit(type: string, ev: unknown): void { - for (const handler of this.#listeners.get(type) ?? []) handler(ev); + this.#events.emit(type, ev); } } diff --git a/lib/src/remote/test-fake-socket.ts b/lib/src/remote/test-fake-socket.ts index 1f4418133..a4b7eae44 100644 --- a/lib/src/remote/test-fake-socket.ts +++ b/lib/src/remote/test-fake-socket.ts @@ -10,6 +10,26 @@ import type { RemoteWebSocket } from './ws'; +/** + * The listener map every fake in this stack keeps: `addEventListener` plus a + * way to fire one. Shared because the fake socket, the fake peer connection, + * and the fake data channel all had a private copy of exactly this. + */ +export class FakeEventTarget { + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + /** Deliver one event to everything listening for `type`. */ + emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } +} + export class FakeSocket implements RemoteWebSocket { /** `CONNECTING` until {@link open}, as a real socket is. */ readyState = 0; @@ -26,12 +46,10 @@ export class FakeSocket implements RemoteWebSocket { * timing one. */ onSend: ((frame: Record) => void) | null = null; - readonly #handlers = new Map void>>(); + readonly #events = new FakeEventTarget(); addEventListener(type: string, handler: (ev: unknown) => void): void { - const list = this.#handlers.get(type) ?? []; - list.push(handler); - this.#handlers.set(type, list); + this.#events.addEventListener(type, handler); } send(data: string): void { @@ -87,6 +105,6 @@ export class FakeSocket implements RemoteWebSocket { } #emit(type: string, ev: unknown): void { - for (const handler of this.#handlers.get(type) ?? []) handler(ev); + this.#events.emit(type, ev); } } From 613f1937ffe9111657ec609398fe6407863762b9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:59:19 -0700 Subject: [PATCH 25/46] refactor(host): the native peer factory takes no options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveFrom` and `warn` existed only for the test, which now resolves the polyfill itself and injects a plain factory — so the shipped factory is two bare requires and a `console.warn`, exactly what the sidecar bundle runs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- .../host/remote/native-direct-peer.test.ts | 132 +++++++++++------- lib/src/host/remote/native-direct-peer.ts | 33 +---- 2 files changed, 83 insertions(+), 82 deletions(-) diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index 2ad824906..e821e93f3 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -13,11 +13,14 @@ * real DTLS/SCTP, and the session riding it is the real Noise session — the * whole loop from `test-e2e-harness.ts`, with only the addon swapped in. * - * The addon is resolved from `standalone/sidecar`, which is where it is - * installed and where the shipped bundle finds it; `lib` must not depend on it, - * because `lib` is a browser bundle root. + * **The addon is resolved here rather than through the shipped factory.** It is + * installed under `standalone/sidecar`, which is where the shipped bundle finds + * it with a bare `require`; `lib` must not depend on it, because `lib` is a + * browser bundle root. So these cases inject a plain factory over the polyfill + * this file resolves, and the shipped factory is driven by the last case alone. */ +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { NOISE_MAX_MESSAGE_LENGTH, type TerminalDataEvent } from 'remote-lib-common'; @@ -25,14 +28,32 @@ import { DirectPeer, type DirectPeerLike } from '../../remote/direct/direct-peer import { STREAMED_CHUNK, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; +/** A file inside the package that declares the addon, to resolve it from. */ +const sidecarRequire = createRequire( + fileURLToPath(new URL('../../../../standalone/sidecar/package.json', import.meta.url)), +); + +interface NativePolyfill { + readonly RTCPeerConnection: new (config: { iceServers: [] }) => DirectPeerLike; +} + +const { RTCPeerConnection } = sidecarRequire('node-datachannel/polyfill') as NativePolyfill; + /** - * A file inside the package that declares the addon. The sidecar bundle sits - * beside its own `node_modules` and needs no such hint; this file, running from - * source under `lib/`, does. + * `iceServers: []` as both shipped factories pass it: host candidates only, + * never a public STUN or TURN default. */ -const SIDECAR = fileURLToPath( - new URL('../../../../standalone/sidecar/package.json', import.meta.url), -); +const buildPeer = (): DirectPeerLike => new RTCPeerConnection({ iceServers: [] }); + +/** Whether the last case already tore the addon down through the shipped path. */ +let disposedByFactory = false; + +afterAll(() => { + // The addon runs its own threads, which outlive every peer and would hold + // this worker open after the last assertion. One teardown, whichever path + // reached it — both resolve the same native module. + if (!disposedByFactory) (sidecarRequire('node-datachannel') as { cleanup: () => void }).cleanup(); +}); /** * What one negotiation is given before it is written off. A local pair settles @@ -45,18 +66,6 @@ const NEGOTIATION_ATTEMPTS = 4; /** Every attempt, plus the ceremonies in front of them — never vitest's default. */ const CASE_BUDGET_MS = 45_000; -const warnings: string[] = []; -const buildPeer = createNativeDirectPeerFactory({ - resolveFrom: SIDECAR, - warn: (message) => warnings.push(message), -}); - -afterAll(() => { - // The addon runs its own threads, which outlive every peer and would hold - // this worker open after the last assertion. - disposeNativeDirectPeers(); -}); - interface Negotiation { /** Whether the channel this attempt describes has come up. */ open(): boolean; @@ -92,10 +101,10 @@ async function untilOpen(start: () => Promise, what: s } /** Build a peer, keeping it so a case can close the far end by hand. */ -function collect(into: DirectPeerLike[]): () => DirectPeerLike | null { +function collect(into: DirectPeerLike[]): () => DirectPeerLike { return () => { const peer = buildPeer(); - if (peer) into.push(peer); + into.push(peer); return peer; }; } @@ -111,19 +120,11 @@ async function startConnected() { deps: { createDirectPeer: collect(clientPeers) }, burrowDirect: collect(burrowPeers), }); - await harness.pairAndApprove(await harness.mintInvitation()); - expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); - - const transportFrames = (frames: Array>) => - frames.filter((frame) => frame.kind === 'connection' && frame.step === 'transport'); + await harness.connectPaired(); return { harness, clientPeers, burrowPeers, - /** Client→relay transport frames on the connection, which stop at the switch. */ - clientFrames: () => transportFrames(harness.clientSocket().frames('e2e')), - /** Burrow→relay transport frames on the connection, which stop at its own switch. */ - burrowFrames: () => transportFrames(harness.relay.burrowSocket.frames('e2e')), open: () => harness.client.transportPath === 'direct', abandon: () => { for (const peer of [...clientPeers, ...burrowPeers]) peer.close(); @@ -138,33 +139,31 @@ describe('the direct path over the native addon', () => { 'negotiates a channel and carries protocol-v1 on it, the relay silent after', async () => { const run = await connectedDirect(); + const { harness } = run; - // Both ends built a peer, and nothing warned — a machine that cannot load - // the addon would have declined and stayed relayed instead. - expect(warnings).toEqual([]); expect(run.clientPeers).toHaveLength(1); expect(run.burrowPeers).toHaveLength(1); - expect(run.harness.client.transportPath).toBe('direct'); + expect(harness.client.transportPath).toBe('direct'); // Three Client→Burrow transport frames on this connection: the connection // request the ceremony ended with, the offer, and the switch. Three back: // the outcome, the answer, and the Burrow's own switch. The SDPs crossed // inside the session, so the Relay saw only padded control bodies. - expect(run.clientFrames()).toHaveLength(3); - expect(run.burrowFrames()).toHaveLength(3); + expect(harness.clientTransportFrames()).toHaveLength(3); + expect(harness.burrowTransportFrames()).toHaveLength(3); - const clientBefore = run.clientFrames().length; - const burrowBefore = run.burrowFrames().length; + const clientBefore = harness.clientTransportFrames().length; + const burrowBefore = harness.burrowTransportFrames().length; const chunks: TerminalDataEvent[] = []; - expect(await run.harness.client.hello()).toMatchObject({ protocolVersion: 1 }); - await run.harness.client.watchDirectory(() => {}); - await run.harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); - await run.harness.client.write('surface-1', 'ls\n'); + expect(await harness.client.hello()).toMatchObject({ protocolVersion: 1 }); + await harness.client.watchDirectory(() => {}); + await harness.client.attach('surface-1', 80, 24, { onData: (e) => chunks.push(e) }); + await harness.client.write('surface-1', 'ls\n'); // Requests, answers, and the burrow→client stream all crossed the channel; // the relay carried none of it, in either direction. - expect(run.clientFrames()).toHaveLength(clientBefore); - expect(run.burrowFrames()).toHaveLength(burrowBefore); + expect(harness.clientTransportFrames()).toHaveLength(clientBefore); + expect(harness.burrowTransportFrames()).toHaveLength(burrowBefore); expect(chunks).toEqual([STREAMED_CHUNK]); }, CASE_BUDGET_MS, @@ -194,13 +193,9 @@ describe('the direct path over the native addon', () => { onClosed: (reason: string) => lost.push(reason), onViolation: (reason: string) => lost.push(reason), }); - const offererPeer = buildPeer(); - const answererPeer = buildPeer(); - expect(offererPeer).not.toBeNull(); - expect(answererPeer).not.toBeNull(); - const offerer = new DirectPeer({ peer: offererPeer!, handlers: handlers(() => {}) }); + const offerer = new DirectPeer({ peer: buildPeer(), handlers: handlers(() => {}) }); const answerer = new DirectPeer({ - peer: answererPeer!, + peer: buildPeer(), handlers: handlers((frame) => inbound.push(frame)), }); const offer = await offerer.offer(); @@ -224,7 +219,7 @@ describe('the direct path over the native addon', () => { try { const payload = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); crypto.getRandomValues(payload); - run.offerer.send(payload); + expect(run.offerer.send(payload)).toBe(true); await waitFor(() => run.inbound.length === 1, 'the frame to arrive', ATTEMPT_BUDGET_MS); expect(run.inbound[0]).toEqual(payload); @@ -263,4 +258,35 @@ describe('the direct path over the native addon', () => { }, CASE_BUDGET_MS, ); + + /** + * The shipped factory, last because its teardown is the process's. + * + * **A teardown is terminal**: the native threads are gone, so a peer built on + * them is not one this process can use and every later offer declines, + * leaving that session relayed. The other half of the contract — a load that + * fails warns once and declines from then on — is not drivable here: vitest's + * `require` resolves from inside the store, where every workspace package is + * reachable, so the addon cannot be made to not load without replacing the + * module system this case exists to exercise. + */ + it('builds a peer through a bare require, and declines once torn down', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const factory = createNativeDirectPeerFactory(); + const peer = factory(); + expect(peer).not.toBeNull(); + peer!.close(); + + disposeNativeDirectPeers(); + disposedByFactory = true; + + expect(factory()).toBeNull(); + expect(createNativeDirectPeerFactory()()).toBeNull(); + // Silent: only an installation the addon never loaded on warns, and once. + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); }); diff --git a/lib/src/host/remote/native-direct-peer.ts b/lib/src/host/remote/native-direct-peer.ts index b8d0b13a3..51f0aebcd 100644 --- a/lib/src/host/remote/native-direct-peer.ts +++ b/lib/src/host/remote/native-direct-peer.ts @@ -15,7 +15,6 @@ * gets no `direct-offer` never opens it at all. */ -import { createRequire } from 'node:module'; import type { DirectPeerFactory, DirectPeerLike } from '../../remote/direct/direct-peer'; /** The one polyfill export a direct path needs. */ @@ -33,20 +32,6 @@ interface NativeDirect { readonly addon: DirectAddon; } -export interface NativeDirectPeerOptions { - /** - * A file to resolve the addon relative to, for a caller that does not sit - * beside the `node_modules` holding it. The sidecar bundle does — it is - * emitted into `standalone/sidecar/`, whose `package.json` declares the - * platform package — so the shipped Burrow passes nothing and the loader - * below uses the bundle's own `require`. A test running this file from source - * under `lib/` names the sidecar instead. - */ - readonly resolveFrom?: string; - /** Where a load failure is reported; `console.warn` by default. */ - readonly warn?: (message: string) => void; -} - /** * The addon once some factory has loaded it. Process-wide rather than * per-factory: one native library, one thread pool, one teardown. @@ -69,14 +54,7 @@ let declined = false; * esbuild `external` entries in `standalone/scripts/build-sidecar-proxy.mjs` * are what keeps them bare, and that build asserts it. */ -function requireNative(resolveFrom: string | undefined): NativeDirect { - if (resolveFrom !== undefined) { - const required = createRequire(resolveFrom); - return { - polyfill: required('node-datachannel/polyfill') as DirectPolyfill, - addon: required('node-datachannel') as DirectAddon, - }; - } +function requireNative(): NativeDirect { return { polyfill: require('node-datachannel/polyfill') as DirectPolyfill, addon: require('node-datachannel') as DirectAddon, @@ -92,17 +70,14 @@ function requireNative(resolveFrom: string | undefined): NativeDirect { * native load attempt each time — and the Burrow's answer is the same either * way: `direct-decline`, and the session stays on the relay. */ -export function createNativeDirectPeerFactory( - options: NativeDirectPeerOptions = {}, -): DirectPeerFactory { - const warn = options.warn ?? ((message: string) => console.warn(message)); +export function createNativeDirectPeerFactory(): DirectPeerFactory { return () => { if (!native && !declined) { try { - native = requireNative(options.resolveFrom); + native = requireNative(); } catch (error) { declined = true; - warn(`[burrow] no direct path: the WebRTC addon did not load: ${String(error)}`); + console.warn(`[burrow] no direct path: the WebRTC addon did not load: ${String(error)}`); } } // `iceServers: []` here and nowhere else: host candidates only, never a From 44ee25d763151044f6d08c8f21d9220df78e8e7c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 20:01:18 -0700 Subject: [PATCH 26/46] docs(remote): point the direct path's rules at the endpoint that holds them Every pointer that named a symbol this refactor moved: the one-attempt gate is `DirectCutover.begin`, the fatal-on-abandoned-switch is `onSwitchDecrypted`, and disposal is `DirectEndpoint.dispose` on every path that ends a session. `direct-endpoint.ts` joins the e2e boundary's module list in the lint and in the audit prompt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JkBrKQZnhTvjcPCwmzUCcP --- .github/audit/application-security.md | 5 +++-- docs/specs/remote-api.md | 10 +++++++--- docs/specs/remote-security-model.md | 8 +++++--- docs/specs/security-remote.md | 8 ++++---- scripts/e2e-lint.mjs | 1 + 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index a944d2aa2..14a04f1b5 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -27,8 +27,9 @@ The end-to-end boundary is where the depth goes. Its modules are `e2e-ceremony.ts`, `e2e-bounds.ts`, `token-bucket.ts`, `push-seal.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-peer.ts` (the data channel the same session may -move onto — `docs/specs/security-remote.md` -> "Direct path"); +`lib/src/remote/direct/direct-endpoint.ts` and `direct-peer.ts` (the data +channel the same session may move onto, and the one cutover 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); diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index f6f5daff9..4019a8ea8 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -135,9 +135,13 @@ injected factory** — `PocketClientDeps.createDirectPeer`, Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, their guard, the constants, and the `DirectCutover` both ends run), `lib/src/remote/direct/direct-peer.ts` (`DirectPeerLike` and the negotiation), -`PocketClient.#offerDirect` in `lib/src/remote/client/pocket-client.ts`, -`BurrowRuntime.#answerDirect` in `lib/src/remote/burrow/burrow-runtime.ts`; -pinned by `remote-lib-common/test/direct-path.test.mjs`, +`DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` (the whole +cutover policy, one per authorized session, 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`. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index e5fbe0b1d..805186650 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -456,9 +456,11 @@ sees the traffic ([Residual metadata](#residual-metadata)). Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, their guard, and `DirectCutover`), `lib/src/remote/direct/direct-peer.ts` -(`DirectPeer`), `BurrowRuntime.#answerDirect` in -`lib/src/remote/burrow/burrow-runtime.ts`, `PocketClient.#offerDirect` in -`lib/src/remote/client/pocket-client.ts`. The audited rows are +(`DirectPeer`), `DirectEndpoint` in +`lib/src/remote/direct/direct-endpoint.ts` (the attempt, the peer, and the +cutover, created at promotion by `BurrowRuntime.#promoteConnection` in +`lib/src/remote/burrow/burrow-runtime.ts` and `PocketClient.#directEndpoint` in +`lib/src/remote/client/pocket-client.ts`). The audited rows are `docs/specs/security-remote.md` -> "Direct path". ## Noise suite diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index a67d2d0c0..7ee0de33e 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -218,13 +218,13 @@ bounds. `docs/specs/remote-api.md` -> "Direct path" owns the design and `docs/specs/remote-security-model.md` -> "Direct path" owns why it adds no trust layer; neither is restated below. -- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. `BurrowRuntime.#answerDirect` in `lib/src/remote/burrow/burrow-runtime.ts` must return on the session's `directAttempted` flag and set it before its first `await`; `PocketClient.#offerDirect` in `lib/src/remote/client/pocket-client.ts` must be reachable only from the `ok: true` branch of a connection outcome, since a peer connection built earlier is one an unauthorized party steered. +- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. Both halves of `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` must pass `DirectCutover.begin`, which answers `true` once per cutover, before their first `await`; and the endpoint holding it must be built only at promotion — `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`, the `ok: true` branch alone in `lib/src/remote/client/pocket-client.ts` — since a peer connection built earlier is one an unauthorized party steered. - **FAIL IF** a byte crosses the channel that is not a Noise transport message of the promoted session: one message per frame, raw bytes, no second handshake, no plaintext, and no framing of ours beside it. **Every inbound frame is bounded at `NOISE_MAX_MESSAGE_LENGTH` before it reaches a cipher** — `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse an over-cap frame and a non-binary message as violations rather than parse either. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. -- **FAIL IF** a peer connection can outlive its session or precede promotion. `#disposeDirect` must run on every path that ends an established session in `lib/src/remote/burrow/burrow-runtime.ts` — `#disposeEstablished`, and through `#disposeClient` the `client-gone`, socket-loss and `stop()` paths — and `#disposeCeremony` in `lib/src/remote/client/pocket-client.ts` must close the peer on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three and both endpoints must act on every outcome it returns. Pinned by `remote-lib-common/test/direct-path.test.mjs` and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. -- **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through an import performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. +- **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. +- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). ### Revocation and the audit trail diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 51b11e8ce..3273029c1 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -79,6 +79,7 @@ const E2E_MODULES = [ // it is inside the boundary for the same reasons the transport is: one key // agreement, one AEAD, and no second construction reachable from either. 'remote-lib-common/src/security/direct-path.ts', + 'lib/src/remote/direct/direct-endpoint.ts', 'lib/src/remote/direct/direct-peer.ts', 'remote-lib-common/src/remote/wire.ts', 'lib/src/remote/burrow/burrow-runtime.ts', From 0a68f27a66857d526741e056e9929cc7b3002395 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 20:57:49 -0700 Subject: [PATCH 27/46] fix(remote): size the direct path's holding queue for a real terminal stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver holds channel frames for one relay one-way hop — 100-300 ms to a phone on cellular — and a PTY's ~1 KiB chunks reach the channel uncoalesced, so 64 frames overflowed in ~65 ms and overflow is fatal. Bytes are now the operative bound at 4 MiB, with the frame cap raised past where 1 KiB frames can reach it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- docs/specs/remote-security-model.md | 2 +- docs/specs/remote-security-model.rationale.md | 20 +++++++++++++++++++ remote-lib-common/src/security/direct-path.ts | 20 +++++++++++++++---- remote-lib-common/test/direct-path.test.mjs | 18 +++++++++++++++++ scripts/spec-word-budgets.json | 2 +- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 805186650..1198000a0 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -342,7 +342,7 @@ omits `client-gone`, invents client IDs, or reorders frames. | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | -| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 64 frames / 1 MiB | same | +| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative received-string length; both `e2e` and `client-gone` share one FIFO and one diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index bb548ef79..441341ee7 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -257,6 +257,26 @@ transport messages on the same counters (so no second cipher exists to get wrong). A design that trickled candidates, or that negotiated on a side channel, would have broken all three at once. +**Why the holding queue is 4 MiB and 8,192 frames, and why bytes are the bound +that matters** *(2026-09)*. A receiver holds channel frames from the instant the +peer's channel opens until the peer's `direct-switch` decrypts off the relay, so +the window is exactly one relay one-way hop — 100–300 ms with a VPS relay and a +phone on cellular. What fills it is the Burrow's terminal stream, and that +stream is not coalesced: node-pty's `onData` becomes one `data` message per +chunk (`standalone/sidecar/pty-core.js`), one `terminalData` event +(`lib/src/remote/burrow/remote-api.ts`), and one `#sendApp` call, at the ~1 KiB +chunks a PTY emits. A single streaming pane is therefore of the order of a +thousand frames a second, and the original 64-frame cap overflowed in ~65 ms — +with overflow fatal, a `yes` running in one pane would have killed the session +at the moment it switched. Bytes are what the machine actually holds, so the +byte cap is the real one: 4 MiB is half a second of a 5 MB/s stream, well past +the worst hop. The frame cap is then placed above where 1 KiB frames can reach +it (8,192 × 1 KiB > 4 MiB) so that it constrains only a peer sending thousands +of frames too small to fill the byte cap. The worst case is per session, and +transient: `MAX_ESTABLISHED_E2E_SESSIONS` × 4 MiB = 64 MiB of held ciphertext if +all sixteen authorized phones cut over at once, on a machine that is already +running their terminals. + **Why no ICE servers, stated as a hard rule rather than a default.** A STUN server learns the client's public address and the fact of a session, from a party neither endpoint chose; a TURN server learns the traffic pattern and diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index d3f44f7b2..4d99a2f9a 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -43,11 +43,23 @@ export const DIRECT_GATHER_TIMEOUT_MS = 3_000; */ export const MAX_DIRECT_SDP_LENGTH = 2000; -/** How many channel frames a receiver holds while awaiting the peer's switch. */ -export const MAX_DIRECT_PENDING_FRAMES = 64; +/** + * How many bytes of held channel frames a receiver holds while awaiting the + * peer's switch. **The operative bound of the two**: what the window has to + * cover is one relay one-way hop of a terminal stream, and bytes are what the + * machine actually holds (`docs/specs/remote-security-model.md` -> "Burrow + * bounds"). + */ +export const MAX_DIRECT_PENDING_BYTES = 4 * 1024 * 1024; -/** How many bytes of held channel frames a receiver holds, whatever the count. */ -export const MAX_DIRECT_PENDING_BYTES = 1024 * 1024; +/** + * How many channel frames a receiver holds, whatever their size. Set above + * where the ~1 KiB frames a PTY produces can reach it, so it stops only a peer + * sending thousands of tiny ones; {@link MAX_DIRECT_PENDING_BYTES} is what + * bounds real traffic. The relationship is pinned by + * `remote-lib-common/test/direct-path.test.mjs`. + */ +export const MAX_DIRECT_PENDING_FRAMES = 8192; /** * Which path carries a session's traffic. `direct` only once **both** diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index c7b745884..f4f38b439 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -103,6 +103,24 @@ test('the timings the spec names are the values that ship', () => { assert.ok(DIRECT_GATHER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); }); +/** + * The two holding bounds are not independent: the byte cap is the one meant to + * bind, and the frame cap only exists so a peer cannot hold the queue open with + * frames too small to fill it. + */ +test('the holding queue is bounded in bytes first', () => { + assert.equal(MAX_DIRECT_PENDING_BYTES, 4 * 1024 * 1024); + // A PTY emits ~1 KiB chunks uncoalesced, one per channel frame, so at the + // frame size terminal traffic actually has the byte cap is reached first. + assert.ok( + MAX_DIRECT_PENDING_FRAMES * 1024 >= MAX_DIRECT_PENDING_BYTES, + `${MAX_DIRECT_PENDING_FRAMES} frames of 1 KiB is under the ${MAX_DIRECT_PENDING_BYTES}-byte cap`, + ); + // And the window it covers is one relay one-way hop, which is hundreds of + // milliseconds to a phone on cellular: half a second of a fast stream fits. + assert.ok(MAX_DIRECT_PENDING_BYTES >= 0.5 * 5_000_000); +}); + // --- The cutover ------------------------------------------------------------ const frame = (n, size = 4) => new Uint8Array(size).fill(n); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5a0a8b073..c96d20b50 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -16,7 +16,7 @@ "docs/specs/pocket-app.md": 4200, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 4200, - "docs/specs/remote-security-model.md": 4750, + "docs/specs/remote-security-model.md": 4800, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, From d3254d38aa845e5efc0d369318bf31468e20ff70 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:03:13 -0700 Subject: [PATCH 28/46] fix(remote): one relay-frame path for both ends, and decode inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DirectEndpoint.onRelayFrame` is now the single way a relay `transport` frame reaches an authorized session: it refuses one that arrives after the peer's switch, decodes the `ct` itself, and hands the receipt's control messages back to its own signal dispatch. The decode moving inside is the fix — a `ct` that passes the wire guard but will not decode was throwing out of the Client's socket handler and being warned-and-dropped by the Burrow's drain, instead of ending the session. A failed decrypt on the Client is likewise `#loseBurrow` rather than `#teardown`: what died is the end-to-end session, never the relay socket. `#teardown` and `#loseBurrow` now share `#endSession`. Also drops a why-clause from three rules that already carry a `(rationale)` marker (AGENTS.md -> "Keying"). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- docs/specs/remote-api.md | 10 +-- docs/specs/remote-security-model.md | 9 +-- docs/specs/security-remote.md | 2 +- lib/src/remote/burrow/burrow-runtime.ts | 44 ++++------- lib/src/remote/client/pocket-client.test.ts | 78 +++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 69 ++++++++-------- lib/src/remote/direct/direct-endpoint.test.ts | 33 +++++++- lib/src/remote/direct/direct-endpoint.ts | 51 +++++++++--- scripts/spec-word-budgets.json | 4 +- 9 files changed, 210 insertions(+), 90 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 4019a8ea8..c594cd426 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -88,9 +88,8 @@ 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.** `iceServers: []` at both ends, host candidates only. -**Never a public STUN or TURN default** — it would hand a third party the user's -address. (rationale) +**No ICE servers**, and **never a public STUN or TURN default**: `iceServers: +[]` at both ends, host candidates only. (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 @@ -107,7 +106,7 @@ non-binary channel message — disposes the session. (rationale) `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. + 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 @@ -136,7 +135,8 @@ Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, their guard, the constants, 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 -cutover policy, one per authorized session, constructed at promotion by +cutover policy, one per authorized session; `onRelayFrame` is both ends' only +way in from the relay; 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 diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 1198000a0..325a24d17 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -426,9 +426,7 @@ restated here. nothing the Noise session does not already protect, and **the fingerprints in an SDP are authentic for exactly one reason — that SDP arrived inside the session**. A DTLS peer is never an authenticated one. -- **Never an ICE server.** A public STUN or TURN default hands a third party the - user's address, and a TURN relay hands it the traffic; both ends pass an empty - list (rationale). +- **Never an ICE server.** Both ends pass an empty list. (rationale) - **One peer connection per session, and never a longer-lived one.** Created at the offer and closed by every path that ends the session — outcome, expiry, `client-gone`, a lost relay socket, `stop()`. @@ -448,9 +446,8 @@ on a standalone Burrow (`docs/specs/security-supply-chain.md`). A memory-safety bug in either is reachable by any stranger on those networks, and nothing above it mitigates that. -**A channel lost after cutover is burrow loss, and that is accepted**: the -counters have moved, a stream cipher has no resynchronization point, and both -ends end the session rather than resume on the Relay (rationale). The Relay +**A channel lost after cutover is burrow loss, and that is accepted**: both ends +end the session rather than resume on the Relay. (rationale) The Relay still sees that the session exists and whether each end is online; it no longer sees the traffic ([Residual metadata](#residual-metadata)). diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 7ee0de33e..a2d68a406 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -223,7 +223,7 @@ layer; neither is restated below. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index 267185aae..dc08c886b 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -58,6 +58,7 @@ import { type PresenceBinding, type SealedPushV1, type RelayToBurrowFrame, + type TransportReceipt, } from 'remote-lib-common'; import type { BurrowEnrollment } from './enrollment'; import { createSerialQueue } from '../../host/remote/serial-queue'; @@ -1336,7 +1337,10 @@ export class BurrowRuntime { const state = this.#clients.get(frame.clientId); if (!state) return; if (state.established?.connectionId === frame.id) { - this.#onEstablishedFrame(frame.clientId, state.established, frame.ct); + // Every rule about a frame on an authorized session — which path may + // carry it, and that its `ct` must decode — is the endpoint's + // (`docs/specs/remote-api.md` → Transport → "Direct path"). + state.established.direct.onRelayFrame(frame.ct); return; } const pending = state.connection; @@ -1571,48 +1575,29 @@ export class BurrowRuntime { } /** - * One transport frame on an authorized session, arriving on the relay. - * - * **After the Client has switched there is nothing left for it to send here**, - * so a frame that arrives anyway is a peer whose two paths this Burrow can no - * longer keep in order (`docs/specs/remote-api.md` → Transport → "Direct - * path"). - */ - #onEstablishedFrame(clientId: string, established: EstablishedSession, ct: string): void { - if (!established.direct.onRelayTransport()) { - this.#disposeEstablished(clientId); - return; - } - this.#receiveOnSession(clientId, established, fromBase64Url(ct)); - } - - /** - * Decrypt one transport ciphertext, whichever path carried it: protocol-v1, a - * keepalive, or one of the direct path's signals. + * Decrypt one transport ciphertext, whichever path carried it — protocol-v1, + * a keepalive, or one of the direct path's signals — and answer the receipt + * for the endpoint to read a signal out of. */ #receiveOnSession( clientId: string, established: EstablishedSession, ciphertext: Uint8Array, - ): void { - let receipt; + ): TransportReceipt | null { + let receipt: TransportReceipt; try { receipt = established.session.receive(ciphertext); } catch { // A failed decrypt is not activity: it proves only that *something* // reached this Burrow, and the session is dead either way. this.#disposeEstablished(clientId); - return; + return null; } // The one thing that refreshes the idle deadline, keepalive or application // data alike, and on either path // (`docs/specs/remote-security-model.md` → Burrow bounds). established.lastClientActivityAt = this.#now(); - if (receipt.kind === 'control') { - established.direct.onSignal(receipt.value); - return; - } - if (receipt.kind !== 'app') return; + if (receipt.kind !== 'app') return receipt; for (const message of receipt.messages) { let payload: unknown; try { @@ -1629,8 +1614,9 @@ export class BurrowRuntime { // session from inside this loop. Handing the rest of the receipt to an // api that is already disposed would leave whatever it allocates with no // owner left to tear it down. - if (this.#clients.get(clientId)?.established !== established) return; + if (this.#clients.get(clientId)?.established !== established) return null; } + return receipt; } #sendApp( @@ -1651,7 +1637,7 @@ export class BurrowRuntime { // **Only a poisoned session is burrow loss.** An over-cap message is // refused before the first `encryptWithAd`, so no ciphertext exists and // no counter moved; disposing there would turn a caller's size error into - // a re-handshake, re-entrantly from inside `#onEstablishedFrame`'s loop. + // a re-handshake, re-entrantly from inside `#receiveOnSession`'s loop. if (!session.isPoisoned) { console.warn('[burrow] discarding an application message the transport refused'); return; diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index da2f06fb4..677d0e611 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -914,6 +914,84 @@ describe('the direct path, end to end', () => { ); }); + /** + * `isE2eCiphertext` bounds a `ct`'s alphabet and its length, not its padding, + * so a relay can put a well-shaped envelope on the wire whose ciphertext will + * not decode. Both ends must end the session on it, rather than throw out of + * the socket handler or warn and drop it. + */ + it('disposes the Client’s session on a relay frame that will not decode', async () => { + // Nothing switches, so the relay is still the path this frame belongs on + // and the decode is the only thing that can refuse it. + const run = await connectedDirect({ network: { opening: 'never' } }); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + expect(() => + run.harness.clientSocket().receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + // Two base64url characters: one byte, with nonzero trailing bits. + ct: 'AB', + }), + ).not.toThrow(); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + }); + + it('disposes the Burrow’s session on a relay frame that will not decode', async () => { + const run = await connectedDirect({ network: { opening: 'never' } }); + + run.harness.relay.burrowSocket.receive({ + t: 'e2e', + clientId: run.harness.relay.clientId, + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'AB', + }); + + await waitFor( + () => run.harness.burrow.establishedSessionCount === 0, + 'the Burrow to drop the session', + ); + }); + + /** + * What a failed decrypt kills is the end-to-end session; the relay socket is + * to the *Relay*, and reconnecting is a fresh handshake over the one already + * open. Nulling it without closing it would leave it live and unreferenced, + * with the app's next `openSocket()` opening a second beside it. + */ + it('keeps the relay socket open when the end-to-end session fails', async () => { + const run = await connectedDirect({ network: { opening: 'never' } }); + const socket = run.harness.clientSocket(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Decodes, and then fails to decrypt: 18 bytes that are not this session's. + socket.receive({ + t: 'e2e', + burrowId: run.harness.burrowId, + kind: 'connection', + id: run.connectionId, + step: 'transport', + ct: 'A'.repeat(24), + }); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + expect(run.harness.client.socketOpen).toBe(true); + // The same socket, still open: `openSocket()` would reuse it. + expect(run.harness.clientSocket()).toBe(socket); + expect(socket.readyState).toBe(1); + }); + /** * The race the holding queue exists for: the Burrow's answers overtake the * `direct-switch` that precedes them on the relay. The in-memory relay routes diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 38d16ec84..1efcf0ff8 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -75,6 +75,7 @@ import { type TerminalAttachResult, type TerminalClosedEvent, type TerminalDataEvent, + type TransportReceipt, } from 'remote-lib-common'; import { PasskeyAlreadyRegisteredError, @@ -1198,11 +1199,13 @@ export class PocketClient { * The end-to-end session is over while the relay socket is not: dispose it, * fail everything in flight, and tell the app — the same three steps a * `burrow-gone` frame takes, because from here they are the same event. + * + * **The socket is left alone**, which is the whole difference from + * {@link #teardown}: it is the socket to the *Relay*, and reconnecting is a + * fresh handshake over the one already open. */ #loseBurrow(reason: string): void { - this.#disposeCeremony(); - this.#rejectAll(new Error(reason)); - this.#onBurrowGone?.(); + this.#endSession(reason, { notifyGone: true }); } /** Where this session's frames are addressed on the relay. */ @@ -1412,7 +1415,11 @@ export class PocketClient { frame.id === established.connectionId && frame.step === 'transport' ) { - this.#onEstablishedFrame(established, frame.ct); + // Every rule about a frame on an authorized session — which path may + // carry it, and that its `ct` must decode — is the endpoint's, and it is + // created and dropped with `#established` (`docs/specs/remote-api.md` → + // Transport → "Direct path"). + this.#direct?.onRelayFrame(frame.ct); return; } const key = waiterKey(frame.kind, frame.id, frame.step); @@ -1423,42 +1430,28 @@ export class PocketClient { } /** - * One transport frame on an authorized session, arriving on the relay. - * - * **After the Burrow has switched there is nothing left for it to send here**, - * so a frame that arrives anyway is a peer whose two paths this Client can no - * longer keep in order (`docs/specs/remote-api.md` → Transport → "Direct - * path"). + * Decrypt one transport ciphertext, whichever path carried it, and answer the + * receipt for the endpoint to read a signal out of. **Any decrypt or framing + * failure ends the session**: there is no resynchronization point in a stream + * cipher, so a poisoned session is burrow loss and the app must leave the + * wall. */ - #onEstablishedFrame(established: EstablishedSession, ct: string): void { - if (this.#direct && !this.#direct.onRelayTransport()) { - this.#loseBurrow('a relay frame arrived after the direct switch'); - return; - } - this.#receiveOnSession(established, fromBase64Url(ct)); - } - - /** - * Decrypt one transport ciphertext, whichever path carried it. **Any decrypt - * or framing failure ends the session**: there is no resynchronization point - * in a stream cipher, so a poisoned session is burrow loss and the app must - * leave the wall. - */ - #receiveOnSession(established: EstablishedSession, ciphertext: Uint8Array): void { - let receipt; + #receiveOnSession( + established: EstablishedSession, + ciphertext: Uint8Array, + ): TransportReceipt | null { + let receipt: TransportReceipt; try { receipt = established.session.receive(ciphertext); } catch { - this.#teardown('the end-to-end session failed', { notifyGone: true }); - return; - } - // A keepalive is accepted and ignored; the only control messages on an - // established session are the direct path's signals. - if (receipt.kind === 'control') { - this.#direct?.onSignal(receipt.value); - return; + // The end-to-end session is what died, never the relay socket: it is to + // the *Relay*, and the app reconnects with a fresh handshake over it. + this.#loseBurrow('the end-to-end session failed'); + return null; } - if (receipt.kind !== 'app') return; + // A keepalive is accepted and ignored; a control message is one of the + // direct path's signals, which the endpoint reads off this receipt. + if (receipt.kind !== 'app') return receipt; for (const message of receipt.messages) { let payload: unknown; try { @@ -1468,6 +1461,7 @@ export class PocketClient { } this.#onMsg(payload); } + return receipt; } #onMsg(data: unknown): void { @@ -1506,6 +1500,11 @@ export class PocketClient { */ #teardown(reason: string, { notifyGone }: { notifyGone: boolean }): void { this.#ws = null; // never reuse a closed socket; openSocket() makes a fresh one + this.#endSession(reason, { notifyGone }); + } + + /** Everything a session's end does short of the socket; see {@link #teardown}. */ + #endSession(reason: string, { notifyGone }: { notifyGone: boolean }): void { this.#disposeCeremony(); this.#rejectAll(new Error(reason)); if (notifyGone) this.#onBurrowGone?.(); diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index f9b4b5901..a8c426828 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest'; import { DIRECT_SETUP_TIMEOUT_MS, MAX_DIRECT_PENDING_FRAMES, + toBase64Url, type DirectPath, type DirectSignalV1, } from 'remote-lib-common'; @@ -99,7 +100,12 @@ function pair(options: Options = {}) { else deliver(signal); return true; }, - receive: (ciphertext) => void side.received.push(ciphertext), + receive: (ciphertext) => { + side.received.push(ciphertext); + // Signals ride the stand-in relay here rather than a real session, so + // nothing a frame decrypts to is a control message. + return { kind: 'keepalive' } as const; + }, fatal: (reason) => void side.fatals.push(reason), isCurrent: () => side.live, onPathChanged: (path) => void side.paths.push(path), @@ -324,10 +330,31 @@ describe('DirectEndpoint', () => { await cutover(run); // Before the peer's switch the relay is still the path it sends on. - expect(run.offerer.endpoint.onRelayTransport()).toBe(true); + run.offerer.endpoint.onRelayFrame(toBase64Url(frame(1))); + expect(run.offerer.received).toEqual([frame(1)]); + run.fake.openChannels(); + run.offerer.endpoint.onRelayFrame(toBase64Url(frame(2))); - expect(run.offerer.endpoint.onRelayTransport()).toBe(false); + expect(run.offerer.fatals).toEqual(['a relay frame arrived after the direct switch']); + // Refused before any decrypt: the ciphertext is never even looked at. + expect(run.offerer.received).toEqual([frame(1)]); + }); + + /** + * The wire guard bounds a `ct`'s alphabet and length, not its padding, so a + * peer can put a well-shaped envelope on the relay whose ciphertext will not + * decode. That is the session's failure, not an exception out of a socket + * handler. + */ + it('ends the session on a relay frame whose ciphertext will not decode', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.offerer.endpoint.onRelayFrame('AB'); + + expect(run.offerer.fatals).toEqual(['a relay frame was not a ciphertext']); + expect(run.offerer.received).toEqual([]); }); it('routes a ciphertext onto the channel only after this end has switched', async () => { diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 95897485f..285a0365b 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -15,9 +15,11 @@ import { DirectCutover, + fromBase64Url, isDirectSignalV1, type DirectPath, type DirectSignalV1, + type TransportReceipt, } from 'remote-lib-common'; import { DirectPeer, type DirectPeerFactory } from './direct-peer'; @@ -38,10 +40,14 @@ export interface DirectEndpointDeps { */ sendSignal(signal: DirectSignalV1): boolean; /** - * Decrypt one transport ciphertext that arrived on the channel — the same - * path a relay ciphertext takes, because it is the same session. + * Decrypt one transport ciphertext, whichever path carried it, process + * everything that is not a signal, and answer the receipt. `null` where the + * decrypt failed — a poisoned session, which the owner has already disposed. + * + * The signals come back here rather than being dispatched by the owner: the + * endpoint is the only thing that knows what one means. */ - receive(ciphertext: Uint8Array): void; + receive(ciphertext: Uint8Array): TransportReceipt | null; /** * The session is unrecoverable: the endpoint's owner disposes it (the Burrow * through `#disposeEstablished`, the Client through `#loseBurrow`). @@ -132,7 +138,7 @@ export class DirectEndpoint { // take: what was held is exactly what was sent after the switch. for (const frame of outcome.frames) { if (!this.#alive()) return; - this.#deps.receive(frame); + this.#deliver(frame); } return; } @@ -140,14 +146,31 @@ export class DirectEndpoint { } /** - * One transport frame arriving on the relay; `false` is a violation. + * One transport frame arriving on the relay, as the envelope carried it. + * **Both ends read a relay frame through here**, so every rule about what may + * arrive on which path is stated once. * * **After the peer has switched there is nothing left for it to send there**, * so a frame that arrives anyway is a peer whose two paths this end can no - * longer keep in order. + * longer keep in order. **A `ct` that will not decode ends the session too**: + * the wire guard bounds the alphabet and the length, not the padding, so the + * decode belongs inside the session's own failure path rather than thrown out + * of a socket handler. */ - onRelayTransport(): boolean { - return this.#cutover.onRelayTransport() === 'process'; + onRelayFrame(ct: string): void { + if (!this.#alive()) return; + if (this.#cutover.onRelayTransport() !== 'process') { + this.#deps.fatal('a relay frame arrived after the direct switch'); + return; + } + let ciphertext: Uint8Array; + try { + ciphertext = fromBase64Url(ct); + } catch { + this.#deps.fatal('a relay frame was not a ciphertext'); + return; + } + this.#deliver(ciphertext); } /** @@ -253,7 +276,7 @@ export class DirectEndpoint { if (!this.#alive()) return; switch (this.#cutover.onChannelFrame(frame)) { case 'process': - this.#deps.receive(frame); + this.#deliver(frame); return; case 'held': return; @@ -263,6 +286,16 @@ export class DirectEndpoint { } } + /** + * One transport ciphertext into the session, whichever path carried it. **A + * control message on an established session is one of this path's signals**, + * and reading it here is what keeps the two ends' receive paths identical. + */ + #deliver(ciphertext: Uint8Array): void { + const receipt = this.#deps.receive(ciphertext); + if (receipt?.kind === 'control') this.onSignal(receipt.value); + } + #onClosed(reason: string): void { if (!this.#alive()) return; this.#giveUp(reason); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c96d20b50..2851add9f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,12 +15,12 @@ "docs/specs/notepad.md": 3700, "docs/specs/pocket-app.md": 4200, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4200, + "docs/specs/remote-api.md": 4250, "docs/specs/remote-security-model.md": 4800, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 5600, + "docs/specs/security-remote.md": 5650, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, From 5bff45d572167c508bfbca2b7bd09bff69465d62 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:06:21 -0700 Subject: [PATCH 29/46] fix(remote): stop a chunked message at the send that ended the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DirectEndpoint.send` disposes the session when the channel refuses a ciphertext, so a caller mid-message would route the remaining chunks onto the relay of a session that no longer exists — post-switch ciphertext on the relay, killing the peer with "relay frame after switch". Both `#sendApp` loops now stop as soon as their session is no longer the live one, and a send on a disposed endpoint answers `true` so nothing reaches the wire in between. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- lib/src/remote/burrow/burrow-bounds.test.ts | 30 +++++++++++++++++++ lib/src/remote/burrow/burrow-runtime.ts | 8 ++++- lib/src/remote/client/pocket-client.test.ts | 24 +++++++++++++++ lib/src/remote/client/pocket-client.ts | 4 +++ lib/src/remote/direct/direct-endpoint.test.ts | 6 ++-- lib/src/remote/direct/direct-endpoint.ts | 15 +++++++--- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/lib/src/remote/burrow/burrow-bounds.test.ts b/lib/src/remote/burrow/burrow-bounds.test.ts index 80e016fba..d5e4928a2 100644 --- a/lib/src/remote/burrow/burrow-bounds.test.ts +++ b/lib/src/remote/burrow/burrow-bounds.test.ts @@ -1002,6 +1002,36 @@ describe('BurrowRuntime bounds', () => { expect(sessions[0]!.disposed).toBe(true); }); + /** + * The Burrow's half of the same rule the Client keeps: a refused chunk + * disposes the session synchronously, from inside the loop still chunking the + * message, and the rest of it must not fall back onto the relay. + */ + it('stops a multi-chunk reply when the channel refuses its first chunk', async () => { + const network = directBurrow(); + const live = await establish('c1'); + await openDirectPath({ + socket, + burrowId: enrollment.burrowId, + clientId: 'c1', + connectionId: live.connectionId, + session: live.session, + network, + setTimer: clock.setTimer, + }); + const before = e2eFramesFor(socket, 'connection', live.connectionId).length; + // Closed under the session, which a radio gap does between two sends. + network.answererChannel!.close(); + + // Over one Noise message, so the transport chunks it into two ciphertexts. + sessions[0]!.send({ requestId: 'r1', ok: true, result: 'x'.repeat(70_000) }); + await settle(); + + expect(sessions[0]!.disposed).toBe(true); + expect(burrow.establishedSessionCount).toBe(0); + expect(e2eFramesFor(socket, 'connection', live.connectionId)).toHaveLength(before); + }); + it('disposes a session whose held channel frames outrun the queue', async () => { const network = directBurrow(); const live = await establish('c1'); diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index dc08c886b..4ede7d943 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -1630,7 +1630,13 @@ export class BurrowRuntime { const direct = this.#directFor(clientId, connectionId); try { for (const ciphertext of session.sendApp(utf8Encode(JSON.stringify(payload)))) { - if (direct?.send(ciphertext)) continue; + if (direct?.send(ciphertext)) { + // A channel that refuses a chunk disposes this session synchronously: + // the rest of the message has no session left to belong to, and must + // not fall back onto the relay of one that is over. + if (this.#directFor(clientId, connectionId) !== direct) return; + continue; + } this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); } } catch { diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 677d0e611..ba331625a 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -920,6 +920,30 @@ describe('the direct path, end to end', () => { * not decode. Both ends must end the session on it, rather than throw out of * the socket handler or warn and drop it. */ + /** + * A refused chunk disposes the session synchronously, from inside the loop + * that is still chunking the message. The rest of it belongs nowhere: routing + * it onto the relay would put post-switch ciphertext there and kill the peer + * with a misleading reason. + */ + it('stops a multi-chunk message when the channel refuses its first chunk', async () => { + const run = await connectedDirect(); + await run.cutover(); + const clientBefore = run.clientFrames().length; + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + // Closed under the session, which a radio gap does between two sends. + run.network.offererChannel!.close(); + + // Over one Noise message, so the transport chunks it into two ciphertexts. + await expect(run.harness.client.write('surface-1', 'x'.repeat(70_000))).rejects.toThrow(); + + expect(gone).toHaveBeenCalledOnce(); + expect(run.harness.client.connectedBurrowId).toBeNull(); + // Neither chunk reached the relay of a session that had just been torn down. + expect(run.clientFrames()).toHaveLength(clientBefore); + }); + it('disposes the Client’s session on a relay frame that will not decode', async () => { // Nothing switches, so the relay is still the path this frame belongs on // and the decode is the only thing that can refuse it. diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 1efcf0ff8..cb6d06df8 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -1318,6 +1318,10 @@ export class PocketClient { if (this.#reapedByBurrow(established)) throw new Error(BURROW_SESSION_REAPED_MESSAGE); for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { this.#deliver(established, burrowId, ciphertext); + // A channel that refuses a chunk is burrow loss, taken synchronously: the + // rest of this message has no session left to belong to, and must not + // fall back onto the relay of one that has just been torn down. + if (this.#established !== established) return; } established.lastSentAt = this.#now(); } diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index a8c426828..ee001f079 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -389,8 +389,10 @@ describe('DirectEndpoint', () => { expect(run.offerer.peers[0]!.closed).toBe(true); expect(run.offerer.endpoint.path).toBe('relay'); expect(run.offerer.paths).toEqual(['direct', 'relay']); - // Inert afterwards: nothing it is told does anything to a dead session. - expect(run.offerer.endpoint.send(frame(1))).toBe(false); + // Inert afterwards: nothing it is told does anything to a dead session, and + // a send is consumed rather than handed back for the relay to carry. + expect(run.offerer.endpoint.send(frame(1))).toBe(true); + expect(run.fake.offererChannel!.sent).toEqual([]); run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); expect(run.offerer.fatals).toEqual([]); }); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 285a0365b..c792a9724 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -174,12 +174,19 @@ export class DirectEndpoint { } /** - * One transport ciphertext, `true` once it is on the channel. The caller puts - * it on the relay when this answers `false`, so "after the switch, nothing on - * the relay" is one line rather than a rule each caller keeps. + * One transport ciphertext, `true` once it is consumed. The caller puts it on + * the relay when this answers `false`, so "after the switch, nothing on the + * relay" is one line rather than a rule each caller keeps. + * + * **A disposed endpoint consumes it too.** A refused send disposes the session + * synchronously, and the caller's loop is mid-message: the remaining chunks + * belong nowhere, least of all on the relay of a session that is over. The + * callers stop on their own next check; this only keeps the interval between + * the two from reaching the wire. */ send(ciphertext: Uint8Array): boolean { - if (this.#disposed || this.#cutover.outbound !== 'direct') return false; + if (this.#disposed) return true; + if (this.#cutover.outbound !== 'direct') return false; if (this.#peer?.send(ciphertext)) return true; // Switched, and the channel will not take it: there is no relay to fall // back to, so this is burrow loss rather than a message to re-route. From 038237ed9079d23d9b6fbfb5b10f03de0e5fdd9a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:08:00 -0700 Subject: [PATCH 30/46] fix(remote): a replaced Client session takes its peer with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect()` was overwriting `#established` and `#direct` without disposing what they held, so a second Connect leaked the previous peer and channel — and `onViolation`, alone among the channel handlers, was ungated, so a bad frame on the orphaned channel called `fatal` on the session that had replaced it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- lib/src/remote/client/pocket-client.test.ts | 26 +++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 5 ++++ lib/src/remote/direct/direct-endpoint.test.ts | 13 ++++++++++ lib/src/remote/direct/direct-endpoint.ts | 15 ++++++++--- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index ba331625a..eb2a38d13 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1044,6 +1044,32 @@ describe('the direct path, end to end', () => { expect(run.harness.client.transportPath).toBe('direct'); }); + /** + * A Client that connects twice replaces its own session, and the peer and + * channel of the one it replaced go with it: left alive, the orphan's channel + * would still be reporting violations against the session that replaced it. + */ + it('closes the previous session’s peer when the Client connects again', async () => { + const run = await connectedDirect(); + await run.cutover(); + const firstPeer = run.clientPeers[0]!; + const firstChannel = run.network.offererChannel!; + + const second = await run.harness.client.connect(run.harness.burrowId); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + expect(second.ok).toBe(true); + expect(run.clientPeers).toHaveLength(2); + expect(firstPeer.closed).toBe(true); + + // Nothing arriving on the orphan can touch the session that replaced it. + firstChannel.receiveRaw('a text frame'); + + expect(gone).not.toHaveBeenCalled(); + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + }); + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { const run = await connectedDirect(); await run.cutover(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index cb6d06df8..473c8cb38 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -900,6 +900,11 @@ export class PocketClient { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['burrow-error'], pairingRequired: false }; } if (outcome.ok) { + // A second Connect on one Client replaces the first: its predecessor's + // endpoint, peer and channel go before the replacement is promoted, the + // mirror of `BurrowRuntime.#promoteConnection`. Left alive, the orphan's + // channel would still be reporting violations against *this* session. + this.#disposeCeremony(); this.#established = { connectionId, session, lastSentAt: this.#now() }; this.#connectedBurrowId = burrowId; this.#startKeepalives(); diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index ee001f079..860496398 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -276,6 +276,19 @@ describe('DirectEndpoint', () => { expect(run.offerer.fatals).toEqual(['a direct channel message was not binary']); }); + it('ignores a channel violation once its session is no longer the live one', async () => { + const run = pair(); + await cutover(run); + // What a replacement promotion does to the session under an endpoint: the + // channel is still there and still reporting, and none of it is this + // session's business any more. + run.offerer.live = false; + + run.fake.offererChannel!.receiveRaw('a text frame'); + + expect(run.offerer.fatals).toEqual([]); + }); + it('holds channel frames until the peer’s switch, then drains them in order', async () => { const run = pair({ opening: 'manual' }); await cutover(run); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index c792a9724..90b052419 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -255,9 +255,7 @@ export class DirectEndpoint { onOpen: () => this.#onOpen(), onFrame: (frame) => this.#onFrame(frame), onClosed: (reason) => this.#onClosed(reason), - // A peer speaking something else on the channel is not one this - // session's counters can stay synchronized with, switched or not. - onViolation: (reason) => this.#deps.fatal(reason), + onViolation: (reason) => this.#onViolation(reason), }, }); return this.#peer; @@ -308,6 +306,17 @@ export class DirectEndpoint { this.#giveUp(reason); } + /** + * A peer speaking something else on the channel is not one this session's + * counters can stay synchronized with, switched or not — but only *this* + * session's, so it is gated like every other channel event: a leftover + * channel must never end the session that replaced it. + */ + #onViolation(reason: string): void { + if (!this.#alive()) return; + this.#deps.fatal(reason); + } + /** * The attempt is over. **Before either direction has switched that is merely an * abandoned attempt** and the session carries on relayed; afterwards what was From 953129f38c3c0d8063be5a2b5fe98eedd2ef0595 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:09:56 -0700 Subject: [PATCH 31/46] fix(remote): give the answerer the setup deadline that fires first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ends armed `DIRECT_SETUP_TIMEOUT_MS` on their own clock, the answerer a relay hop later, so a channel coming up near the end of the budget had the answerer open, switch, and land its `direct-switch` on an offerer that had just abandoned — fatal at both ends, for a healthy relayed session on a slow ICE. `DIRECT_ANSWER_TIMEOUT_MS` is 10 s against the offerer's 15 s, so the answerer's channel closing is what reaches the offerer while it can still abandon. The fake data channel now reports a close to its linked end, as an SCTP association does, which is the path that carries it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- docs/specs/remote-api.md | 5 +++- docs/specs/remote-security-model.md | 2 +- lib/src/remote/direct/direct-endpoint.test.ts | 29 +++++++++++++++++++ lib/src/remote/direct/direct-peer.ts | 14 +++++---- lib/src/remote/direct/test-fake-peer.ts | 3 ++ remote-lib-common/src/security/direct-path.ts | 10 +++++++ remote-lib-common/test/direct-path.test.mjs | 9 ++++++ 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index c594cd426..7604f259e 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -83,7 +83,10 @@ 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. **An SDP +`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. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 325a24d17..117332fa7 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -340,7 +340,7 @@ omits `client-gone`, invents client IDs, or reorders frames. | `MAX_ESTABLISHED_E2E_SESSIONS` | 16 | `remote-lib-common/src/security/e2e-bounds.ts` | | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | -| `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | +| `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | | `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index 860496398..c7abf7eb3 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { + DIRECT_ANSWER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, MAX_DIRECT_PENDING_FRAMES, toBase64Url, @@ -323,6 +324,34 @@ describe('DirectEndpoint', () => { expect(run.offerer.received).toEqual([]); }); + /** + * The two deadlines are armed on different clocks — the offerer's at `offer()` + * and the answerer's a relay hop later, at `answer()` — so a channel that + * comes up near the end of the budget must not have the answerer opening, + * switching, and landing its `direct-switch` on an offerer that has just + * abandoned. Giving the answerer the shorter budget makes its channel closing + * the event that reaches the offerer, and both ends abandon. + */ + it('gives the answerer the deadline that fires first, so a slow channel only abandons', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_ANSWER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.answerer.peers[0]!.closed).toBe(true); + // The answerer's channel closing is what the offerer sees, while it is + // still unswitched and can give the attempt up. + expect(run.offerer.peers[0]!.closed).toBe(true); + expect(run.offerer.fatals).toEqual([]); + expect(run.answerer.fatals).toEqual([]); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.answerer.endpoint.path).toBe('relay'); + // And the offerer's own budget is gone with its peer, so nothing is left + // armed to fire on a session that has moved on. + expect(run.timers.live).toEqual([]); + }); + it('ends the session when the peer switches onto a channel this end abandoned', async () => { const run = pair({ opening: 'manual' }); await cutover(run); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 768f7a63b..09706f1df 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -16,6 +16,7 @@ */ import { + DIRECT_ANSWER_TIMEOUT_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, NOISE_MAX_MESSAGE_LENGTH, @@ -100,7 +101,8 @@ export interface DirectPeerDeps { * **One negotiation, no trickle**: each side sends its whole description once * ICE gathering has completed (or `DIRECT_GATHER_TIMEOUT_MS` has passed), so * the candidates travel inside the session with the SDP and the relay never - * sees either. **The channel must be open by `DIRECT_SETUP_TIMEOUT_MS`** or the + * sees either. **The channel must be open by `DIRECT_SETUP_TIMEOUT_MS`** — by + * `DIRECT_ANSWER_TIMEOUT_MS` on the answering side, which arms later — or the * attempt is abandoned and the session stays relayed. */ export class DirectPeer { @@ -132,7 +134,7 @@ export class DirectPeer { * never offers. */ async offer(): Promise { - this.#armSetupTimeout(); + this.#armSetupTimeout(DIRECT_SETUP_TIMEOUT_MS); try { this.#adopt(this.#peer.createDataChannel(DIRECT_CHANNEL_LABEL, { ordered: true })); const offer = await this.#peer.createOffer(); @@ -149,7 +151,9 @@ export class DirectPeer { * answer with the SDP to put in a `direct-answer`. `null` means decline. */ async answer(offerSdp: string): Promise { - this.#armSetupTimeout(); + // The shorter budget, because this end arms it a relay hop after the + // offerer armed its own; see {@link DIRECT_ANSWER_TIMEOUT_MS}. + this.#armSetupTimeout(DIRECT_ANSWER_TIMEOUT_MS); try { // Registered before the remote description is set: the channel event can // fire inside `setRemoteDescription`, and a listener added after it would @@ -296,13 +300,13 @@ export class DirectPeer { }); } - #armSetupTimeout(): void { + #armSetupTimeout(budgetMs: number): void { this.#clearSetupTimeout(); this.#cancelSetup = this.#setTimer(() => { this.#cancelSetup = null; if (this.#open || this.#closed) return; this.#fail('the direct channel did not open in time'); - }, DIRECT_SETUP_TIMEOUT_MS); + }, budgetMs); } #clearSetupTimeout(): void { diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index b40c5ff87..6a6d80a3d 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -249,6 +249,9 @@ export class FakeChannel implements DirectChannelLike { close(): void { if (this.readyState === 'closed') return; this.readyState = 'closed'; + // The far end learns of it, as it does over a real SCTP association — on a + // task rather than in this stack, since the network is between them. + queueMicrotask(() => this.#peer?.drop()); } open(): void { diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 4d99a2f9a..47352d210 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -21,6 +21,16 @@ import { CONTROL_PAYLOAD_SIZE } from './noise-transport.js'; */ export const DIRECT_SETUP_TIMEOUT_MS = 15_000; +/** + * The same deadline for the answerer, which arms it a relay round trip later + * than the offerer does and so **must be the one that gives up first**. Its + * channel closing is what reaches the offerer while the offerer is still + * unswitched, so both ends abandon a slow attempt and stay relayed instead of + * meeting on the fatal rule for a switch onto an abandoned channel. The + * ordering is pinned by `remote-lib-common/test/direct-path.test.mjs`. + */ +export const DIRECT_ANSWER_TIMEOUT_MS = 10_000; + /** * How long a peer waits for ICE gathering to finish before sending whatever * local description it has. There is no trickle path — the SDP crosses inside diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index f4f38b439..c9dca8c9a 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -13,6 +13,7 @@ import assert from 'node:assert/strict'; import { CONTROL_PAYLOAD_SIZE, + DIRECT_ANSWER_TIMEOUT_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, DirectCutover, @@ -99,8 +100,16 @@ test('any signal with a maximal sdp fits one control message', () => { test('the timings the spec names are the values that ship', () => { assert.equal(DIRECT_SETUP_TIMEOUT_MS, 15_000); + assert.equal(DIRECT_ANSWER_TIMEOUT_MS, 10_000); assert.equal(DIRECT_GATHER_TIMEOUT_MS, 3_000); assert.ok(DIRECT_GATHER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); + // The answerer arms its deadline a relay hop after the offerer arms its own, + // so it has to be the end that gives up first: its channel closing reaches + // the offerer while the offerer is still unswitched and can abandon cleanly. + // The other order kills a healthy relayed session on a slow ICE — the + // answerer opens, switches, and its `direct-switch` decrypts at an offerer + // that has just abandoned, which is fatal at both ends. + assert.ok(DIRECT_ANSWER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); }); /** From dc0a0f0e04de2a84f8ff45f1221e7fda0f0577fd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:11:03 -0700 Subject: [PATCH 32/46] refactor(remote): copy a channel frame at the hold, and nowhere else `binaryType = 'arraybuffer'` means both the browser and the polyfill dispatch a fresh `ArrayBuffer` per message, so the copying branch in `#onMessage` was dead and the live one was already a view. The only frame whose buffer has to outlive its call is one the cutover holds, so that is where the copy belongs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- lib/src/remote/direct/direct-peer.ts | 10 ++++++---- remote-lib-common/src/security/direct-path.ts | 8 +++++++- remote-lib-common/test/direct-path.test.mjs | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 09706f1df..2e507f167 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -245,13 +245,15 @@ export class DirectPeer { if (this.#closed) return; const data = (ev as { data?: unknown } | null)?.data; let frame: Uint8Array; + // A view either way, never a copy: everything downstream reads the frame + // inside this call, except the one the cutover holds until the peer's + // switch decrypts — and `DirectCutover.onChannelFrame` copies that one, + // which is the only place that knows a frame is about to outlive its + // buffer. if (data instanceof ArrayBuffer) { frame = new Uint8Array(data); } else if (ArrayBuffer.isView(data)) { - // Copied, not viewed: the cutover may hold this frame until the peer's - // switch decrypts, and a view over a pooled or reused buffer (the Node - // polyfill hands over a `Buffer`) would read whatever landed there next. - frame = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); + frame = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } else { this.#handlers.onViolation('a direct channel message was not binary'); return; diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 47352d210..55f17e551 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -282,6 +282,12 @@ export class DirectCutover { /** * One inbound channel frame: processed once the peer's switch has been read, * held until then, and `overflow` when holding it would break the bound. + * + * **A held frame is copied, and only a held frame.** Holding is the one thing + * here that outlives the call, so it is the one place the caller's buffer — + * a view over whatever the runtime handed it — cannot be trusted to still say + * the same thing when the queue drains. A frame answered `process` is + * decrypted before this returns. */ onChannelFrame(frame: Uint8Array): DirectChannelOutcome { if (this.#inbound === 'direct') return 'process'; @@ -291,7 +297,7 @@ export class DirectCutover { ) { return 'overflow'; } - this.#held.push(frame); + this.#held.push(frame.slice()); this.#heldBytes += frame.length; return 'held'; } diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index c9dca8c9a..5fec26eb7 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -248,6 +248,21 @@ test('overflows on the byte cap, whatever the frame count', () => { assert.ok(cutover.pendingFrames < MAX_DIRECT_PENDING_FRAMES); }); +/** + * A held frame is the one thing here that outlives the call that delivered it, + * so it is the one the caller's buffer cannot be trusted for: both ends hand + * over a view, and a runtime that pools or reuses that buffer would otherwise + * drain whatever landed in it next. + */ +test('a held frame is copied, so the caller’s buffer may be reused', () => { + const cutover = new DirectCutover(); + const buffer = frame(1); + assert.equal(cutover.onChannelFrame(buffer), 'held'); + buffer.fill(9); + + assert.deepEqual(cutover.onSwitchDecrypted(), { kind: 'drain', frames: [frame(1)] }); +}); + test('clear releases what a disposed session will never drain', () => { const cutover = new DirectCutover(); cutover.onChannelFrame(frame(1)); From 8d0911dfa73deb132c4c54f998707c58292c1b73 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:12:06 -0700 Subject: [PATCH 33/46] fix(remote): a closed peer cancels the gathering deadline it is waiting on `close()` cancelled the setup timer but not the 3 s gathering one, and `RTCPeerConnection.close()` fires no `icegatheringstatechange`, so a peer closed mid-negotiation kept the suspended `offer()`/`answer()` frame, the endpoint and the session alive behind a timer nothing would cancel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- lib/src/remote/direct/direct-peer.test.ts | 22 ++++++++++++++++++++++ lib/src/remote/direct/direct-peer.ts | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index 79ecdd0e1..1bfe55e2a 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -178,6 +178,28 @@ describe('DirectPeer', () => { expect(peer.closed).toBe(true); }); + /** + * `RTCPeerConnection.close()` fires no `icegatheringstatechange`, so nothing + * but `close()` can ever settle this wait — and until it does, the suspended + * negotiation holds the endpoint and its session alive behind a timer that is + * not `unref`ed. + */ + it('cancels the gathering deadline when it is closed mid-negotiation', async () => { + const { clientPeer, timers } = pair({ gathering: 'pending' }); + + const offering = clientPeer.offer(); + await flushMicrotasks(); + expect(timers.live.map((timer) => timer.delayMs)).toEqual([ + DIRECT_SETUP_TIMEOUT_MS, + DIRECT_GATHER_TIMEOUT_MS, + ]); + + clientPeer.close(); + + expect(await offering).toBeNull(); + expect(timers.live).toEqual([]); + }); + it('closes idempotently, reporting nothing', () => { const { clientPeer, client } = pair(); clientPeer.close(); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 2e507f167..ed382fafe 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -111,6 +111,13 @@ export class DirectPeer { readonly #setTimer: RemoteTimer; #channel: DirectChannelLike | null = null; #cancelSetup: (() => void) | null = null; + /** + * Settles the gathering wait — cancelling its deadline with it — or null when + * none is outstanding. Held on the instance because {@link close} has to + * reach it: `RTCPeerConnection.close()` fires no `icegatheringstatechange`, + * so nothing else would. + */ + #endGathering: (() => void) | null = null; #open = false; #closed = false; @@ -204,6 +211,10 @@ export class DirectPeer { close(): void { this.#closed = true; this.#clearSetupTimeout(); + // The suspended `offer()`/`answer()` finishes here rather than in three + // seconds' time: it sees `#closed`, answers `null`, and releases the + // endpoint and the session it was still holding open. + this.#endGathering?.(); try { this.#channel?.close(); } catch { @@ -287,14 +298,14 @@ export class DirectPeer { #awaitGathering(): Promise { if (this.#peer.iceGatheringState === 'complete') return Promise.resolve(); return new Promise((resolve) => { - let settled = false; let cancel: (() => void) | null = null; const finish = (): void => { - if (settled) return; - settled = true; + if (this.#endGathering !== finish) return; + this.#endGathering = null; cancel?.(); resolve(); }; + this.#endGathering = finish; cancel = this.#setTimer(finish, DIRECT_GATHER_TIMEOUT_MS); this.#peer.addEventListener('icegatheringstatechange', () => { if (this.#peer.iceGatheringState === 'complete') finish(); From 24de2d8f52da2b2982f8b9c5af84e04f18e68dea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:13:53 -0700 Subject: [PATCH 34/46] refactor(remote): share the endpoint's post-await guard, and tighten the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#answer()` carried its own copy of `offer()`'s staleness guard and of the decline block; both are named now, and the `setTimer` spread that could never be false is gone. `#promoteConnection` inlined `#disposeEstablished`'s body — which it cannot call, since the prune would detach the state it is about to write — so both go through `#clearEstablished`. remote-security-model.md -> "Direct path" said the design was not restated and then restated four of remote-api.md's rules; they collapse to one sentence of pointers, and the dated candidate measurement moves to the rationale. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- docs/specs/remote-security-model.md | 26 +++++------- docs/specs/remote-security-model.rationale.md | 5 +++ lib/src/remote/burrow/burrow-runtime.ts | 19 ++++++--- lib/src/remote/direct/direct-endpoint.ts | 40 ++++++++++++------- scripts/spec-word-budgets.json | 2 +- 5 files changed, 53 insertions(+), 39 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 117332fa7..1571b12e7 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -412,31 +412,23 @@ admits Burrow enrollment with ([relay.md](./relay.md#http-api)). Pinned by the Relay as the carrier of an already-authorized session; every rule above holds unchanged, because nothing about *what* is carried changes. [remote-api.md](./remote-api.md) -> "Direct path" owns the design and is not -restated here. - -- **Same session, same counters.** The channel carries transport messages of the - session promoted at [Connection](#connection), on the two `CipherState`s from - that `Split`. **Never a second handshake, a rekey, or a byte of plaintext.** -- **Signaling never leaves the ciphertext**, which is what makes it trustworthy: - a description the Relay could have written would be one it could point at - itself. -- **Offered only after promotion.** A peer connection built before the - connection outcome would be one an unauthorized party had steered. +restated here: that the channel carries transport messages of the session +promoted at [Connection](#connection) on that `Split`'s own two `CipherState`s, +that every signal rides inside the ciphertext, that nothing is offered before +promotion, and that one peer connection per session is closed by every path +that ends one, are its rules. What this model adds is what is *underneath* them. + - **DTLS beneath is transport hygiene this model does not rely on.** It protects nothing the Noise session does not already protect, and **the fingerprints in an SDP are authentic for exactly one reason — that SDP arrived inside the session**. A DTLS peer is never an authenticated one. - **Never an ICE server.** Both ends pass an empty list. (rationale) -- **One peer connection per session, and never a longer-lived one.** Created at - the offer and closed by every path that ends the session — outcome, expiry, - `client-gone`, a lost relay socket, `stop()`. **The listener is UDP on every interface a candidate names, for the life of an attempt.** The standalone Burrow's addon binds one socket on the unspecified -address and advertises each routable interface at that port (measured -2026-09: four host candidates, one port, no loopback or link-local); a browser -binds per interface. Either way the host answers UDP from anyone who can route -to it on any of those networks. +address and advertises each routable interface at that port; a browser binds per +interface. Either way the host answers UDP from anyone who can route to it on +any of those networks. (rationale) **Two parsers sit behind it and both are attack surface**: before DTLS, ICE's own STUN parser, which answers a binding request only under this attempt's ufrag and password (RFC 8445 requires 24 and 128 bits of randomness), both diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 441341ee7..95569c94c 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -287,6 +287,11 @@ is already there. Relay-supplied ICE servers stay in `remote-api.md`'s `## Future` for a SaaS deployment, where the trade is real and would need its own analysis. +**What the listener actually opens** *(measured 2026-09, standalone Burrow on +macOS)*: four host candidates sharing one port, no loopback and no link-local +address among them. One socket on the unspecified address, advertised once per +routable interface. + **Why the UDP listener is named in the spec at all.** Everything else the product opens to a network is TCP behind loopback or behind the Relay's HTTPS origin, and the audit's listener sweep is written for that shape diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index 4ede7d943..7433179c7 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -1438,11 +1438,9 @@ export class BurrowRuntime { // Cleared with the dispose, not merely overwritten below: without a session // factory there is no replacement, and a leftover reference would route the // next frame on the old id into a handler that has already been disposed. - if (state.established) { - state.established.direct.dispose(); - state.established.api.dispose(); - state.established = undefined; - } + // Never `#disposeEstablished`, whose prune would detach the `state` this + // promotion is about to write into. + this.#clearEstablished(state); this.#sendControl(clientId, 'connection', pending.connectionId, pending.session, { ok: true, burrowLabel: boundedBurrowLabel(this.#enrollment.label), @@ -1664,10 +1662,19 @@ export class BurrowRuntime { #disposeEstablished(clientId: string): void { const state = this.#clients.get(clientId); if (!state?.established) return; + this.#clearEstablished(state); + this.#pruneClient(clientId); + } + + /** + * Tear one established session down and clear the slot, leaving the entry + * itself to the caller — a promotion is about to fill it, a disposal prunes. + */ + #clearEstablished(state: ClientState): void { + if (!state.established) return; state.established.direct.dispose(); state.established.api.dispose(); state.established = undefined; - this.#pruneClient(clientId); } // --- Shared plumbing ----------------------------------------------------- diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 90b052419..6ecdb4d0e 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -101,12 +101,7 @@ export class DirectEndpoint { return; } const sdp = await peer.offer(); - // The session may have been replaced or disposed while the description was - // being built; a peer left over from one is not this one's. - if (!this.#alive() || this.#peer !== peer) { - peer.close(); - return; - } + if (!this.#stillOurs(peer)) return; if (sdp === null || !this.#deps.sendSignal({ v: 1, t: 'direct-offer', sdp })) this.#giveUp(); } @@ -219,23 +214,38 @@ export class DirectEndpoint { if (!this.#cutover.begin()) return; const peer = this.#build(); if (!peer) { - this.#giveUp(); - this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + this.#decline(); return; } const sdp = await peer.answer(offerSdp); - if (!this.#alive() || this.#peer !== peer) { - peer.close(); - return; - } + if (!this.#stillOurs(peer)) return; if (sdp === null) { - this.#giveUp(); - this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + this.#decline(); return; } if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); } + /** + * Whether `peer` is still this endpoint's own live attempt. A promotion or a + * teardown can replace the session while a description is being built, and a + * peer left over from one is not this one's — it is closed here. + */ + #stillOurs(peer: DirectPeer): boolean { + if (this.#alive() && this.#peer === peer) return true; + peer.close(); + return false; + } + + /** + * Give the attempt up and say so, rather than leaving the offerer to wait out + * its setup deadline for a channel that is never coming. + */ + #decline(): void { + this.#giveUp(); + this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + } + /** This attempt's peer, wired to the four channel events, or null if there is none. */ #build(): DirectPeer | null { const factory = this.#deps.createPeer; @@ -250,7 +260,7 @@ export class DirectEndpoint { if (!connection) return null; this.#peer = new DirectPeer({ peer: connection, - ...(this.#deps.setTimer ? { setTimer: this.#deps.setTimer } : {}), + setTimer: this.#deps.setTimer, handlers: { onOpen: () => this.#onOpen(), onFrame: (frame) => this.#onFrame(frame), diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 2851add9f..3bc1df0dd 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -16,7 +16,7 @@ "docs/specs/pocket-app.md": 4200, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 4250, - "docs/specs/remote-security-model.md": 4800, + "docs/specs/remote-security-model.md": 4700, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, From bf1fee7480b035fe0a31579a3a577400cc40ead0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:17:12 -0700 Subject: [PATCH 35/46] docs(remote): say where a signal is read out of the receipt Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NuqLYWrpEfQMmKLTPAfEpY --- lib/src/remote/direct/direct-endpoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 6ecdb4d0e..ba3639922 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -44,7 +44,7 @@ export interface DirectEndpointDeps { * everything that is not a signal, and answer the receipt. `null` where the * decrypt failed — a poisoned session, which the owner has already disposed. * - * The signals come back here rather than being dispatched by the owner: the + * A signal is left in the receipt rather than dispatched by the owner: the * endpoint is the only thing that knows what one means. */ receive(ciphertext: Uint8Array): TransportReceipt | null; From 9189d6b411e7c9222e9d7d01a147ce4fe8f8907a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 11:43:32 -0700 Subject: [PATCH 36/46] feat(remote): bound and guard what the direct path rides on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five safeguards the shipped cutover had no answer for, ported from the competing `pocket-webrtc` branch and re-sized for terminal traffic. A sender now bounds its own queue instead of handing the implementation whatever the session produces: past DIRECT_BUFFER_HIGH the ciphertext queues and drains at DIRECT_BUFFER_LOW, and once anything is queued everything does, so nothing overtakes a frame encrypted before it. A `cat` of a large file used to reach the runtime's own refusal, which on a switched session is burrow loss. Both queues are one DirectFrameQueue now, so "over the bound" means one thing in both directions and the copy-on-queue rule is stated once. A channel is checked before a session rides it — reliable, ordered, this negotiation's label, and an association that can carry one whole Noise transport message — and each failure abandons the attempt while the relay is still carrying the session, rather than killing it later. A switched end waits DIRECT_HANDOFF_TIMEOUT_MS for its peer's own switch rather than until the hold overruns, which was a function of how chatty the session happened to be. A connection reporting `failed` or `closed` ends the attempt at once, while `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. Every route that gives an attempt up now says why, and Pocket carries that reason in the indicator's hover text — never in the label, since a session that quietly stayed relayed is still `relay` and a third state for the common case would read as a fault. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/pocket-app.md | 11 +- docs/specs/remote-api.md | 26 ++- docs/specs/remote-security-model.md | 7 + docs/specs/security-remote.md | 3 +- .../host/remote/native-direct-peer.test.ts | 7 + lib/src/remote/client/pocket-client.ts | 32 +++- lib/src/remote/direct/direct-endpoint.test.ts | 76 +++++++- lib/src/remote/direct/direct-endpoint.ts | 80 ++++++-- lib/src/remote/direct/direct-peer.test.ts | 161 ++++++++++++++-- lib/src/remote/direct/direct-peer.ts | 172 ++++++++++++++++-- lib/src/remote/direct/test-fake-peer.ts | 55 +++++- lib/src/remote/pocket-app/App.push.test.tsx | 2 +- lib/src/remote/pocket-app/App.scan.test.tsx | 40 +++- lib/src/remote/pocket-app/App.tsx | 37 +++- remote-lib-common/src/security/direct-path.ts | 137 ++++++++++++-- remote-lib-common/test/direct-path.test.mjs | 86 ++++++++- scripts/spec-word-budgets.json | 8 +- 17 files changed, 846 insertions(+), 94 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index e0caa6fca..ec98c9b85 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -462,14 +462,17 @@ the relay when the browser has none or the Burrow declines ([remote-api.md](./remote-api.md) → Direct path owns the whole protocol). **The connected header names the live path** — `relay` or `direct`, captioned, -never coloured — so a relayed fallback is visible rather than silent. **A +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. **A channel that dies after the cutover 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 cutover a failed channel costs nothing. -Source of truth: `PocketClient.transportPath` in -`lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` in -`lib/src/remote/pocket-app/App.tsx`. +Source of truth: `PocketClient.transportPath` / `transportDetail` in +`lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` and +`transportTitle` in `lib/src/remote/pocket-app/App.tsx`. ## An expired session drops to sign-in diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 7604f259e..4af555b12 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -100,6 +100,21 @@ 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. A limit the implementation does not report is not treated as small. + +**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. + **Cutover preserves order per direction:** * A sender's `direct-switch` is its **last** message on the relay path; every @@ -118,6 +133,14 @@ non-binary channel message — disposes the session. (rationale) * **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 @@ -135,7 +158,8 @@ injected factory** — `PocketClientDeps.createDirectPeer`, ([pocket-app.md](./pocket-app.md)). Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, -their guard, the constants, and the `DirectCutover` both ends run), +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 cutover policy, one per authorized session; `onRelayFrame` is both ends' only diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 1571b12e7..49c7a6d40 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -341,8 +341,11 @@ omits `client-gone`, invents client IDs, or reorders frames. | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | +| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | 5 000 / 5 000 | same | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | | `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | +| `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` | the same pair, for what a sender holds | same | +| `DIRECT_BUFFER_HIGH` / `DIRECT_BUFFER_LOW` | 256 KiB / 64 KiB | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative received-string length; both `e2e` and `client-gone` share one FIFO and one @@ -423,6 +426,10 @@ that ends one, are its rules. What this model adds is what is *underneath* them. an SDP are authentic for exactly one reason — that SDP arrived inside the session**. A DTLS peer is never an authenticated one. - **Never an ICE server.** Both ends pass an empty list. (rationale) +- **What the channel may buffer is this side's bound, not the + implementation's**, in both directions ([remote-api.md](./remote-api.md) -> + "Direct path"): the same pair of numbers holds a sender's queue and a + receiver's, and overrunning either disposes the session. **The listener is UDP on every interface a candidate names, for the life of an attempt.** The standalone Burrow's addon binds one socket on the unspecified diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index a2d68a406..34809aec3 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -223,8 +223,9 @@ layer; neither is restated below. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, overflow disposing the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. +- **FAIL IF** a switched end waits on its peer without a deadline, or a channel a Noise stream cannot ride is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is unordered, partially reliable, or not `DIRECT_CHANNEL_LABEL`, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH`, both before it reports the open — each abandons the attempt while the relay still carries the session. `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). ### Revocation and the audit trail diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index e821e93f3..a94a070cf 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -144,6 +144,13 @@ describe('the direct path over the native addon', () => { expect(run.clientPeers).toHaveLength(1); expect(run.burrowPeers).toHaveLength(1); expect(harness.client.transportPath).toBe('direct'); + // The two facts `DirectPeer` reads off a real connection before it lets a + // session ride one: the association's own per-message limit, and that the + // seam's `sctp` really is where the polyfill reports it. A cutover + // happened, so both were already good enough — this says which numbers. + const sctp = run.clientPeers[0]!.sctp; + expect(sctp).not.toBeNull(); + expect(sctp!.maxMessageSize).toBeGreaterThanOrEqual(NOISE_MAX_MESSAGE_LENGTH); // Three Client→Burrow transport frames on this connection: the connection // request the ceremony ended with, the offer, and the switch. Three back: // the outcome, the answer, and the Burrow's own switch. The SDPs crossed diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 473c8cb38..4c2d7f610 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -365,7 +365,12 @@ export class PocketClient { #onBurrowGone: (() => void) | null = null; /** This session's direct path, or null while there is no authorized session. */ #direct: DirectEndpoint | null = null; - #onTransportPath: ((path: DirectPath) => void) | null = null; + #onTransportChanged: ((path: DirectPath, detail: string | null) => void) | null = null; + /** + * Why the live session is on the path it is on. Held here rather than read + * off the endpoint, which is dropped with the session that owned it. + */ + #transportDetail: string | null = null; /** Cancels the armed keepalive, and the visibility subscription behind it. */ #cancelKeepalive: (() => void) | null = null; #cancelVisibility: (() => void) | null = null; @@ -415,9 +420,20 @@ export class PocketClient { return this.#direct?.path ?? 'relay'; } - /** Notified whenever {@link transportPath} changes. */ - setOnTransportPathChanged(callback: ((path: DirectPath) => void) | null): void { - this.#onTransportPath = callback; + /** + * Why this session is on the path it is on, or `null` where there is nothing + * to say — the sentence behind the indicator, so a session that stayed + * relayed can say which of the silent reasons it was. + */ + get transportDetail(): string | null { + return this.#transportDetail; + } + + /** Notified whenever {@link transportPath} or {@link transportDetail} changes. */ + setOnTransportChanged( + callback: ((path: DirectPath, detail: string | null) => void) | null, + ): void { + this.#onTransportChanged = callback; } /** @@ -1174,7 +1190,10 @@ export class PocketClient { // same event, and the app must leave the wall either way. fatal: (reason) => this.#loseBurrow(reason), isCurrent: () => this.#established === established, - onPathChanged: (path) => this.#onTransportPath?.(path), + onTransportChanged: (path, detail) => { + this.#transportDetail = detail; + this.#onTransportChanged?.(path, detail); + }, setTimer: this.#setTimer, }); } @@ -1526,6 +1545,9 @@ export class PocketClient { // none can outlive the session that authorized it. this.#direct?.dispose(); this.#direct = null; + // After the dispose, whose own announcement is still this session's: the + // previous session's reason says nothing about the next one. + this.#transportDetail = null; this.#connectedBurrowId = null; this.#established = null; } diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index c7abf7eb3..cddf2105a 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -17,6 +17,7 @@ import { describe, expect, it } from 'vitest'; import { DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_HANDOFF_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, MAX_DIRECT_PENDING_FRAMES, toBase64Url, @@ -38,6 +39,8 @@ interface Side { readonly fatals: string[]; /** Every path change announced. */ readonly paths: DirectPath[]; + /** Every detail announced beside a path; see `DirectEndpoint.detail`. */ + readonly details: Array; /** Every signal this side put on the relay. */ readonly sent: DirectSignalV1[]; /** What `isCurrent()` answers; a promotion or teardown flips it. */ @@ -72,6 +75,7 @@ function pair(options: Options = {}) { received: [], fatals: [], paths: [], + details: [], sent: [], live: true, sendable: true, @@ -109,7 +113,10 @@ function pair(options: Options = {}) { }, fatal: (reason) => void side.fatals.push(reason), isCurrent: () => side.live, - onPathChanged: (path) => void side.paths.push(path), + onTransportChanged: (path, detail) => { + side.paths.push(path); + side.details.push(detail); + }, setTimer: timers.setTimer, }); sides.set(role, side); @@ -352,6 +359,73 @@ describe('DirectEndpoint', () => { expect(run.timers.live).toEqual([]); }); + /** + * From its own switch this end sends only on the channel, so a peer that + * never switches back leaves it talking into one nothing reads. Without a + * deadline of its own the wait would end only when the held frames overran + * their bound — a function of how chatty the session happens to be. + */ + it('ends the session when the peer never follows onto the channel', async () => { + const run = pair({ opening: 'manual' }); + await cutover(run); + + // The answerer's own `direct-switch` never leaves it, so this end is + // outbound-direct with the relay still carrying the other half. + run.answerer.hold(); + run.fake.openChannels(); + expect(run.offerer.endpoint.path).toBe('relay'); + expect(run.offerer.fatals).toEqual([]); + + run.timers.fireAt(DIRECT_HANDOFF_TIMEOUT_MS); + + expect(run.offerer.fatals).toEqual(['the peer did not follow onto the direct path']); + }); + + it('leaves nothing armed once the peer has followed', async () => { + const run = pair(); + + await cutover(run); + + // Both setup budgets are gone with the opens, and both handoff deadlines + // with the switches that answered them. + expect(run.timers.live).toEqual([]); + }); + + describe('the reason behind the path', () => { + it('says why an attempt was given up, beside the unchanged path', async () => { + const run = pair({ offererHasPeer: false }); + + await run.offerer.endpoint.offer(); + + const reason = 'this device has no direct connection to offer'; + expect(run.offerer.endpoint.detail).toBe(reason); + expect(run.offerer.details).toEqual([reason]); + // The path never changed; the announcement is the reason alone. + expect(run.offerer.paths).toEqual(['relay']); + }); + + it('says a decline is what ended the offerer’s attempt', async () => { + const run = pair({ answererHasPeer: false }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + expect(run.offerer.endpoint.detail).toBe('the peer declined a direct path'); + expect(run.answerer.endpoint.detail).toBe( + 'this device has no direct connection to answer with', + ); + }); + + it('has nothing to say once the session is on the channel', async () => { + const run = pair(); + + await cutover(run); + + expect(run.offerer.endpoint.detail).toBeNull(); + expect(run.answerer.endpoint.detail).toBeNull(); + }); + }); + it('ends the session when the peer switches onto a channel this end abandoned', async () => { const run = pair({ opening: 'manual' }); await cutover(run); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index ba3639922..59231c3f1 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -14,6 +14,7 @@ */ import { + DIRECT_HANDOFF_TIMEOUT_MS, DirectCutover, fromBase64Url, isDirectSignalV1, @@ -23,7 +24,7 @@ import { } from 'remote-lib-common'; import { DirectPeer, type DirectPeerFactory } from './direct-peer'; -import type { RemoteTimer } from '../ws'; +import { realTimer, type RemoteTimer } from '../ws'; /** * Which half of the negotiation this end plays. The Client offers and the @@ -59,8 +60,12 @@ export interface DirectEndpointDeps { * session while a description is being built. */ isCurrent(): boolean; - /** Notified whenever {@link DirectEndpoint.path} changes; the Client's indicator. */ - onPathChanged?(path: DirectPath): void; + /** + * Notified whenever {@link DirectEndpoint.path} or {@link + * DirectEndpoint.detail} changes; the Client's indicator. The detail is why + * the session is on the path it is on — `null` once nothing is worth saying. + */ + onTransportChanged?(path: DirectPath, detail: string | null): void; /** Every deadline the peer arms; see {@link RemoteTimer}. */ readonly setTimer?: RemoteTimer; } @@ -69,14 +74,19 @@ export class DirectEndpoint { readonly #role: DirectRole; readonly #deps: DirectEndpointDeps; readonly #cutover = new DirectCutover(); + readonly #setTimer: RemoteTimer; #peer: DirectPeer | null = null; #disposed = false; - /** The last path announced, so an unchanged one is not announced twice. */ - #announced: DirectPath = 'relay'; + #detail: string | null = null; + /** Cancels the wait for the peer's switch; see {@link #armHandoffTimeout}. */ + #cancelHandoff: (() => void) | null = null; + /** The last pair announced, so an unchanged one is not announced twice. */ + #announced: { path: DirectPath; detail: string | null } = { path: 'relay', detail: null }; constructor(role: DirectRole, deps: DirectEndpointDeps) { this.#role = role; this.#deps = deps; + this.#setTimer = deps.setTimer ?? realTimer; } /** What carries this session; `direct` only once **both** directions have switched. */ @@ -84,6 +94,15 @@ export class DirectEndpoint { return this.#disposed ? 'relay' : this.#cutover.path; } + /** + * Why the session is on the path it is on, or `null` where there is nothing + * to say. Set by every route that gives an attempt up, so a session that + * stayed relayed can say which of the many silent reasons it was. + */ + get detail(): string | null { + return this.#detail; + } + /** * Offer a direct path, which is the offerer's alone to do: **once per session, * never retried**. The whole description travels inside the session, so the @@ -97,7 +116,7 @@ export class DirectEndpoint { if (this.#role !== 'offerer' || !this.#cutover.begin()) return; const peer = this.#build(); if (!peer) { - this.#giveUp(); + this.#giveUp('this device has no direct connection to offer'); return; } const sdp = await peer.offer(); @@ -128,6 +147,7 @@ export class DirectEndpoint { this.#deps.fatal('the peer moved to a direct path this end had abandoned'); return; } + this.#clearHandoffTimeout(); this.#announce(); // In arrival order, through the same decrypt path the relay's frames // take: what was held is exactly what was sent after the switch. @@ -197,6 +217,7 @@ export class DirectEndpoint { dispose(): void { if (this.#disposed) return; this.#disposed = true; + this.#clearHandoffTimeout(); this.#peer?.close(); this.#peer = null; this.#cutover.clear(); @@ -214,13 +235,13 @@ export class DirectEndpoint { if (!this.#cutover.begin()) return; const peer = this.#build(); if (!peer) { - this.#decline(); + this.#decline('this device has no direct connection to answer with'); return; } const sdp = await peer.answer(offerSdp); if (!this.#stillOurs(peer)) return; if (sdp === null) { - this.#decline(); + this.#decline('this device could not describe a direct connection'); return; } if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); @@ -241,8 +262,8 @@ export class DirectEndpoint { * Give the attempt up and say so, rather than leaving the offerer to wait out * its setup deadline for a channel that is never coming. */ - #decline(): void { - this.#giveUp(); + #decline(reason: string): void { + this.#giveUp(reason); this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); } @@ -282,10 +303,37 @@ export class DirectEndpoint { this.#giveUp(); return; } - this.#cutover.switchOutbound(); + if (this.#cutover.switchOutbound()) this.#armHandoffTimeout(); + this.#detail = null; this.#announce(); } + /** + * **The cutover gets a deadline of its own.** From here this end sends only on + * the channel, so a peer that never switches back leaves it talking into a + * channel nothing reads; without this the wait would end only when the held + * frames overran their bound, which is a function of how chatty the session + * happens to be rather than of anything going wrong. + * + * There is no relay left to fall back to, so expiry is burrow loss. + */ + #armHandoffTimeout(): void { + // Nothing to wait for where the peer switched first — the ordinary order + // for whichever end's channel opens second. + if (this.#cutover.inbound === 'direct') return; + this.#clearHandoffTimeout(); + this.#cancelHandoff = this.#setTimer(() => { + this.#cancelHandoff = null; + if (!this.#alive() || this.#cutover.inbound === 'direct') return; + this.#deps.fatal('the peer did not follow onto the direct path'); + }, DIRECT_HANDOFF_TIMEOUT_MS); + } + + #clearHandoffTimeout(): void { + this.#cancelHandoff?.(); + this.#cancelHandoff = null; + } + /** One frame off the channel: processed, held until the peer's switch, or fatal. */ #onFrame(frame: Uint8Array): void { if (!this.#alive()) return; @@ -338,9 +386,12 @@ export class DirectEndpoint { this.#deps.fatal(reason); return; } + this.#clearHandoffTimeout(); this.#peer?.close(); this.#peer = null; this.#cutover.abandon(); + this.#detail = reason; + this.#announce(); } /** Whether this endpoint still belongs to the session the caller is serving. */ @@ -350,8 +401,9 @@ export class DirectEndpoint { #announce(): void { const path = this.path; - if (path === this.#announced) return; - this.#announced = path; - this.#deps.onPathChanged?.(path); + const detail = this.#detail; + if (path === this.#announced.path && detail === this.#announced.detail) return; + this.#announced = { path, detail }; + this.#deps.onTransportChanged?.(path, detail); } } diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index 1bfe55e2a..a02bae463 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -6,7 +6,14 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; +import { + DIRECT_BUFFER_HIGH, + DIRECT_DISCONNECTED_GRACE_MS, + DIRECT_GATHER_TIMEOUT_MS, + DIRECT_SETUP_TIMEOUT_MS, + MAX_DIRECT_OUTBOUND_BYTES, + NOISE_MAX_MESSAGE_LENGTH, +} from 'remote-lib-common'; import { DIRECT_CHANNEL_LABEL, DirectPeer, type DirectPeerHandlers } from './direct-peer'; import { FakeDirectNetwork, type FakeDirectNetworkOptions } from './test-fake-peer'; @@ -37,27 +44,33 @@ function pair(options: FakeDirectNetworkOptions = {}) { const timers = fakeTimers(); const client = handlers(); const burrow = handlers(); + const offerer = network.createOfferer(); + const answerer = network.createAnswerer(); return { network, timers, client, burrow, - clientPeer: new DirectPeer({ - peer: network.createOfferer(), - handlers: client, - setTimer: timers.setTimer, - }), - burrowPeer: new DirectPeer({ - peer: network.createAnswerer(), - handlers: burrow, - setTimer: timers.setTimer, - }), + /** The connections themselves, for the cases that move their state. */ + offerer, + answerer, + clientPeer: new DirectPeer({ peer: offerer, handlers: client, setTimer: timers.setTimer }), + burrowPeer: new DirectPeer({ peer: answerer, handlers: burrow, setTimer: timers.setTimer }), }; } /** Let the fake network's queued microtasks run. */ const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); +/** One negotiated pair with both channels open, as most cases start. */ +async function connected(options: FakeDirectNetworkOptions = {}) { + const run = pair(options); + const offer = await run.clientPeer.offer(); + await run.clientPeer.acceptAnswer((await run.burrowPeer.answer(offer!))!); + await flushMicrotasks(); + return run; +} + describe('DirectPeer', () => { it('negotiates one ordered channel and carries raw bytes both ways', async () => { const { network, clientPeer, burrowPeer, client, burrow } = pair(); @@ -206,4 +219,130 @@ describe('DirectPeer', () => { clientPeer.close(); expect(client.closes).toEqual([]); }); + + describe('what a sender may hold', () => { + it('queues past the high-water mark and drains when the channel catches up', async () => { + const { network, clientPeer, burrow } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + + expect(clientPeer.send(Uint8Array.of(1))).toBe(true); + expect(clientPeer.send(Uint8Array.of(2))).toBe(true); + await flushMicrotasks(); + // Held here rather than handed to a buffer that would refuse them. + expect(channel.sent).toEqual([]); + expect(burrow.frames).toEqual([]); + + channel.drained(); + await flushMicrotasks(); + + expect(burrow.frames).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + }); + + it('holds a later frame behind an earlier one, whatever the buffer says', async () => { + const { network, clientPeer } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + clientPeer.send(Uint8Array.of(1)); + + // The buffer drains without the event that says so: a frame that jumped + // the queue here would reach the peer ahead of the one before it. + channel.bufferedAmount = 0; + clientPeer.send(Uint8Array.of(2)); + await flushMicrotasks(); + + expect(channel.sent).toEqual([]); + }); + + it('refuses a send that would outrun the queue, in bytes', async () => { + const { network, clientPeer } = await connected(); + network.offererChannel!.bufferedAmount = DIRECT_BUFFER_HIGH; + const frame = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); + + let held = 0; + while (clientPeer.send(frame)) held += 1; + + // Bytes bind first: the frame cap is far above what this many reaches. + expect(held * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); + expect((held + 1) * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); + }); + + it('reports the channel gone when a queued frame will not go out', async () => { + const { network, clientPeer, client } = await connected(); + const channel = network.offererChannel!; + channel.bufferedAmount = DIRECT_BUFFER_HIGH; + clientPeer.send(Uint8Array.of(1)); + + // Closed under the queue: the drain finds a channel that will not take it. + channel.readyState = 'closed'; + channel.drained(); + + expect(client.closes).toEqual(['the direct channel refused a message']); + }); + }); + + describe('what the channel has to be', () => { + it.each(['unordered', 'lossy', 'mislabeled'] as const)( + 'refuses a %s channel, before anything rides it', + async (defect) => { + const { clientPeer, client } = pair({ channel: defect }); + + expect(await clientPeer.offer()).toBeNull(); + + expect(client.opens).toBe(0); + expect(client.closes).toEqual([ + 'the direct channel is not the reliable ordered one this session opens', + ]); + }, + ); + + it('abandons a channel that cannot carry one Noise message', async () => { + const { clientPeer, client } = await connected({ maxMessageSize: 16_384 }); + + expect(client.opens).toBe(0); + expect(client.closes).toEqual(['the direct channel carries only 16384 bytes per message']); + expect(clientPeer.isOpen).toBe(false); + }); + + it('opens where the association reports no limit to check', async () => { + const { clientPeer, client } = await connected({ maxMessageSize: null }); + + expect(client.opens).toBe(1); + expect(clientPeer.isOpen).toBe(true); + }); + }); + + describe('the connection under the channel', () => { + it('ends the attempt at once when the connection fails', async () => { + const { offerer, clientPeer, client } = await connected(); + + offerer.setConnectionState('failed'); + + expect(client.closes).toEqual(['the direct connection failed']); + expect(clientPeer.isOpen).toBe(false); + }); + + it('waits a disconnected connection out, then gives up on one that stays down', async () => { + const { offerer, client, timers } = await connected(); + + offerer.setConnectionState('disconnected'); + // A gap ICE often recovers from: nothing is reported while it may. + expect(client.closes).toEqual([]); + + timers.fireAt(DIRECT_DISCONNECTED_GRACE_MS); + + expect(client.closes).toEqual(['the direct connection stayed disconnected']); + }); + + it('keeps a connection that comes back inside the grace', async () => { + const { offerer, clientPeer, client, timers } = await connected(); + + offerer.setConnectionState('disconnected'); + offerer.setConnectionState('connected'); + + expect(timers.live).toEqual([]); + expect(client.closes).toEqual([]); + expect(clientPeer.isOpen).toBe(true); + }); + }); }); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index ed382fafe..31429f91a 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -17,8 +17,14 @@ import { DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_BUFFER_HIGH, + DIRECT_BUFFER_LOW, + DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, + DirectFrameQueue, + MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, NOISE_MAX_MESSAGE_LENGTH, isDirectSdp, } from 'remote-lib-common'; @@ -39,11 +45,28 @@ export interface DirectSessionDescription { /** The subset of `RTCDataChannel` a Noise transport rides on. */ export interface DirectChannelLike { binaryType: string; + /** The four properties {@link DirectPeer} checks before it adopts a channel. */ + readonly label: string; + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; + /** What the implementation is still holding; see {@link DIRECT_BUFFER_HIGH}. */ + readonly bufferedAmount: number; + bufferedAmountLowThreshold: number; send(data: ArrayBuffer | ArrayBufferView): void; close(): void; addEventListener(type: string, handler: (ev: unknown) => void): void; } +/** + * As much of `RTCSctpTransport` as the size check needs. The association's own + * limit, negotiated from both ends' `a=max-message-size`, so it is knowable only + * once the channel is open. + */ +export interface DirectSctpLike { + readonly maxMessageSize: number; +} + /** The subset of `RTCPeerConnection` one negotiation needs. */ export interface DirectPeerLike { createDataChannel(label: string, init?: { ordered?: boolean }): DirectChannelLike; @@ -53,6 +76,9 @@ export interface DirectPeerLike { setRemoteDescription(description: DirectSessionDescription): Promise; readonly localDescription: DirectSessionDescription | null; readonly iceGatheringState: string; + /** Null until the association exists; see {@link DirectSctpLike}. */ + readonly sctp: DirectSctpLike | null; + readonly connectionState: string; addEventListener(type: string, handler: (ev: unknown) => void): void; close(): void; } @@ -104,13 +130,23 @@ export interface DirectPeerDeps { * sees either. **The channel must be open by `DIRECT_SETUP_TIMEOUT_MS`** — by * `DIRECT_ANSWER_TIMEOUT_MS` on the answering side, which arms later — or the * attempt is abandoned and the session stays relayed. + * + * **Sends are bounded here, not left to the implementation.** Past + * `DIRECT_BUFFER_HIGH` the ciphertext queues instead, draining on the channel's + * own low-water event, so a burst of terminal output waits in a queue with a + * stated bound rather than in a runtime buffer whose refusal would kill the + * session. */ export class DirectPeer { readonly #peer: DirectPeerLike; #handlers: DirectPeerHandlers; readonly #setTimer: RemoteTimer; #channel: DirectChannelLike | null = null; + /** Ciphertext waiting on the channel to drain; see {@link send}. */ + readonly #outbound = new DirectFrameQueue(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_OUTBOUND_BYTES); #cancelSetup: (() => void) | null = null; + /** Cancels the grace a `disconnected` connection is given, if one is running. */ + #cancelDisconnected: (() => void) | null = null; /** * Settles the gathering wait — cancelling its deadline with it — or null when * none is outstanding. Held on the instance because {@link close} has to @@ -125,6 +161,9 @@ export class DirectPeer { this.#peer = deps.peer; this.#handlers = deps.handlers; this.#setTimer = deps.setTimer ?? realTimer; + // Registered before either half of the negotiation runs: a connection that + // fails while a description is still being built has nothing else watching. + this.#peer.addEventListener('connectionstatechange', () => this.#onConnectionState()); } /** Whether the channel has opened and not since gone. */ @@ -199,18 +238,25 @@ export class DirectPeer { send(ciphertext: Uint8Array): boolean { const channel = this.#channel; if (!channel || !this.isOpen) return false; - try { - channel.send(ciphertext); - return true; - } catch { - return false; + // **Once anything is queued, everything queues.** A frame handed straight to + // the channel while others wait would reach the peer ahead of ciphertext + // encrypted before it, and a Noise stream has no way back from a counter + // read out of order. + if (this.#outbound.length === 0 && channel.bufferedAmount < DIRECT_BUFFER_HIGH) { + return this.#write(channel, ciphertext); } + if (this.#outbound.wouldOverflow(ciphertext.length)) return false; + this.#outbound.push(ciphertext); + return true; } /** Close the channel and the connection. Idempotent, and reports nothing. */ close(): void { this.#closed = true; this.#clearSetupTimeout(); + this.#cancelDisconnected?.(); + this.#cancelDisconnected = null; + this.#outbound.clear(); // The suspended `offer()`/`answer()` finishes here rather than in three // seconds' time: it sees `#closed`, answers `null`, and releases the // endpoint and the session it was still holding open. @@ -234,24 +280,126 @@ export class DirectPeer { // --- Internals ------------------------------------------------------------- - /** Wire one channel's four events, whichever side created it. */ + /** + * Wire one channel's events, whichever side created it. + * + * **Reliable and ordered, or not at all.** A Noise stream is one counter per + * direction with no resynchronization point, so a channel that may drop or + * reorder a frame is one the session would die on at the first gap rather + * than the first byte — and dying here, before any switch, only abandons the + * attempt. The label is checked alongside them: this negotiation creates + * exactly one channel and calls it {@link DIRECT_CHANNEL_LABEL}. + */ #adopt(channel: DirectChannelLike): void { if (this.#channel) return; + if ( + channel.label !== DIRECT_CHANNEL_LABEL || + !channel.ordered || + channel.maxRetransmits !== null || + channel.maxPacketLifeTime !== null + ) { + try { + channel.close(); + } catch { + // Never adopted, so nothing here depends on it closing cleanly. + } + this.#fail('the direct channel is not the reliable ordered one this session opens'); + return; + } this.#channel = channel; // Set before any message can arrive, so every frame is bytes rather than a // `Blob` this stack has no synchronous way to read. channel.binaryType = 'arraybuffer'; - channel.addEventListener('open', () => { - if (this.#closed || this.#open) return; - this.#open = true; - this.#clearSetupTimeout(); - this.#handlers.onOpen(); - }); + channel.bufferedAmountLowThreshold = DIRECT_BUFFER_LOW; + channel.addEventListener('open', () => this.#onOpen()); channel.addEventListener('message', (ev) => this.#onMessage(ev)); + channel.addEventListener('bufferedamountlow', () => this.#drain()); channel.addEventListener('close', () => this.#fail('the direct channel closed')); channel.addEventListener('error', () => this.#fail('the direct channel failed')); } + /** + * The channel reported open. + * + * **The association's message limit is checked here**, where the attempt can + * still be abandoned onto a relay that is still carrying the session. One + * Noise transport message is one channel frame and may be + * {@link NOISE_MAX_MESSAGE_LENGTH} bytes, so an association that would refuse + * one is a session that dies on its first large paste instead. + */ + #onOpen(): void { + if (this.#closed || this.#open) return; + const limit = this.#peer.sctp?.maxMessageSize; + // Unknown is not small: an implementation reporting no association yet, or + // no usable number, is one this cannot rule out either way — and every + // inbound frame is bounded again in `#onMessage` regardless. + if (typeof limit === 'number' && limit > 0 && limit < NOISE_MAX_MESSAGE_LENGTH) { + this.#fail(`the direct channel carries only ${limit} bytes per message`); + return; + } + this.#open = true; + this.#clearSetupTimeout(); + this.#handlers.onOpen(); + } + + /** + * Hand the channel as much of the queue as it will take. + * + * **A frame is written once or not at all**: `send` either consumes the + * message or throws, so retrying one here would put ciphertext the peer has + * already counted on the wire twice. + */ + #drain(): void { + const channel = this.#channel; + if (!channel || !this.isOpen) return; + while (this.#outbound.length > 0 && channel.bufferedAmount < DIRECT_BUFFER_HIGH) { + const frame = this.#outbound.shift(); + if (!frame) return; + if (this.#write(channel, frame)) continue; + // The channel took this frame into the queue and will not take it now: + // it is gone, and the endpoint decides what that costs. + this.#fail('the direct channel refused a message'); + return; + } + } + + #write(channel: DirectChannelLike, frame: Uint8Array): boolean { + try { + channel.send(frame); + return true; + } catch { + return false; + } + } + + /** + * The connection's own state, which reaches here before the channel's does. + * + * **`disconnected` is waited out** ({@link DIRECT_DISCONNECTED_GRACE_MS}): + * ICE reports it on a gap the connection often recovers from, and ending a + * switched session there costs a fresh handshake and a WebAuthn prompt. + * `failed` and `closed` are terminal and end the attempt at once. + */ + #onConnectionState(): void { + if (this.#closed) return; + const state = this.#peer.connectionState; + if (state === 'failed' || state === 'closed') { + this.#fail(`the direct connection ${state}`); + return; + } + if (state !== 'disconnected') { + this.#cancelDisconnected?.(); + this.#cancelDisconnected = null; + return; + } + if (this.#cancelDisconnected) return; + this.#cancelDisconnected = this.#setTimer(() => { + this.#cancelDisconnected = null; + if (this.#closed || this.#peer.connectionState !== 'disconnected') return; + this.#fail('the direct connection stayed disconnected'); + }, DIRECT_DISCONNECTED_GRACE_MS); + } + #onMessage(ev: unknown): void { if (this.#closed) return; const data = (ev as { data?: unknown } | null)?.data; diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index 6a6d80a3d..a9760e23b 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -16,10 +16,12 @@ * `TestRelay.holdToClient()`, on the side that is actually slow. */ +import { NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; import { DIRECT_CHANNEL_LABEL, type DirectChannelLike, type DirectPeerLike, + type DirectSctpLike, type DirectSessionDescription, } from './direct-peer'; import { FakeEventTarget } from '../test-fake-socket'; @@ -42,6 +44,14 @@ export interface FakeDirectNetworkOptions { readonly gathering?: 'complete' | 'pending'; /** Which side describes itself with an SDP over the signal's bound. */ readonly oversize?: 'offer' | 'answer'; + /** + * What the association reports as its per-message limit, for the case where + * it is too small to carry one Noise transport message. `null` models an + * implementation that reports no association at all. + */ + readonly maxMessageSize?: number | null; + /** A channel a Noise stream cannot ride; see {@link FakeChannel}. */ + readonly channel?: 'unordered' | 'lossy' | 'mislabeled'; } /** One end of the linked pair; the offerer creates the channel. */ @@ -128,6 +138,7 @@ export class FakePeer implements DirectPeerLike { readonly #events = new FakeEventTarget(); #local: DirectSessionDescription | null = null; #gathering: string; + #connectionState = 'connecting'; closed = false; constructor( @@ -149,8 +160,24 @@ export class FakePeer implements DirectPeerLike { return this.#local; } + get sctp(): DirectSctpLike | null { + const limit = this.#options.maxMessageSize; + if (limit === null) return null; + return { maxMessageSize: limit ?? NOISE_MAX_MESSAGE_LENGTH }; + } + + get connectionState(): string { + return this.#connectionState; + } + + /** Move the connection, firing the event a real one does. */ + setConnectionState(state: string): void { + this.#connectionState = state; + this.#emit('connectionstatechange', {}); + } + createDataChannel(label: string): DirectChannelLike { - const channel = new FakeChannel(label); + const channel = new FakeChannel(label, this.#options.channel); this.#network.registerChannel(this.#role, channel); return channel; } @@ -170,7 +197,7 @@ export class FakePeer implements DirectPeerLike { async setRemoteDescription(description: DirectSessionDescription): Promise { if (description.type === 'offer') { // The answerer learns of the channel here, exactly as a real one does. - const channel = new FakeChannel(DIRECT_CHANNEL_LABEL); + const channel = new FakeChannel(DIRECT_CHANNEL_LABEL, this.#options.channel); this.#network.registerChannel(this.#role, channel); this.#emit('datachannel', { channel }); return; @@ -213,6 +240,17 @@ export class FakeChannel implements DirectChannelLike { readonly label: string; binaryType = 'blob'; readyState = 'connecting'; + /** The three reliability facts `DirectPeer` checks before it adopts one. */ + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; + /** + * What the implementation is still holding. Sends do not move it — a case + * that wants a busy channel sets it, then calls {@link drained} to model the + * association catching up. + */ + bufferedAmount = 0; + bufferedAmountLowThreshold = 0; /** Every frame this end was asked to send, in order. */ readonly sent: Uint8Array[] = []; #peer: FakeChannel | null = null; @@ -220,8 +258,17 @@ export class FakeChannel implements DirectChannelLike { readonly #inbox: Uint8Array[] = []; readonly #events = new FakeEventTarget(); - constructor(label: string) { - this.label = label; + constructor(label: string, defect?: 'unordered' | 'lossy' | 'mislabeled') { + this.label = defect === 'mislabeled' ? `${label}-other` : label; + this.ordered = defect !== 'unordered'; + this.maxRetransmits = defect === 'lossy' ? 3 : null; + this.maxPacketLifeTime = null; + } + + /** The association caught up: drop to the low-water mark and wake the sender. */ + drained(): void { + this.bufferedAmount = 0; + this.#events.emit('bufferedamountlow', {}); } link(peer: FakeChannel): void { diff --git a/lib/src/remote/pocket-app/App.push.test.tsx b/lib/src/remote/pocket-app/App.push.test.tsx index bec428093..5c9732ec0 100644 --- a/lib/src/remote/pocket-app/App.push.test.tsx +++ b/lib/src/remote/pocket-app/App.push.test.tsx @@ -61,7 +61,7 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => true; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; - setOnTransportPathChanged = () => undefined; + setOnTransportChanged = () => undefined; close = () => undefined; openSocket = async () => undefined; signin = async () => ({}); diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index dcee08641..51d05085f 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -21,6 +21,7 @@ import App, { BURROWS_TITLE, SCAN_LABEL, TRANSPORT_PATH_LABELS, + transportTitle, UNSUPPORTED_BROWSER_TITLE, } from './App'; import type { ConnectResult, PairingResult } from '../client/pocket-client'; @@ -46,7 +47,7 @@ import { setNativeFieldValue } from '../../lib/dom'; const fake = vi.hoisted(() => ({ noiseSupported: true as boolean, /** The path callback `App` registered, so a case can report a cutover. */ - onTransportPath: null as ((path: 'relay' | 'direct') => void) | null, + onTransportPath: null as ((path: 'relay' | 'direct', detail: string | null) => void) | null, hasPriorUse: false, sessionToken: null as string | null, setup: vi.fn<(credential: { setupToken: string }, label: string) => Promise>(), @@ -98,7 +99,9 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ hasPriorUse = () => fake.hasPriorUse; registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; - setOnTransportPathChanged = (callback: ((path: 'relay' | 'direct') => void) | null) => { + setOnTransportChanged = ( + callback: ((path: 'relay' | 'direct', detail: string | null) => void) | null, + ) => { fake.onTransportPath = callback; }; close = () => fake.clientClose(); @@ -504,13 +507,44 @@ describe('the Burrows list', () => { // Every session starts on the relay and says so. expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.relay.label); - act(() => fake.onTransportPath?.('direct')); + act(() => fake.onTransportPath?.('direct', null)); await settle(); expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.direct.label); expect(container.textContent).not.toContain(TRANSPORT_PATH_LABELS.relay.label); }); + /** + * An attempt that quietly stayed relayed changes only the reason, so the + * label alone would say nothing had happened. **The reason rides the hover + * text, never the label**: inventing a third state for the common case would + * make it look like a fault. + */ + it('carries the reason a session stayed relayed in the indicator’s hover text', async () => { + fake.hasPriorUse = true; + fake.listKnownBurrows.mockResolvedValue([await knownBurrow('burrow-1', 'First laptop')]); + fake.listBurrows.mockResolvedValue([{ burrowId: 'burrow-1', label: '', online: true }]); + await boot(); + await click(container, 'Sign in with passkey'); + await click(container, 'Connect'); + + act(() => fake.onTransportPath?.('relay', 'the peer declined a direct path')); + await settle(); + + const note = container.querySelector('[title]:not([title=""])'); + expect(note?.textContent).toBe(TRANSPORT_PATH_LABELS.relay.label); + expect(note?.getAttribute('title')).toBe( + transportTitle('relay', 'the peer declined a direct path'), + ); + }); + + it('leads the reason with a capital, and says nothing where there is none', () => { + expect(transportTitle('direct')).toBe(TRANSPORT_PATH_LABELS.direct.title); + expect(transportTitle('relay', 'the peer declined a direct path')).toBe( + `${TRANSPORT_PATH_LABELS.relay.title} The peer declined a direct path.`, + ); + }); + /** * An authenticated `pairing-required` removes local authorization without * discarding the pin, so the row offers *Pair again* — which starts at the diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 0d93ad9dd..0c1782b0b 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -349,13 +349,17 @@ export default function App({ }, [client, teardownAdapter]); /** - * Which path carries the live session. Subscribed rather than read on render: - * the cutover happens seconds into a session, long after the wall is up. + * Which path carries the live session, and why. Subscribed rather than read on + * render: the cutover happens seconds into a session, long after the wall is + * up, and an attempt that quietly stays relayed changes only the detail. */ - const [transportPath, setTransportPath] = useState('relay'); + const [transport, setTransport] = useState<{ path: DirectPath; detail: string | null }>({ + path: 'relay', + detail: null, + }); useEffect(() => { - client.setOnTransportPathChanged(setTransportPath); - return () => client.setOnTransportPathChanged(null); + client.setOnTransportChanged((path, detail) => setTransport({ path, detail })); + return () => client.setOnTransportChanged(null); }, [client]); /** The connect half, shared so a fresh pairing can continue straight into it. */ @@ -631,7 +635,8 @@ export default function App({ @@ -795,11 +800,23 @@ export const TRANSPORT_PATH_LABELS: Record void; onError?: (error: unknown) => void; }): React.ReactElement { - const path = TRANSPORT_PATH_LABELS[transportPath]; + const label = TRANSPORT_PATH_LABELS[transportPath].label; return (
@@ -818,8 +837,8 @@ export function ConnectedView({ ‹ {BURROWS_TITLE}

{burrow.label || burrow.burrowId}

- - {path.label} + + {label}
diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 55f17e551..ed5831584 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -71,6 +71,113 @@ export const MAX_DIRECT_PENDING_BYTES = 4 * 1024 * 1024; */ export const MAX_DIRECT_PENDING_FRAMES = 8192; +/** + * How long this end waits, after putting its own `direct-switch` on the relay, + * for the peer's to come back the other way. + * + * Its own bound rather than the holding queue's: a peer that has stopped + * switching leaves this end sending into a channel nothing reads, and waiting + * for {@link MAX_DIRECT_PENDING_BYTES} to fill makes the wait a function of how + * chatty the session happens to be. Generous next to the relay round trip the + * peer's switch actually takes. + */ +export const DIRECT_HANDOFF_TIMEOUT_MS = 5_000; + +/** + * How long a connection may sit `disconnected` before the attempt is written + * off. ICE reports that state on a gap the connection may well recover from — a + * phone changing networks, a radio blip — and ending a switched session there + * costs a fresh handshake and a WebAuthn prompt, so the transient case is + * waited out. `failed` and `closed` are terminal and are never waited on. + */ +export const DIRECT_DISCONNECTED_GRACE_MS = 5_000; + +/** + * How much a sender holds while the channel drains, in bytes and in frames. + * + * The same pair of numbers as the receiver's hold, for the same reasons: what a + * queue has to cover is a burst of a terminal stream, bytes are what the machine + * actually holds, and the frame count sits above where the ~1 KiB frames a PTY + * produces can reach it. A sender that overruns them is one whose peer is not + * draining fast enough to stay in order, which is a dead session rather than a + * dropped frame — the same answer the receiver gives. + */ +export const MAX_DIRECT_OUTBOUND_BYTES = MAX_DIRECT_PENDING_BYTES; +export const MAX_DIRECT_OUTBOUND_FRAMES = MAX_DIRECT_PENDING_FRAMES; + +/** + * How much the channel implementation may have buffered before a sender stops + * handing it more and queues instead, and the level it must drain back to + * before sending resumes. + * + * Two levels rather than one, so a busy stream is not woken on every frame. + * {@link MAX_DIRECT_OUTBOUND_BYTES} is what bounds the wait; these only decide + * where the ciphertext sits while the association catches up. + */ +export const DIRECT_BUFFER_HIGH = 256 * 1024; +export const DIRECT_BUFFER_LOW = 64 * 1024; + +/** + * A run of channel frames bounded in both frames and bytes, **bytes binding + * first**. + * + * Both of the direct path's queues are one of these — what a receiver holds + * until the peer's switch decrypts, and what a sender holds while the channel + * drains — so "over the bound" means one thing in both directions. + * + * **A queued frame is copied.** Queueing is what makes a frame outlive the call + * that produced it, and on the receive side that call's buffer is a view over + * whatever the runtime handed it. + */ +export class DirectFrameQueue { + readonly #frames: Uint8Array[] = []; + readonly #maxFrames: number; + readonly #maxBytes: number; + #bytes = 0; + + constructor(maxFrames: number, maxBytes: number) { + this.#maxFrames = maxFrames; + this.#maxBytes = maxBytes; + } + + get length(): number { + return this.#frames.length; + } + + get bytes(): number { + return this.#bytes; + } + + /** Whether one more frame of `length` bytes would break either bound. */ + wouldOverflow(length: number): boolean { + return this.#frames.length >= this.#maxFrames || this.#bytes + length > this.#maxBytes; + } + + push(frame: Uint8Array): void { + this.#frames.push(frame.slice()); + this.#bytes += frame.length; + } + + /** The oldest frame, or `undefined` where there is none. */ + shift(): Uint8Array | undefined { + const frame = this.#frames.shift(); + if (frame) this.#bytes -= frame.length; + return frame; + } + + /** Everything held, in arrival order, leaving the queue empty. */ + take(): Uint8Array[] { + const frames = [...this.#frames]; + this.clear(); + return frames; + } + + clear(): void { + this.#frames.length = 0; + this.#bytes = 0; + } +} + /** * Which path carries a session's traffic. `direct` only once **both** * directions have switched — until then the relay is still carrying half of it, @@ -178,8 +285,7 @@ export class DirectCutover { #state: DirectAttemptState = 'idle'; #outbound: DirectPath = 'relay'; #inbound: DirectPath = 'relay'; - readonly #held: Uint8Array[] = []; - #heldBytes = 0; + readonly #held = new DirectFrameQueue(MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_PENDING_BYTES); /** How far this end's one attempt has got. */ get state(): DirectAttemptState { @@ -211,7 +317,7 @@ export class DirectCutover { } get pendingBytes(): number { - return this.#heldBytes; + return this.#held.bytes; } /** @@ -274,37 +380,28 @@ export class DirectCutover { onSwitchDecrypted(): DirectSwitchOutcome { if (this.#state === 'abandoned') return { kind: 'fatal' }; this.#inbound = 'direct'; - const frames = [...this.#held]; - this.clear(); - return { kind: 'drain', frames }; + return { kind: 'drain', frames: this.#held.take() }; } /** * One inbound channel frame: processed once the peer's switch has been read, * held until then, and `overflow` when holding it would break the bound. * - * **A held frame is copied, and only a held frame.** Holding is the one thing - * here that outlives the call, so it is the one place the caller's buffer — - * a view over whatever the runtime handed it — cannot be trusted to still say - * the same thing when the queue drains. A frame answered `process` is + * **A held frame is copied, and only a held frame.** Holding is what makes a + * frame outlive this call, and the caller's buffer — a view over whatever the + * runtime handed it — cannot be trusted to still say the same thing when the + * queue drains ({@link DirectFrameQueue}). A frame answered `process` is * decrypted before this returns. */ onChannelFrame(frame: Uint8Array): DirectChannelOutcome { if (this.#inbound === 'direct') return 'process'; - if ( - this.#held.length >= MAX_DIRECT_PENDING_FRAMES || - this.#heldBytes + frame.length > MAX_DIRECT_PENDING_BYTES - ) { - return 'overflow'; - } - this.#held.push(frame.slice()); - this.#heldBytes += frame.length; + if (this.#held.wouldOverflow(frame.length)) return 'overflow'; + this.#held.push(frame); return 'held'; } /** Release held frames; a disposed session has nothing left to drain them. */ clear(): void { - this.#held.length = 0; - this.#heldBytes = 0; + this.#held.clear(); } } diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 5fec26eb7..10eb4358f 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -14,9 +14,16 @@ import assert from 'node:assert/strict'; import { CONTROL_PAYLOAD_SIZE, DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_BUFFER_HIGH, + DIRECT_BUFFER_LOW, + DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, + DIRECT_HANDOFF_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, DirectCutover, + DirectFrameQueue, + MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_BYTES, MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_SDP_LENGTH, @@ -110,6 +117,81 @@ test('the timings the spec names are the values that ship', () => { // answerer opens, switches, and its `direct-switch` decrypts at an offerer // that has just abandoned, which is fatal at both ends. assert.ok(DIRECT_ANSWER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); + // The wait for the peer's own switch begins where the setup budget ends, and + // is a relay round trip rather than a whole negotiation. + assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, 5_000); + assert.ok(DIRECT_HANDOFF_TIMEOUT_MS < DIRECT_ANSWER_TIMEOUT_MS); + // Long enough that a gap ICE recovers from is waited out rather than charged + // a fresh handshake and a WebAuthn prompt. + assert.equal(DIRECT_DISCONNECTED_GRACE_MS, 5_000); +}); + +/** + * The two water marks bracket where ciphertext sits while the association + * catches up. They only matter relative to each other and to the queue: a low + * mark at or above the high one would wake the sender on every frame, and a + * high mark above what may be held would let the implementation buffer more + * than this side is willing to. + */ +test('the send water marks bracket the queue they feed', () => { + assert.equal(DIRECT_BUFFER_HIGH, 256 * 1024); + assert.equal(DIRECT_BUFFER_LOW, 64 * 1024); + assert.ok(DIRECT_BUFFER_LOW < DIRECT_BUFFER_HIGH); + assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_OUTBOUND_BYTES); +}); + +/** + * A sender holds what a receiver holds. The window each covers is a burst of + * the same terminal stream, so sizing them apart would mean one of the two + * numbers had a reason the other did not. + */ +test('both directions are bounded the same way', () => { + assert.equal(MAX_DIRECT_OUTBOUND_BYTES, MAX_DIRECT_PENDING_BYTES); + assert.equal(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_FRAMES); +}); + +// --- The cutover ------------------------------------------------------------ + +const frame = (n, size = 4) => new Uint8Array(size).fill(n); + +// --- The queue both directions use ------------------------------------------ + +test('a queue admits frames until either bound, bytes first at PTY sizes', () => { + const queue = new DirectFrameQueue(4, 10); + assert.equal(queue.wouldOverflow(10), false); + queue.push(frame(1, 6)); + assert.equal(queue.length, 1); + assert.equal(queue.bytes, 6); + // 6 + 5 is over the byte cap while the frame cap has room to spare. + assert.equal(queue.wouldOverflow(5), true); + assert.equal(queue.wouldOverflow(4), false); +}); + +test('a queue admits no more frames than its frame cap, whatever their size', () => { + const queue = new DirectFrameQueue(2, 1_000); + queue.push(frame(1, 1)); + queue.push(frame(2, 1)); + assert.equal(queue.wouldOverflow(1), true); +}); + +test('a queued frame is copied, so the caller may reuse its buffer', () => { + const queue = new DirectFrameQueue(4, 100); + const buffer = frame(1); + queue.push(buffer); + buffer.fill(9); + assert.deepEqual(queue.take(), [frame(1)]); +}); + +test('a queue gives frames back in arrival order, and empties on take', () => { + const queue = new DirectFrameQueue(4, 100); + queue.push(frame(1)); + queue.push(frame(2)); + assert.deepEqual(queue.shift(), frame(1)); + assert.equal(queue.bytes, 4); + assert.deepEqual(queue.take(), [frame(2)]); + assert.equal(queue.length, 0); + assert.equal(queue.bytes, 0); + assert.equal(queue.shift(), undefined); }); /** @@ -130,10 +212,6 @@ test('the holding queue is bounded in bytes first', () => { assert.ok(MAX_DIRECT_PENDING_BYTES >= 0.5 * 5_000_000); }); -// --- The cutover ------------------------------------------------------------ - -const frame = (n, size = 4) => new Uint8Array(size).fill(n); - test('starts relayed in both directions', () => { const cutover = new DirectCutover(); assert.equal(cutover.outbound, 'relay'); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 3bc1df0dd..fc3ddfe64 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,14 +13,14 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4200, + "docs/specs/pocket-app.md": 4250, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4250, - "docs/specs/remote-security-model.md": 4700, + "docs/specs/remote-api.md": 4500, + "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 5650, + "docs/specs/security-remote.md": 5750, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, From 6252a21f0bb07cc43d07ba465591cff7954e9546 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 11:44:10 -0700 Subject: [PATCH 37/46] build(standalone): check the signed sidecar can load the WebRTC addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `*.node` sweep signs the addon's binary, but only a load proves the hardened runtime lets the sidecar open it. Without this the first thing to find out is a phone's first `direct-offer` on a user's machine — where the answer is a silent `direct-decline` and a session that stays relayed, which is exactly the outcome that looks like nothing is wrong. Required through `node-datachannel/polyfill`, the specifier the sidecar itself uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/deploy.md | 2 +- scripts/sign-and-deploy.sh | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index 10cd769c4..cc73fe3be 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -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 diff --git a/scripts/sign-and-deploy.sh b/scripts/sign-and-deploy.sh index 0a9a50da4..7468f4457 100755 --- a/scripts/sign-and-deploy.sh +++ b/scripts/sign-and-deploy.sh @@ -629,6 +629,12 @@ sign_macos_app() { || error "Signed Node sidecar failed to launch" (cd "$sidecar_dir" && "$node_sidecar" -e "require('node-pty')") \ || error "Signed Node sidecar failed to load node-pty" + # The direct path's addon, loaded through the same bare specifier the + # sidecar uses. Its own `.node` is signed by the sweep above, but only a + # load proves the hardened runtime lets it open one — and nothing before a + # phone's first `direct-offer` on a user's machine would otherwise find out. + (cd "$sidecar_dir" && "$node_sidecar" -e "require('node-datachannel/polyfill')") \ + || error "Signed Node sidecar failed to load node-datachannel" log "macOS signing complete ($arch_label)" } From 3fb268ca51d0ee33508606d8da92c340fef4ed8f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 11:50:16 -0700 Subject: [PATCH 38/46] test(remote): negotiate a real browser against the real addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two in-process suites cover the halves that can be faked: one links two in-memory peers, the other runs the addon against itself. What actually ships is a phone's browser stack negotiating with node-datachannel in the sidecar, and no CI job has a browser — so that pair had nothing behind it at all. `scripts/direct-interop/run.mjs` serves the shipped `DirectPeer` over a real `RTCPeerConnection` on a loopback page and answers it from the addon, in the production direction: the browser offers, the Burrow answers, `iceServers: []` at both ends. It is manual, gated by the loopback guard and a per-run token, and it answers what a fake cannot — whether a real browser's offer fits MAX_DIRECT_SDP_LENGTH, and whether a real association carries a whole Noise message between the two stacks. First run (macOS 26.0, Chromium, libdatachannel 0.24.3): the offer was 587 characters against the 2 000-character bound with one host candidate, both ends reported a 262 144-byte association, and 65 535-, 4 096- and 33-byte frames crossed browser to addon and back byte for byte, in order. The measurement and its caveat — that the SDP headroom belongs to the host's interfaces, not to the code — are in the rationale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/remote-api.md | 6 + docs/specs/remote-api.rationale.md | 4 + scripts/direct-interop/browser.ts | 112 +++++++++++++ scripts/direct-interop/run.mjs | 258 +++++++++++++++++++++++++++++ scripts/spec-word-budgets.json | 2 +- 5 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 scripts/direct-interop/browser.ts create mode 100644 scripts/direct-interop/run.mjs diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 4af555b12..216ed4801 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -94,6 +94,12 @@ 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 diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 0f5727b17..164e83665 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -16,6 +16,10 @@ In September 2026, both production installations use `createAskSurfaceProvider`: **Why host candidates alone reach.** The shipped deployment is a tailnet: the Burrow's tailnet address is a host candidate the phone can route to, and the phone's own mDNS-obfuscated candidate is learned peer-reflexively from the first packet. A STUN server would buy a public reflexive candidate the deployment does not need, at the price of telling a third party both addresses. +**What a browser and the addon actually negotiate.** `scripts/direct-interop/run.mjs` is the only fixture that puts the two shipped stacks on one channel — the in-process suites link two fakes or run the addon against itself, and no CI job has a browser. Run on macOS 26.0 with Chromium and node-datachannel 0.33.2 (libdatachannel 0.24.3), 2026-09-10: the browser's whole offer was 587 characters against `MAX_DIRECT_SDP_LENGTH`'s 2 000, one host candidate on a machine with one usable interface; the association reported `maxMessageSize` 262 144 at both ends; and 65 535-, 4 096-, and 33-byte frames crossed browser→addon→browser byte for byte and in order. + +**Why the SDP bound is measured rather than reasoned about.** 587 characters is 29% of the budget on a host with one interface, and each further candidate line costs roughly 80 more — so the headroom is real but it is a property of the machine, not of the code. A host with docker bridges, VMs, and several VPNs is the case that would spend it, and the failure there is silent by design: the attempt is skipped and the session stays relayed. Re-run the fixture on such a host before treating the bound as settled. + **Why DTLS is not part of the trust model.** The channel is encrypted twice — DTLS underneath, Noise inside — and only the inner one is load-bearing. The DTLS fingerprints are authentic because the SDP carrying them was decrypted inside an authorized session, so DTLS adds transport hygiene rather than a second authority; a peer that broke it would still face the promoted session's ciphers. ## Envelope diff --git a/scripts/direct-interop/browser.ts b/scripts/direct-interop/browser.ts new file mode 100644 index 000000000..b07dad939 --- /dev/null +++ b/scripts/direct-interop/browser.ts @@ -0,0 +1,112 @@ +/** + * The browser half of the direct path's interop fixture: the Client's side, + * exactly as Pocket builds it (`docs/specs/remote-api.md` -> Transport -> + * "Direct path"). + * + * Bundled and served by `./run.mjs`; see its header for how to run the pair. + * The wrapper under test is the shipped `DirectPeer` — nothing here + * reimplements a negotiation — over the browser's own `RTCPeerConnection` with + * no ICE servers, which is the one combination the in-process suites cannot + * reach: `direct-peer.test.ts` links two fakes, and `native-direct-peer.test.ts` + * runs the addon against itself. + */ + +import { MAX_DIRECT_SDP_LENGTH } from 'remote-lib-common'; +import { DirectPeer, type DirectPeerLike } from '../../lib/src/remote/direct/direct-peer'; + +/** What the page reports back for `run.mjs` to print. Test data only. */ +export interface BrowserReport { + readonly ok: boolean; + readonly error?: string; + /** The offer this browser actually produced, against the signal's bound. */ + readonly offerSdpLength?: number; + readonly maxDirectSdpLength: number; + /** Candidate lines in that offer — what makes a multi-homed machine large. */ + readonly candidates?: number; + /** The association's own per-message limit, as this browser reports it. */ + readonly maxMessageSize?: number | null; + /** The size of every frame echoed back, in the order this end saw them. */ + readonly echoed?: number[]; +} + +declare const __INTEROP_TOKEN__: string; + +/** How long the echo half waits before reporting what it did get. */ +const ECHO_BUDGET_MS = 10_000; + +async function post(path: string, body: unknown): Promise { + const response = await fetch(`${path}?t=${encodeURIComponent(__INTEROP_TOKEN__)}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`${path} answered ${response.status}`); + return response.json(); +} + +async function run(): Promise { + const echoed: number[] = []; + const connection = new RTCPeerConnection({ iceServers: [] }) as unknown as DirectPeerLike; + const opened = Promise.withResolvers(); + const lost = Promise.withResolvers(); + let expected = 0; + const allEchoed = Promise.withResolvers(); + + const peer: DirectPeer = new DirectPeer({ + peer: connection, + handlers: { + onOpen: () => opened.resolve(), + // The addon's side sends; this end echoes each frame straight back, so + // the bytes and the order they arrive in are checked over a real + // association rather than a linked pair. + onFrame: (frame) => { + echoed.push(frame.length); + peer.send(frame); + if (echoed.length >= expected) allEchoed.resolve(); + }, + onClosed: (reason) => lost.reject(new Error(reason)), + onViolation: (reason) => lost.reject(new Error(reason)), + }, + }); + + const sdp = await peer.offer(); + const measured = { + maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH, + offerSdpLength: sdp?.length, + candidates: sdp ? (sdp.match(/^a=candidate/gm) ?? []).length : undefined, + }; + // `offer()` answers null for a description this end would not send, which on + // a machine with many interfaces is the interesting outcome rather than a + // crash: the attempt is skipped and the session stays relayed. + if (!sdp) return { ...measured, ok: false, error: 'the offer did not fit one signal' }; + + const answer = (await post('/answer', { sdp })) as { + sdp?: string; + frames?: number; + error?: string; + }; + if (!answer.sdp) return { ...measured, ok: false, error: answer.error ?? 'no answer' }; + expected = answer.frames ?? 0; + await peer.acceptAnswer(answer.sdp); + await Promise.race([opened.promise, lost.promise]); + + const timeout = new Promise((resolve) => setTimeout(resolve, ECHO_BUDGET_MS)); + await Promise.race([allEchoed.promise, lost.promise, timeout]); + + return { + ...measured, + ok: echoed.length === expected, + error: echoed.length === expected ? undefined : `echoed ${echoed.length} of ${expected}`, + maxMessageSize: connection.sctp?.maxMessageSize ?? null, + echoed, + }; +} + +function show(report: BrowserReport): void { + document.body.textContent = JSON.stringify(report, null, 2); + void post('/report', report); +} + +run().then(show, (error: unknown) => { + show({ ok: false, error: String(error), maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH }); +}); diff --git a/scripts/direct-interop/run.mjs b/scripts/direct-interop/run.mjs new file mode 100644 index 000000000..0836458e1 --- /dev/null +++ b/scripts/direct-interop/run.mjs @@ -0,0 +1,258 @@ +/** + * Browser-to-addon interop for the direct path (`docs/specs/remote-api.md` -> + * Transport -> "Direct path"). Manual, and test data only. + * + * ```sh + * dor ensure -- pnpm exec node scripts/direct-interop/run.mjs + * dor ab --key direct-interop open "$(cat "$TMPDIR/dormouse-direct-interop.url")" + * ``` + * + * The URL carries a per-run token, so it is written to that file as well as + * printed: a terminal pane wraps it into unreadable single characters. + * + * **The combination nothing else covers.** `direct-peer.test.ts` links two + * in-memory fakes and `native-direct-peer.test.ts` runs the addon against + * itself; what actually ships is a phone's browser stack negotiating with + * `node-datachannel` in the sidecar, and no CI job has a browser. So this pair + * is the shipped one: the browser offers, as Pocket does, and the addon answers, + * as the standalone Burrow does — over the shipped `DirectPeer` on both sides, + * with `iceServers: []` at both ends. + * + * It answers three questions a fake cannot: + * 1. Does a real browser's offer fit `MAX_DIRECT_SDP_LENGTH`, and by how + * much? That bound is derived from one padded control body, and an SDP + * over it silently costs the direct path on that machine. + * 2. Does a real association carry a whole `NOISE_MAX_MESSAGE_LENGTH` frame + * between the two stacks, in order, byte for byte? + * 3. Do the two `DirectPeerLike` implementations satisfy the seam as written? + * + * Run it on a machine with the interfaces you care about — a tailnet, a VPN, + * docker bridges — since question 1 is a property of the host, not the code. + */ + +import { createServer } from 'node:http'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isLoopbackHost, isOwnOrigin } from '../../lib/src/host/loopback-guard.ts'; + +/** What the addon puts on the channel, largest first; see question 2 above. */ +const FRAME_SIZES = [65_535, 4_096, 33]; +/** How long the whole run is given before it reports what it has. */ +const RUN_BUDGET_MS = 60_000; + +const here = (path) => fileURLToPath(new URL(path, import.meta.url)); +// esbuild resolves a bare specifier from the importing file's own directory, +// and `scripts/` is not a package: `browser.ts` names `remote-lib-common`, which +// the workspace links under `lib/`. Both bundles are pointed there. +const LIB = here('../../lib'); +const LIB_MODULES = [join(LIB, 'node_modules')]; +const sidecarRequire = createRequire(here('../../standalone/sidecar/package.json')); +const buildRequire = createRequire(here('../../standalone/package.json')); +const { build } = buildRequire('esbuild'); + +const token = randomBytes(24).toString('hex'); +const temp = await mkdtemp(join(tmpdir(), 'dormouse-direct-interop-')); + +// The wrapper under test, bundled rather than imported: it is TypeScript that +// reaches into the webview library, and this file is neither. +await build({ + entryPoints: [here('../../lib/src/remote/direct/direct-peer.ts')], + outfile: join(temp, 'direct-peer.cjs'), + absWorkingDir: LIB, + nodePaths: LIB_MODULES, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'warning', +}); +const { DirectPeer } = createRequire(import.meta.url)(join(temp, 'direct-peer.cjs')); + +const browserBundle = await build({ + entryPoints: [here('./browser.ts')], + absWorkingDir: LIB, + nodePaths: LIB_MODULES, + bundle: true, + platform: 'browser', + format: 'iife', + target: 'es2023', + write: false, + logLevel: 'warning', + define: { __INTEROP_TOKEN__: JSON.stringify(token) }, +}); +const javascript = browserBundle.outputFiles[0].text; + +// `iceServers: []` as both shipped factories pass it: host candidates only. +const { RTCPeerConnection } = sidecarRequire('node-datachannel/polyfill'); +const addon = sidecarRequire('node-datachannel'); + +const frames = FRAME_SIZES.map((size, index) => new Uint8Array(size).fill(index + 1)); +/** What the addon got back, in the order it got it. */ +const returned = []; +let browserReport = null; +let verdict = null; + +const done = Promise.withResolvers(); +const finish = (result) => { + if (verdict) return; + verdict = result; + done.resolve(); +}; + +/** Whether every frame came back byte for byte, in the order it was sent. */ +const echoedIntact = () => + returned.length === frames.length && + returned.every((frame, index) => Buffer.from(frame).equals(Buffer.from(frames[index]))); + +/** + * The run ends when both halves have spoken. The page's report and the last + * echo race each other over two different transports — the report goes back up + * the loopback HTTP the page was served on, the echoes over the channel — so + * neither one on its own says the exchange finished. + */ +const maybeFinish = () => { + if (!browserReport || !echoedIntact()) return; + finish({ ok: Boolean(browserReport.ok), error: browserReport.error }); +}; + +const peer = new DirectPeer({ + peer: new RTCPeerConnection({ iceServers: [] }), + handlers: { + onOpen: () => { + for (const frame of frames) { + if (!peer.send(frame)) finish({ ok: false, error: 'the addon could not send a frame' }); + } + }, + onFrame: (frame) => { + returned.push(Uint8Array.from(frame)); + maybeFinish(); + }, + onClosed: (reason) => finish({ ok: false, error: `channel gone: ${reason}` }), + onViolation: (reason) => finish({ ok: false, error: `violation: ${reason}` }), + }, +}); + +const readJson = (req) => + new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1_000_000) reject(new Error('body too large')); + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + +const server = createServer(async (req, res) => { + const send = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + try { + const port = server.address().port; + const url = new URL(req.url, `http://127.0.0.1:${port}`); + const supplied = url.searchParams.get('t') ?? ''; + // A loopback bind is not an access control: any page in the user's browser + // reaches 127.0.0.1 too (`docs/specs/security-local.md` -> "Loopback + // Listeners"), so the guard modules gate the Host and Origin headers and a + // per-run token gates the rest. + if ( + !isLoopbackHost(req.headers.host, port) || + supplied.length !== token.length || + !timingSafeEqual(Buffer.from(supplied), Buffer.from(token)) + ) { + send(403, { error: 'forbidden' }); + return; + } + if (url.pathname === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end( + `direct interop` + + `
negotiating…
`, + ); + return; + } + if (req.method !== 'POST' || !isOwnOrigin(req.headers.origin, port)) { + send(403, { error: 'forbidden' }); + return; + } + if (url.pathname === '/answer') { + const { sdp } = await readJson(req); + const answer = await peer.answer(String(sdp)); + if (!answer) { + send(200, { error: 'the addon would not answer that offer' }); + finish({ ok: false, error: 'the addon declined the browser offer' }); + return; + } + send(200, { sdp: answer, frames: frames.length }); + return; + } + if (url.pathname === '/report') { + browserReport = await readJson(req); + send(200, { ok: true }); + // A page that gave up says so at once; otherwise the last echoes may + // still be in flight, and the run ends when they land. + if (browserReport.ok) maybeFinish(); + else finish({ ok: false, error: browserReport.error }); + return; + } + send(404, { error: 'not found' }); + } catch (error) { + send(500, { error: String(error) }); + } +}); + +/** Where the run's URL is left, since a narrow pane wraps it unreadably. */ +const URL_FILE = join(tmpdir(), 'dormouse-direct-interop.url'); + +server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + const url = `http://127.0.0.1:${port}/?t=${token}`; + void writeFile(URL_FILE, url); + console.log(`listening on 127.0.0.1:${port}`); + console.log(` dor ab --key direct-interop open "$(cat ${URL_FILE})"`); +}); + +setTimeout( + () => + finish({ + ok: false, + error: browserReport + ? 'the addon did not get its frames back intact' + : 'the page never reported', + }), + RUN_BUDGET_MS, +); +await done.promise; + +console.log( + JSON.stringify( + { + ...verdict, + browser: browserReport, + addon: { + received: returned.map((frame) => frame.length), + expected: FRAME_SIZES, + libraryVersion: addon.getLibraryVersion(), + }, + }, + null, + 2, + ), +); + +peer.close(); +addon.cleanup(); +server.close(); +await rm(temp, { recursive: true, force: true }); +await rm(URL_FILE, { force: true }); +process.exit(verdict.ok ? 0 : 1); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index fc3ddfe64..bb6d8b97c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/notepad.md": 3700, "docs/specs/pocket-app.md": 4250, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4500, + "docs/specs/remote-api.md": 4550, "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, From 99436f7c9f27a3d2b91b149d9f31d6a4ba09f1a9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 12:10:16 -0700 Subject: [PATCH 39/46] refactor(remote): hand the phone a cause, not an attempt's failure text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the safeguards found the reason string had become product copy without a boundary: `#giveUp` is reached from `#onClosed`, so every string `DirectPeer.#fail` minted — a raw `String(error)` from a description that threw included — was one sentence away from a phone's screen, grammar-patched by the UI to make it read. The transport now hands up a `DirectRelayCause`: unsupported, declined, or failed. Pocket owns the sentence for each, beside every other Pocket string, and the reason text stays what it always was — the operator's log line. Adding a give-up path is now a compile error until someone decides which of the three the user is told. Also from the review: - The queue keeps what it is given; `DirectCutover` copies, and only it. A sender's frames are ciphertext its session just minted and nothing else holds, so the copy was 4 MiB of pure waste exactly when the machine is already behind. - `push` answers false instead of `wouldOverflow` then `push`, so there is no way to push past the bound. - The peer reports its own failures: our queue overrunning and the channel refusing a write are opposite diagnoses, and the reason was the only thing telling them apart. The endpoint keeps one rule. - The handoff deadline is derived in `#settle` rather than armed and cleared by hand at the four sites that already announce. - `DIRECT_CHANNEL_LABEL` moved beside the other two-end constants: the answerer now validates a channel the peer created against it. - `PocketClient.transportRelayCause` derives from the endpoint the way `transportPath` does, instead of mirroring it in a field nothing read. - The interop fixture uses the shared `isAuthorized` gate, builds its two bundles together, and computes one verdict instead of two that could disagree. Re-run: still green, offer 585 characters. - A channel defect can now sit on the answerer alone — the end where the check can fail in production — and `maxPacketLifeTime` has a case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/pocket-app.md | 12 +- docs/specs/remote-api.md | 10 +- .../host/remote/native-direct-peer.test.ts | 2 +- lib/src/remote/client/pocket-client.ts | 32 ++--- lib/src/remote/direct/direct-endpoint.test.ts | 51 ++++--- lib/src/remote/direct/direct-endpoint.ts | 126 +++++++++--------- lib/src/remote/direct/direct-peer.test.ts | 51 +++++-- lib/src/remote/direct/direct-peer.ts | 54 ++++---- lib/src/remote/direct/test-fake-peer.ts | 30 +++-- lib/src/remote/pocket-app/App.scan.test.tsx | 54 +++----- lib/src/remote/pocket-app/App.tsx | 55 +++++--- remote-lib-common/src/security/direct-path.ts | 51 ++++--- remote-lib-common/test/direct-path.test.mjs | 34 +++-- scripts/direct-interop/browser.ts | 47 +++---- scripts/direct-interop/run.mjs | 95 +++++++------ scripts/spec-word-budgets.json | 2 +- 16 files changed, 394 insertions(+), 312 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index ec98c9b85..6c806ec96 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -465,14 +465,18 @@ the relay when the browser has none or the Burrow declines 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. **A +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 the cutover 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 cutover a failed channel costs nothing. -Source of truth: `PocketClient.transportPath` / `transportDetail` in -`lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` and -`transportTitle` in `lib/src/remote/pocket-app/App.tsx`. +Source of truth: `PocketClient.transportPath` / `transportRelayCause` 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 diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 216ed4801..1fd8b895d 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -119,7 +119,10 @@ 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. +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. **Cutover preserves order per direction:** @@ -160,8 +163,9 @@ 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** -([pocket-app.md](./pocket-app.md)). +**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 diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index a94a070cf..2618fd577 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -226,7 +226,7 @@ describe('the direct path over the native addon', () => { try { const payload = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); crypto.getRandomValues(payload); - expect(run.offerer.send(payload)).toBe(true); + run.offerer.send(payload); await waitFor(() => run.inbound.length === 1, 'the frame to arrive', ATTEMPT_BUDGET_MS); expect(run.inbound[0]).toEqual(payload); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 4c2d7f610..965745156 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -45,6 +45,7 @@ import { type ConnectionDenialCode, type ConnectionRequestV1, type DirectPath, + type DirectRelayCause, type DirectSignalV1, type DirectoryEntry, type DirectorySnapshot, @@ -365,12 +366,9 @@ export class PocketClient { #onBurrowGone: (() => void) | null = null; /** This session's direct path, or null while there is no authorized session. */ #direct: DirectEndpoint | null = null; - #onTransportChanged: ((path: DirectPath, detail: string | null) => void) | null = null; - /** - * Why the live session is on the path it is on. Held here rather than read - * off the endpoint, which is dropped with the session that owned it. - */ - #transportDetail: string | null = null; + #onTransportChanged: + | ((path: DirectPath, cause: DirectRelayCause | null) => void) + | null = null; /** Cancels the armed keepalive, and the visibility subscription behind it. */ #cancelKeepalive: (() => void) | null = null; #cancelVisibility: (() => void) | null = null; @@ -421,17 +419,17 @@ export class PocketClient { } /** - * Why this session is on the path it is on, or `null` where there is nothing - * to say — the sentence behind the indicator, so a session that stayed - * relayed can say which of the silent reasons it was. + * Why this session is still relayed, or `null` where there is nothing to say + * — what the indicator explains, so a session that quietly stayed relayed can + * say which of the three it was. */ - get transportDetail(): string | null { - return this.#transportDetail; + get transportRelayCause(): DirectRelayCause | null { + return this.#direct?.relayCause ?? null; } - /** Notified whenever {@link transportPath} or {@link transportDetail} changes. */ + /** Notified whenever {@link transportPath} or {@link transportRelayCause} changes. */ setOnTransportChanged( - callback: ((path: DirectPath, detail: string | null) => void) | null, + callback: ((path: DirectPath, cause: DirectRelayCause | null) => void) | null, ): void { this.#onTransportChanged = callback; } @@ -1190,10 +1188,7 @@ export class PocketClient { // same event, and the app must leave the wall either way. fatal: (reason) => this.#loseBurrow(reason), isCurrent: () => this.#established === established, - onTransportChanged: (path, detail) => { - this.#transportDetail = detail; - this.#onTransportChanged?.(path, detail); - }, + onTransportChanged: (path, cause) => this.#onTransportChanged?.(path, cause), setTimer: this.#setTimer, }); } @@ -1545,9 +1540,6 @@ export class PocketClient { // none can outlive the session that authorized it. this.#direct?.dispose(); this.#direct = null; - // After the dispose, whose own announcement is still this session's: the - // previous session's reason says nothing about the next one. - this.#transportDetail = null; this.#connectedBurrowId = null; this.#established = null; } diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index cddf2105a..ef820a6d3 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -22,6 +22,7 @@ import { MAX_DIRECT_PENDING_FRAMES, toBase64Url, type DirectPath, + type DirectRelayCause, type DirectSignalV1, } from 'remote-lib-common'; @@ -39,8 +40,8 @@ interface Side { readonly fatals: string[]; /** Every path change announced. */ readonly paths: DirectPath[]; - /** Every detail announced beside a path; see `DirectEndpoint.detail`. */ - readonly details: Array; + /** Every cause announced beside a path; see `DirectEndpoint.relayCause`. */ + readonly causes: Array; /** Every signal this side put on the relay. */ readonly sent: DirectSignalV1[]; /** What `isCurrent()` answers; a promotion or teardown flips it. */ @@ -75,7 +76,7 @@ function pair(options: Options = {}) { received: [], fatals: [], paths: [], - details: [], + causes: [], sent: [], live: true, sendable: true, @@ -113,9 +114,9 @@ function pair(options: Options = {}) { }, fatal: (reason) => void side.fatals.push(reason), isCurrent: () => side.live, - onTransportChanged: (path, detail) => { + onTransportChanged: (path, cause) => { side.paths.push(path); - side.details.push(detail); + side.causes.push(cause); }, setTimer: timers.setTimer, }); @@ -391,29 +392,42 @@ describe('DirectEndpoint', () => { expect(run.timers.live).toEqual([]); }); - describe('the reason behind the path', () => { - it('says why an attempt was given up, beside the unchanged path', async () => { + /** + * **A closed set, never the failure text.** The strings an attempt gives up + * with include a runtime's own exception message; what the peer is told is + * which of the three answers a person can act on it was. + */ + describe('why a session stayed relayed', () => { + it('says an attempt was never possible, beside the unchanged path', async () => { const run = pair({ offererHasPeer: false }); await run.offerer.endpoint.offer(); - const reason = 'this device has no direct connection to offer'; - expect(run.offerer.endpoint.detail).toBe(reason); - expect(run.offerer.details).toEqual([reason]); - // The path never changed; the announcement is the reason alone. + expect(run.offerer.endpoint.relayCause).toBe('unsupported'); + expect(run.offerer.causes).toEqual(['unsupported']); + // The path never changed; the announcement is the cause alone. expect(run.offerer.paths).toEqual(['relay']); }); - it('says a decline is what ended the offerer’s attempt', async () => { + it('tells the offerer it was declined, and the answerer why it declined', async () => { const run = pair({ answererHasPeer: false }); await run.offerer.endpoint.offer(); await flushMicrotasks(); - expect(run.offerer.endpoint.detail).toBe('the peer declined a direct path'); - expect(run.answerer.endpoint.detail).toBe( - 'this device has no direct connection to answer with', - ); + expect(run.offerer.endpoint.relayCause).toBe('declined'); + expect(run.answerer.endpoint.relayCause).toBe('unsupported'); + }); + + it('calls a channel that never opened a failure, not a refusal', async () => { + const run = pair({ opening: 'never' }); + await cutover(run); + + run.timers.fireAt(DIRECT_ANSWER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.offerer.endpoint.relayCause).toBe('failed'); + expect(run.answerer.endpoint.relayCause).toBe('failed'); }); it('has nothing to say once the session is on the channel', async () => { @@ -421,8 +435,9 @@ describe('DirectEndpoint', () => { await cutover(run); - expect(run.offerer.endpoint.detail).toBeNull(); - expect(run.answerer.endpoint.detail).toBeNull(); + expect(run.offerer.endpoint.relayCause).toBeNull(); + expect(run.answerer.endpoint.relayCause).toBeNull(); + expect(run.offerer.causes).toEqual([null]); }); }); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 59231c3f1..8a1c4d022 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -19,6 +19,7 @@ import { fromBase64Url, isDirectSignalV1, type DirectPath, + type DirectRelayCause, type DirectSignalV1, type TransportReceipt, } from 'remote-lib-common'; @@ -62,10 +63,9 @@ export interface DirectEndpointDeps { isCurrent(): boolean; /** * Notified whenever {@link DirectEndpoint.path} or {@link - * DirectEndpoint.detail} changes; the Client's indicator. The detail is why - * the session is on the path it is on — `null` once nothing is worth saying. + * DirectEndpoint.relayCause} changes; the Client's indicator. */ - onTransportChanged?(path: DirectPath, detail: string | null): void; + onTransportChanged?(path: DirectPath, cause: DirectRelayCause | null): void; /** Every deadline the peer arms; see {@link RemoteTimer}. */ readonly setTimer?: RemoteTimer; } @@ -77,11 +77,11 @@ export class DirectEndpoint { readonly #setTimer: RemoteTimer; #peer: DirectPeer | null = null; #disposed = false; - #detail: string | null = null; - /** Cancels the wait for the peer's switch; see {@link #armHandoffTimeout}. */ + #cause: DirectRelayCause | null = null; + /** Cancels the wait for the peer's switch; see {@link #settle}. */ #cancelHandoff: (() => void) | null = null; /** The last pair announced, so an unchanged one is not announced twice. */ - #announced: { path: DirectPath; detail: string | null } = { path: 'relay', detail: null }; + #announced: { path: DirectPath; cause: DirectRelayCause | null } = { path: 'relay', cause: null }; constructor(role: DirectRole, deps: DirectEndpointDeps) { this.#role = role; @@ -95,12 +95,12 @@ export class DirectEndpoint { } /** - * Why the session is on the path it is on, or `null` where there is nothing - * to say. Set by every route that gives an attempt up, so a session that - * stayed relayed can say which of the many silent reasons it was. + * Why the session is still relayed, or `null` where there is nothing to say. + * Set by every route that gives an attempt up, so a session that stayed + * relayed can say which of the three {@link DirectRelayCause}s it was. */ - get detail(): string | null { - return this.#detail; + get relayCause(): DirectRelayCause | null { + return this.#cause; } /** @@ -116,7 +116,7 @@ export class DirectEndpoint { if (this.#role !== 'offerer' || !this.#cutover.begin()) return; const peer = this.#build(); if (!peer) { - this.#giveUp('this device has no direct connection to offer'); + this.#giveUp('this runtime builds no peer connection', 'unsupported'); return; } const sdp = await peer.offer(); @@ -139,7 +139,7 @@ export class DirectEndpoint { if (this.#role === 'offerer') void this.#peer?.acceptAnswer(value.sdp); return; case 'direct-decline': - if (this.#role === 'offerer') this.#giveUp('the peer declined a direct path'); + if (this.#role === 'offerer') this.#giveUp('the peer declined a direct path', 'declined'); return; case 'direct-switch': { const outcome = this.#cutover.onSwitchDecrypted(); @@ -147,8 +147,7 @@ export class DirectEndpoint { this.#deps.fatal('the peer moved to a direct path this end had abandoned'); return; } - this.#clearHandoffTimeout(); - this.#announce(); + this.#settle(); // In arrival order, through the same decrypt path the relay's frames // take: what was held is exactly what was sent after the switch. for (const frame of outcome.frames) { @@ -202,10 +201,11 @@ export class DirectEndpoint { send(ciphertext: Uint8Array): boolean { if (this.#disposed) return true; if (this.#cutover.outbound !== 'direct') return false; - if (this.#peer?.send(ciphertext)) return true; - // Switched, and the channel will not take it: there is no relay to fall - // back to, so this is burrow loss rather than a message to re-route. - this.#deps.fatal('the direct channel refused a message'); + // Switched, so the channel is the only path this may take — there is no + // relay left to re-route onto. A peer that cannot carry it says why through + // `onClosed`, and this end's rule about a channel lost after a switch does + // the rest. + this.#peer?.send(ciphertext); return true; } @@ -217,11 +217,10 @@ export class DirectEndpoint { dispose(): void { if (this.#disposed) return; this.#disposed = true; - this.#clearHandoffTimeout(); this.#peer?.close(); this.#peer = null; this.#cutover.clear(); - this.#announce(); + this.#settle(); } // --- Internals ------------------------------------------------------------- @@ -235,13 +234,13 @@ export class DirectEndpoint { if (!this.#cutover.begin()) return; const peer = this.#build(); if (!peer) { - this.#decline('this device has no direct connection to answer with'); + this.#decline('this runtime builds no peer connection', 'unsupported'); return; } const sdp = await peer.answer(offerSdp); if (!this.#stillOurs(peer)) return; if (sdp === null) { - this.#decline('this device could not describe a direct connection'); + this.#decline('this end could not describe a direct connection'); return; } if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); @@ -262,8 +261,8 @@ export class DirectEndpoint { * Give the attempt up and say so, rather than leaving the offerer to wait out * its setup deadline for a channel that is never coming. */ - #decline(reason: string): void { - this.#giveUp(reason); + #decline(reason: string, cause: DirectRelayCause = 'failed'): void { + this.#giveUp(reason, cause); this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); } @@ -281,7 +280,7 @@ export class DirectEndpoint { if (!connection) return null; this.#peer = new DirectPeer({ peer: connection, - setTimer: this.#deps.setTimer, + setTimer: this.#setTimer, handlers: { onOpen: () => this.#onOpen(), onFrame: (frame) => this.#onFrame(frame), @@ -303,35 +302,9 @@ export class DirectEndpoint { this.#giveUp(); return; } - if (this.#cutover.switchOutbound()) this.#armHandoffTimeout(); - this.#detail = null; - this.#announce(); - } - - /** - * **The cutover gets a deadline of its own.** From here this end sends only on - * the channel, so a peer that never switches back leaves it talking into a - * channel nothing reads; without this the wait would end only when the held - * frames overran their bound, which is a function of how chatty the session - * happens to be rather than of anything going wrong. - * - * There is no relay left to fall back to, so expiry is burrow loss. - */ - #armHandoffTimeout(): void { - // Nothing to wait for where the peer switched first — the ordinary order - // for whichever end's channel opens second. - if (this.#cutover.inbound === 'direct') return; - this.#clearHandoffTimeout(); - this.#cancelHandoff = this.#setTimer(() => { - this.#cancelHandoff = null; - if (!this.#alive() || this.#cutover.inbound === 'direct') return; - this.#deps.fatal('the peer did not follow onto the direct path'); - }, DIRECT_HANDOFF_TIMEOUT_MS); - } - - #clearHandoffTimeout(): void { - this.#cancelHandoff?.(); - this.#cancelHandoff = null; + this.#cutover.switchOutbound(); + this.#cause = null; + this.#settle(); } /** One frame off the channel: processed, held until the peer's switch, or fatal. */ @@ -381,17 +354,16 @@ export class DirectEndpoint { * riding the channel is gone and a stream cipher has no resynchronization * point, so the session is over. */ - #giveUp(reason = 'the direct path was abandoned'): void { + #giveUp(reason = 'the direct path was abandoned', cause: DirectRelayCause = 'failed'): void { if (this.#cutover.switched) { this.#deps.fatal(reason); return; } - this.#clearHandoffTimeout(); this.#peer?.close(); this.#peer = null; this.#cutover.abandon(); - this.#detail = reason; - this.#announce(); + this.#cause = cause; + this.#settle(); } /** Whether this endpoint still belongs to the session the caller is serving. */ @@ -399,11 +371,37 @@ export class DirectEndpoint { return !this.#disposed && this.#deps.isCurrent(); } - #announce(): void { + /** + * Reconcile the handoff deadline with the cutover, then announce. + * + * **The deadline is derived, never remembered.** It is armed exactly while + * this end has switched and its peer has not — the one window in which this + * end sends only on the channel while nothing can reach it back — so every + * route that moves the cutover reconciles it by calling this, rather than by + * remembering to arm or clear. + * + * Its expiry is burrow loss, since there is no relay left to fall back to. + * Without it the wait would end only when the held frames overran their + * bound, which is a function of how chatty the session happens to be rather + * than of anything going wrong. + */ + #settle(): void { + const waiting = + !this.#disposed && this.#cutover.outbound === 'direct' && this.#cutover.inbound === 'relay'; + if (waiting && !this.#cancelHandoff) { + this.#cancelHandoff = this.#setTimer(() => { + this.#cancelHandoff = null; + if (!this.#alive()) return; + this.#deps.fatal('the peer did not follow onto the direct path'); + }, DIRECT_HANDOFF_TIMEOUT_MS); + } else if (!waiting && this.#cancelHandoff) { + this.#cancelHandoff(); + this.#cancelHandoff = null; + } const path = this.path; - const detail = this.#detail; - if (path === this.#announced.path && detail === this.#announced.detail) return; - this.#announced = { path, detail }; - this.#deps.onTransportChanged?.(path, detail); + const cause = this.#cause; + if (path === this.#announced.path && cause === this.#announced.cause) return; + this.#announced = { path, cause }; + this.#deps.onTransportChanged?.(path, cause); } } diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index a02bae463..f00e3fc11 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -8,14 +8,16 @@ import { describe, expect, it, vi } from 'vitest'; import { DIRECT_BUFFER_HIGH, + DIRECT_CHANNEL_LABEL, DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, MAX_DIRECT_OUTBOUND_BYTES, + MAX_DIRECT_OUTBOUND_FRAMES, NOISE_MAX_MESSAGE_LENGTH, } from 'remote-lib-common'; -import { DIRECT_CHANNEL_LABEL, DirectPeer, type DirectPeerHandlers } from './direct-peer'; +import { DirectPeer, type DirectPeerHandlers } from './direct-peer'; import { FakeDirectNetwork, type FakeDirectNetworkOptions } from './test-fake-peer'; import { fakeTimers } from '../test-timers'; @@ -136,8 +138,10 @@ describe('DirectPeer', () => { expect(client.closes).toEqual(['the direct channel did not open in time']); expect(clientPeer.isOpen).toBe(false); - // And nothing may be sent on it afterwards. - expect(clientPeer.send(Uint8Array.of(1))).toBe(false); + // And nothing may be sent on it afterwards: the channel is gone, and a + // closed peer reports nothing twice. + clientPeer.send(Uint8Array.of(1)); + expect(client.closes).toEqual(['the direct channel did not open in time']); }); it('reports a channel that dies under a live session, at both ends', async () => { @@ -226,8 +230,8 @@ describe('DirectPeer', () => { const channel = network.offererChannel!; channel.bufferedAmount = DIRECT_BUFFER_HIGH; - expect(clientPeer.send(Uint8Array.of(1))).toBe(true); - expect(clientPeer.send(Uint8Array.of(2))).toBe(true); + clientPeer.send(Uint8Array.of(1)); + clientPeer.send(Uint8Array.of(2)); await flushMicrotasks(); // Held here rather than handed to a buffer that would refuse them. expect(channel.sent).toEqual([]); @@ -254,17 +258,23 @@ describe('DirectPeer', () => { expect(channel.sent).toEqual([]); }); - it('refuses a send that would outrun the queue, in bytes', async () => { - const { network, clientPeer } = await connected(); + it('reports the path gone when a send would outrun the queue, in bytes', async () => { + const { network, clientPeer, client } = await connected(); network.offererChannel!.bufferedAmount = DIRECT_BUFFER_HIGH; const frame = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); let held = 0; - while (clientPeer.send(frame)) held += 1; - + while (client.closes.length === 0 && held <= MAX_DIRECT_OUTBOUND_FRAMES) { + clientPeer.send(frame); + held += 1; + } + + // **Our bound, not the peer's stack**: an operator reading a burrow-loss + // log has only the reason to tell those two apart. + expect(client.closes).toEqual(['the direct path outran what a sender can hold in order']); // Bytes bind first: the frame cap is far above what this many reaches. - expect(held * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); - expect((held + 1) * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); + expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); + expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); }); it('reports the channel gone when a queued frame will not go out', async () => { @@ -282,7 +292,7 @@ describe('DirectPeer', () => { }); describe('what the channel has to be', () => { - it.each(['unordered', 'lossy', 'mislabeled'] as const)( + it.each(['unordered', 'lossy', 'expiring', 'mislabeled'] as const)( 'refuses a %s channel, before anything rides it', async (defect) => { const { clientPeer, client } = pair({ channel: defect }); @@ -296,6 +306,23 @@ describe('DirectPeer', () => { }, ); + it('refuses one the peer created, which is where the check can fail', async () => { + // The offerer only re-reads the channel it asked for; the answerer is + // handed one by a peer it has no reason to trust to have asked for the + // same thing. + const { clientPeer, burrowPeer, burrow } = pair({ + channel: 'unordered', + channelSide: 'answerer', + }); + + const offer = await clientPeer.offer(); + expect(await burrowPeer.answer(offer!)).toBeNull(); + + expect(burrow.closes).toEqual([ + 'the direct channel is not the reliable ordered one this session opens', + ]); + }); + it('abandons a channel that cannot carry one Noise message', async () => { const { clientPeer, client } = await connected({ maxMessageSize: 16_384 }); diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 31429f91a..d21bfadd0 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -18,6 +18,7 @@ import { DIRECT_ANSWER_TIMEOUT_MS, DIRECT_BUFFER_HIGH, + DIRECT_CHANNEL_LABEL, DIRECT_BUFFER_LOW, DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, @@ -30,9 +31,6 @@ import { } from 'remote-lib-common'; import { realTimer, type RemoteTimer } from '../ws'; -/** The label of the one data channel a session opens. */ -export const DIRECT_CHANNEL_LABEL = 'dormouse'; - /** The four `RTCSdpType` values, so a real description assigns to ours. */ export type DirectSdpType = 'offer' | 'answer' | 'pranswer' | 'rollback'; @@ -231,31 +229,36 @@ export class DirectPeer { * One Noise transport message as one channel frame — raw bytes, never base64 * or JSON, so the channel carries exactly what the relay would have. * - * Answers `false` where the channel cannot take it rather than throwing: the - * endpoint decides what a refused send means, and on a session that has - * already switched it is burrow loss rather than an error for the caller. + * **Every way this can fail is reported here, in its own words**, through + * `onClosed`: the channel refusing a write and this end's own queue + * overrunning are opposite diagnoses — one is the peer's stack, one is our + * bound — and an operator reading a burrow-loss log has only the reason to + * tell them apart. What a report *costs* is still the endpoint's question. + * + * The frame is kept by reference: it is ciphertext the session just minted + * and nothing else holds ({@link DirectFrameQueue}). */ - send(ciphertext: Uint8Array): boolean { + send(ciphertext: Uint8Array): void { const channel = this.#channel; - if (!channel || !this.isOpen) return false; + if (!channel || !this.isOpen) return; // **Once anything is queued, everything queues.** A frame handed straight to // the channel while others wait would reach the peer ahead of ciphertext // encrypted before it, and a Noise stream has no way back from a counter // read out of order. if (this.#outbound.length === 0 && channel.bufferedAmount < DIRECT_BUFFER_HIGH) { - return this.#write(channel, ciphertext); + if (!this.#write(channel, ciphertext)) this.#fail('the direct channel refused a message'); + return; + } + if (!this.#outbound.push(ciphertext)) { + this.#fail('the direct path outran what a sender can hold in order'); } - if (this.#outbound.wouldOverflow(ciphertext.length)) return false; - this.#outbound.push(ciphertext); - return true; } /** Close the channel and the connection. Idempotent, and reports nothing. */ close(): void { this.#closed = true; this.#clearSetupTimeout(); - this.#cancelDisconnected?.(); - this.#cancelDisconnected = null; + this.#clearDisconnectedGrace(); this.#outbound.clear(); // The suspended `offer()`/`answer()` finishes here rather than in three // seconds' time: it sees `#closed`, answers `null`, and releases the @@ -329,11 +332,11 @@ export class DirectPeer { */ #onOpen(): void { if (this.#closed || this.#open) return; - const limit = this.#peer.sctp?.maxMessageSize; // Unknown is not small: an implementation reporting no association yet, or // no usable number, is one this cannot rule out either way — and every // inbound frame is bounded again in `#onMessage` regardless. - if (typeof limit === 'number' && limit > 0 && limit < NOISE_MAX_MESSAGE_LENGTH) { + const limit = this.#peer.sctp?.maxMessageSize ?? 0; + if (limit > 0 && limit < NOISE_MAX_MESSAGE_LENGTH) { this.#fail(`the direct channel carries only ${limit} bytes per message`); return; } @@ -352,12 +355,11 @@ export class DirectPeer { #drain(): void { const channel = this.#channel; if (!channel || !this.isOpen) return; - while (this.#outbound.length > 0 && channel.bufferedAmount < DIRECT_BUFFER_HIGH) { + while (channel.bufferedAmount < DIRECT_BUFFER_HIGH) { const frame = this.#outbound.shift(); if (!frame) return; if (this.#write(channel, frame)) continue; - // The channel took this frame into the queue and will not take it now: - // it is gone, and the endpoint decides what that costs. + // Accepted into the queue and refused now: the channel is gone. this.#fail('the direct channel refused a message'); return; } @@ -376,9 +378,9 @@ export class DirectPeer { * The connection's own state, which reaches here before the channel's does. * * **`disconnected` is waited out** ({@link DIRECT_DISCONNECTED_GRACE_MS}): - * ICE reports it on a gap the connection often recovers from, and ending a - * switched session there costs a fresh handshake and a WebAuthn prompt. - * `failed` and `closed` are terminal and end the attempt at once. + * ICE reports it on a gap that often recovers, so it is not a report of loss + * until it persists. `failed` and `closed` are terminal and end the attempt + * at once. */ #onConnectionState(): void { if (this.#closed) return; @@ -388,8 +390,7 @@ export class DirectPeer { return; } if (state !== 'disconnected') { - this.#cancelDisconnected?.(); - this.#cancelDisconnected = null; + this.#clearDisconnectedGrace(); return; } if (this.#cancelDisconnected) return; @@ -475,6 +476,11 @@ export class DirectPeer { this.#cancelSetup = null; } + #clearDisconnectedGrace(): void { + this.#cancelDisconnected?.(); + this.#cancelDisconnected = null; + } + /** Report the channel gone, once, and take the connection down with it. */ #fail(reason: string): void { if (this.#closed) return; diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index a9760e23b..cb3481d6b 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -16,9 +16,8 @@ * `TestRelay.holdToClient()`, on the side that is actually slow. */ -import { NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; +import { DIRECT_CHANNEL_LABEL, NOISE_MAX_MESSAGE_LENGTH } from 'remote-lib-common'; import { - DIRECT_CHANNEL_LABEL, type DirectChannelLike, type DirectPeerLike, type DirectSctpLike, @@ -50,13 +49,22 @@ export interface FakeDirectNetworkOptions { * implementation that reports no association at all. */ readonly maxMessageSize?: number | null; - /** A channel a Noise stream cannot ride; see {@link FakeChannel}. */ - readonly channel?: 'unordered' | 'lossy' | 'mislabeled'; + /** A channel a Noise stream cannot ride; see {@link ChannelDefect}. */ + readonly channel?: ChannelDefect; + /** + * Which end's channel carries that defect; both by default. The answerer is + * the end where the check can fail in production — it validates a channel the + * *peer* created, while the offerer only re-reads its own request. + */ + readonly channelSide?: FakePeerRole; } /** One end of the linked pair; the offerer creates the channel. */ export type FakePeerRole = 'offerer' | 'answerer'; +/** One way a channel can be something a Noise stream cannot ride. */ +export type ChannelDefect = 'unordered' | 'lossy' | 'expiring' | 'mislabeled'; + export class FakeDirectNetwork { readonly #options: FakeDirectNetworkOptions; readonly #peers = new Map(); @@ -176,8 +184,14 @@ export class FakePeer implements DirectPeerLike { this.#emit('connectionstatechange', {}); } + /** This end's channel defect, if the run put one on this side. */ + get #defect(): ChannelDefect | undefined { + const { channel, channelSide } = this.#options; + return !channelSide || channelSide === this.#role ? channel : undefined; + } + createDataChannel(label: string): DirectChannelLike { - const channel = new FakeChannel(label, this.#options.channel); + const channel = new FakeChannel(label, this.#defect); this.#network.registerChannel(this.#role, channel); return channel; } @@ -197,7 +211,7 @@ export class FakePeer implements DirectPeerLike { async setRemoteDescription(description: DirectSessionDescription): Promise { if (description.type === 'offer') { // The answerer learns of the channel here, exactly as a real one does. - const channel = new FakeChannel(DIRECT_CHANNEL_LABEL, this.#options.channel); + const channel = new FakeChannel(DIRECT_CHANNEL_LABEL, this.#defect); this.#network.registerChannel(this.#role, channel); this.#emit('datachannel', { channel }); return; @@ -258,11 +272,11 @@ export class FakeChannel implements DirectChannelLike { readonly #inbox: Uint8Array[] = []; readonly #events = new FakeEventTarget(); - constructor(label: string, defect?: 'unordered' | 'lossy' | 'mislabeled') { + constructor(label: string, defect?: ChannelDefect) { this.label = defect === 'mislabeled' ? `${label}-other` : label; this.ordered = defect !== 'unordered'; this.maxRetransmits = defect === 'lossy' ? 3 : null; - this.maxPacketLifeTime = null; + this.maxPacketLifeTime = defect === 'expiring' ? 500 : null; } /** The association caught up: drop to the low-water mark and wake the sender. */ diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index 51d05085f..ef81da5ad 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -21,7 +21,7 @@ import App, { BURROWS_TITLE, SCAN_LABEL, TRANSPORT_PATH_LABELS, - transportTitle, + TRANSPORT_RELAY_CAUSES, UNSUPPORTED_BROWSER_TITLE, } from './App'; import type { ConnectResult, PairingResult } from '../client/pocket-client'; @@ -47,7 +47,9 @@ import { setNativeFieldValue } from '../../lib/dom'; const fake = vi.hoisted(() => ({ noiseSupported: true as boolean, /** The path callback `App` registered, so a case can report a cutover. */ - onTransportPath: null as ((path: 'relay' | 'direct', detail: string | null) => void) | null, + onTransportPath: null as + | ((path: 'relay' | 'direct', cause: 'unsupported' | 'declined' | 'failed' | null) => void) + | null, hasPriorUse: false, sessionToken: null as string | null, setup: vi.fn<(credential: { setupToken: string }, label: string) => Promise>(), @@ -100,7 +102,9 @@ vi.mock('../client/pocket-client', async (importOriginal) => ({ registeredPushEndpoint = () => null; setOnBurrowGone = () => undefined; setOnTransportChanged = ( - callback: ((path: 'relay' | 'direct', detail: string | null) => void) | null, + callback: + | ((path: 'relay' | 'direct', cause: 'unsupported' | 'declined' | 'failed' | null) => void) + | null, ) => { fake.onTransportPath = callback; }; @@ -507,42 +511,24 @@ describe('the Burrows list', () => { // Every session starts on the relay and says so. expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.relay.label); - act(() => fake.onTransportPath?.('direct', null)); + // An attempt that quietly stayed relayed changes only the reason, so the + // label alone would say nothing had happened. **The reason rides the hover + // text, never the label**: a third state for the common case would read as + // a fault. + act(() => fake.onTransportPath?.('relay', 'declined')); await settle(); - expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.direct.label); - expect(container.textContent).not.toContain(TRANSPORT_PATH_LABELS.relay.label); - }); - - /** - * An attempt that quietly stayed relayed changes only the reason, so the - * label alone would say nothing had happened. **The reason rides the hover - * text, never the label**: inventing a third state for the common case would - * make it look like a fault. - */ - it('carries the reason a session stayed relayed in the indicator’s hover text', async () => { - fake.hasPriorUse = true; - fake.listKnownBurrows.mockResolvedValue([await knownBurrow('burrow-1', 'First laptop')]); - fake.listBurrows.mockResolvedValue([{ burrowId: 'burrow-1', label: '', online: true }]); - await boot(); - await click(container, 'Sign in with passkey'); - await click(container, 'Connect'); + const note = container.querySelector(`[title="${TRANSPORT_PATH_LABELS.relay.title} ` + + `${TRANSPORT_RELAY_CAUSES.declined}"]`); + expect(note?.textContent).toBe(TRANSPORT_PATH_LABELS.relay.label); - act(() => fake.onTransportPath?.('relay', 'the peer declined a direct path')); + act(() => fake.onTransportPath?.('direct', null)); await settle(); - const note = container.querySelector('[title]:not([title=""])'); - expect(note?.textContent).toBe(TRANSPORT_PATH_LABELS.relay.label); - expect(note?.getAttribute('title')).toBe( - transportTitle('relay', 'the peer declined a direct path'), - ); - }); - - it('leads the reason with a capital, and says nothing where there is none', () => { - expect(transportTitle('direct')).toBe(TRANSPORT_PATH_LABELS.direct.title); - expect(transportTitle('relay', 'the peer declined a direct path')).toBe( - `${TRANSPORT_PATH_LABELS.relay.title} The peer declined a direct path.`, - ); + expect(container.textContent).toContain(TRANSPORT_PATH_LABELS.direct.label); + expect(container.textContent).not.toContain(TRANSPORT_PATH_LABELS.relay.label); + // Nothing left to explain: the hover text is the path alone. + expect(container.querySelector(`[title="${TRANSPORT_PATH_LABELS.direct.title}"]`)).not.toBeNull(); }); /** diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 0c1782b0b..f6f9b433b 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -26,7 +26,12 @@ import { } from '../client/pocket-client'; import { PasskeyAlreadyRegisteredError, browserWebAuthn } from '../client/webauthn'; import { BURROW_IS_AN_APP, SCAN_LABEL } from '../setup-copy'; -import { probeNoiseSupport, type DirectPath, type PairingInvitation } from 'remote-lib-common'; +import { + probeNoiseSupport, + type DirectPath, + type DirectRelayCause, + type PairingInvitation, +} from 'remote-lib-common'; import { indexedDbKnownBurrowStore, indexedDbPendingDeletionStore, @@ -353,12 +358,9 @@ export default function App({ * render: the cutover happens seconds into a session, long after the wall is * up, and an attempt that quietly stays relayed changes only the detail. */ - const [transport, setTransport] = useState<{ path: DirectPath; detail: string | null }>({ - path: 'relay', - detail: null, - }); + const [transport, setTransport] = useState({ path: 'relay', cause: null }); useEffect(() => { - client.setOnTransportChanged((path, detail) => setTransport({ path, detail })); + client.setOnTransportChanged((path, cause) => setTransport({ path, cause })); return () => client.setOnTransportChanged(null); }, [client]); @@ -635,8 +637,7 @@ export default function App({ @@ -800,36 +801,52 @@ export const TRANSPORT_PATH_LABELS: Record = { + unsupported: 'This device cannot make a direct connection.', + declined: 'The computer turned a direct connection down.', + failed: 'A direct connection was tried and did not work.', +}; + +/** Which path carries the session, and why it is not the direct one. */ +export interface TransportView { + readonly path: DirectPath; + readonly cause: DirectRelayCause | null; +} + /** * The indicator's hover text: which path, and the reason behind it where there * is one. **The reason is shown, never the label** — an attempt that quietly * stayed relayed is still `relay`, and inventing a third state for it would * make the common case look like a fault. */ -export function transportTitle(path: DirectPath, detail?: string | null): string { +export function transportTitle({ path, cause }: TransportView): string { const { title } = TRANSPORT_PATH_LABELS[path]; - return detail ? `${title} ${detail.charAt(0).toUpperCase()}${detail.slice(1)}.` : title; + return cause ? `${title} ${TRANSPORT_RELAY_CAUSES[cause]}` : title; } /** The connected Pocket shell: Burrow navigation chrome over the remote wall. */ export function ConnectedView({ burrow, adapter, - transportPath = 'relay', - transportDetail = null, + transport = { path: 'relay', cause: null }, onLeave, onError, }: { burrow: BurrowView; adapter: RemotePtyAdapter; - /** Which path carries the session; see {@link TRANSPORT_PATH_LABELS}. */ - transportPath?: DirectPath; - /** Why it is on that path; see {@link transportTitle}. */ - transportDetail?: string | null; + /** Which path carries the session, and why; see {@link transportTitle}. */ + transport?: TransportView; onLeave: () => void; onError?: (error: unknown) => void; }): React.ReactElement { - const label = TRANSPORT_PATH_LABELS[transportPath].label; return (
@@ -837,8 +854,8 @@ export function ConnectedView({ ‹ {BURROWS_TITLE}

{burrow.label || burrow.burrowId}

- - {label} + + {TRANSPORT_PATH_LABELS[transport.path].label}
diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index ed5831584..af61fcf3e 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -21,6 +21,15 @@ import { CONTROL_PAYLOAD_SIZE } from './noise-transport.js'; */ export const DIRECT_SETUP_TIMEOUT_MS = 15_000; +/** + * The label of the one data channel a session opens. + * + * A two-end agreement rather than one end's naming choice: the answerer checks + * an incoming channel against it, so an offerer that renamed the channel would + * be declined by a Burrow it had not shipped alongside. + */ +export const DIRECT_CHANNEL_LABEL = 'dormouse'; + /** * The same deadline for the answerer, which arms it a relay round trip later * than the offerer does and so **must be the one that gives up first**. Its @@ -125,9 +134,11 @@ export const DIRECT_BUFFER_LOW = 64 * 1024; * until the peer's switch decrypts, and what a sender holds while the channel * drains — so "over the bound" means one thing in both directions. * - * **A queued frame is copied.** Queueing is what makes a frame outlive the call - * that produced it, and on the receive side that call's buffer is a view over - * whatever the runtime handed it. + * **What goes in is kept by reference.** Whether a frame outlives its caller's + * buffer is a question about where that buffer came from, which only the caller + * knows: a sender's frames are ciphertext its session just minted and nothing + * else holds, while a receiver's are a view over the runtime's own message + * buffer and are copied at {@link DirectCutover.onChannelFrame}. */ export class DirectFrameQueue { readonly #frames: Uint8Array[] = []; @@ -148,14 +159,14 @@ export class DirectFrameQueue { return this.#bytes; } - /** Whether one more frame of `length` bytes would break either bound. */ - wouldOverflow(length: number): boolean { - return this.#frames.length >= this.#maxFrames || this.#bytes + length > this.#maxBytes; - } - - push(frame: Uint8Array): void { - this.#frames.push(frame.slice()); + /** Take one frame, or answer `false` where it would break either bound. */ + push(frame: Uint8Array): boolean { + if (this.#frames.length >= this.#maxFrames || this.#bytes + frame.length > this.#maxBytes) { + return false; + } + this.#frames.push(frame); this.#bytes += frame.length; + return true; } /** The oldest frame, or `undefined` where there is none. */ @@ -186,6 +197,19 @@ export class DirectFrameQueue { */ export type DirectPath = 'relay' | 'direct'; +/** + * Why a session is still on the relay, as the three answers a person can act + * on: this device brought no WebRTC, the other end said no, or it was tried and + * did not work. + * + * **A closed set, never the failure text.** The reasons an attempt gives up on + * include a runtime's own exception message, and a set is what keeps those out + * of a phone's screen while leaving them in the log the operator reads. It is + * also what makes a new give-up path a compile error until someone decides + * which of the three a user is being told. + */ +export type DirectRelayCause = 'unsupported' | 'declined' | 'failed'; + /** * The signaling messages, as `control` transport plaintexts on an established * session. Versioned and discriminated so a peer that does not know them @@ -390,14 +414,11 @@ export class DirectCutover { * **A held frame is copied, and only a held frame.** Holding is what makes a * frame outlive this call, and the caller's buffer — a view over whatever the * runtime handed it — cannot be trusted to still say the same thing when the - * queue drains ({@link DirectFrameQueue}). A frame answered `process` is - * decrypted before this returns. + * queue drains. A frame answered `process` is decrypted before this returns. */ onChannelFrame(frame: Uint8Array): DirectChannelOutcome { if (this.#inbound === 'direct') return 'process'; - if (this.#held.wouldOverflow(frame.length)) return 'overflow'; - this.#held.push(frame); - return 'held'; + return this.#held.push(frame.slice()) ? 'held' : 'overflow'; } /** Release held frames; a disposed session has nothing left to drain them. */ diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 10eb4358f..c38005fe7 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -150,36 +150,42 @@ test('both directions are bounded the same way', () => { assert.equal(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_FRAMES); }); -// --- The cutover ------------------------------------------------------------ +// --- The queue both directions use ------------------------------------------ const frame = (n, size = 4) => new Uint8Array(size).fill(n); -// --- The queue both directions use ------------------------------------------ - test('a queue admits frames until either bound, bytes first at PTY sizes', () => { const queue = new DirectFrameQueue(4, 10); - assert.equal(queue.wouldOverflow(10), false); - queue.push(frame(1, 6)); + assert.equal(queue.push(frame(1, 6)), true); assert.equal(queue.length, 1); assert.equal(queue.bytes, 6); - // 6 + 5 is over the byte cap while the frame cap has room to spare. - assert.equal(queue.wouldOverflow(5), true); - assert.equal(queue.wouldOverflow(4), false); + // 6 + 5 is over the byte cap while the frame cap has room to spare, and a + // refused frame changes nothing. + assert.equal(queue.push(frame(2, 5)), false); + assert.equal(queue.length, 1); + assert.equal(queue.push(frame(2, 4)), true); }); test('a queue admits no more frames than its frame cap, whatever their size', () => { const queue = new DirectFrameQueue(2, 1_000); - queue.push(frame(1, 1)); - queue.push(frame(2, 1)); - assert.equal(queue.wouldOverflow(1), true); + assert.equal(queue.push(frame(1, 1)), true); + assert.equal(queue.push(frame(2, 1)), true); + assert.equal(queue.push(frame(3, 1)), false); }); -test('a queued frame is copied, so the caller may reuse its buffer', () => { +/** + * Whether a frame outlives its caller's buffer is a question about where that + * buffer came from, which only the caller knows — a sender's is ciphertext its + * session just minted and nothing else holds, a receiver's is a view over the + * runtime's own message buffer. So the queue keeps what it is given, and + * `DirectCutover` is what copies (see "a held frame is copied", below). + */ +test('a queue keeps what it is given, without copying it', () => { const queue = new DirectFrameQueue(4, 100); const buffer = frame(1); queue.push(buffer); buffer.fill(9); - assert.deepEqual(queue.take(), [frame(1)]); + assert.deepEqual(queue.take(), [frame(9)]); }); test('a queue gives frames back in arrival order, and empties on take', () => { @@ -212,6 +218,8 @@ test('the holding queue is bounded in bytes first', () => { assert.ok(MAX_DIRECT_PENDING_BYTES >= 0.5 * 5_000_000); }); +// --- The cutover ------------------------------------------------------------ + test('starts relayed in both directions', () => { const cutover = new DirectCutover(); assert.equal(cutover.outbound, 'relay'); diff --git a/scripts/direct-interop/browser.ts b/scripts/direct-interop/browser.ts index b07dad939..f51bcf9da 100644 --- a/scripts/direct-interop/browser.ts +++ b/scripts/direct-interop/browser.ts @@ -14,9 +14,14 @@ import { MAX_DIRECT_SDP_LENGTH } from 'remote-lib-common'; import { DirectPeer, type DirectPeerLike } from '../../lib/src/remote/direct/direct-peer'; -/** What the page reports back for `run.mjs` to print. Test data only. */ +/** + * What the page reports back for `run.mjs` to print. Test data only. + * + * **Measurements, not a verdict.** `run.mjs` decides whether the run passed, + * against the frames it actually got back — the strictly stronger check, and + * one definition of "finished" rather than two that can disagree. + */ export interface BrowserReport { - readonly ok: boolean; readonly error?: string; /** The offer this browser actually produced, against the signal's bound. */ readonly offerSdpLength?: number; @@ -31,8 +36,8 @@ export interface BrowserReport { declare const __INTEROP_TOKEN__: string; -/** How long the echo half waits before reporting what it did get. */ -const ECHO_BUDGET_MS = 10_000; +/** How long after the channel opens the page reports what it echoed. */ +const ECHO_SETTLE_MS = 2_000; async function post(path: string, body: unknown): Promise { const response = await fetch(`${path}?t=${encodeURIComponent(__INTEROP_TOKEN__)}`, { @@ -49,8 +54,6 @@ async function run(): Promise { const connection = new RTCPeerConnection({ iceServers: [] }) as unknown as DirectPeerLike; const opened = Promise.withResolvers(); const lost = Promise.withResolvers(); - let expected = 0; - const allEchoed = Promise.withResolvers(); const peer: DirectPeer = new DirectPeer({ peer: connection, @@ -58,11 +61,12 @@ async function run(): Promise { onOpen: () => opened.resolve(), // The addon's side sends; this end echoes each frame straight back, so // the bytes and the order they arrive in are checked over a real - // association rather than a linked pair. + // association rather than a linked pair. The frame is a view over the + // event's buffer, and `send` may queue it — so it is copied here, which + // production never has to do. onFrame: (frame) => { echoed.push(frame.length); - peer.send(frame); - if (echoed.length >= expected) allEchoed.resolve(); + peer.send(frame.slice()); }, onClosed: (reason) => lost.reject(new Error(reason)), onViolation: (reason) => lost.reject(new Error(reason)), @@ -78,28 +82,17 @@ async function run(): Promise { // `offer()` answers null for a description this end would not send, which on // a machine with many interfaces is the interesting outcome rather than a // crash: the attempt is skipped and the session stays relayed. - if (!sdp) return { ...measured, ok: false, error: 'the offer did not fit one signal' }; + if (!sdp) return { ...measured, error: 'the offer did not fit one signal' }; - const answer = (await post('/answer', { sdp })) as { - sdp?: string; - frames?: number; - error?: string; - }; - if (!answer.sdp) return { ...measured, ok: false, error: answer.error ?? 'no answer' }; - expected = answer.frames ?? 0; + const answer = (await post('/answer', { sdp })) as { sdp?: string; error?: string }; + if (!answer.sdp) return { ...measured, error: answer.error ?? 'no answer' }; await peer.acceptAnswer(answer.sdp); await Promise.race([opened.promise, lost.promise]); - const timeout = new Promise((resolve) => setTimeout(resolve, ECHO_BUDGET_MS)); - await Promise.race([allEchoed.promise, lost.promise, timeout]); + const settled = new Promise((resolve) => setTimeout(resolve, ECHO_SETTLE_MS)); + await Promise.race([settled, lost.promise]); - return { - ...measured, - ok: echoed.length === expected, - error: echoed.length === expected ? undefined : `echoed ${echoed.length} of ${expected}`, - maxMessageSize: connection.sctp?.maxMessageSize ?? null, - echoed, - }; + return { ...measured, maxMessageSize: connection.sctp?.maxMessageSize ?? null, echoed }; } function show(report: BrowserReport): void { @@ -108,5 +101,5 @@ function show(report: BrowserReport): void { } run().then(show, (error: unknown) => { - show({ ok: false, error: String(error), maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH }); + show({ error: String(error), maxDirectSdpLength: MAX_DIRECT_SDP_LENGTH }); }); diff --git a/scripts/direct-interop/run.mjs b/scripts/direct-interop/run.mjs index 0836458e1..5955b79b1 100644 --- a/scripts/direct-interop/run.mjs +++ b/scripts/direct-interop/run.mjs @@ -31,14 +31,18 @@ */ import { createServer } from 'node:http'; -import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isLoopbackHost, isOwnOrigin } from '../../lib/src/host/loopback-guard.ts'; +import { isOwnOrigin } from '../../lib/src/host/loopback-guard.ts'; +// The same gate `standalone/scripts/dev-agent-browser.mjs` uses, for the same +// reason: an unbundled dev script cannot import the TypeScript guard, and the +// loopback-plus-token rule must not have a second implementation. +import { isAuthorized } from '../../standalone/scripts/dev-host-guard.mjs'; /** What the addon puts on the channel, largest first; see question 2 above. */ const FRAME_SIZES = [65_535, 4_096, 33]; @@ -47,10 +51,9 @@ const RUN_BUDGET_MS = 60_000; const here = (path) => fileURLToPath(new URL(path, import.meta.url)); // esbuild resolves a bare specifier from the importing file's own directory, -// and `scripts/` is not a package: `browser.ts` names `remote-lib-common`, which -// the workspace links under `lib/`. Both bundles are pointed there. +// and `scripts/` is not a package: `browser.ts` names `remote-lib-common`, +// which the workspace links under `lib/`. const LIB = here('../../lib'); -const LIB_MODULES = [join(LIB, 'node_modules')]; const sidecarRequire = createRequire(here('../../standalone/sidecar/package.json')); const buildRequire = createRequire(here('../../standalone/package.json')); const { build } = buildRequire('esbuild'); @@ -58,39 +61,39 @@ const { build } = buildRequire('esbuild'); const token = randomBytes(24).toString('hex'); const temp = await mkdtemp(join(tmpdir(), 'dormouse-direct-interop-')); -// The wrapper under test, bundled rather than imported: it is TypeScript that -// reaches into the webview library, and this file is neither. -await build({ - entryPoints: [here('../../lib/src/remote/direct/direct-peer.ts')], - outfile: join(temp, 'direct-peer.cjs'), - absWorkingDir: LIB, - nodePaths: LIB_MODULES, - bundle: true, - platform: 'node', - format: 'cjs', - logLevel: 'warning', -}); +// The wrapper under test is bundled rather than imported: it is TypeScript +// that reaches into the webview library, and this file is neither. The two +// bundles share nothing, so they are built together. +const [, browserBundle] = await Promise.all([ + build({ + entryPoints: [here('../../lib/src/remote/direct/direct-peer.ts')], + outfile: join(temp, 'direct-peer.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'warning', + }), + build({ + entryPoints: [here('./browser.ts')], + absWorkingDir: LIB, + nodePaths: [join(LIB, 'node_modules')], + bundle: true, + platform: 'browser', + format: 'iife', + target: 'es2023', + write: false, + logLevel: 'warning', + define: { __INTEROP_TOKEN__: JSON.stringify(token) }, + }), +]); const { DirectPeer } = createRequire(import.meta.url)(join(temp, 'direct-peer.cjs')); - -const browserBundle = await build({ - entryPoints: [here('./browser.ts')], - absWorkingDir: LIB, - nodePaths: LIB_MODULES, - bundle: true, - platform: 'browser', - format: 'iife', - target: 'es2023', - write: false, - logLevel: 'warning', - define: { __INTEROP_TOKEN__: JSON.stringify(token) }, -}); const javascript = browserBundle.outputFiles[0].text; // `iceServers: []` as both shipped factories pass it: host candidates only. const { RTCPeerConnection } = sidecarRequire('node-datachannel/polyfill'); const addon = sidecarRequire('node-datachannel'); -const frames = FRAME_SIZES.map((size, index) => new Uint8Array(size).fill(index + 1)); +const frames = FRAME_SIZES.map((size, index) => Buffer.alloc(size, index + 1)); /** What the addon got back, in the order it got it. */ const returned = []; let browserReport = null; @@ -105,8 +108,7 @@ const finish = (result) => { /** Whether every frame came back byte for byte, in the order it was sent. */ const echoedIntact = () => - returned.length === frames.length && - returned.every((frame, index) => Buffer.from(frame).equals(Buffer.from(frames[index]))); + returned.length === frames.length && returned.every((frame, index) => frame.equals(frames[index])); /** * The run ends when both halves have spoken. The page's report and the last @@ -116,19 +118,19 @@ const echoedIntact = () => */ const maybeFinish = () => { if (!browserReport || !echoedIntact()) return; - finish({ ok: Boolean(browserReport.ok), error: browserReport.error }); + finish({ ok: !browserReport.error, error: browserReport.error }); }; const peer = new DirectPeer({ peer: new RTCPeerConnection({ iceServers: [] }), handlers: { + // The addon sends; a failure comes back through `onClosed` in its own words. onOpen: () => { - for (const frame of frames) { - if (!peer.send(frame)) finish({ ok: false, error: 'the addon could not send a frame' }); - } + for (const frame of frames) peer.send(frame); }, onFrame: (frame) => { - returned.push(Uint8Array.from(frame)); + // Copied out of the event's buffer, which the runtime may reuse. + returned.push(Buffer.from(frame)); maybeFinish(); }, onClosed: (reason) => finish({ ok: false, error: `channel gone: ${reason}` }), @@ -160,16 +162,11 @@ const server = createServer(async (req, res) => { try { const port = server.address().port; const url = new URL(req.url, `http://127.0.0.1:${port}`); - const supplied = url.searchParams.get('t') ?? ''; // A loopback bind is not an access control: any page in the user's browser // reaches 127.0.0.1 too (`docs/specs/security-local.md` -> "Loopback - // Listeners"), so the guard modules gate the Host and Origin headers and a - // per-run token gates the rest. - if ( - !isLoopbackHost(req.headers.host, port) || - supplied.length !== token.length || - !timingSafeEqual(Buffer.from(supplied), Buffer.from(token)) - ) { + // Listeners"), so the shared guard gates the Host header, the per-run + // token, and a POST's content-type, and `isOwnOrigin` gates the rest. + if (!isAuthorized(req, { token, port })) { send(403, { error: 'forbidden' }); return; } @@ -193,7 +190,7 @@ const server = createServer(async (req, res) => { finish({ ok: false, error: 'the addon declined the browser offer' }); return; } - send(200, { sdp: answer, frames: frames.length }); + send(200, { sdp: answer }); return; } if (url.pathname === '/report') { @@ -201,8 +198,8 @@ const server = createServer(async (req, res) => { send(200, { ok: true }); // A page that gave up says so at once; otherwise the last echoes may // still be in flight, and the run ends when they land. - if (browserReport.ok) maybeFinish(); - else finish({ ok: false, error: browserReport.error }); + if (browserReport.error) finish({ ok: false, error: browserReport.error }); + else maybeFinish(); return; } send(404, { error: 'not found' }); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index bb6d8b97c..79c475482 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/notepad.md": 3700, "docs/specs/pocket-app.md": 4250, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4550, + "docs/specs/remote-api.md": 4600, "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, From 30ab785c00cd63a8a7a59d74c53529d8fc82cb46 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 12:27:36 -0700 Subject: [PATCH 40/46] fix(remote): close the review's holes, and stop claiming the one that stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-effort review of the four safeguard commits found five things. **An answerer whose channel is refused now declines.** `#adopt`'s new validation reports through `onClosed` from inside `peer.answer()`, which nulls the peer; `#answer` then found `#stillOurs` false and returned without a word, leaving the offerer to wait out its whole 15 s setup budget for a channel that was never coming — the exact thing `#decline` exists to prevent. An end that has taken up an offer and not yet answered it now declines from `#onClosed`, which also closes the same hole for a negotiation that threw. That one predates these commits. **The handoff deadline was measuring the relay this path exists to escape.** Five seconds is a wager on a hop over a congested phone uplink, and expiry is burrow loss while waiting costs only queue space — which is separately bounded. It is now no shorter than the negotiation before it. **A new session no longer wears the previous one's reason.** A fresh endpoint announces nothing until something changes, and its idea of unchanged is `relay` with no cause, so the header kept explaining a Burrow the phone had already left. The other two were claims, not bugs, and the claims are what changed. Measured against node-datachannel 0.33.2: the polyfill rebuilds every incoming channel with its own defaults, so an offerer's `{ordered: false, maxRetransmits: 0}` reaches the answerer as `ordered: true, maxRetransmits: null`. The reliability half of the check therefore reaches nothing on the standalone Burrow — only the label does — and the addon exposes no other way to read what was negotiated. The spec said it was enforced there; it now says what is true, calls the check what it is (defence in depth against a paired Client, not a boundary control), and a native case pins the behaviour so an addon that starts reporting the flags is noticed instead of silently upgrading a documented gap into an enforced rule. The message-limit check has the same shape of caveat: the number is the remote's advertised one, so it is per direction, and `sctp` is null on both stacks until the association is up — so there is no earlier moment to check and no way to decline before a peer may have switched. Stated in the spec and the rationale rather than left implied by a comment that said the attempt could always be abandoned. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/remote-api.md | 12 +++- docs/specs/remote-api.rationale.md | 4 ++ docs/specs/security-remote.md | 2 +- .../host/remote/native-direct-peer.test.ts | 69 ++++++++++++++++++- lib/src/remote/direct/direct-endpoint.test.ts | 15 ++++ lib/src/remote/direct/direct-endpoint.ts | 16 ++++- lib/src/remote/direct/direct-peer.ts | 25 ++++++- lib/src/remote/pocket-app/App.tsx | 5 ++ remote-lib-common/src/security/direct-path.ts | 13 +++- remote-lib-common/test/direct-path.test.mjs | 9 +-- scripts/spec-word-budgets.json | 4 +- 11 files changed, 158 insertions(+), 16 deletions(-) diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 1fd8b895d..d3ac6e389 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -110,7 +110,17 @@ non-binary channel message — disposes the session. (rationale) `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. A limit the implementation does not report is not treated as small. +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 diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 164e83665..1deb73e2d 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -20,6 +20,10 @@ In September 2026, both production installations use `createAskSurfaceProvider`: **Why the SDP bound is measured rather than reasoned about.** 587 characters is 29% of the budget on a host with one interface, and each further candidate line costs roughly 80 more — so the headroom is real but it is a property of the machine, not of the code. A host with docker bridges, VMs, and several VPNs is the case that would spend it, and the failure there is silent by design: the attempt is skipped and the session stays relayed. Re-run the fixture on such a host before treating the bound as settled. +**Why the answerer's reliability check is documented as reaching nothing on the Burrow.** Measured against node-datachannel 0.33.2, 2026-09-10: an offerer creating `{ordered: false, maxRetransmits: 0}` reaches the polyfill's answerer as `ordered: true, maxRetransmits: null, maxPacketLifeTime: null`, because `RTCPeerConnection` builds every incoming channel as `new RTCDataChannel(channel)` with no options and the constructor defaults them. A browser answerer reports what was negotiated, so the check bites there. Reading them some other way would mean parsing the offer's DCEP parameters, which neither the polyfill nor the addon exposes — so the limit is stated rather than closed, and the behaviour is pinned so an addon that starts reporting them is noticed rather than silently upgrading a documented gap into an enforced rule. + +**Why the message-limit check stays at open despite being per-direction.** `sctp` is null on both stacks until the association is up — measured after `setLocalDescription` and `setRemoteDescription` at both ends, 2026-09-10 — so there is no earlier moment at which the number exists, and no way for a refusing end to decline before its peer may have switched. Both shipped stacks advertise 262 144, so the asymmetric case needs a peer that advertises under 65 535; the symmetric case abandons at both ends and stays relayed. + **Why DTLS is not part of the trust model.** The channel is encrypted twice — DTLS underneath, Noise inside — and only the inner one is load-bearing. The DTLS fingerprints are authentic because the SDP carrying them was decrypted inside an authorized session, so DTLS adds transport hygiene rather than a second authority; a peer that broke it would still face the promoted session's ciphers. ## Envelope diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 34809aec3..0271b1360 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -225,7 +225,7 @@ layer; neither is restated below. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. - **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. -- **FAIL IF** a switched end waits on its peer without a deadline, or a channel a Noise stream cannot ride is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is unordered, partially reliable, or not `DIRECT_CHANNEL_LABEL`, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH`, both before it reports the open — each abandons the attempt while the relay still carries the session. `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. +- **FAIL IF** a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is not `DIRECT_CHANNEL_LABEL`, one reported unordered or partially reliable, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH` — all before it reports the open, so each abandons the attempt while the relay still carries the session. **The reliability half is defence in depth against a paired Client, not a boundary control**, and reaches only as far as the implementation reports those flags: on the standalone Burrow it does not, which `lib/src/host/remote/native-direct-peer.test.ts` pins so a version that changes it is noticed (`docs/specs/remote-api.md` -> Transport -> "Direct path"). `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). ### Revocation and the audit trail diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index 2618fd577..431c245b6 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -23,11 +23,20 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import { NOISE_MAX_MESSAGE_LENGTH, type TerminalDataEvent } from 'remote-lib-common'; +import { + DIRECT_CHANNEL_LABEL, + NOISE_MAX_MESSAGE_LENGTH, + type TerminalDataEvent, +} from 'remote-lib-common'; import { DirectPeer, type DirectPeerLike } from '../../remote/direct/direct-peer'; import { STREAMED_CHUNK, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; +/** The one call the reliability case needs that `DirectPeerLike` has no reason to. */ +interface AddsCandidates { + addIceCandidate(candidate: unknown): Promise; +} + /** A file inside the package that declares the addon, to resolve it from. */ const sidecarRequire = createRequire( fileURLToPath(new URL('../../../../standalone/sidecar/package.json', import.meta.url)), @@ -277,6 +286,64 @@ describe('the direct path over the native addon', () => { * reachable, so the addon cannot be made to not load without replacing the * module system this case exists to exercise. */ + /** + * **What the reliability check does *not* reach on this stack.** `DirectPeer` + * refuses a channel that is unordered or partially reliable, and the answerer + * is the end where that could bite — it adopts a channel the peer created. A + * browser reports the parameters the offerer actually negotiated; this + * polyfill rebuilds every incoming channel with its own defaults, so the + * flags never survive the crossing and only the label comparison is + * load-bearing on a standalone Burrow. + * + * Pinned rather than left as prose: an addon version that starts reporting + * them turns a documented limitation into an enforced rule, and this is what + * says so (`docs/specs/remote-api.md` → Transport → "Direct path"). + */ + it('does not carry a channel’s reliability across to the answerer', async () => { + const offerer = buildPeer(); + const answerer = buildPeer(); + const adopted = Promise.withResolvers>(); + answerer.addEventListener('datachannel', (ev) => { + const channel = (ev as { channel: Record }).channel; + adopted.resolve(channel); + }); + // Everything a Noise stream cannot ride, asked for explicitly. + const asked = offerer.createDataChannel(DIRECT_CHANNEL_LABEL, { + ordered: false, + maxRetransmits: 0, + } as { ordered?: boolean }); + try { + // The whole negotiation: unlike a browser, this stack raises `datachannel` + // when the association carries the channel, not when the offer describes + // it — so nothing is adopted until both ends are actually connected. + for (const [from, to] of [ + [offerer, answerer], + [answerer, offerer], + ] as const) { + from.addEventListener('icecandidate', (ev) => { + const candidate = (ev as { candidate?: unknown }).candidate; + if (candidate) void (to as unknown as AddsCandidates).addIceCandidate(candidate); + }); + } + const offer = await offerer.createOffer(); + await offerer.setLocalDescription(offer); + await answerer.setRemoteDescription(offerer.localDescription!); + const answer = await answerer.createAnswer(); + await answerer.setLocalDescription(answer); + await offerer.setRemoteDescription(answerer.localDescription!); + + const seen = await adopted.promise; + expect((asked as unknown as { ordered: boolean }).ordered).toBe(false); + expect(seen.label).toBe(DIRECT_CHANNEL_LABEL); + expect(seen.ordered).toBe(true); + expect(seen.maxRetransmits).toBeNull(); + expect(seen.maxPacketLifeTime).toBeNull(); + } finally { + offerer.close(); + answerer.close(); + } + }); + it('builds a peer through a bare require, and declines once torn down', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index ef820a6d3..4be7cd1d5 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -211,6 +211,21 @@ describe('DirectEndpoint', () => { expect(answering.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); }); + it('declines rather than leaving the offerer to wait, when its channel is refused', async () => { + const run = pair({ channel: 'unordered', channelSide: 'answerer' }); + + await run.offerer.endpoint.offer(); + await flushMicrotasks(); + + // The refusal reports from inside `answer()`, before there is any + // description to send — the offerer still has to hear about it. + expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); + expect(run.offerer.endpoint.relayCause).toBe('declined'); + expect(run.offerer.endpoint.path).toBe('relay'); + // And nothing is left armed to fire on a session that stayed relayed. + expect(run.timers.live).toEqual([]); + }); + it('abandons the attempt when the session cannot carry a signal', async () => { const run = pair(); run.offerer.sendable = false; diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 8a1c4d022..460b4505e 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -78,6 +78,8 @@ export class DirectEndpoint { #peer: DirectPeer | null = null; #disposed = false; #cause: DirectRelayCause | null = null; + /** Whether an offer has been taken up and not yet answered or declined. */ + #owesAnswer = false; /** Cancels the wait for the peer's switch; see {@link #settle}. */ #cancelHandoff: (() => void) | null = null; /** The last pair announced, so an unchanged one is not announced twice. */ @@ -232,6 +234,7 @@ export class DirectEndpoint { */ async #answer(offerSdp: string): Promise { if (!this.#cutover.begin()) return; + this.#owesAnswer = true; const peer = this.#build(); if (!peer) { this.#decline('this runtime builds no peer connection', 'unsupported'); @@ -243,6 +246,7 @@ export class DirectEndpoint { this.#decline('this end could not describe a direct connection'); return; } + this.#owesAnswer = false; if (!this.#deps.sendSignal({ v: 1, t: 'direct-answer', sdp })) this.#giveUp(); } @@ -262,6 +266,7 @@ export class DirectEndpoint { * its setup deadline for a channel that is never coming. */ #decline(reason: string, cause: DirectRelayCause = 'failed'): void { + this.#owesAnswer = false; this.#giveUp(reason, cause); this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); } @@ -332,9 +337,18 @@ export class DirectEndpoint { if (receipt?.kind === 'control') this.onSignal(receipt.value); } + /** + * The channel went away. **An answerer that has not answered yet owes the + * offerer a decline**: the peer has heard nothing back and would otherwise + * wait out its whole setup budget for a channel that is never coming. The + * route matters because this can run *inside* `peer.answer()` — a channel + * refused on adopt reports here before the description is even built — and + * `#answer` then finds the peer already replaced and returns without a word. + */ #onClosed(reason: string): void { if (!this.#alive()) return; - this.#giveUp(reason); + if (this.#owesAnswer) this.#decline(reason); + else this.#giveUp(reason); } /** diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index d21bfadd0..a1cfd5bf6 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -292,6 +292,17 @@ export class DirectPeer { * than the first byte — and dying here, before any switch, only abandons the * attempt. The label is checked alongside them: this negotiation creates * exactly one channel and calls it {@link DIRECT_CHANNEL_LABEL}. + * + * **The reliability half reaches only as far as the implementation reports + * it.** A browser hands an answerer the parameters the offerer actually + * negotiated, so there the check bites. `node-datachannel`'s polyfill builds + * every incoming channel with its own defaults instead — measured against + * 0.33.2, an offerer's `{ordered: false, maxRetransmits: 0}` reaches the + * answerer as `ordered: true, maxRetransmits: null` — so on the standalone + * Burrow only the label comparison is load-bearing, and a paired Client that + * opened an unordered channel would be adopted. Pinned, so a version that + * starts reporting them is noticed, by + * `lib/src/host/remote/native-direct-peer.test.ts`. */ #adopt(channel: DirectChannelLike): void { if (this.#channel) return; @@ -324,11 +335,19 @@ export class DirectPeer { /** * The channel reported open. * - * **The association's message limit is checked here**, where the attempt can - * still be abandoned onto a relay that is still carrying the session. One - * Noise transport message is one channel frame and may be + * **The association's message limit is checked here**, the first moment it is + * knowable — both stacks report `sctp` as null until the association is up. + * One Noise transport message is one channel frame and may be * {@link NOISE_MAX_MESSAGE_LENGTH} bytes, so an association that would refuse * one is a session that dies on its first large paste instead. + * + * **The number is the *remote's* advertised limit, so it is per direction.** + * Where both ends advertise the same — as both shipped stacks do, at 262 144 + * — they abandon together and the session stays relayed. Where they disagree + * and only one end refuses, a peer that had already switched has no relay + * left to fall back to and loses the session. That is accepted: the + * alternative is carrying a session that dies on its first large frame + * anyway (`docs/specs/remote-api.md` -> Transport -> "Direct path"). */ #onOpen(): void { if (this.#closed || this.#open) return; diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index f6f9b433b..478045f08 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -367,6 +367,11 @@ export default function App({ /** The connect half, shared so a fresh pairing can continue straight into it. */ const connectTo = useCallback( async (burrow: BurrowView) => { + // A fresh endpoint announces nothing until something changes, and its + // idea of unchanged is `relay` with no cause — so the previous session's + // reason would sit in the header through the whole of this one's + // negotiation. + setTransport({ path: 'relay', cause: null }); const decision: ConnectResult = await client.connect(burrow.burrowId); if (!decision.ok) { // The record has already been rewritten where the Burrow said diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index af61fcf3e..63ed693a9 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -87,10 +87,17 @@ export const MAX_DIRECT_PENDING_FRAMES = 8192; * Its own bound rather than the holding queue's: a peer that has stopped * switching leaves this end sending into a channel nothing reads, and waiting * for {@link MAX_DIRECT_PENDING_BYTES} to fill makes the wait a function of how - * chatty the session happens to be. Generous next to the relay round trip the - * peer's switch actually takes. + * chatty the session happens to be. + * + * **Biased long, because the two outcomes are not symmetric.** What is being + * waited on is a relay hop — on exactly the congested uplink the direct path + * exists to escape — while expiry is burrow loss, costing a fresh handshake and + * a WebAuthn prompt. Waiting costs only queue space, which + * {@link MAX_DIRECT_PENDING_BYTES} already bounds, so this is no shorter than + * the negotiation it follows. The ordering is pinned by + * `remote-lib-common/test/direct-path.test.mjs`. */ -export const DIRECT_HANDOFF_TIMEOUT_MS = 5_000; +export const DIRECT_HANDOFF_TIMEOUT_MS = DIRECT_SETUP_TIMEOUT_MS; /** * How long a connection may sit `disconnected` before the attempt is written diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index c38005fe7..8d7a6a355 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -117,10 +117,11 @@ test('the timings the spec names are the values that ship', () => { // answerer opens, switches, and its `direct-switch` decrypts at an offerer // that has just abandoned, which is fatal at both ends. assert.ok(DIRECT_ANSWER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); - // The wait for the peer's own switch begins where the setup budget ends, and - // is a relay round trip rather than a whole negotiation. - assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, 5_000); - assert.ok(DIRECT_HANDOFF_TIMEOUT_MS < DIRECT_ANSWER_TIMEOUT_MS); + // The wait for the peer's own switch is a relay hop on the uplink the direct + // path exists to escape, and firing early is burrow loss while waiting costs + // only queue space — so it is never shorter than the negotiation before it. + assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, 15_000); + assert.ok(DIRECT_HANDOFF_TIMEOUT_MS >= DIRECT_SETUP_TIMEOUT_MS); // Long enough that a gap ICE recovers from is waited out rather than charged // a fresh handshake and a WebAuthn prompt. assert.equal(DIRECT_DISCONNECTED_GRACE_MS, 5_000); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 79c475482..d0264662e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,12 +15,12 @@ "docs/specs/notepad.md": 3700, "docs/specs/pocket-app.md": 4250, "docs/specs/relay.md": 9950, - "docs/specs/remote-api.md": 4600, + "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 5750, + "docs/specs/security-remote.md": 5800, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, From 98a7f79c792a294e130b73a306dcf6cd50559bdb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 13:08:51 -0700 Subject: [PATCH 41/46] docs: keep "cutover" meaning one thing in the remote specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word already belonged to the migration off the plaintext relay protocol — "the end-to-end cutover", five files on main across relay/, vscode-ext/, lib/, and two specs, one of them a sentence e2e-lint pins verbatim. The direct path arrived and started using it bare for the relay-to-channel switch, so a reader hitting "after cutover" in remote-security-model.md had two events to choose between and no way to tell which. The newcomer yields, since it is the newcomer and since it already owns a precise word: `direct-switch` is on the wire, `switchOutbound` is in the code, and "after the switch, nothing on the relay" is how the rule already reads. Bare uses in the specs and the audit prompt now say the switch, or name the policy rather than the moment. `DirectCutover` keeps its name — it is qualified, and it is exactly the per-direction state machine the word describes. The historical sense is untouched, so the pinned lint sentence still matches. Comments inside `lib/src/remote/direct/` are left alone too: the specs are the shared vocabulary, a directory that is entirely one feature is not ambiguous. One wording fix fell out: `DirectCutover.begin` answers true "once per session", which is what its own doc says, not "once per cutover". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- .github/audit/application-security.md | 2 +- docs/specs/pocket-app.md | 7 ++++--- docs/specs/remote-api.md | 4 ++-- docs/specs/remote-security-model.md | 6 +++--- docs/specs/remote-security-model.rationale.md | 2 +- docs/specs/security-remote.md | 4 ++-- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index 14a04f1b5..49ab208b8 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -28,7 +28,7 @@ The end-to-end boundary is where the depth goes. Its modules are `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 cutover policy both ends +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` diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 6c806ec96..f98a9cb23 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -469,9 +469,10 @@ 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 the cutover 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 cutover a failed channel costs nothing. +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.transportPath` / `transportRelayCause` in `lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` / diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index d3ac6e389..2fdd7deb6 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -134,7 +134,7 @@ 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. -**Cutover preserves order per direction:** +**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. @@ -182,7 +182,7 @@ 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 -cutover policy, one per authorized session; `onRelayFrame` is both ends' only +direct-path policy, one per authorized session; `onRelayFrame` is both ends' only way in from the relay; constructed at promotion by `PocketClient.#directEndpoint` in `lib/src/remote/client/pocket-client.ts` and `BurrowRuntime.#promoteConnection` in diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 49c7a6d40..a1b573566 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -445,8 +445,8 @@ on a standalone Burrow (`docs/specs/security-supply-chain.md`). A memory-safety bug in either is reachable by any stranger on those networks, and nothing above it mitigates that. -**A channel lost after cutover is burrow loss, and that is accepted**: both ends -end the session rather than resume on the Relay. (rationale) The Relay +**A channel lost after a session has switched is burrow loss, and that is +accepted**: both ends end the session rather than resume on the Relay. (rationale) The Relay still sees that the session exists and whether each end is online; it no longer sees the traffic ([Residual metadata](#residual-metadata)). @@ -454,7 +454,7 @@ Source of truth: `remote-lib-common/src/security/direct-path.ts` (the signals, their guard, and `DirectCutover`), `lib/src/remote/direct/direct-peer.ts` (`DirectPeer`), `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` (the attempt, the peer, and the -cutover, created at promotion by `BurrowRuntime.#promoteConnection` in +switch, created at promotion by `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts` and `PocketClient.#directEndpoint` in `lib/src/remote/client/pocket-client.ts`). The audited rows are `docs/specs/security-remote.md` -> "Direct path". diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 95569c94c..f3a039707 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -304,7 +304,7 @@ small but not nothing: an unauthenticated STUN parser gated only by a per-attempt credential, and a DTLS stack. It exists between the offer and the session's disposal, which is minutes, not the process's lifetime. -**Why a lost channel after cutover ends the session instead of falling back.** +**Why a lost channel after the switch ends the session instead of falling back.** Falling back would mean resuming a `CipherState` at the counter the peer believes it is on, across a gap of unknown length in an ordered stream — a resynchronization the Noise transport deliberately has no construction for diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 0271b1360..df7812f48 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -218,12 +218,12 @@ bounds. `docs/specs/remote-api.md` -> "Direct path" owns the design and `docs/specs/remote-security-model.md` -> "Direct path" owns why it adds no trust layer; neither is restated below. -- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. Both halves of `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` must pass `DirectCutover.begin`, which answers `true` once per cutover, before their first `await`; and the endpoint holding it must be built only at promotion — `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`, the `ok: true` branch alone in `lib/src/remote/client/pocket-client.ts` — since a peer connection built earlier is one an unauthorized party steered. +- **FAIL IF** a `direct-offer` is accepted or sent before promotion, or a session runs a second attempt. Both halves of `DirectEndpoint` in `lib/src/remote/direct/direct-endpoint.ts` must pass `DirectCutover.begin`, which answers `true` once per session, before their first `await`; and the endpoint holding it must be built only at promotion — `BurrowRuntime.#promoteConnection` in `lib/src/remote/burrow/burrow-runtime.ts`, the `ok: true` branch alone in `lib/src/remote/client/pocket-client.ts` — since a peer connection built earlier is one an unauthorized party steered. - **FAIL IF** a byte crosses the channel that is not a Noise transport message of the promoted session: one message per frame, raw bytes, no second handshake, no plaintext, and no framing of ours beside it. **Every inbound frame is bounded at `NOISE_MAX_MESSAGE_LENGTH` before it reaches a cipher** — `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse an over-cap frame and a non-binary message as violations rather than parse either. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the cutover stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. - **FAIL IF** a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is not `DIRECT_CHANNEL_LABEL`, one reported unordered or partially reliable, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH` — all before it reports the open, so each abandons the attempt while the relay still carries the session. **The reliability half is defence in depth against a paired Client, not a boundary control**, and reaches only as far as the implementation reports those flags: on the standalone Burrow it does not, which `lib/src/host/remote/native-direct-peer.test.ts` pins so a version that changes it is noticed (`docs/specs/remote-api.md` -> Transport -> "Direct path"). `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). From ee8394a82b52bd9602a85ff84adb85240da74ba1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 13:31:55 -0700 Subject: [PATCH 42/46] fix(remote): retire old sessions before replacement handshakes --- docs/specs/pocket-app.md | 8 ++++- lib/src/remote/client/pocket-client.test.ts | 33 +++++++++++++++++++++ lib/src/remote/client/pocket-client.ts | 7 ++--- scripts/spec-word-budgets.json | 2 +- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index f98a9cb23..c94332673 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -461,6 +461,12 @@ 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 an existing session and reject its pending requests before starting +a replacement connection handshake, without reporting burrow loss.** Closure of +the old channel must not cancel the replacement ceremony. Pinned by +`pocket-client.test.ts`'s “preserves a replacement connection when the old channel +closes before its outcome arrives”. + **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 @@ -474,7 +480,7 @@ 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.transportPath` / `transportRelayCause` in +Source of truth: `PocketClient.connect` / `transportPath` / `transportRelayCause` in `lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` / `TRANSPORT_RELAY_CAUSES` / `transportTitle` in `lib/src/remote/pocket-app/App.tsx`. diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index eb2a38d13..190493118 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1070,6 +1070,39 @@ describe('the direct path, end to end', () => { expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); }); + it('preserves a replacement connection when the old channel closes before its outcome arrives', async () => { + const run = await connectedDirect(); + await run.cutover(); + const firstPeer = run.clientPeers[0]!; + const firstChannel = run.network.offererChannel!; + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + + // Deliver the handshake normally, but hold the replacement's outcome so + // the previous channel's close reaches the Client first. + const socket = run.harness.relay.burrowSocket; + const forward = socket.onSend!; + let outcomeHeld = false; + socket.onSend = (frame) => { + if (frame.kind === 'connection' && frame.id !== run.connectionId && frame.step === 'transport') { + run.harness.relay.holdToClient(); + outcomeHeld = true; + socket.onSend = forward; + } + forward(frame); + }; + const replacement = run.harness.client.connect(run.harness.burrowId); + await waitFor(() => outcomeHeld && firstChannel.readyState === 'closed', 'the old channel to close before the outcome'); + run.harness.relay.releaseToClient(); + + expect(await replacement).toEqual({ ok: true, burrowLabel: BURROW_LABEL }); + expect(firstPeer.closed).toBe(true); + expect(gone).not.toHaveBeenCalled(); + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + await run.cutover(); + expect((await run.harness.client.hello()).burrowId).toBe(run.harness.burrowId); + }); + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { const run = await connectedDirect(); await run.cutover(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 965745156..7025084b3 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -873,6 +873,9 @@ export class PocketClient { if (record.authorization.state !== 'paired') { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['pairing-required'], pairingRequired: true }; } + // Retire the old channel before registering any replacement waiters: the + // Burrow closes it at promotion, which can beat the outcome over the relay. + if (this.#established) this.#endSession('connection replaced', { notifyGone: false }); const deadline = this.#now() + DEFAULT_CHALLENGE_TTL_MS; const connectionId = randomBase64Url(E2E_ID_BYTE_LENGTH); const route = { kind: 'connection', id: connectionId, burrowId } as const; @@ -914,10 +917,6 @@ export class PocketClient { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['burrow-error'], pairingRequired: false }; } if (outcome.ok) { - // A second Connect on one Client replaces the first: its predecessor's - // endpoint, peer and channel go before the replacement is promoted, the - // mirror of `BurrowRuntime.#promoteConnection`. Left alive, the orphan's - // channel would still be reporting violations against *this* session. this.#disposeCeremony(); this.#established = { connectionId, session, lastSentAt: this.#now() }; this.#connectedBurrowId = burrowId; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d0264662e..b4831e687 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,7 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4250, + "docs/specs/pocket-app.md": 4300, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4750, From 15cc5a7d6679ba464d0f39245e11a3c285982c16 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 14:06:03 -0700 Subject: [PATCH 43/46] fix(remote): retire a session no earlier than the request that races it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviews over the previous commit — one of them of another agent's work — found one regression, one latent bug of my own, and a test that had already flaked once. **The retire moved.** Retiring the previous session at the top of `connect()` closed the race it was written for, but destroyed a healthy session on four paths that used to leave it alone: a passkey prompt the user dismisses, a denial, a handshake that throws, a malformed outcome. `notifyGone: false` meant nobody was told. Verified against both revisions — cancel the prompt and `hello()` went from working to `'not connected to a burrow'`. Only the connection request can reach `#promoteConnection`, so moving the retire to just above it closes the same race and leaves every pre-flight failure harmless. **A decline could land on a session that was already over.** A peer may put `direct-switch` on the relay before it has been answered, which `DirectCutover` accepts while the attempt is still `attempting`. Then `#giveUp` took its fatal branch and `#decline` sent anyway — reaching a Client that reads a refusal where the truth was a channel that never opened, and, since the fatal branch leaves `#peer` set, doing it twice. `#giveUp` now answers whether it abandoned, and only an abandoned attempt is worth a signal. **A dated measurement was wrong.** `sctp` is not null on the polyfill before the association is up — the object is there from construction and `maxMessageSize` is what reads null. The conclusion held, the fact didn't, and the seam now types the field as nullable because the shipped implementation returns null. The native reliability case ran on the runner's default timeout with no retry, in the one file whose comments say ~2% of local attempts lose DTLS; it flaked in this session's own suite. It goes through `untilOpen` now, like every other negotiation there. Also: `TestRelay` grew `holdToClientWhen` / `isHoldingToClient` so a case can hold a specific in-flight frame through the shared relay rather than by rebinding its router — and the wait has to see the hold arm, since releasing first leaves the predicate live with nothing to free it. The `#disposeCeremony` in the `ok` branch stays, now saying why: a concurrent Connect to another Burrow is the case the retire cannot see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- docs/specs/pocket-app.md | 16 ++- docs/specs/remote-api.rationale.md | 2 +- .../host/remote/native-direct-peer.test.ts | 115 +++++++++++------- lib/src/remote/client/pocket-client.test.ts | 47 ++++--- lib/src/remote/client/pocket-client.ts | 19 ++- lib/src/remote/direct/direct-endpoint.test.ts | 33 ++++- lib/src/remote/direct/direct-endpoint.ts | 17 ++- lib/src/remote/direct/direct-peer.ts | 8 +- lib/src/remote/test-relay.ts | 33 ++++- remote-lib-common/test/direct-path.test.mjs | 7 +- scripts/spec-word-budgets.json | 2 +- 11 files changed, 215 insertions(+), 84 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index c94332673..fed1923f0 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -461,11 +461,17 @@ 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 an existing session and reject its pending requests before starting -a replacement connection handshake, without reporting burrow loss.** Closure of -the old channel must not cancel the replacement ceremony. Pinned by -`pocket-client.test.ts`'s “preserves a replacement connection when the old channel -closes before its outcome arrives”. +**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 diff --git a/docs/specs/remote-api.rationale.md b/docs/specs/remote-api.rationale.md index 1deb73e2d..8eb5cd0b7 100644 --- a/docs/specs/remote-api.rationale.md +++ b/docs/specs/remote-api.rationale.md @@ -22,7 +22,7 @@ In September 2026, both production installations use `createAskSurfaceProvider`: **Why the answerer's reliability check is documented as reaching nothing on the Burrow.** Measured against node-datachannel 0.33.2, 2026-09-10: an offerer creating `{ordered: false, maxRetransmits: 0}` reaches the polyfill's answerer as `ordered: true, maxRetransmits: null, maxPacketLifeTime: null`, because `RTCPeerConnection` builds every incoming channel as `new RTCDataChannel(channel)` with no options and the constructor defaults them. A browser answerer reports what was negotiated, so the check bites there. Reading them some other way would mean parsing the offer's DCEP parameters, which neither the polyfill nor the addon exposes — so the limit is stated rather than closed, and the behaviour is pinned so an addon that starts reporting them is noticed rather than silently upgrading a documented gap into an enforced rule. -**Why the message-limit check stays at open despite being per-direction.** `sctp` is null on both stacks until the association is up — measured after `setLocalDescription` and `setRemoteDescription` at both ends, 2026-09-10 — so there is no earlier moment at which the number exists, and no way for a refusing end to decline before its peer may have switched. Both shipped stacks advertise 262 144, so the asymmetric case needs a peer that advertises under 65 535; the symmetric case abandons at both ends and stays relayed. +**Why the message-limit check stays at open despite being per-direction.** Neither stack has a number before the association is up: measured against node-datachannel 0.33.2, 2026-09-10, `sctp` is a transport object from construction but its `maxMessageSize` reads null after `setLocalDescription` and `setRemoteDescription` at both ends, and 262 144 once the channel opens. So there is no earlier moment at which the number exists, and no way for a refusing end to decline before its peer may have switched. Both shipped stacks advertise 262 144, so the asymmetric case needs a peer that advertises under 65 535; the symmetric case abandons at both ends and stays relayed. **Why DTLS is not part of the trust model.** The channel is encrypted twice — DTLS underneath, Noise inside — and only the inner one is load-bearing. The DTLS fingerprints are authentic because the SDP carrying them was decrypted inside an authorized session, so DTLS adds transport hygiene rather than a second authority; a peer that broke it would still face the promoted session's ciphers. diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index 431c245b6..c56809d93 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -143,6 +143,62 @@ async function startConnected() { const connectedDirect = () => untilOpen(startConnected, 'the session to go direct'); +/** As much of `RTCDataChannel` as the reliability probe reads off either end. */ +interface ChannelFacts { + readonly label: string; + readonly ordered: boolean; + readonly maxRetransmits: number | null; + readonly maxPacketLifeTime: number | null; +} + +/** + * One negotiation whose offerer asks for everything a Noise stream cannot ride, + * so the answerer's view of the channel can be compared against it. + * + * Unlike a browser, this stack raises `datachannel` when the association + * carries the channel rather than when the offer describes it, so the whole + * negotiation has to complete — which is why it goes through + * {@link untilOpen} like every other one here. + */ +async function startReliabilityProbe() { + const offerer = buildPeer(); + const answerer = buildPeer(); + let adopted: ChannelFacts | null = null; + answerer.addEventListener('datachannel', (ev) => { + adopted = (ev as { channel: ChannelFacts }).channel; + }); + for (const [from, to] of [ + [offerer, answerer], + [answerer, offerer], + ] as const) { + from.addEventListener('icecandidate', (ev) => { + const candidate = (ev as { candidate?: unknown }).candidate; + if (candidate) void (to as unknown as AddsCandidates).addIceCandidate(candidate); + }); + } + const asked = offerer.createDataChannel(DIRECT_CHANNEL_LABEL, { + ordered: false, + maxRetransmits: 0, + } as { ordered?: boolean }) as unknown as ChannelFacts; + const offer = await offerer.createOffer(); + await offerer.setLocalDescription(offer); + await answerer.setRemoteDescription(offerer.localDescription!); + const answer = await answerer.createAnswer(); + await answerer.setLocalDescription(answer); + await offerer.setRemoteDescription(answerer.localDescription!); + return { + asked, + get adopted() { + return adopted; + }, + open: () => adopted !== null, + abandon: () => { + offerer.close(); + answerer.close(); + }, + }; +} + describe('the direct path over the native addon', () => { it( 'negotiates a channel and carries protocol-v1 on it, the relay silent after', @@ -299,50 +355,23 @@ describe('the direct path over the native addon', () => { * them turns a documented limitation into an enforced rule, and this is what * says so (`docs/specs/remote-api.md` → Transport → "Direct path"). */ - it('does not carry a channel’s reliability across to the answerer', async () => { - const offerer = buildPeer(); - const answerer = buildPeer(); - const adopted = Promise.withResolvers>(); - answerer.addEventListener('datachannel', (ev) => { - const channel = (ev as { channel: Record }).channel; - adopted.resolve(channel); - }); - // Everything a Noise stream cannot ride, asked for explicitly. - const asked = offerer.createDataChannel(DIRECT_CHANNEL_LABEL, { - ordered: false, - maxRetransmits: 0, - } as { ordered?: boolean }); - try { - // The whole negotiation: unlike a browser, this stack raises `datachannel` - // when the association carries the channel, not when the offer describes - // it — so nothing is adopted until both ends are actually connected. - for (const [from, to] of [ - [offerer, answerer], - [answerer, offerer], - ] as const) { - from.addEventListener('icecandidate', (ev) => { - const candidate = (ev as { candidate?: unknown }).candidate; - if (candidate) void (to as unknown as AddsCandidates).addIceCandidate(candidate); - }); + it( + 'does not carry a channel’s reliability across to the answerer', + async () => { + const run = await untilOpen(startReliabilityProbe, 'the answerer to adopt the channel'); + try { + const seen = run.adopted!; + expect(run.asked.ordered).toBe(false); + expect(seen.label).toBe(DIRECT_CHANNEL_LABEL); + expect(seen.ordered).toBe(true); + expect(seen.maxRetransmits).toBeNull(); + expect(seen.maxPacketLifeTime).toBeNull(); + } finally { + run.abandon(); } - const offer = await offerer.createOffer(); - await offerer.setLocalDescription(offer); - await answerer.setRemoteDescription(offerer.localDescription!); - const answer = await answerer.createAnswer(); - await answerer.setLocalDescription(answer); - await offerer.setRemoteDescription(answerer.localDescription!); - - const seen = await adopted.promise; - expect((asked as unknown as { ordered: boolean }).ordered).toBe(false); - expect(seen.label).toBe(DIRECT_CHANNEL_LABEL); - expect(seen.ordered).toBe(true); - expect(seen.maxRetransmits).toBeNull(); - expect(seen.maxPacketLifeTime).toBeNull(); - } finally { - offerer.close(); - answerer.close(); - } - }); + }, + CASE_BUDGET_MS, + ); it('builds a peer through a bare require, and declines once torn down', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 190493118..4bff6a62c 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1073,36 +1073,49 @@ describe('the direct path, end to end', () => { it('preserves a replacement connection when the old channel closes before its outcome arrives', async () => { const run = await connectedDirect(); await run.cutover(); - const firstPeer = run.clientPeers[0]!; const firstChannel = run.network.offererChannel!; const gone = vi.fn(); run.harness.client.setOnBurrowGone(gone); - // Deliver the handshake normally, but hold the replacement's outcome so - // the previous channel's close reaches the Client first. - const socket = run.harness.relay.burrowSocket; - const forward = socket.onSend!; - let outcomeHeld = false; - socket.onSend = (frame) => { - if (frame.kind === 'connection' && frame.id !== run.connectionId && frame.step === 'transport') { - run.harness.relay.holdToClient(); - outcomeHeld = true; - socket.onSend = forward; - } - forward(frame); - }; + // Hold the replacement's outcome — anything on a connection that is not the + // live one — so the previous channel's close reaches the Client first. + run.harness.relay.holdToClientWhen( + (frame) => frame.id !== run.connectionId && frame.step === 'transport', + ); const replacement = run.harness.client.connect(run.harness.burrowId); - await waitFor(() => outcomeHeld && firstChannel.readyState === 'closed', 'the old channel to close before the outcome'); + await waitFor( + () => run.harness.relay.isHoldingToClient() && firstChannel.readyState === 'closed', + 'the old channel to close while the outcome is held', + ); run.harness.relay.releaseToClient(); expect(await replacement).toEqual({ ok: true, burrowLabel: BURROW_LABEL }); - expect(firstPeer.closed).toBe(true); expect(gone).not.toHaveBeenCalled(); - expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + // And the replacement is a working session, not just a resolved promise. await run.cutover(); expect((await run.harness.client.hello()).burrowId).toBe(run.harness.burrowId); }); + /** + * The retire destroys a live session, so it may not run until the only thing + * that can race it — the connection request — is about to be sent. A presence + * proof the user dismisses never reaches the Burrow. + */ + it('leaves a working session alone when the replacement never reaches the Burrow', async () => { + const run = await connectedDirect(); + await run.cutover(); + const gone = vi.fn(); + run.harness.client.setOnBurrowGone(gone); + vi.spyOn(run.harness.authenticator, 'assert').mockRejectedValueOnce(new Error('dismissed')); + + await expect(run.harness.client.connect(run.harness.burrowId)).rejects.toThrow('dismissed'); + + expect(gone).not.toHaveBeenCalled(); + expect(run.harness.client.connectedBurrowId).toBe(run.harness.burrowId); + expect(run.harness.client.transportPath).toBe('direct'); + expect((await run.harness.client.hello()).burrowId).toBe(run.harness.burrowId); + }); + it('closes the Burrow’s peer with the client the Relay says is gone', async () => { const run = await connectedDirect(); await run.cutover(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 7025084b3..596007272 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -873,9 +873,6 @@ export class PocketClient { if (record.authorization.state !== 'paired') { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['pairing-required'], pairingRequired: true }; } - // Retire the old channel before registering any replacement waiters: the - // Burrow closes it at promotion, which can beat the outcome over the relay. - if (this.#established) this.#endSession('connection replaced', { notifyGone: false }); const deadline = this.#now() + DEFAULT_CHALLENGE_TTL_MS; const connectionId = randomBase64Url(E2E_ID_BYTE_LENGTH); const route = { kind: 'connection', id: connectionId, burrowId } as const; @@ -906,6 +903,19 @@ export class PocketClient { handshakeHash: toBase64Url(session.handshakeHash), passkeyCredentialId: record.passkeyCredentialId, }); + // **A second Connect on one Client replaces the first**, the mirror of + // `BurrowRuntime.#promoteConnection`: its predecessor's endpoint, peer and + // channel go, and left alive the orphan's channel would report violations + // against *this* session. The Burrow closes that 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 `#rejectAll` would + // fail the waiter registered on the next line. + // + // **Here and no earlier.** Only the connection request can reach + // `#promoteConnection`, so nothing before this line can close the old + // channel — and a presence proof the user cancels, or a handshake that + // throws, must leave a working session exactly as it was. + if (this.#established) this.#endSession('connection replaced', { notifyGone: false }); let outcome: unknown; try { const request: ConnectionRequestV1 = { presence }; @@ -917,6 +927,9 @@ export class PocketClient { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['burrow-error'], pairingRequired: false }; } if (outcome.ok) { + // Still load-bearing for a *concurrent* Connect to another Burrow, which + // the retire above cannot see: without it that session's endpoint and + // peer would be overwritten below rather than closed. this.#disposeCeremony(); this.#established = { connectionId, session, lastSentAt: this.#now() }; this.#connectedBurrowId = burrowId; diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index 4be7cd1d5..cf0eaa06d 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -17,6 +17,7 @@ import { describe, expect, it } from 'vitest'; import { DIRECT_ANSWER_TIMEOUT_MS, + DIRECT_GATHER_TIMEOUT_MS, DIRECT_HANDOFF_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, MAX_DIRECT_PENDING_FRAMES, @@ -220,12 +221,38 @@ describe('DirectEndpoint', () => { // The refusal reports from inside `answer()`, before there is any // description to send — the offerer still has to hear about it. expect(run.answerer.sent.map((s) => s.t)).toEqual(['direct-decline']); - expect(run.offerer.endpoint.relayCause).toBe('declined'); - expect(run.offerer.endpoint.path).toBe('relay'); - // And nothing is left armed to fire on a session that stayed relayed. + // And the answerer's setup deadline, armed inside `answer()`, went with it. expect(run.timers.live).toEqual([]); }); + /** + * A decline is a message on the relay, so it only means anything while there + * is still a relay to send it on. A peer that put its own `direct-switch` + * across before this end had answered has already taken the session with it — + * `DirectCutover` accepts that switch while the attempt is merely + * `attempting` — and a decline sent after would reach a Client that reads it + * as a refusal rather than as the channel that never opened. + */ + it('never declines onto a session its own give-up has already ended', async () => { + // Gathering is held open so the answerer is still suspended inside + // `answer()` when the switch lands. + const run = pair({ oversize: 'answer', gathering: 'pending' }); + const offering = run.offerer.endpoint.offer(); + await flushMicrotasks(); + run.timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); + await offering; + await flushMicrotasks(); + + // The peer switches before it has been answered, and only then does this + // end find it has no description it can send. + run.answerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); + run.timers.fireAt(DIRECT_GATHER_TIMEOUT_MS); + await flushMicrotasks(); + + expect(run.answerer.sent.map((s) => s.t)).toEqual([]); + expect(run.answerer.fatals).toHaveLength(1); + }); + it('abandons the attempt when the session cannot carry a signal', async () => { const run = pair(); run.offerer.sendable = false; diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 460b4505e..a8d937466 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -264,11 +264,16 @@ export class DirectEndpoint { /** * Give the attempt up and say so, rather than leaving the offerer to wait out * its setup deadline for a channel that is never coming. + * + * **Only where the attempt was merely abandoned.** A peer that put its own + * `direct-switch` on the relay before this end had answered leaves + * {@link #giveUp} taking its fatal branch instead — and a decline encrypted + * onto a session its owner has just disposed would reach a Client that reads + * it as a refusal rather than as the channel that never opened. */ #decline(reason: string, cause: DirectRelayCause = 'failed'): void { this.#owesAnswer = false; - this.#giveUp(reason, cause); - this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); + if (this.#giveUp(reason, cause)) this.#deps.sendSignal({ v: 1, t: 'direct-decline' }); } /** This attempt's peer, wired to the four channel events, or null if there is none. */ @@ -367,17 +372,21 @@ export class DirectEndpoint { * abandoned attempt** and the session carries on relayed; afterwards what was * riding the channel is gone and a stream cipher has no resynchronization * point, so the session is over. + * + * Answers whether the attempt was abandoned — `false` means the session went + * with it, and the caller has nothing left to say on it. */ - #giveUp(reason = 'the direct path was abandoned', cause: DirectRelayCause = 'failed'): void { + #giveUp(reason = 'the direct path was abandoned', cause: DirectRelayCause = 'failed'): boolean { if (this.#cutover.switched) { this.#deps.fatal(reason); - return; + return false; } this.#peer?.close(); this.#peer = null; this.#cutover.abandon(); this.#cause = cause; this.#settle(); + return true; } /** Whether this endpoint still belongs to the session the caller is serving. */ diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index a1cfd5bf6..420dff704 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -59,10 +59,12 @@ export interface DirectChannelLike { /** * As much of `RTCSctpTransport` as the size check needs. The association's own * limit, negotiated from both ends' `a=max-message-size`, so it is knowable only - * once the channel is open. + * once the channel is open — `node-datachannel`'s polyfill exposes the transport + * from construction and leaves this null until then, which is why it is nullable + * here even though the W3C type is not. */ export interface DirectSctpLike { - readonly maxMessageSize: number; + readonly maxMessageSize: number | null; } /** The subset of `RTCPeerConnection` one negotiation needs. */ @@ -336,7 +338,7 @@ export class DirectPeer { * The channel reported open. * * **The association's message limit is checked here**, the first moment it is - * knowable — both stacks report `sctp` as null until the association is up. + * knowable — until the association is up neither stack has a number to give. * One Noise transport message is one channel frame and may be * {@link NOISE_MAX_MESSAGE_LENGTH} bytes, so an association that would refuse * one is a session that dies on its first large paste instead. diff --git a/lib/src/remote/test-relay.ts b/lib/src/remote/test-relay.ts index 83c8ab652..7305e6d97 100644 --- a/lib/src/remote/test-relay.ts +++ b/lib/src/remote/test-relay.ts @@ -17,7 +17,12 @@ * session, so a pairing or connection that succeeds here succeeded end to end. */ -import { isE2eClientFrame, isE2eBurrowFrame, type E2eKind } from 'remote-lib-common'; +import { + isE2eClientFrame, + isE2eBurrowFrame, + type E2eBurrowFrame, + type E2eKind, +} from 'remote-lib-common'; import { FakeSocket } from './test-fake-socket'; import { testRoutingId } from './test-e2e-client'; @@ -47,6 +52,20 @@ export interface TestRelay { * exactly the race the receiver's holding queue exists for. */ holdToClient(): void; + /** + * Start holding at the first Burrow→Client frame `match` answers true for, + * and hold everything after it. For an ordering that depends on *which* + * frame is in flight — a replacement session's outcome, say — where + * {@link holdToClient} would have to be armed before the frame exists. + */ + holdToClientWhen(match: (frame: E2eBurrowFrame) => boolean): void; + /** + * Whether frames are being held. **A case that armed + * {@link holdToClientWhen} must wait for this before releasing**: releasing + * first leaves the arming predicate live, so the frame it was meant to catch + * is buffered with nothing left to let it go. + */ + isHoldingToClient(): boolean; /** Deliver everything held, in the order it was sent. */ releaseToClient(): void; /** Stop routing, without closing either socket. */ @@ -70,6 +89,8 @@ export function createTestRelay(options: { let tamper = false; /** Buffered Burrow→Client deliveries, or null while routing normally. */ let held: Array<() => void> | null = null; + /** Arms {@link TestRelay.holdToClientWhen}, and is spent by the frame it matches. */ + let holdWhen: ((frame: E2eBurrowFrame) => boolean) | null = null; burrowSocket.onSend = (frame) => { if (!live || !client) return; @@ -92,6 +113,10 @@ export function createTestRelay(options: { step: frame.step, ct, }); + if (holdWhen?.(frame)) { + held ??= []; + holdWhen = null; + } if (held) held.push(deliver); else deliver(); }; @@ -144,6 +169,12 @@ export function createTestRelay(options: { holdToClient() { held ??= []; }, + holdToClientWhen(match) { + holdWhen = match; + }, + isHoldingToClient() { + return held !== null; + }, releaseToClient() { const pending = held ?? []; held = null; diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 8d7a6a355..00a1e9b97 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -119,9 +119,10 @@ test('the timings the spec names are the values that ship', () => { assert.ok(DIRECT_ANSWER_TIMEOUT_MS < DIRECT_SETUP_TIMEOUT_MS); // The wait for the peer's own switch is a relay hop on the uplink the direct // path exists to escape, and firing early is burrow loss while waiting costs - // only queue space — so it is never shorter than the negotiation before it. - assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, 15_000); - assert.ok(DIRECT_HANDOFF_TIMEOUT_MS >= DIRECT_SETUP_TIMEOUT_MS); + // only queue space — so it is the setup budget itself rather than a number of + // its own. Asserted as the alias it is: `>=` would be a tautology through it, + // and a literal would fail a legitimate re-tuning of the budget. + assert.equal(DIRECT_HANDOFF_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS); // Long enough that a gap ICE recovers from is waited out rather than charged // a fresh handshake and a WebAuthn prompt. assert.equal(DIRECT_DISCONNECTED_GRACE_MS, 5_000); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b4831e687..d93308e65 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,7 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4300, + "docs/specs/pocket-app.md": 4400, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4750, From a4b84c7fd744db6bc1ff49e7f5ec48e3c542838c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 14:22:28 -0700 Subject: [PATCH 44/46] fix(remote): say why a session stayed relayed, for the operator too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#giveUp` read `reason` only on the branch that ends the session, so the rare outcome was logged and the common one was silent. A `DirectRelayCause` is three buckets wide and is what the phone shows; the reason is the sentence that says which failure it actually was, and "why is this session still relayed" is the question an operator actually has. Once per session at most — the attempt is never retried. Found by dormouse-bot on #613. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ViV8RRXasxC1DMdNgqTKC8 --- lib/src/remote/direct/direct-endpoint.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index a8d937466..8fe38ddaf 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -381,6 +381,11 @@ export class DirectEndpoint { this.#deps.fatal(reason); return false; } + // **The abandoned half of the reason, which the user never sees.** A cause + // is three buckets wide; this is the sentence that says which failure it + // actually was, and staying relayed is the outcome an operator most often + // has to explain. Once per session at most: the attempt is not retried. + console.warn(`[direct] staying on the relay: ${reason}`); this.#peer?.close(); this.#peer = null; this.#cutover.abandon(); From f843ff9db306b0c5b13cac61dba9c6fd3e7ea118 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 09:26:51 -0700 Subject: [PATCH 45/46] refactor(remote): one place decides which path a byte takes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DirectEndpoint.send` answered a boolean and each end turned it into its own relay fallback, with a `true` for a disposed endpoint standing in for "drop it". The rule was spelled four ways across the endpoint and both callers, and the two ends disagreed on what a replaced session does with the relay half. It now takes a `sendRelay` dep and returns nothing: direct once switched, relay until then, dropped once disposed — the mirror of `onRelayFrame` on the way in. A `disposed` getter replaces both ends' mid-message re-lookups, so `BurrowRuntime.#directFor` (a map walk per PTY chunk) and `PocketClient.#deliver` are gone. The Client's session record now carries its own endpoint and its own routing triple, so no live session is ever without one and `#route` stops minting an object per outbound chunk. A session that ends announces `relay` with no cause from `#disposeCeremony`, where the endpoint's lifetime is known, rather than leaving `App.tsx` to reset the indicator on its way into the next connect. Alongside: the sidecar's `external` list is derived from the manifest that installs those packages, and the metafile assertion checks that none was inlined rather than re-checking the same hardcoded list; the remote suites share the frame reader, the microtask drain, the poller and the peer collector they had each re-spelled; and `fakeTimers` drops a deadline on cancel instead of flagging it. One behavior change: a message on an already-replaced session is dropped rather than encrypted onto the relay, which is what the disposed case already did. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/pocket-app.md | 4 +- docs/specs/remote-api.md | 2 +- docs/specs/standalone.md | 11 +- .../host/remote/native-direct-peer.test.ts | 15 +-- lib/src/remote/burrow/burrow-runtime.test.ts | 22 ++-- lib/src/remote/burrow/burrow-runtime.ts | 50 ++++----- lib/src/remote/client/pocket-client.test.ts | 17 +-- lib/src/remote/client/pocket-client.ts | 106 ++++++++---------- lib/src/remote/client/test-e2e-harness.ts | 22 ++-- lib/src/remote/direct/direct-endpoint.test.ts | 35 ++++-- lib/src/remote/direct/direct-endpoint.ts | 39 +++++-- lib/src/remote/direct/direct-peer.test.ts | 5 +- lib/src/remote/direct/test-fake-peer.ts | 8 ++ lib/src/remote/pocket-app/App.tsx | 15 +-- lib/src/remote/test-e2e-client.ts | 42 ++++--- lib/src/remote/test-timers.ts | 33 +++--- remote-lib-common/src/security/direct-path.ts | 35 ++---- remote-lib-common/test/direct-path.test.mjs | 10 +- standalone/scripts/build-sidecar-proxy.mjs | 55 +++++---- 19 files changed, 267 insertions(+), 259 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index fed1923f0..cc419bfb5 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -486,8 +486,8 @@ 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` / `transportRelayCause` in -`lib/src/remote/client/pocket-client.ts`, `TRANSPORT_PATH_LABELS` / +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`. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 2fdd7deb6..57ca3f7ba 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -183,7 +183,7 @@ their guard, the constants, the `DirectFrameQueue` both queues are, and the `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; constructed at promotion by +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 diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 2127a0559..cc66ddbc4 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -142,14 +142,15 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si `node-datachannel`'s W3C polyfill. **A sidecar package's transitive dependencies do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and nothing else — so the addon's platform package and `detect-libc` are declared in -`standalone/sidecar/package.json` directly. **Both specifiers stay `external` to -`burrow.cjs`**, which the build asserts from esbuild's metafile — each has to -leave the bundle as an external `require-call` edge: the addon resolves its -`.node` relative to its own `__dirname`, and inlining would move that out of the +`standalone/sidecar/package.json` directly. **Every runtime dependency that +manifest declares stays `external` to `burrow.cjs`**, the `external` list being +derived from it rather than listed beside it, and the build asserts from +esbuild's metafile that none was inlined: the addon resolves its `.node` +relative to its own `__dirname`, and inlining would move that out of the installed package. Source of truth: `standalone/sidecar/package.json`, -`lib/src/host/remote/native-direct-peer.ts`, `assertExternalImports` in +`lib/src/host/remote/native-direct-peer.ts`, `assertNothingInlined` in `standalone/scripts/build-sidecar-proxy.mjs`. **The bridge.** Webview → sidecar is one generic passthrough invoke, diff --git a/lib/src/host/remote/native-direct-peer.test.ts b/lib/src/host/remote/native-direct-peer.test.ts index c56809d93..26db905aa 100644 --- a/lib/src/host/remote/native-direct-peer.test.ts +++ b/lib/src/host/remote/native-direct-peer.test.ts @@ -29,7 +29,7 @@ import { type TerminalDataEvent, } from 'remote-lib-common'; import { DirectPeer, type DirectPeerLike } from '../../remote/direct/direct-peer'; -import { STREAMED_CHUNK, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; +import { STREAMED_CHUNK, collect, makeE2eHarness, waitFor } from '../../remote/client/test-e2e-harness'; import { createNativeDirectPeerFactory, disposeNativeDirectPeers } from './native-direct-peer'; /** The one call the reliability case needs that `DirectPeerLike` has no reason to. */ @@ -109,15 +109,6 @@ async function untilOpen(start: () => Promise, what: s throw lost; } -/** Build a peer, keeping it so a case can close the far end by hand. */ -function collect(into: DirectPeerLike[]): () => DirectPeerLike { - return () => { - const peer = buildPeer(); - into.push(peer); - return peer; - }; -} - /** * The whole loop — phone, relay, Burrow — with both ends on the native addon, * paired, connected, and offered a direct path. @@ -126,8 +117,8 @@ async function startConnected() { const clientPeers: DirectPeerLike[] = []; const burrowPeers: DirectPeerLike[] = []; const harness = await makeE2eHarness({ - deps: { createDirectPeer: collect(clientPeers) }, - burrowDirect: collect(burrowPeers), + deps: { createDirectPeer: collect(clientPeers, buildPeer) }, + burrowDirect: collect(burrowPeers, buildPeer), }); await harness.connectPaired(); return { diff --git a/lib/src/remote/burrow/burrow-runtime.test.ts b/lib/src/remote/burrow/burrow-runtime.test.ts index f7264e2a8..7f80bcb5c 100644 --- a/lib/src/remote/burrow/burrow-runtime.test.ts +++ b/lib/src/remote/burrow/burrow-runtime.test.ts @@ -151,6 +151,11 @@ describe('BurrowRuntime end-to-end ceremonies', () => { return e2eFramesFor(socket, kind, id); } + /** The Burrow's transport frames on one connection; index 0 is the outcome. */ + function transportFrames(connectionId: string): Array> { + return e2eFramesFor(socket, 'connection', connectionId, 'transport'); + } + function sendE2e( clientId: string, kind: 'pairing' | 'connection', @@ -1369,21 +1374,16 @@ describe('BurrowRuntime end-to-end ceremonies', () => { return { session, connectionId, clientId }; } - /** The Burrow's transport frames on one connection; index 0 is the outcome. */ - function transportFrames(connectionId: string): Array> { - return e2eFrames('connection', connectionId).filter((frame) => frame.step === 'transport'); - } - - /** Decrypt the transport frame at `index`, which must be a control message. */ - async function controlAt( + /** + * Decrypt the transport frame at `index`, which must be a control message; + * index 0 is the connection outcome and the signals follow it. + */ + function controlAt( session: NoiseTransportSession, connectionId: string, index: number, ): Promise> { - const frame = await flushUntil(() => transportFrames(connectionId)[index]); - const receipt = session.receive(fromBase64Url(frame.ct as string)); - if (receipt.kind !== 'control') throw new Error(`expected a signal, got ${receipt.kind}`); - return receipt.value; + return readOutcome(socket, session, 'connection', connectionId, index); } /** One `direct-*` signal from the Client, on the established session. */ diff --git a/lib/src/remote/burrow/burrow-runtime.ts b/lib/src/remote/burrow/burrow-runtime.ts index 7433179c7..c084b5513 100644 --- a/lib/src/remote/burrow/burrow-runtime.ts +++ b/lib/src/remote/burrow/burrow-runtime.ts @@ -1451,22 +1451,24 @@ export class BurrowRuntime { this.#pruneClient(clientId); return; } - // Destructured, so the `send` closure retains only what an established + // Destructured, so the endpoint's closures retain only what an established // session is — the id and the two cipher states — and not the pending // record, whose handshake hash, Client static and challenge are spent. const { connectionId, session, clientStaticPublicKey } = pending; + // Declared first so the send path and the endpoint's liveness check can both + // name the session they belong to; assigned before any frame can reach it. + let established: EstablishedSession; const api = this.#createSession({ burrowId: this.#enrollment.burrowId, send: (payload) => { - this.#sendApp(clientId, connectionId, session, payload); + this.#sendApp(clientId, established, payload); }, }); - // Declared first so the endpoint's liveness check can name the session it - // belongs to; assigned before any frame can reach it. - let established: EstablishedSession; const direct = new DirectEndpoint('answerer', { createPeer: this.#createDirectPeer, sendSignal: (signal) => this.#sendControl(clientId, 'connection', connectionId, session, signal), + sendRelay: (ciphertext) => + this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext), receive: (ciphertext) => this.#receiveOnSession(clientId, established, ciphertext), fatal: (reason) => { console.warn(`[burrow] the direct path ended this session: ${reason}`); @@ -1617,25 +1619,20 @@ export class BurrowRuntime { return receipt; } - #sendApp( - clientId: string, - connectionId: string, - session: NoiseTransportSession, - payload: unknown, - ): void { - // Resolved once for the whole message: every chunk of it takes the path the - // first one did, and "after the switch, nothing on the relay" is this line. - const direct = this.#directFor(clientId, connectionId); + /** + * One protocol-v1 message on an established session, chunked as it needs. + * **The endpoint routes every chunk**, relay or channel, so which path carries + * them is {@link DirectEndpoint.send}'s rule rather than this loop's. + */ + #sendApp(clientId: string, established: EstablishedSession, payload: unknown): void { + const { session, direct } = established; try { for (const ciphertext of session.sendApp(utf8Encode(JSON.stringify(payload)))) { - if (direct?.send(ciphertext)) { - // A channel that refuses a chunk disposes this session synchronously: - // the rest of the message has no session left to belong to, and must - // not fall back onto the relay of one that is over. - if (this.#directFor(clientId, connectionId) !== direct) return; - continue; - } - this.#sendE2e(clientId, 'connection', connectionId, 'transport', ciphertext); + // A channel that refuses a chunk disposes this session synchronously, + // and so does the promotion that replaces it: the rest of the message + // has no session left to belong to, and must reach neither path. + if (direct.disposed) return; + direct.send(ciphertext); } } catch { // **Only a poisoned session is burrow loss.** An over-cap message is @@ -1650,15 +1647,6 @@ export class BurrowRuntime { } } - /** - * The endpoint carrying one connection, or null once that connection is no - * longer this client's live session. - */ - #directFor(clientId: string, connectionId: string): DirectEndpoint | null { - const established = this.#clients.get(clientId)?.established; - return established?.connectionId === connectionId ? established.direct : null; - } - #disposeEstablished(clientId: string): void { const state = this.#clients.get(clientId); if (!state?.established) return; diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 4bff6a62c..c723e6146 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -65,6 +65,7 @@ import { RP_ID, SESSION_TOKEN, STREAMED_CHUNK, + collect, makeE2eHarness, makeFetch, memoryKnownBurrows, @@ -747,23 +748,11 @@ describe('the direct path, end to end', () => { setTimer: timers.setTimer, ...(options.clientHasPeer === false ? {} - : { - createDirectPeer: () => { - const peer = network.createOfferer(); - clientPeers.push(peer); - return peer; - }, - }), + : { createDirectPeer: collect(clientPeers, () => network.createOfferer()) }), }, ...(options.burrowHasPeer === false ? {} - : { - burrowDirect: () => { - const peer = network.createAnswerer(); - burrowPeers.push(peer); - return peer; - }, - }), + : { burrowDirect: collect(burrowPeers, () => network.createAnswerer()) }), }); await harness.connectPaired(); return { diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 596007272..c045d7939 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -335,6 +335,14 @@ interface PendingRequest { interface EstablishedSession { readonly connectionId: string; readonly session: NoiseTransportSession; + /** Where this session's frames are addressed on the relay; fixed for its life. */ + readonly route: E2eRoute; + /** + * This session's direct path, created with it and disposed with it. **Every + * byte this Client sends goes through it**, relayed or not, so there is no + * moment at which a live session has no endpoint to route through. + */ + readonly direct: DirectEndpoint; /** * When this Client last put a byte on this session — the mirror of the * Burrow's `lastClientActivityAt`, because that is the clock the Burrow reaps on @@ -364,8 +372,6 @@ export class PocketClient { #established: EstablishedSession | null = null; #connectedBurrowId: string | null = null; #onBurrowGone: (() => void) | null = null; - /** This session's direct path, or null while there is no authorized session. */ - #direct: DirectEndpoint | null = null; #onTransportChanged: | ((path: DirectPath, cause: DirectRelayCause | null) => void) | null = null; @@ -415,19 +421,14 @@ export class PocketClient { * have left the relay — before that the relay is still carrying half of it. */ get transportPath(): DirectPath { - return this.#direct?.path ?? 'relay'; + return this.#established?.direct.path ?? 'relay'; } /** - * Why this session is still relayed, or `null` where there is nothing to say - * — what the indicator explains, so a session that quietly stayed relayed can - * say which of the three it was. + * Notified whenever {@link transportPath} or the reason a session is still + * relayed changes — the indicator's one seam, since a cause is only ever read + * as it changes. */ - get transportRelayCause(): DirectRelayCause | null { - return this.#direct?.relayCause ?? null; - } - - /** Notified whenever {@link transportPath} or {@link transportRelayCause} changes. */ setOnTransportChanged( callback: ((path: DirectPath, cause: DirectRelayCause | null) => void) | null, ): void { @@ -931,13 +932,18 @@ export class PocketClient { // the retire above cannot see: without it that session's endpoint and // peer would be overwritten below rather than closed. this.#disposeCeremony(); - this.#established = { connectionId, session, lastSentAt: this.#now() }; - this.#connectedBurrowId = burrowId; - this.#startKeepalives(); + // Declared first so the endpoint's deps can name the session they serve; + // assigned before anything can reach them. + let established: EstablishedSession; + const route: E2eRoute = { kind: 'connection', id: connectionId, burrowId }; // After the outcome and never before: a peer connection that existed // ahead of authorization would be one an unauthorized party had steered. - this.#direct = this.#directEndpoint(this.#established, burrowId); - void this.#direct.offer(); + const direct = this.#directEndpoint(() => established, route); + established = { connectionId, session, route, direct, lastSentAt: this.#now() }; + this.#established = established; + this.#connectedBurrowId = burrowId; + this.#startKeepalives(); + void direct.offer(); return { ok: true, burrowLabel: outcome.burrowLabel }; } if (outcome.code === 'pairing-required') { @@ -1191,15 +1197,16 @@ export class PocketClient { * {@link DirectEndpoint}'s; what is injected here is how this Client puts a * signal on the relay, decrypts a channel frame, and reports the session gone. */ - #directEndpoint(established: EstablishedSession, burrowId: string): DirectEndpoint { + #directEndpoint(current: () => EstablishedSession, route: E2eRoute): DirectEndpoint { return new DirectEndpoint('offerer', { createPeer: this.#createDirectPeer, - sendSignal: (signal) => this.#sendDirectSignal(established, burrowId, signal), - receive: (ciphertext) => this.#receiveOnSession(established, ciphertext), + sendSignal: (signal) => this.#sendDirectSignal(current(), signal), + sendRelay: (ciphertext) => this.#sendE2e(route, 'transport', ciphertext), + receive: (ciphertext) => this.#receiveOnSession(current(), ciphertext), // Reported exactly as a `burrow-gone` frame is: from here they are the // same event, and the app must leave the wall either way. fatal: (reason) => this.#loseBurrow(reason), - isCurrent: () => this.#established === established, + isCurrent: () => this.#established === current(), onTransportChanged: (path, cause) => this.#onTransportChanged?.(path, cause), setTimer: this.#setTimer, }); @@ -1209,17 +1216,9 @@ export class PocketClient { * One signal on the relay — the path that carries them until the switch. A * poisoned session has nothing to say; whatever poisoned it ends it. */ - #sendDirectSignal( - established: EstablishedSession, - burrowId: string, - signal: DirectSignalV1, - ): boolean { + #sendDirectSignal(established: EstablishedSession, signal: DirectSignalV1): boolean { try { - this.#sendE2e( - this.#route(established, burrowId), - 'transport', - established.session.sendControl({ ...signal }), - ); + this.#sendE2e(established.route, 'transport', established.session.sendControl({ ...signal })); return true; } catch { return false; @@ -1239,11 +1238,6 @@ export class PocketClient { this.#endSession(reason, { notifyGone: true }); } - /** Where this session's frames are addressed on the relay. */ - #route(established: EstablishedSession, burrowId: string): E2eRoute { - return { kind: 'connection', id: established.connectionId, burrowId }; - } - // --- Keepalives ---------------------------------------------------------- /** @@ -1253,13 +1247,12 @@ export class PocketClient { */ sendKeepalive(): void { const established = this.#established; - const burrowId = this.#connectedBurrowId; - if (!established || burrowId === null) return; + if (!established) return; if (this.#reapedByBurrow(established)) return; try { // Path-agnostic: a keepalive off the channel refreshes the Burrow's idle // deadline exactly as one off the relay does. - this.#deliver(established, burrowId, established.session.sendKeepalive()); + established.direct.send(established.session.sendKeepalive()); established.lastSentAt = this.#now(); } catch { // A closed socket or a poisoned session; both have their own teardown, @@ -1340,33 +1333,25 @@ export class PocketClient { }); } - /** One protocol-v1 message on the established session, chunked as it needs. */ + /** + * One protocol-v1 message on the established session, chunked as it needs. + * **The endpoint routes every chunk**, relay or channel, so which path carries + * them is {@link DirectEndpoint.send}'s rule rather than this loop's. + */ #sendApp(payload: unknown): void { const established = this.#established; if (!established) throw new Error('not connected to a burrow'); - const burrowId = this.#connectedBurrowId; - if (burrowId === null) throw new Error('not connected to a burrow'); if (this.#reapedByBurrow(established)) throw new Error(BURROW_SESSION_REAPED_MESSAGE); for (const ciphertext of established.session.sendApp(utf8Encode(JSON.stringify(payload)))) { - this.#deliver(established, burrowId, ciphertext); // A channel that refuses a chunk is burrow loss, taken synchronously: the - // rest of this message has no session left to belong to, and must not - // fall back onto the relay of one that has just been torn down. - if (this.#established !== established) return; + // rest of this message has no session left to belong to, and must reach + // neither path. + if (established.direct.disposed) return; + established.direct.send(ciphertext); } established.lastSentAt = this.#now(); } - /** - * One transport ciphertext on whichever path this end has switched to. Every - * byte of an established session goes through here, so "after the switch, - * nothing on the relay" is one line rather than a rule each caller keeps. - */ - #deliver(established: EstablishedSession, burrowId: string, ciphertext: Uint8Array): void { - if (this.#direct?.send(ciphertext)) return; - this.#sendE2e(this.#route(established, burrowId), 'transport', ciphertext); - } - #send(frame: E2eClientFrame): void { if (!this.#ws) throw new Error('relay socket is not open'); this.#ws.send(JSON.stringify(frame)); @@ -1454,7 +1439,7 @@ export class PocketClient { // carry it, and that its `ct` must decode — is the endpoint's, and it is // created and dropped with `#established` (`docs/specs/remote-api.md` → // Transport → "Direct path"). - this.#direct?.onRelayFrame(frame.ct); + established.direct.onRelayFrame(frame.ct); return; } const key = waiterKey(frame.kind, frame.id, frame.step); @@ -1550,8 +1535,13 @@ export class PocketClient { this.#stopKeepalives(); // The peer connection is this session's: every disposal path closes it, so // none can outlive the session that authorized it. - this.#direct?.dispose(); - this.#direct = null; + const direct = this.#established?.direct ?? null; + direct?.dispose(); + // The endpoint's "announce only what changed" ends with the endpoint, while + // the subscriber outlives it and the next session's fresh endpoint announces + // nothing until something changes. Without this, the reason *this* session + // stayed relayed would sit in the indicator through the whole of the next. + if (direct?.relayCause) this.#onTransportChanged?.('relay', null); this.#connectedBurrowId = null; this.#established = null; } diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index 3f0ceffd4..918b8edf4 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -48,7 +48,7 @@ import type { BurrowEnrollment } from '../burrow/enrollment'; import type { PendingPairing } from '../burrow/pairing-approval'; import type { DirectPeerLike } from '../direct/direct-peer'; import { FakeSocket } from '../test-fake-socket'; -import { createTestAuthenticator, type TestAuthenticator } from '../test-e2e-client'; +import { createTestAuthenticator, pollFor, type TestAuthenticator } from '../test-e2e-client'; import { createTestRelay, type TestRelay } from '../test-relay'; // --- Fakes ------------------------------------------------------------------ @@ -159,12 +159,20 @@ export async function waitFor( what = 'a condition', timeoutMs = 800, ): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - if (predicate()) return; - if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`); - await new Promise((r) => setTimeout(r, 2)); - } + const held = await pollFor(() => predicate() || undefined, timeoutMs); + if (!held) throw new Error(`timed out waiting for ${what}`); +} + +/** + * A peer factory that keeps what it builds, so a case can close or inspect the + * far end by hand. Both ends of a session get their own array. + */ +export function collect(into: T[], build: () => T): () => T { + return () => { + const peer = build(); + into.push(peer); + return peer; + }; } export const CREDENTIAL_ID = 'cred-123'; diff --git a/lib/src/remote/direct/direct-endpoint.test.ts b/lib/src/remote/direct/direct-endpoint.test.ts index cf0eaa06d..6ad1f929d 100644 --- a/lib/src/remote/direct/direct-endpoint.test.ts +++ b/lib/src/remote/direct/direct-endpoint.test.ts @@ -28,7 +28,12 @@ import { } from 'remote-lib-common'; import { DirectEndpoint } from './direct-endpoint'; -import { FakeDirectNetwork, type FakeDirectNetworkOptions, type FakePeer } from './test-fake-peer'; +import { + FakeDirectNetwork, + flushMicrotasks, + type FakeDirectNetworkOptions, + type FakePeer, +} from './test-fake-peer'; import { fakeTimers } from '../test-timers'; interface Side { @@ -45,6 +50,8 @@ interface Side { readonly causes: Array; /** Every signal this side put on the relay. */ readonly sent: DirectSignalV1[]; + /** Every transport ciphertext this side put on the relay. */ + readonly relayed: Uint8Array[]; /** What `isCurrent()` answers; a promotion or teardown flips it. */ live: boolean; /** Whether the session can still encrypt a signal. */ @@ -76,6 +83,7 @@ function pair(options: Options = {}) { peers, received: [], fatals: [], + relayed: [], paths: [], causes: [], sent: [], @@ -107,6 +115,7 @@ function pair(options: Options = {}) { else deliver(signal); return true; }, + sendRelay: (ciphertext) => void side.relayed.push(ciphertext), receive: (ciphertext) => { side.received.push(ciphertext); // Signals ride the stand-in relay here rather than a real session, so @@ -130,9 +139,6 @@ function pair(options: Options = {}) { return { fake, timers, offerer, answerer }; } -/** Let the fake network's queued microtasks run. */ -const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); - /** Offer, answer, and let the channel open at both ends. */ async function cutover(run: ReturnType): Promise { await run.offerer.endpoint.offer(); @@ -305,7 +311,8 @@ describe('DirectEndpoint', () => { expect(run.offerer.endpoint.path).toBe('relay'); // Never switched, so this is an abandoned attempt rather than burrow loss. expect(run.offerer.fatals).toEqual([]); - expect(run.offerer.endpoint.send(frame(1))).toBe(false); + run.offerer.endpoint.send(frame(1)); + expect(run.offerer.relayed).toEqual([frame(1)]); }); it('ends the session when the channel dies after the switch', async () => { @@ -533,11 +540,14 @@ describe('DirectEndpoint', () => { it('routes a ciphertext onto the channel only after this end has switched', async () => { const run = pair({ opening: 'manual' }); await cutover(run); - // Nothing on the channel yet: the caller puts it on the relay. - expect(run.offerer.endpoint.send(frame(1))).toBe(false); + // Nothing on the channel yet: it goes on the relay. + run.offerer.endpoint.send(frame(1)); + expect(run.offerer.relayed).toEqual([frame(1)]); run.fake.openChannels(); - expect(run.offerer.endpoint.send(frame(2))).toBe(true); + run.offerer.endpoint.send(frame(2)); + // Switched, so the channel is the only path left for it. + expect(run.offerer.relayed).toEqual([frame(1)]); await flushMicrotasks(); expect(run.answerer.received).toEqual([frame(2)]); }); @@ -548,8 +558,10 @@ describe('DirectEndpoint', () => { // Closed under the endpoint, which a radio gap does between two sends. run.fake.offererChannel!.close(); - expect(run.offerer.endpoint.send(frame(1))).toBe(true); + run.offerer.endpoint.send(frame(1)); expect(run.offerer.fatals).toEqual(['the direct channel refused a message']); + // And never onto the relay of a session the refusal has just ended. + expect(run.offerer.relayed).toEqual([]); }); it('disposes idempotently, closing the peer and reporting the relay', async () => { @@ -563,9 +575,10 @@ describe('DirectEndpoint', () => { expect(run.offerer.endpoint.path).toBe('relay'); expect(run.offerer.paths).toEqual(['direct', 'relay']); // Inert afterwards: nothing it is told does anything to a dead session, and - // a send is consumed rather than handed back for the relay to carry. - expect(run.offerer.endpoint.send(frame(1))).toBe(true); + // a send is dropped rather than falling back onto the relay. + run.offerer.endpoint.send(frame(1)); expect(run.fake.offererChannel!.sent).toEqual([]); + expect(run.offerer.relayed).toEqual([]); run.offerer.endpoint.onSignal({ v: 1, t: 'direct-switch' }); expect(run.offerer.fatals).toEqual([]); }); diff --git a/lib/src/remote/direct/direct-endpoint.ts b/lib/src/remote/direct/direct-endpoint.ts index 8fe38ddaf..4e99f4783 100644 --- a/lib/src/remote/direct/direct-endpoint.ts +++ b/lib/src/remote/direct/direct-endpoint.ts @@ -41,6 +41,12 @@ export interface DirectEndpointDeps { * `false` if the session could not send it. */ sendSignal(signal: DirectSignalV1): boolean; + /** + * Put one transport ciphertext on the relay path — the half of + * {@link DirectEndpoint.send}'s routing that carries a session until it has + * switched, and every session that never does. + */ + sendRelay(ciphertext: Uint8Array): void; /** * Decrypt one transport ciphertext, whichever path carried it, process * everything that is not a signal, and answer the receipt. `null` where the @@ -96,6 +102,16 @@ export class DirectEndpoint { return this.#disposed ? 'relay' : this.#cutover.path; } + /** + * Whether the session this endpoint carries is over. **Its owner's one test + * for a session that is no longer live**: every route that replaces or tears + * one down disposes its endpoint first, so a send loop mid-message needs no + * second look at where the session was registered. + */ + get disposed(): boolean { + return this.#disposed; + } + /** * Why the session is still relayed, or `null` where there is nothing to say. * Set by every route that gives an attempt up, so a session that stayed @@ -190,25 +206,26 @@ export class DirectEndpoint { } /** - * One transport ciphertext, `true` once it is consumed. The caller puts it on - * the relay when this answers `false`, so "after the switch, nothing on the - * relay" is one line rather than a rule each caller keeps. + * One transport ciphertext onto whichever path this session has. **Both ends + * send every byte of an established session through here**, so "after the + * switch, nothing on the relay" is one rule in one place rather than one each + * caller keeps — the mirror of {@link onRelayFrame} on the way in. * - * **A disposed endpoint consumes it too.** A refused send disposes the session + * **A disposed endpoint drops it.** A refused send disposes the session * synchronously, and the caller's loop is mid-message: the remaining chunks - * belong nowhere, least of all on the relay of a session that is over. The - * callers stop on their own next check; this only keeps the interval between - * the two from reaching the wire. + * belong nowhere, least of all on the relay of a session that is over. */ - send(ciphertext: Uint8Array): boolean { - if (this.#disposed) return true; - if (this.#cutover.outbound !== 'direct') return false; + send(ciphertext: Uint8Array): void { + if (this.#disposed) return; + if (this.#cutover.outbound !== 'direct') { + this.#deps.sendRelay(ciphertext); + return; + } // Switched, so the channel is the only path this may take — there is no // relay left to re-route onto. A peer that cannot carry it says why through // `onClosed`, and this end's rule about a channel lost after a switch does // the rest. this.#peer?.send(ciphertext); - return true; } /** diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index f00e3fc11..220421c7a 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -18,7 +18,7 @@ import { } from 'remote-lib-common'; import { DirectPeer, type DirectPeerHandlers } from './direct-peer'; -import { FakeDirectNetwork, type FakeDirectNetworkOptions } from './test-fake-peer'; +import { FakeDirectNetwork, flushMicrotasks, type FakeDirectNetworkOptions } from './test-fake-peer'; import { fakeTimers } from '../test-timers'; function handlers(): DirectPeerHandlers & { @@ -61,9 +61,6 @@ function pair(options: FakeDirectNetworkOptions = {}) { }; } -/** Let the fake network's queued microtasks run. */ -const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); - /** One negotiated pair with both channels open, as most cases start. */ async function connected(options: FakeDirectNetworkOptions = {}) { const run = pair(options); diff --git a/lib/src/remote/direct/test-fake-peer.ts b/lib/src/remote/direct/test-fake-peer.ts index cb3481d6b..3a3377b5d 100644 --- a/lib/src/remote/direct/test-fake-peer.ts +++ b/lib/src/remote/direct/test-fake-peer.ts @@ -65,6 +65,14 @@ export type FakePeerRole = 'offerer' | 'answerer'; /** One way a channel can be something a Noise stream cannot ride. */ export type ChannelDefect = 'unordered' | 'lossy' | 'expiring' | 'mislabeled'; +/** + * Let this file's queued microtasks run: every fake channel event is delivered + * through `queueMicrotask`, so a case that has just opened or sent needs one + * turn of the loop before it can read what happened. + */ +export const flushMicrotasks = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); + export class FakeDirectNetwork { readonly #options: FakeDirectNetworkOptions; readonly #peers = new Map(); diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 478045f08..89960c3ee 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -358,7 +358,7 @@ export default function App({ * render: the cutover happens seconds into a session, long after the wall is * up, and an attempt that quietly stays relayed changes only the detail. */ - const [transport, setTransport] = useState({ path: 'relay', cause: null }); + const [transport, setTransport] = useState(RELAYED_TRANSPORT); useEffect(() => { client.setOnTransportChanged((path, cause) => setTransport({ path, cause })); return () => client.setOnTransportChanged(null); @@ -367,11 +367,6 @@ export default function App({ /** The connect half, shared so a fresh pairing can continue straight into it. */ const connectTo = useCallback( async (burrow: BurrowView) => { - // A fresh endpoint announces nothing until something changes, and its - // idea of unchanged is `relay` with no cause — so the previous session's - // reason would sit in the header through the whole of this one's - // negotiation. - setTransport({ path: 'relay', cause: null }); const decision: ConnectResult = await client.connect(burrow.burrowId); if (!decision.ok) { // The record has already been rewritten where the Burrow said @@ -826,6 +821,12 @@ export interface TransportView { readonly cause: DirectRelayCause | null; } +/** + * Where every session starts and where each one ends: relayed, with no reason + * to give. Shared rather than rebuilt, so re-announcing it re-renders nothing. + */ +export const RELAYED_TRANSPORT: TransportView = { path: 'relay', cause: null }; + /** * The indicator's hover text: which path, and the reason behind it where there * is one. **The reason is shown, never the label** — an attempt that quietly @@ -841,7 +842,7 @@ export function transportTitle({ path, cause }: TransportView): string { export function ConnectedView({ burrow, adapter, - transport = { path: 'relay', cause: null }, + transport = RELAYED_TRANSPORT, onLeave, onError, }: { diff --git a/lib/src/remote/test-e2e-client.ts b/lib/src/remote/test-e2e-client.ts index b0e4da16c..8c2a000a3 100644 --- a/lib/src/remote/test-e2e-client.ts +++ b/lib/src/remote/test-e2e-client.ts @@ -156,7 +156,7 @@ export async function flushUntil(get: () => T | undefined, timeoutMs = 2000): } /** Poll for at most `timeoutMs`, answering `undefined` if it never arrives. */ -async function pollFor(get: () => T | undefined, timeoutMs: number): Promise { +export async function pollFor(get: () => T | undefined, timeoutMs: number): Promise { const start = Date.now(); for (;;) { const value = get(); @@ -228,13 +228,21 @@ export async function settleUntilQuiet(read: () => number, stableRounds = 3): Pr throw new Error(`settleUntilQuiet: reading never went quiet after 40 settles (last ${last})`); } -/** The Burrow's outgoing `e2e` frames for one ceremony, in order. */ +/** + * The Burrow's outgoing `e2e` frames for one ceremony, in order, narrowed to one + * `step` where a caller wants only the transport half. + */ export function e2eFramesFor( socket: FakeSocket, kind: string, id: string, + step?: string, ): Array> { - return socket.frames('e2e').filter((frame) => frame.kind === kind && frame.id === id); + return socket + .frames('e2e') + .filter( + (frame) => frame.kind === kind && frame.id === id && (step === undefined || frame.step === step), + ); } /** Deliver one relay-stamped `e2e` frame to the Burrow. */ @@ -253,8 +261,9 @@ export function sendE2eFrame( } /** - * Decrypt the Burrow's most recent control message on one ceremony, waiting for - * one to arrive. + * Decrypt one of the Burrow's control messages on a ceremony, waiting for it to + * arrive: the most recent by default, or the one at `index` where a case is + * reading a session's signals in order. * * The wait is the point: every step of a ceremony awaits several WebCrypto * calls, so a test that read the frame log after a fixed number of turns would @@ -266,13 +275,14 @@ export async function readOutcome( session: NoiseTransportSession, kind: string, id: string, + index?: number, ): Promise> { - const last = await pollFor(() => { - const frames = e2eFramesFor(socket, kind, id).filter((frame) => frame.step === 'transport'); - return frames[frames.length - 1]; + const frame = await pollFor(() => { + const frames = e2eFramesFor(socket, kind, id, 'transport'); + return index === undefined ? frames[frames.length - 1] : frames[index]; }, 2000); - if (!last) throw new Error('the Burrow sent no outcome'); - const receipt = session.receive(fromBase64Url(last.ct as string)); + if (!frame) throw new Error('the Burrow sent no outcome'); + const receipt = session.receive(fromBase64Url(frame.ct as string)); if (receipt.kind !== 'control') { throw new Error(`expected a control message, got ${receipt.kind}`); } @@ -438,16 +448,12 @@ export async function openDirectPath(options: { onViolation: () => {}, }, }); - const transportFrames = () => - e2eFramesFor(socket, 'connection', connectionId).filter((frame) => frame.step === 'transport'); - let cursor = transportFrames().length; + let cursor = e2eFramesFor(socket, 'connection', connectionId, 'transport').length; const nextSignal = async (): Promise> => { - const frame = await flushUntil(() => transportFrames()[cursor]); + const value = await readOutcome(socket, session, 'connection', connectionId, cursor); cursor += 1; - const receipt = session.receive(fromBase64Url(frame.ct as string)); - if (receipt.kind !== 'control') throw new Error(`expected a signal, got ${receipt.kind}`); - signals.push(receipt.value); - return receipt.value; + signals.push(value); + return value; }; const sendControl = (value: Record): void => { sendE2eFrame(socket, { diff --git a/lib/src/remote/test-timers.ts b/lib/src/remote/test-timers.ts index 426de0063..453833133 100644 --- a/lib/src/remote/test-timers.ts +++ b/lib/src/remote/test-timers.ts @@ -21,30 +21,33 @@ export interface FakeTimers { } export function fakeTimers(): FakeTimers { - const armed: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; + // Dropped on cancel and on fire rather than flagged, so this *is* `live`: a + // case that re-arms a keepalive a hundred times neither grows it without + // bound nor pays a filtered copy per read. + const live: Array<{ run: () => void; delayMs: number; cancelled: boolean }> = []; + const take = (index: number): (() => void) => { + const timer = live.splice(index, 1)[0]!; + timer.cancelled = true; + return timer.run; + }; return { setTimer(run: () => void, delayMs: number): () => void { const timer = { run, delayMs, cancelled: false }; - armed.push(timer); + live.push(timer); return () => { - timer.cancelled = true; + const index = live.indexOf(timer); + if (index >= 0) take(index); }; }, - get live() { - return armed.filter((timer) => !timer.cancelled); - }, + live, fire(): void { - const live = this.live; - const timer = live[live.length - 1]; - if (!timer) throw new Error('no timer is armed'); - timer.cancelled = true; - timer.run(); + if (live.length === 0) throw new Error('no timer is armed'); + take(live.length - 1)(); }, fireAt(delayMs: number): void { - const timer = this.live.find((entry) => entry.delayMs === delayMs); - if (!timer) throw new Error(`no timer armed for ${delayMs}ms`); - timer.cancelled = true; - timer.run(); + const index = live.findIndex((entry) => entry.delayMs === delayMs); + if (index < 0) throw new Error(`no timer armed for ${delayMs}ms`); + take(index)(); }, }; } diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 63ed693a9..1c514ac0c 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -11,7 +11,6 @@ */ import { isBoundedString } from './bytes.js'; -import { CONTROL_PAYLOAD_SIZE } from './noise-transport.js'; /** * How long a peer waits for the channel to open before abandoning the attempt @@ -51,7 +50,8 @@ export const DIRECT_GATHER_TIMEOUT_MS = 3_000; /** * The most SDP one signal may carry, in characters. * - * Derived from {@link CONTROL_PAYLOAD_SIZE}, which every control body is padded + * Derived from `CONTROL_PAYLOAD_SIZE` in + * `remote-lib-common/src/security/noise-transport.ts`, which every control body is padded * to and may not exceed: with the SDP restricted to the characters SDP is made * of ({@link isDirectSdp}), JSON encodes each of them as at most two bytes, so * `2 * MAX_DIRECT_SDP_LENGTH` plus the envelope always fits. The relationship @@ -284,14 +284,6 @@ export type DirectRelayOutcome = 'process' | 'violation'; /** What one inbound channel frame turned out to be. */ export type DirectChannelOutcome = 'process' | 'held' | 'overflow'; -/** - * How far this end's one attempt has got: `idle` before it starts, `attempting` - * from {@link DirectCutover.begin} until {@link DirectCutover.abandon}, and - * `abandoned` forever after. There is no way back to `idle`, which is what makes - * the attempt once-per-session. - */ -export type DirectAttemptState = 'idle' | 'attempting' | 'abandoned'; - /** What the peer's `direct-switch` turned out to mean; see {@link DirectCutover.onSwitchDecrypted}. */ export type DirectSwitchOutcome = | { readonly kind: 'drain'; readonly frames: Uint8Array[] } @@ -313,16 +305,17 @@ export type DirectSwitchOutcome = * when one may start or what a switch means after one has been given up. */ export class DirectCutover { - #state: DirectAttemptState = 'idle'; + /** + * How far this end's one attempt has got: `idle` before it starts, + * `attempting` from {@link begin} until {@link abandon}, and `abandoned` + * forever after. There is no way back to `idle`, which is what makes the + * attempt once-per-session. + */ + #state: 'idle' | 'attempting' | 'abandoned' = 'idle'; #outbound: DirectPath = 'relay'; #inbound: DirectPath = 'relay'; readonly #held = new DirectFrameQueue(MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_PENDING_BYTES); - /** How far this end's one attempt has got. */ - get state(): DirectAttemptState { - return this.#state; - } - /** Where this end's own messages go. */ get outbound(): DirectPath { return this.#outbound; @@ -379,15 +372,11 @@ export class DirectCutover { /** * Move this end's sends onto the channel. The caller sends its * `direct-switch` on the relay *first*: this is the line after which nothing - * else may. - * - * Answers `false` for a second call, so a duplicate open cannot put two - * switches on the wire. + * else may. Idempotent; what keeps a duplicate open from putting a second + * switch on the wire is `DirectPeer`'s own once-only open guard. */ - switchOutbound(): boolean { - if (this.#outbound === 'direct') return false; + switchOutbound(): void { this.#outbound = 'direct'; - return true; } /** diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 00a1e9b97..9b1f73a78 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -233,13 +233,10 @@ test('starts relayed in both directions', () => { test('claims the session’s one attempt, and never a second', () => { const cutover = new DirectCutover(); - assert.equal(cutover.state, 'idle'); assert.equal(cutover.begin(), true); - assert.equal(cutover.state, 'attempting'); // The one-attempt-per-session gate: a second offer allocates nothing. assert.equal(cutover.begin(), false); cutover.abandon(); - assert.equal(cutover.state, 'abandoned'); // And an attempt that was given up cannot be restarted either. assert.equal(cutover.begin(), false); }); @@ -282,9 +279,10 @@ test('a switch onto an abandoned channel is fatal, and a switch while attempting test('switches each direction on its own, and only both make it direct', () => { const cutover = new DirectCutover(); cutover.begin(); - assert.equal(cutover.switchOutbound(), true); - // A second open must not put a second switch on the relay. - assert.equal(cutover.switchOutbound(), false); + cutover.switchOutbound(); + // Idempotent, so a duplicate open cannot move the cutover twice. + cutover.switchOutbound(); + assert.equal(cutover.outbound, 'direct'); assert.equal(cutover.switched, true); // The peer is still on the relay, so the relay still carries half of it. assert.equal(cutover.path, 'relay'); diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index c21fc22cb..f36bac484 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -6,6 +6,7 @@ // - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs // See docs/specs/dor-browser.md and docs/specs/remote-api.md. import { build } from 'esbuild'; +import { readFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -23,11 +24,17 @@ const sidecar = path.resolve(here, '../sidecar'); // so this is the enforcement point — there is no webview CSP in front of it. const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); -// The Burrow's direct-path peer. `node-datachannel` resolves its platform -// package and `detect-libc` relative to its own `__dirname`, so it has to stay -// an installed package under `sidecar/node_modules` and be required by name — -// inlining it here would leave that loader looking beside `burrow.cjs`. -const NATIVE_DIRECT = ['node-datachannel', 'node-datachannel/polyfill']; +// What the sidecar installs at runtime, read from the manifest that installs +// it: `node-datachannel` resolves its platform package and `detect-libc` +// relative to its own `__dirname`, so every one of these has to stay an +// installed package under `sidecar/node_modules` and be required by name — +// inlining one here would leave that loader looking beside `burrow.cjs`. +// Derived rather than listed, so declaring a dependency is what keeps it out. +const SIDECAR_RUNTIME_DEPS = Object.keys( + JSON.parse(readFileSync(path.resolve(sidecar, 'package.json'), 'utf8')).dependencies ?? {}, +); +// Each package by name, plus every subpath export of it (`node-datachannel/polyfill`). +const NATIVE_DIRECT = SIDECAR_RUNTIME_DEPS.flatMap((name) => [name, `${name}/*`]); const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, @@ -42,32 +49,34 @@ const bundles = [ ]; /** - * Fail the build if esbuild bundled a module the `external` list has to keep out. + * Fail the build if esbuild inlined a package the sidecar installs at runtime. * * `native-direct-peer.ts` calls `require('')` by literal, so an - * external specifier stays a `require-call` edge out of the bundle and a bundled - * one becomes an inlined module with no edge at all. That difference is the - * whole check: a lost `external` entry produces a `burrow.cjs` that loads and - * then cannot find the addon's `.node` file, which nothing before the first - * `direct-offer` on a real machine would notice. + * external specifier stays a `require-call` edge out of the bundle while a + * bundled one becomes an input of it. That difference is the whole check: an + * inlined addon produces a `burrow.cjs` that loads and then cannot find the + * addon's `.node` file, which nothing before the first `direct-offer` on a real + * machine would notice. */ -function assertExternalImports(metafile, outfile, specifiers) { +function assertNothingInlined(metafile, outfile, names) { // esbuild keys `metafile.outputs` by path relative to the process cwd, with // `/` separators on every platform. const outputKey = path.relative(process.cwd(), outfile).split(path.sep).join('/'); - const imports = metafile.outputs[outputKey]?.imports; - if (!imports) { + const output = metafile.outputs[outputKey]; + if (!output) { throw new Error( - `sidecar: esbuild metafile has no output for "${outputKey}" — cannot check external imports.`, + `sidecar: esbuild metafile has no output for "${outputKey}" — cannot check what it bundled.`, ); } - for (const specifier of specifiers) { - const kept = imports.some( - (edge) => edge.path === specifier && edge.kind === 'require-call' && edge.external === true, + for (const name of names) { + // Every layout a package manager resolves through ends in this segment, + // pnpm's content-addressed store included. + const inlined = Object.keys(output.inputs).find((input) => + input.includes(`node_modules/${name}/`), ); - if (kept) continue; + if (!inlined) continue; throw new Error( - `sidecar: ${outputKey} has no external require("${specifier}") — esbuild bundled it, and ` + + `sidecar: ${outputKey} inlined "${inlined}" — "${name}" is a sidecar runtime dependency, and ` + 'the addon would look for its platform package beside the bundle instead of inside ' + 'sidecar/node_modules.', ); @@ -89,11 +98,11 @@ for (const { entry, out, define, assertBaked, external } of bundles) { format: 'cjs', target: 'node24', logLevel: 'warning', - metafile: true, ...(define ? { define } : {}), - ...(external ? { external } : {}), + // Only the bundle with externals to check reads one. + ...(external ? { external, metafile: true } : {}), }); if (assertBaked) assertConnectSrcBaked(outfile, remoteSrc); - if (external) assertExternalImports(result.metafile, outfile, external); + if (external) assertNothingInlined(result.metafile, outfile, SIDECAR_RUNTIME_DEPS); console.log(`[sidecar] built ${path.relative(process.cwd(), outfile)}`); } From 14327ef3d28c72f91988ad304126f9ef2d112b7f Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 09:51:26 -0700 Subject: [PATCH 46/46] fix(standalone): keep the inline check loud when the manifest stops declaring the addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertNothingInlined` scanned the same derived list that builds `external`, so the two could only be wrong together: moving `node-datachannel` to `optionalDependencies` — beside the six `@node-datachannel/*` platform packages already there — dropped it from both, and the loop ran zero iterations and passed on a `burrow.cjs` that cannot find its `.node`. Guard the one package the check is about, so that edit fails the build. `standalone.md` said every runtime dependency the manifest declares stays external; the derivation reads `dependencies`, and the platform packages are declared under `optionalDependencies`. Also: `RELAYED_TRANSPORT` does not spare a render. The subscriber mints a fresh object on every announcement, so `Object.is` never matches — it is one value for the initial state and the default prop, and the comment now says only that. Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/standalone.md | 7 ++++--- lib/src/remote/pocket-app/App.tsx | 2 +- standalone/scripts/build-sidecar-proxy.mjs | 10 ++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index cc66ddbc4..8e2ca2314 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -142,10 +142,11 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si `node-datachannel`'s W3C polyfill. **A sidecar package's transitive dependencies do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and nothing else — so the addon's platform package and `detect-libc` are declared in -`standalone/sidecar/package.json` directly. **Every runtime dependency that +`standalone/sidecar/package.json` directly. **Every `dependencies` entry that manifest declares stays `external` to `burrow.cjs`**, the `external` list being -derived from it rather than listed beside it, and the build asserts from -esbuild's metafile that none was inlined: the addon resolves its `.node` +derived from that key rather than listed beside it. The build fails if the +manifest stops declaring the addon, and asserts from esbuild's metafile that +none of those packages was inlined: the addon resolves its `.node` relative to its own `__dirname`, and inlining would move that out of the installed package. diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 89960c3ee..5b6a5630c 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -823,7 +823,7 @@ export interface TransportView { /** * Where every session starts and where each one ends: relayed, with no reason - * to give. Shared rather than rebuilt, so re-announcing it re-renders nothing. + * to give. Shared so the initial state and the default prop are one value. */ export const RELAYED_TRANSPORT: TransportView = { path: 'relay', cause: null }; diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index f36bac484..9c7a6dc78 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -35,6 +35,16 @@ const SIDECAR_RUNTIME_DEPS = Object.keys( ); // Each package by name, plus every subpath export of it (`node-datachannel/polyfill`). const NATIVE_DIRECT = SIDECAR_RUNTIME_DEPS.flatMap((name) => [name, `${name}/*`]); +// The list `assertNothingInlined` checks is this same one, so a manifest that +// stopped declaring the addon would take the check away with the `external` +// entry and the build would go green on a `burrow.cjs` that cannot load it. +if (!SIDECAR_RUNTIME_DEPS.includes('node-datachannel')) { + throw new Error( + 'sidecar: package.json no longer declares "node-datachannel" under "dependencies" — it would ' + + 'be inlined into burrow.cjs, and nothing before the first direct-offer on a real machine ' + + 'would notice.', + ); +} const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' },