Skip to content

Relay Client, Pairing & Terminal Tunneling

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Runtime Mechanism

Pairing and key establishment

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? }, where login defaults to app-host and token is the GitHub token when supplied.
  • Client: { t: 'hello', role: 'client', pub, invite }, where invite is 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, or null.
  • peerLogin — relay-side login of the peer, or null.
  • 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.

Envelope encryption

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.

Terminal frame protocol

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.

Output coalescing

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 out for the same terminal and the merged length stays within maxBytes, 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.

Host serving path

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:

  1. If deps.ptyHost is absent, ignore the request.
  2. If ptyHost.list() does not contain term, ignore it. Unknown terminals never error the peer.
  3. If the term is already attached, stop its coalescer and unsubscribe the old listener.
  4. Create a new TermCoalescer whose flush callback sends each out frame through serveTermFrame().
  5. Subscribe to ptyHost.onData(term, data => coalescer.push({ v: 1, k: 'out', term, data })).
  6. Store { unsub, coalescer } in the attached map.

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.

Client tunneling path

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.

Main-process state and lifecycle

The main runtime exposes a small state machine:

  • disconnected
  • connecting
  • paired
  • error

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 a RelayPairingInfo including peerLogin, peerPub, selfId, and fingerprint.
  • 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.

Architecture and Flow

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
Loading

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.

Pairing and Terminal Attach Sequence

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
Loading

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.

Boundaries, Invariants, and Failure Modes

  • No auto-connect. The app dials the relay only when connect() is called.
  • Pairing is single-peer per client instance. RelayPairing stores one peerPub and one peerLogin; 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: null and cannot send frames.
  • selfId must match the relay-assigned id. Outgoing frames are sealed with the local selfId; incoming frames are opened with the relay’s from. 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, and RelaySocket has no off method. Repeated connect()/mintInvite() calls on a long-lived socket accumulate message listeners.
  • sendFrame() throws when peerPub is missing. serveTermFrame() catches and logs. The public sendTermFrame() returns { ok: false, error } instead of throwing.
  • Host serving fails open: missing ptyHost or unknown term is 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 maxBytes is 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.
  • onClose handlers 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() rejects v !== 1 and unknown k. New frame kinds require updating the union, the OUT_KEYS set, and the validator.
  • The frame validator does not range-check cols/rows and does not cap data. 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. error is a state, but retry policy is not part of the provided runtime excerpt.

Extension Points

  • Inject a different RelaySocketFactory to run the client in tests or in the Server Edition without the real ws package. The default factory lives in the main process so src/core stays dependency-free.
  • Implement or decorate PtyHost to 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, and isRelayTermFrame(). Keep the v bump discipline if semantics change incompatibly.
  • Tune intervalMs and maxBytes on createTermCoalescer for 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 peers frames.
  • 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.

Limitations of This Page’s Evidence

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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally