Adds OpenChamber Relay — an opt-in way to reach an instance from a phone, browser, or another desktop from anywhere, with no open inbound ports, no tunnel, and no shared LAN. The instance dials outbound to a relay; all app traffic (HTTP, the event stream, terminal, dictation) is multiplexed and encrypted through a single connection per client, so the relay only ever forwards opaque ciphertext. Transport - End-to-end-encrypted channel over WebCrypto (ECDH P-256 -> HKDF -> AES-256-GCM) with a capability-negotiated handshake and a small HTTP/SSE/WebSocket multiplexing protocol. A byte-compatible JS host mirror is cross-checked by tests. - Host: outbound connection manager, per-client tunnel dispatcher to the local server over loopback, reuse of the existing instance identity key, and management routes. Disabled by default; explicit opt-in. - Client: plugs into the existing runtime layer (runtime-fetch/-url/-switch/ -auth, event pipeline, terminal, dictation) so features work over the relay unchanged; direct-URL and Electron realtime-proxy paths are untouched. Pairing & UX - Relay section in Settings -> Remote Instances (live status, QR/link pairing, revocation via the existing client-token list) and the mobile connect flow. - Frame batching and idle-gated keepalive keep tunnel message volume low without affecting streaming smoothness. Security - The tunnel is transport only; the server authenticates every tunneled request exactly as for a direct remote client. fragments only. The relay stores no keys, tokens, or payloads. Operability - The endpoint can be pinned to a self-hosted rel paired clients inherit it from the offer automatically. - Relay module DOCUMENTATION.md and a relay-trans invariants that future WebSocket/streaming changes must follow. The relay transport is complete and tested; the UI for enabling and pairing is gated behind openchamber_relay_gate and stays
7.0 KiB
name, description, license, compatibility
| name | description | license | compatibility |
|---|---|---|---|
| relay-transport | Use when adding or changing any WebSocket, SSE, or streaming endpoint (terminal, dictation/voice, event stream, notifications), opening a WebSocket in shared UI, refactoring the runtime transport (runtime-fetch/runtime-url/runtime-switch/runtime-auth), touching anything under packages/ui/src/lib/relay or packages/web/server/lib/relay, or porting a realtime feature. These changes silently break OpenChamber's private relay (mobile→desktop over an E2EE tunnel) in ways that pass local/desktop testing and only fail over the relay on a real device. Load this before such work to know the invariants and the traps already hit. | MIT | opencode |
Overview
OpenChamber has a private relay: a client (mobile app, browser, another desktop) reaches a user's instance through an OpenChamber-hosted relay over an end-to-end encrypted tunnel. All of the app's traffic — many HTTP requests, the event stream (SSE), and WebSockets (terminal, dictation) — is multiplexed and encrypted through one connection per client.
Architecture overview: packages/web/server/lib/relay/DOCUMENTATION.md. Code: packages/ui/src/lib/relay/ (client + shared, TS) and packages/web/server/lib/relay/ (host, JS).
Why this skill exists: relay bugs do not show up in normal testing. The event stream is SSE (which behaves differently from WebSockets), so a new WebSocket feature is often the first real WebSocket to cross the tunnel on mobile — and it fails there while working everywhere else. We have fixed the same class of bug across several iterations. The rules below are those lessons.
The core mental model
- The tunnel is transparent. A feature should reach the server through the shared runtime transport (
runtimeFetch,openRuntimeWebSocket) and never know whether it is direct or relayed. If a feature constructs its ownfetch/WebSocketagainst a runtime URL, it bypasses the tunnel and breaks in relay mode. - Three transports behave differently over the tunnel:
- HTTP and SSE authenticate with the client's bearer token (a header). They "just work" through the tunnel for any allowlisted
/api/*,/auth/*,/healthpath. - WebSockets cannot send headers. They authenticate with a short-lived URL-scoped token (
oc_url_token) that must be minted first and passed as a query parameter. This is the source of most relay WS bugs.
- HTTP and SSE authenticate with the client's bearer token (a header). They "just work" through the tunnel for any allowlisted
Rules for adding or changing a WebSocket endpoint
Adding a new WS endpoint (or porting one, e.g. the planned terminal port) requires ALL of these, or it breaks over the relay:
- Open it via
openRuntimeWebSocket(packages/ui/src/lib/relay/runtime-socket.ts), nevernew WebSocket(...)directly. A rawnew WebSocketagainst a runtime URL fails in relay mode (the resolver yields a tunnel-virtual/custom-scheme URL the platform rejects — surfaced as "The string did not match the expected pattern"). - Add the path to BOTH allowlists (they are separate and both required):
- Host tunnel dispatcher:
ALLOWED_WS_PATHSinpackages/web/server/lib/relay/tunnel-host.js. - URL-token auth gate:
isUrlAuthWebSocketPathinpackages/web/server/lib/ui-auth/ui-auth.js(otherwise theoc_url_tokenis refused for that path → 401).
- Host tunnel dispatcher:
- Mint the URL token before connecting. Call
refreshRuntimeUrlAuthToken()and build the URL through the resolver'swebsocket(...)sooc_url_tokenis appended. SSE/HTTP do not need this; WS does. - Do not touch origin handling. The server rejects WS upgrades whose
Originit does not trust. Over the tunnel the host dials loopback and presents the loopback origin (http://127.0.0.1:<port>), which the server trusts as same-origin — this already covers every allowlisted WS path. Never reintroduce reliance onwindow.location.origin: in the iOS WKWebView it is"null"/empty for the custom scheme, so forwarding it produces a 403. - Test over the relay, not just direct/desktop. A new WS may be the first WebSocket the mobile client runs through the tunnel (events are SSE-locked on Capacitor). Passing on desktop or a direct connection proves nothing about the relay path.
Rules for the tunnel/crypto/codec internals
- Two implementations must stay byte-compatible. The E2EE and framing exist as TS (
packages/ui/src/lib/relay/{crypto,handshake,tunnel-codec}.ts, normative) and a JS host mirror (packages/web/server/lib/relay/{e2ee,tunnel-codec}.js). Any wire-format, frame-type, handshake, or batching change must update both and keeppackages/web/server/lib/relay/cross-compat.test.jsgreen. - Frame types live in
protocol.tsand must match acrossprotocol.ts,tunnel-codec.ts, andtunnel-codec.js. Adding a frame type without mirroring it corrupts the stream on one side. - Frame batching is capability-negotiated in the handshake with a legacy fallback, so mixed client/host app versions still interoperate. Preserve the negotiation and the single-frame fallback; do not make batching unconditional.
- The encrypted-frame counter/IV is per-direction and strictly increasing. One encrypted WS message = one encrypt call = one counter tick. Keep encrypt+send serialized per direction; do not reorder or parallelize it.
Rules for the runtime transport layer
- Relay mode routes through
runtime-switch(activates the tunnel singleton),runtime-fetch(routes runtime requests through it),runtime-url/runtime-socket(tunnel-backed URLs/sockets), andruntime-auth(mints the URL token through the tunnel). When refactoring any of these, preserve the relay branch and the direct-URL/Electron-realtime-proxy branches — they must remain byte-identical in behavior for non-relay runtimes. - The host dispatcher never injects credentials. Tunneled requests carry the client's own token; the server authenticates them. Do not add host-side auth shortcuts, and do not trust loopback source address as authentication (relay traffic arrives at loopback but represents remote clients).
Testing guidance (a stub that skips auth/origin hides the exact bugs)
- Exercise the real auth and origin gates. An end-to-end test whose stub server accepts any WS upgrade will pass while the real server rejects it — this is precisely how the origin-check bug shipped. When writing a relay integration test, mirror the real gates (
ensureSessionTokenviaoc_url_token,isRequestOriginAllowed) or run against the real server pieces. - Run relay tests per file (
bun test <file>); the suite has order sensitivity. - Validate both sides:
packages/uitype-check/lint, andnode --checkon changed JS host files.
Quick checklist before finishing relay-adjacent work
- New WS endpoint added to
ALLOWED_WS_PATHSANDisUrlAuthWebSocketPath? - UI opens it via
openRuntimeWebSocket, notnew WebSocket? - URL token minted before the WS connects?
- No new dependence on
window.location.origin? - Wire/codec/handshake change mirrored in TS and JS, cross-compat test green?
- Direct and relay paths both still work; verified over the relay on the transport that actually uses it?