-
Notifications
You must be signed in to change notification settings - Fork 0
Relay Client, Pairing & Terminal Tunneling
This page covers the relay seam that connects a host app to a paired remote peer over an end-to-end encrypted WebSocket tunnel, then turns that tunnel into a terminal stream. The implementation is split into three cooperating modules:
-
src/core/relay-client.ts— Electron-free wire protocol: X25519 key agreement, AES-256-GCM envelopes, hello/peers handshake, invite minting, and the injectable WebSocket seam. -
src/core/relay-term.ts— terminal application protocol that rides inside the encrypted envelope, plus a per-terminal output coalescer. -
src/main/relay.ts— main-process runtime: connection state, IPC-facing methods, pairing fingerprint exposure, host-side PTY serving, and client-side frame forwarding.
The first two modules are pure and testable without Electron. The third is intentionally thin glue between those core contracts, the main-process PTY host, and the renderer status channel.
createRelayClient() generates an ephemeral X25519 keypair and immediately opens a socket through the injected socketFactory. Nothing auto-connects: the caller must invoke connect().
connect() sends a hello frame:
- Host:
{ t: 'hello', role: 'host', login, pub, token? }, wherelogindefaults toapp-hostandtokenis the GitHub token when supplied. - Client:
{ t: 'hello', role: 'client', pub, invite }, whereinviteis a pre-minted single-use code.
It then waits for either an error frame or a peers frame. The host waits for a filled peer (m.peer != null); the client accepts the first peers frame. This encodes the operational assumption that the host connects first and the client’s connection triggers the host’s filled peers frame. If the client connects first, it can resolve with peerPub: null, and sendFrame() will later throw because it cannot seal without a peer key.
The relay assigns each side an id by role and login. The client computes its own selfId locally as host:<login> or client:<opts.login ?? opts.invite>. That id is stored in RelayPairing and used as the AES-GCM AAD for outgoing frames. Incoming frames are opened with the from id attached by the relay. Therefore, selfId must match the relay-side identity exactly; dev relays prefix logins with dev-, and the peers frame reflects that relay-side identity.
The pairing result contains:
-
peerPub— raw base64 X25519 public key of the peer, ornull. -
peerLogin— relay-side login of the peer, ornull. -
selfId— the sender id this side uses as envelope AAD.
src/main/relay.ts converts peerPub into RelayPairingInfo.fingerprint using relayFingerprint(). That fingerprint is the first 16 bytes of SHA-256 over the raw public key, rendered as 8 space-separated lowercase hex pairs. It is a display/eyeball-confirmation value, not an authentication primitive; the trust decision belongs to the renderer-side trust UI.
Invite minting is host-driven. mintInvite() sends { t: 'invite-create' } and waits for either invite or error. A missing code is treated as an error. The single-use invite lifecycle itself — expiry, revocation, quotas — lives in the relay service/store, not in this client.
The envelope is { n, c }, both base64:
-
n— 12-byte random AES-GCM nonce. -
c— ciphertext with the 16-byte GCM auth tag appended.
deriveRelayKey() performs X25519 ECDH and then HKDF-SHA256 with info termsprawl-relay-v1, an empty salt, and a 32-byte output. sealRelay() and openRelay() then encrypt/decrypt with AES-256-GCM. The sender id is set as AAD: sealing uses the local selfId, opening uses the relay-attached from value.
openRelay() validates the envelope shape, requires a 12-byte nonce, and requires at least 16 bytes of ciphertext. Malformed or undecryptable frames are dropped inside onFrame() with a log message rather than thrown to the caller. This means a payload-level parse failure is isolated to the frame, not the connection.
hashRelayToken() is the SHA-256 hex helper used for host token matching on the relay side. relayFingerprint() is only for human comparison.
The relay never sees terminal frames as plaintext: RelayTermFrame JSON is serialized into the encrypted envelope. The protocol is versioned with v: 1 and discriminated by k:
k |
Direction | Payload | Meaning |
|---|---|---|---|
list |
client → host | — | Ask for available terminals. |
term-list |
host → client | terms: { id, title }[] |
Terminal inventory. |
attach |
client → host | term |
Start streaming a terminal. |
detach |
client → host | term |
Stop streaming a terminal. |
in |
client → host |
term, data
|
Input bytes for the PTY. |
out |
host → client |
term, data
|
Output bytes from the PTY. |
resized |
client → host |
term, cols, rows
|
Resize request. |
parseRelayTermFrame() returns null for garbage JSON, unknown k, or v !== 1. The validator is shape-only: it checks that terms entries have string id and title, that term is a string, that data is a string, and that cols/rows are numbers. It does not range-check dimensions or cap data length.
createTermCoalescer(flush, { intervalMs = 16, maxBytes = 32 * 1024 }) buffers out frames and flushes them in batches. The merge rules are:
- If a single frame’s
data.length > maxBytes, flush whatever is buffered plus that oversized frame immediately. - Otherwise, if the last buffered frame is an
outfor the same terminal and the merged length stays withinmaxBytes, replace that buffered frame with the merged data. - Otherwise, append the frame.
The timer is a setInterval that only calls flushNow() when the buffer is non-empty. stop() clears the interval. maxBytes is JavaScript string length, not UTF-8 byte count, so multi-byte terminal output can exceed the nominal cap in bytes.
The host side is defined by PtyHost:
-
list()returns{ id, title }[]. -
onData(id, cb)subscribes to terminal output and returns an unsubscribe function. -
write(id, data)sends input to the PTY. -
resize(id, cols, rows)resizes the PTY.
attachTerm(term) is the core serving flow:
- If
deps.ptyHostis absent, ignore the request. - If
ptyHost.list()does not containterm, ignore it. Unknown terminals never error the peer. - If the term is already attached, stop its coalescer and unsubscribe the old listener.
- Create a new
TermCoalescerwhose flush callback sends eachoutframe throughserveTermFrame(). - Subscribe to
ptyHost.onData(term, data => coalescer.push({ v: 1, k: 'out', term, data })). - Store
{ unsub, coalescer }in theattachedmap.
detachTerm() stops and removes one attachment. teardownHostServing() stops every coalescer, unsubscribes every PTY listener, and clears the map. These are the required cleanup hooks for disconnect, socket close, and reconnect.
serveTermFrame() sends directly to client:${pairing.peerLogin} via the core client’s sendFrame(). It no-ops if there is no client, no peerPub, or no peerLogin, and it catches send errors to avoid breaking the serving loop.
The inbound dispatch that maps list, attach, detach, in, and resized onto term-list, attachTerm, detachTerm, write, and resize is implied by the available primitives, but the exact switch body is beyond the provided excerpt. Verify it before changing frame semantics.
On the client side, sendTermFrame(frame) is the public mirror: it sends one JSON terminal frame to the paired host. It never throws; if this app is not a paired client, it returns { ok: false, error }. Host-role frames are served locally and are not sent through this public method.
Inbound decrypted frames arrive through the core onFrame() callback. In the main runtime, setFrameListener() gates delivery to a subscriber. If no listener is attached, decrypted frames are not surfaced. This is the audit B7 behavior: the old relay:frame channel is not allowed to broadcast plaintext when nobody is listening. Only client-role frames reach this listener; host-role inbound frames are handled locally against PtyHost.
The main runtime exposes a small state machine:
disconnectedconnectingpairederror
createRelayRuntime() holds the live client, pairing, role, state, error, frameListener, and the host-side attached map. Every state transition goes through setState(), which broadcasts relay:status with { state, error }. The file comment identifies relay:connect / relay:status as the IPC-facing channels; handler registration is outside the provided excerpt.
The public runtime surface is:
-
connect(): Promise<{ ok, error?, pairing? }>— returns aRelayPairingInfoincludingpeerLogin,peerPub,selfId, andfingerprint. -
mintInvite(): Promise<{ ok, code?, error? }>— host-side invite minting. -
sendTermFrame(frame): { ok, error? }— client-role terminal frame send. -
disconnect(): void— teardown entry point. -
setFrameListener(listener | null): void— opt-in decrypted frame delivery. -
state()/lastError()— read-only status.
disconnect() and socket-close handling must call teardownHostServing() and close the relay client. The excerpt does not show those exact bodies, but the runtime’s attached map and coalescer timers make this cleanup mandatory to avoid PTY listener leaks and stray interval timers.
flowchart TB
subgraph Core["Electron-free core"]
RClient["relay-client.ts<br/>ECDH + AES-GCM + hello/peers/invite"]
RTerm["relay-term.ts<br/>frame validation + coalescer"]
end
subgraph Main["Main process"]
Runtime["relay.ts<br/>state + IPC-facing methods"]
PtyHost["PtyHost adapter"]
Sock["defaultSocketFactory<br/>ws"]
end
Renderer["Renderer / Trust UI"] -- relay:status, pairing --> Runtime
Runtime -- connect / mintInvite / sendFrame --> RClient
Runtime -- parse / coalesce --> RTerm
Runtime -- list / onData / write / resize --> PtyHost
RClient -- encrypted frames --> Relay[(Relay hub)]
Relay -- frames --> RClient
PtyHost -- live terminal output --> RTerm
RTerm -- out frames --> Runtime
Runtime -- default factory --> Sock
Sock --> RClient
The important boundary is that RelayRuntime never touches X25519, HKDF, or AES-GCM directly. It builds a RelayClient with a socket factory, receives plaintext through onFrame, and sends plaintext through sendFrame. relay-term.ts sits between the plaintext and the runtime’s PTY surface: the runtime parses inbound frames with it and feeds host output through the coalescer before sealing.
sequenceDiagram
participant H as Host RelayRuntime
participant HC as Host RelayClient core
participant R as Relay hub
participant CC as Client RelayClient core
participant P as Host PtyHost
H->>HC: createRelayClient + connect
HC->>R: hello role=host, login, pub, token
R-->>HC: peers peer=null ack
Note over HC: host waits for filled peers
CC->>R: hello role=client, invite, pub
R-->>HC: peers with peer login+pub
HC-->>H: pairing + peerPub
H->>H: relayFingerprint peerPub
R-->>CC: peers with host login+pub
CC-->>CC: pairing
CC->>R: frame to host, env=attach term
R->>HC: frame from client, env
HC->>HC: openRelay env with from
HC-->>H: onFrame plaintext
H->>H: parseRelayTermFrame
H->>P: attachTerm term -> list/onData
P-->>H: onData data
H->>H: coalescer.push out frame
H->>HC: sendFrame out JSON to client peerLogin
HC->>HC: sealRelay key, plaintext, selfId
HC->>R: frame to client, env
R->>CC: frame from host, env
CC-->>CC: openRelay + parse out
The critical points in this sequence are the host-first pairing assumption, the filled peers frame that carries peerPub, the use of the relay-attached from id as AAD during openRelay, and the per-terminal coalescer that batches out frames before sendFrame.
- No auto-connect. The app dials the relay only when
connect()is called. - Pairing is single-peer per client instance.
RelayPairingstores onepeerPuband onepeerLogin; there is no multi-peer routing in this seam. - Host-first is an operational invariant. The host waits for a filled peers frame; the client accepts the first peers frame. A client that connects before the host can pair with
peerPub: nulland cannot send frames. -
selfIdmust match the relay-assigned id. Outgoing frames are sealed with the localselfId; incoming frames are opened with the relay’sfrom. A mismatch causes decrypt failures, which are logged and dropped. -
waitFor()has an 8-second default timeout. It rejects on timeout or socket error and resolves only when its predicate matches. -
waitFor()listeners are not unsubscribed after resolution, andRelaySockethas nooffmethod. Repeatedconnect()/mintInvite()calls on a long-lived socket accumulate message listeners. -
sendFrame()throws whenpeerPubis missing.serveTermFrame()catches and logs. The publicsendTermFrame()returns{ ok: false, error }instead of throwing. - Host serving fails open: missing
ptyHostor unknowntermis ignored rather than returned as an error to the peer. -
openRelay()drops malformed and unauthenticated envelopes. The caller does not get an exception for a bad peer frame. - The coalescer’s
maxBytesis a JavaScript string-length cap, not a UTF-8 byte cap. The default is 32 KiB nominal, with a 16 ms flush interval. - The coalescer timer persists until
stop()is called. Every attached terminal owns an interval timer plus a PTY subscription; detach and teardown must release both. -
onClosehandlers registered through the core client are one-shot. The runtime splices the handler array on first close, so handlers registered after close will not fire. -
parseRelayTermFrame()rejectsv !== 1and unknownk. New frame kinds require updating the union, theOUT_KEYSset, and the validator. - The frame validator does not range-check
cols/rowsand does not capdata. Add stricter validation if the tunnel accepts untrusted peers. - The relay sees only
from,to, and{ n, c }. Terminal frame contents are inside the encrypted envelope. - No reconnect or backoff is visible in this seam.
erroris a state, but retry policy is not part of the provided runtime excerpt.
- Inject a different
RelaySocketFactoryto run the client in tests or in the Server Edition without the realwspackage. The default factory lives in the main process sosrc/corestays dependency-free. - Implement or decorate
PtyHostto serve different terminal backends. The contract is intentionally small: list terminals, subscribe to output with an unsubscribe function, write input, and resize. - Add new terminal frame kinds by extending
RelayTermFrame,OUT_KEYS, andisRelayTermFrame(). Keep thevbump discipline if semantics change incompatibly. - Tune
intervalMsandmaxBytesoncreateTermCoalescerfor latency/throughput trade-offs. The flush callback receives batches, so batching behavior can change without touching the frame schema. - Replace or extend the fingerprint format in
relayFingerprint()only together with the trust UI. The current format is 8 space-separated hex pairs from the first 16 SHA-256 bytes. - Add pairing-update handling if multi-peer or client-first flows become supported. Today the core client stores one pairing and does not update it on later
peersframes. - Add reconnect/backoff around the runtime state machine if the app should recover from relay transport loss automatically. The current surfaces expose state and error but no retry loop.
The provided excerpts for src/core/relay-client.ts and src/main/relay.ts are truncated at line 260. The exact bodies of close()/disconnect(), the inbound terminal-frame switch, the connect() error-mapping path, mintInvite() wrapping, setFrameListener() wiring, and IPC handler registration are outside the excerpt. Renderer pairing/trust UI, settings-derived resolveTarget(), the concrete PtyHost implementation, and the relay service’s auth/store/invite lifecycle are separate modules and are not asserted here beyond the contracts visible in these files.
Sources: src/core/relay-client.ts, src/core/relay-term.ts, src/main/relay.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance