34 KiB
Runtime Relay Connections
Status
Implemented plan for issue #2471.
Problem
Orca mobile, web, and desktop runtime clients currently pair to the host runtime by connecting directly to the host's WebSocket endpoint. That works on a shared LAN, VPN, or tailnet, but fails for users who want to reach their host runtime from a different network without exposing an inbound port on the host.
We need a self-hostable relay option that lets the host and client both connect outbound to a public or private relay, while preserving Orca's current security model:
- the relay must not be trusted with source code, terminal output, browser screenshots, file contents, credentials, or RPC payloads
- existing per-device tokens must remain the authorization mechanism
- existing end-to-end encryption must remain the confidentiality and integrity boundary
- existing direct WebSocket pairing must keep working
- SSH workspaces must continue to work because the runtime RPC surface can target local and SSH-backed worktrees
Goals
- Add a self-hostable relay server that forwards WebSocket frames between Orca runtime hosts and runtime clients without decrypting them.
- Let mobile, web, and desktop runtime clients pair through the relay without being on the same LAN or tailnet as the host.
- Reuse the existing
E2EEChannel,DeviceRegistry, mobile method allowlist, streaming dispatch, and binary terminal stream handling. - Keep relay deployment independent from the Electron app so users can run it on a VPS, container host, or internal server.
- Keep direct pairing as the default low-latency path when it is reachable.
Non-goals
- Do not replace the SSH relay binary in
src/relay/. That code is for remote workspace execution over SSH, not public runtime client pairing. - Do not make the relay a trusted service or add server-side RPC awareness.
- Do not add user accounts, cloud-hosted account identity, or central device sync.
- Do not add offline message delivery. A relay can buffer a small number of pre-data-socket frames during connection setup only; it is not a queue.
- Do not tunnel arbitrary TCP ports through this relay. Browser and terminal behavior continue to use Orca runtime RPCs.
- Do not remove the existing direct WebSocket pairing flow.
Current Orca Architecture
Direct runtime pairing today has these pieces:
src/shared/pairing.tsdefines a pairing offer containing:endpointdeviceTokenpublicKeyB64
src/main/runtime/runtime-rpc.tscreates pairing offers from the local WebSocket transport and owns authorization.src/main/runtime/rpc/ws-transport.tsaccepts local-network WebSocket clients.src/main/runtime/rpc/e2ee-channel.tsperforms the E2EE handshake and binds the authenticateddeviceTokento the WebSocket.mobile/src/transport/rpc-client.ts,src/renderer/src/web/web-runtime-client.ts, andsrc/shared/remote-runtime-request-connection.tsconnect to the pairing endpoint and run the same E2EE handshake.
The current WebSocket path already has the security boundary we want. The relay should only provide reachability.
Proposed Architecture
Add an untrusted frame-forwarding runtime relay with one control socket per host and one data socket per client connection.
control: connected/sync/disconnected
+-----------------------------------------------+
| |
v |
+-------------+ outbound WS +----------------+ |
| Orca host |-------------------------->| Runtime relay |<-----+
| runtime | | server |
| |<--------------------------| |<----------------+
+-------------+ outbound WS +----------------+ |
^ server data socket ^ |
| | client data socket |
| existing runtime RPC after E2EE | v
+--------------------------------------------------------------+-------------+
| Orca client |
+-------------+
The relay never sees decrypted RPC payloads. It sees routing metadata in the WebSocket URL and frame sizes/timing only. Relay pairing also requires a fresh-session E2EE handshake so an active relay cannot replay a captured authenticated session while a device token remains valid.
Why control plus per-client data sockets
Orca's runtime server treats each WebSocket as an independent security and lifecycle boundary:
- each WebSocket owns an
E2EEChannel - each WebSocket gets a connection id used for subscription cleanup
- binary terminal stream handlers are registered per connection
- closing one socket must not tear down other sockets for the same device token
- mobile clients may keep multiple concurrent sockets
A single multiplexed relay tunnel would require inventing a second framing layer inside the existing encrypted frame stream and then remapping lifecycle cleanup back to virtual sockets. That is higher risk and would duplicate behavior that already works. A per-client data socket lets the relay present the same shape as a direct WebSocket, so the runtime can reuse the existing attachment path.
Protocol
All relay endpoints use WebSocket upgrades under /ws.
Query parameters
role=server|client
serverId=<stable host relay id>
connectionId=<relay assigned connection id, data sockets only>
v=1
serverId is a stable high-entropy id persisted in the host's user data
directory. It is a routing key, not an authorization secret. The pairing offer is
still the authorization grant because it carries a revocable deviceToken and
the host public key.
Host-side relay sockets also require a host-only relay secret. That secret is
generated with the relay serverId, stored only on the host, and sent on
role=server control/data socket upgrades in an Authorization: Bearer <hostToken> header. It is never included in client pairing offers or query
strings. Without this separate host capability, anyone who learns a pairing URL
could connect as role=server and replace the real host's relay sockets.
First-time host enrollment also requires a relay enrollment token generated or
configured when orca relay serve starts. Operators add the token to the saved
host relay URL as ?enrollmentToken=<token>. The host strips it from WebSocket
query strings and sends it only as X-Orca-Relay-Enrollment on the host control
socket. The token is not included in client pairing offers, host data sockets,
or relay client URLs.
The relay persists a server-side binding for each host:
- the first valid host control connection for an unknown
serverIdmust present the relay enrollment token and then storessha256(hostToken)in the relay state file before the control socket is accepted - later host control and host data sockets for that
serverIdmust present a token whose hash matches the stored binding - client sockets for an unregistered
serverIdare rejected so they cannot create unbounded pending state before a host has enrolled - host data sockets cannot create a binding; only the host control socket can
enroll a
serverId - relay restart reloads bindings from the state file before accepting sockets
- rotation requires the host to generate a new relay identity or the relay
operator to explicitly reset that
serverIdbinding, after which old pairing offers for the previousserverIdstop working
The enrollment token prevents unauthenticated callers from squatting arbitrary
serverId values or filling the binding file on an exposed relay. Once enrolled,
the relay state file is the source of truth. Relay startup fails closed if that
file exists but cannot be parsed or has an invalid shape; silently treating
corrupt persisted bindings as empty would allow accidental re-enrollment.
Host control socket
The host opens:
wss://relay.example.com/ws?role=server&serverId=<serverId>&v=1
The relay sends plaintext control messages on that socket:
type RelayControlMessage =
| { type: 'sync'; connectionIds: string[] }
| { type: 'connected'; connectionId: string }
| { type: 'disconnected'; connectionId: string }
Control messages contain only relay routing ids. They do not include device tokens, public keys, RPC methods, file paths, or workspace identifiers.
The host treats the relay control socket as untrusted input. It validates every
control message against the same connectionId shape as the relay server,
deduplicates ids, ignores duplicate connected messages for already-open local
data sockets, and applies host-side caps before opening outbound data sockets.
If a sync list exceeds the configured host cap or contains invalid ids, the
host closes the control socket and reconnects with backoff rather than trying to
service the list.
Client data socket
The client opens the endpoint from the pairing offer:
wss://relay.example.com/ws?role=client&serverId=<serverId>&v=1
The relay assigns a random connectionId, accepts the client socket, notifies the
host control socket, and buffers up to a small cap of early client frames until
the matching server data socket attaches.
Host data socket
After a connected or sync control message, the host opens:
wss://relay.example.com/ws?role=server&serverId=<serverId>&connectionId=<connectionId>&v=1
The relay forwards frames in both directions between:
- the single client socket assigned to
connectionId - the one server data socket tagged
server:<connectionId>
For v1, allow exactly one client socket per connectionId. A replacement client
socket closes the older client socket for that connection id only as a duplicate
connection guard. This keeps the relay shape aligned with Orca's existing
one-transport-one-E2EEChannel lifecycle. If the host data socket closes, the
relay closes the client for that connectionId so the client reconnects and
re-handshakes.
Frame handling
The relay treats frames as opaque bytes or text and does not parse application payloads.
Rules:
- max accepted frame size matches or is below Orca's direct WebSocket max
- pending early frames are capped per
connectionId - pending clients, active data sockets, and buffered bytes are capped per
serverIdand globally before the relay notifies the host - pending client connections without a matching host data socket are closed after an attach deadline
- control sockets do not forward data frames
- server data socket replacement closes the older server data socket
- client disconnect of the last client for a
connectionIdcloses the server data socket and removes pending frames - host-role sockets without a matching stored host token binding are rejected before any routing state is changed
Pairing Offer Shape
Use a compatible first version by keeping PairingOffer v2 and setting
endpoint to the relay client WebSocket URL. Existing clients already treat the
endpoint as opaque.
Direct offer:
{
"v": 2,
"endpoint": "ws://192.168.1.20:6768",
"deviceToken": "...",
"publicKeyB64": "..."
}
Relay offer:
{
"v": 2,
"endpoint": "wss://relay.example.com/ws?role=client&serverId=...&v=1",
"deviceToken": "...",
"publicKeyB64": "..."
}
This minimizes client migration. Future work can add multi-endpoint pairing offers so a saved environment can prefer direct when reachable and fall back to relay. That is intentionally deferred to keep the first implementation smaller.
Components
Runtime relay server
New module: src/runtime-relay-server/
Responsibilities:
- start an HTTP server with
/healthand/ws - validate relay query parameters
- route sockets by
serverId,role, andconnectionId - forward frames without parsing application payloads
- enforce connection, frame, and buffer caps
- close paired sockets on disconnect/replacement
- expose clear logs for host/control/data lifecycle without logging secrets
The module name avoids conflict with src/relay/, which remains SSH workspace
relay code.
Initial runtime target: Node. Packaging can be a CLI entry in out/cli or a
separate built script invoked by orca relay serve. The implementation should
not depend on Electron APIs.
Host relay client
New main-process module: src/main/runtime/relay-client.ts
Responsibilities:
- connect the host control socket outbound
- reconnect control with bounded backoff
- heartbeat control and data sockets
- open host data sockets when control reports client connections
- close data sockets when control reports disconnection
- enforce host-side caps for control messages from an untrusted relay:
- reject invalid
connectionIdvalues - reject
synclists longer than the host relay data socket cap - deduplicate
syncids before reconciling - ignore duplicate
connectedevents for already-open ids - refuse to open new data sockets once pending plus active relay sockets reach the host cap
- close local data sockets that do not reach the runtime E2EE ready state by the host data attach deadline
- reject invalid
- pass each host data socket into the runtime's external WebSocket attach path
- expose relay status for settings UI and diagnostics
Runtime external socket attachment
Refactor OrcaRuntimeRpcServer so the local WebSocket listener and relay data
sockets share one attachment method.
The extracted method must own these existing invariants:
- create per-WebSocket connection id
- instantiate
E2EEChannel - validate device token through
DeviceRegistry - set client id after E2EE auth
- update device
lastSeenAt - call
handleWebSocketMessage - forward encrypted binary frames to terminal stream handlers
- abort dispatches and clean subscriptions on close
- call
runtime.onClientDisconnectedonly when the last socket for a device token closes - terminate both direct and relay-backed sockets when a device token is revoked
Local direct WebSocket behavior must not change.
Settings and IPC
Add IPC to:
- read relay config and runtime status
- update relay URL and enabled state
- generate a relay mobile QR
- generate a relay runtime pairing URL
- report whether relay pairing is available
Suggested config fields:
type RuntimeRelayConfig = {
enabled: boolean
endpoint: string
}
endpoint is a canonical WebSocket URL such as ws://127.0.0.1:8787 or
wss://relay.example.com. TLS is derived from the URL scheme so settings do not
carry two conflicting sources of truth. Store this in the same user settings path
used for runtime/mobile settings if one exists. If there is no durable settings
owner that fits, add a narrow runtime-relay config file under user data with
secure write behavior for consistency.
CLI
Add a self-hosted relay command:
orca relay serve --host 0.0.0.0 --port 8787
Optional flags:
--host <host>
--port <port>
--public-url <ws-or-wss-url>
--state-path <path>
--enrollment-token <token>
--json
Do not add TLS certificate management in the first implementation. Production
TLS can be handled by a reverse proxy. The command should print the HTTP and
WebSocket bind URL, and should separately print the public WebSocket URL users
must configure when the relay binds to 0.0.0.0, ::, or another non-client
address. --public-url must point at the relay /ws endpoint, usually a
reverse-proxied wss://relay.example.com/ws URL. Keep stdout JSON-pure when
--json is passed.
--json prints exactly one startup object to stdout and all subsequent logs to
stderr:
{
"ok": true,
"kind": "runtime_relay_server",
"protocolVersion": 1,
"httpUrl": "http://127.0.0.1:8787",
"webSocketUrl": "ws://127.0.0.1:8787/ws",
"advertisedWebSocketUrl": "ws://127.0.0.1:8787/ws",
"publicUrlRequired": false
}
Startup failures print one JSON failure object to stdout and exit non-zero. No
other stdout bytes are allowed in --json mode.
UI
Add relay controls to the existing mobile/runtime pairing settings area instead of creating a separate top-level page.
User-facing states:
| State | Direct pairing | Relay pairing |
|---|---|---|
| WebSocket disabled | unavailable | unavailable with instruction to enable runtime WebSocket |
| Relay not configured | available if LAN address exists | unavailable with endpoint input |
| Relay connecting | available if LAN address exists | disabled, shows connecting |
| Relay connected | available if LAN address exists | available |
| Relay error | available if LAN address exists | disabled, shows concise error |
Use existing design tokens, shadcn primitives, and settings layout patterns. Relay QR/link generation is disabled unless the host control socket is currently connected. This v1 rule favors fewer broken pairing links over offline pre-generation.
Data Flows
Happy path
1. Host runtime starts direct WebSocket and E2EE keypair/device registry.
2. Host relay client connects control socket to relay.
3. User generates a relay QR or runtime pairing URL.
4. Client opens relay client URL from the pairing offer.
5. Relay assigns connectionId and notifies host control socket.
6. Host opens server data socket for connectionId.
7. Client sends plaintext e2ee_hello through relay.
8. Host E2EEChannel replies e2ee_ready with a fresh host challenge through relay.
9. Client sends encrypted e2ee_auth with deviceToken and that host challenge.
10. Host validates the challenge and token, marks device lastSeenAt, and
dispatches RPCs.
Nil path
Relay config is absent or disabled.
- Direct pairing works as it does today.
- Relay QR and relay runtime URL actions are disabled.
- IPC returns
{ available: false, reason: 'relay_not_configured' }.
Empty path
Relay is enabled but no client is connected.
- Control socket stays connected.
- Relay status shows connected with zero active data sockets.
- No runtime subscriptions or E2EE channels are created until a client data socket exists.
Upstream error path
Relay endpoint is unreachable, returns non-WebSocket response, or closes control.
- Host relay client reconnects with bounded backoff.
- Settings status shows the latest concise error.
- Existing direct WebSocket clients remain connected.
- Existing relay clients reconnect according to their current client behavior.
- No device tokens are rotated automatically because endpoint failure is not token compromise.
Security Model
Trust boundaries
Trusted:
- Orca host runtime process
- paired client that has a valid pairing offer
- secure local user data files storing device tokens and E2EE keypair
Untrusted:
- runtime relay server
- network path between relay and host/client
- other clients that can guess or learn
serverIdbut do not have a valid device token
Relay visibility
The relay can see:
- source IPs
- connection timing
- frame sizes
serverId- relay
connectionId - role query parameter
The relay must not see:
- device tokens
- decrypted RPC methods or params
- file paths
- terminal data
- browser screenshots
- source code
- E2EE shared keys
The relay may see plaintext e2ee_hello and e2ee_ready frames because those are
already part of the existing E2EE handshake and contain public-key/control data
only. It cannot complete e2ee_auth without a valid device token encrypted under
the derived shared key.
Authorization
serverId is not authorization. It is routing metadata. Host enrollment and
runtime authorization are separate:
- first host control enrollment requires the relay enrollment token
- every host control/data socket requires the host token bound to
serverId - runtime clients require possession of the pairing offer's device token
- runtime clients complete the E2EE handshake against the host public key
- the host checks
DeviceRegistry.validateToken - the host applies the method allowlist for
mobilescoped devices
Freshness, replay, and downgrade
Relay pairing must upgrade the existing E2EE handshake before it ships. The host
will include a fresh random challenge in e2ee_ready, and the client will echo
that challenge inside encrypted e2ee_auth. The host accepts authentication only
when the encrypted auth frame contains the current challenge for that WebSocket.
Why this is required: with a direct LAN connection, the current protocol relies
on network reachability plus E2EE authentication. With an untrusted relay in the
middle, the relay can observe and replay encrypted frames even though it cannot
decrypt them. A per-socket host challenge makes a previously captured
e2ee_auth unusable on a new relay data socket.
Post-auth encrypted frames already carry a random NaCl box nonce in Orca's shared
encryptBytes format, so modified frames fail authentication. Relay-backed
sockets must also reject exact encrypted-frame replay on the same live socket by
adding a monotonic sequence number inside every encrypted post-auth frame:
- each endpoint keeps independent outbound and inbound sequence counters per WebSocket
- text frames encrypt
{ type: 'e2ee_frame', seq, payload }, wherepayloadis the JSON string the direct transport already encrypts - binary frames encrypt an 8-byte big-endian
seqprefix followed by the binary payload direct transport already encrypts - the receiving side accepts only
seq === previousInboundSeq + 1 - duplicate, old, skipped, non-finite, or malformed sequence numbers close the socket before RPC dispatch or binary-frame delivery
- counters reset only when the WebSocket closes; replaying frames onto a new socket is still rejected by the fresh auth challenge
The relay sees only ciphertext and cannot change sequence numbers without failing
authentication. Relay protocol version v=1 exists so future incompatible
routing changes can add a different framing format if we need one.
Compatibility rule:
- Direct local-network WebSocket sockets may temporarily accept legacy challenge-less auth so existing mobile/runtime clients do not break on host upgrade.
- Relay-backed sockets must reject challenge-less auth.
- New clients always include the challenge when the
e2ee_readyframe includes one. - New host and client transports enforce monotonic inbound encrypted frame sequence numbers on relay-backed sockets.
The runtime socket attachment receives a transport kind (direct or relay) and
passes it into E2EEChannel so the channel can enforce the stricter relay auth
and sequence rules. Runtime clients receive the same transport kind from the
pairing endpoint so they can enforce sequence numbers for host-to-client frames.
Tests must cover both branches: legacy direct auth still works during the
compatibility window, legacy relay auth fails, and replaying, skipping, or
reordering an encrypted frame on a live relay socket closes before duplicate RPC
dispatch.
Logging
Relay and host logs must not include:
- full pairing URLs
hostTokendeviceTokenpublicKeyB64- decrypted RPC payloads
- file paths from encrypted payloads
Log routing ids, connection counts, close codes, and redacted endpoints. The host token is carried in an authorization header rather than the WebSocket URL to avoid routine reverse-proxy query logging; relay docs must still call out proxy header redaction for production deployments.
Operational Design
Deployment
Self-hosted deployment can run:
orca relay serve --host 0.0.0.0 --port 8787
The command prints a bind-only WebSocket URL and an enrollment token. Configure Orca
with the public endpoint plus ?enrollmentToken=<token> for first enrollment.
For internet use, run behind a TLS reverse proxy and configure Orca with the
public wss:// endpoint, or pass it directly as --public-url wss://relay.example.com/ws so startup output names the usable client URL.
Resource limits
Initial defaults:
| Limit | Default |
|---|---|
| Max frame bytes | 1 MiB |
| Max host control sockets per serverId | 1 |
| Max server data sockets per connectionId | 1 |
| Max client sockets per connectionId | 1 |
| Max pending client connections per serverId | 64 |
| Max active data sockets per serverId | 64 |
| Max buffered early frames per connectionId | 4 |
| Max buffered early bytes per connectionId | 4 MiB |
| Max total buffered early bytes per serverId | 16 MiB |
| Client attach deadline | 15 s |
| Control heartbeat interval | 15 s |
| Stale control timeout | 45 s |
| Host max relay data sockets | 32 |
| Host max sync ids | 32 |
| Host data attach deadline | 15 s |
| E2EE relay frame counters | uint53 monotonic per direction per socket |
Why these limits: direct WebSocket already caps message size and the runtime caps connections. The relay should fail closed before it can become an unbounded memory sink. With a 1 MiB frame cap and 4 MiB per-connection byte cap, the effective early buffer is four maximum-sized frames; smaller frames are still bounded by the byte cap. When a per-host or global cap is hit, the relay rejects or closes the newest unauthenticated client with a retryable overload close code before notifying the host.
The relay-side limits are not the only defense. The host relay client also caps
control-plane fanout because the relay is untrusted. A malicious relay can send
arbitrary connected and sync messages, so the host must bound local socket
creation independently of any relay-side admission checks.
The relay frame counters use constant memory. A peer that reaches the maximum safe JavaScript integer sequence value must reconnect and reauthenticate before sending more frames; this is not expected in normal use, and it avoids wraparound ambiguity.
Rollback
Relay is additive. If a problem is found:
- Disable relay in settings or unset the relay endpoint.
- Existing direct pairing continues to work.
- Previously generated relay pairing offers stop working once the host relay client disconnects, but their device tokens remain revocable through the existing device management UI.
No data migration is required for rollback if the first version stores only relay
configuration and a stable serverId.
UX Journey
Pair mobile through relay
- User opens Settings -> Mobile.
- User enters relay endpoint and enables relay.
- UI shows relay status as connecting, then connected.
- User chooses relay QR.
- Phone scans QR and connects from any network.
- Phone appears in paired devices after successful E2EE authentication.
Failure states:
- Invalid relay URL: inline validation error, QR disabled.
- Relay unreachable: status shows last connection error, QR disabled unless we deliberately allow pre-generation. Initial implementation disables QR to avoid giving users a link that cannot work yet.
- Auth rejected: existing mobile auth-failed state remains unchanged.
Pair desktop/web runtime through relay
- User opens runtime pairing controls.
- User copies relay runtime pairing URL.
- Remote desktop/web client imports the URL.
- Client connects through relay and receives
status.get/runtime metadata after E2EE authentication.
Failure states mirror mobile.
Implementation Plan
Phase 1: Relay server
- Add
src/runtime-relay-server/protocol.tswith query parsing and typed control messages. - Add
src/runtime-relay-server/server.tswith HTTP/WebSocket server lifecycle. - Add host-only relay identity storage containing
serverIdandhostToken. - Add CLI handler
orca relay serve. - Add tests for enrollment-token-gated first-use host token binding, wrong-token rejection, malformed persisted state fail-closed behavior, routing, replacement, caps, attach deadline cleanup, and close propagation.
Phase 2: Runtime external socket attach
- Add fresh host challenge support to the E2EE channel and all runtime clients.
- Add relay-backed encrypted frame sequence enforcement to the host
E2EEChanneland to mobile/web/desktop runtime clients. - Extract WebSocket/E2EE attachment from
OrcaRuntimeRpcServer.start(). - Keep
WebSocketTransportresponsible for local accept/heartbeat only. - Add tests proving direct WebSocket pairing behavior is unchanged.
- Add tests proving relay-backed sockets reject challenge-less auth while direct sockets keep the documented compatibility behavior.
- Add tests proving duplicate, skipped, and reordered encrypted frames on a live relay socket close before duplicate RPC dispatch or duplicate binary-frame delivery.
Phase 3: Host relay client
- Add relay config and stable host relay id.
- Add
RuntimeRelayClientfor control/data sockets. - Start/stop relay client with runtime RPC server lifecycle.
- Add status reporting and tests for reconnect, sync, connected, and disconnected.
- Define
syncreconciliation as idempotent: open only missing data sockets, close local data sockets absent fromsync, and never replace a live local data socket only because its id appeared again in a latersync. - Add tests for malicious control messages: oversized
sync, invalidconnectionId, duplicateconnected, data-socket cap exhaustion, and data sockets that never reach E2EE ready.
Phase 4: Pairing and IPC
- Add relay offer generation for mobile and runtime pairing.
- Keep existing direct offer IPC responses stable.
- Add redaction helpers for relay endpoints in logs.
- Add tests for relay pairing URL shape and no token leakage in public responses.
- Add tests proving host tokens are sent only in authorization headers and never in pairing URLs, relay logs, or user-facing copy.
Phase 5: UI
- Add relay configuration and status controls to mobile/runtime pairing settings.
- Use existing settings components and tokens.
- Include direct-vs-relay QR choice only where it does not overload the current screen. If space is tight, show a segmented control between Direct and Relay.
Phase 6: Verification
- Unit tests for protocol parsing, relay forwarding, runtime attach, host relay client, and pairing.
- Integration test with in-process relay and runtime client.
- Hands-on e2e:
- Start
orca relay servelocally. - Start Orca runtime with relay enabled.
- Generate relay pairing offer.
- Connect a mobile-equivalent or runtime client through the relay from a separate process.
- Call
status.get. - Create/list a terminal or subscribe to terminal output through relay.
- Revoke the paired device and verify active relay sockets close.
- Start
Test Plan
Automated:
src/runtime-relay-server/*.test.tssrc/main/runtime/relay-client*.test.tssrc/main/runtime/runtime-rpc*.test.tssrc/main/runtime/rpc/e2ee-channel*.test.tssrc/shared/pairing*.test.ts- mobile/web pairing parser tests if endpoint normalization changes
Manual e2e:
Terminal A: pnpm run build:cli or dev equivalent
Terminal B: orca relay serve --host 127.0.0.1 --port 8787
Terminal C: start Orca runtime with relay endpoint ws://127.0.0.1:8787/ws?enrollmentToken=<token from Terminal B>
Terminal D: connect through relay pairing URL and call status.get
The final verification must prove traffic goes through the relay by using a relay
client endpoint, not by connecting to the direct ws://0.0.0.0:6768 runtime
listener.
Compatibility
- macOS, Linux, and Windows: use Node URL parsing and
path.join; no platform path separators in relay code. - Browser clients: relay endpoints should use
wss://when the web app is served overhttps://to avoid mixed-content blocking. - SSH workspaces: relay clients call the same runtime RPC surface, so SSH-backed worktrees are supported as long as the host runtime is connected to the SSH target.
- Git providers: relay does not inspect review/source-control RPC payloads and therefore remains provider-neutral.
Open Questions
- Should the first shipped UI show both direct and relay QR codes side-by-side or use a mode switch? Recommendation: mode switch to preserve settings density.
- Should
orca relay serveinclude TLS directly? Recommendation: not initially; reverse proxy support is simpler and avoids certificate storage UX. - Should relay status be persisted in runtime metadata for CLI discovery? Recommendation: expose through IPC/UI first; add CLI discovery only if we need automation to choose relay pairing.
Acceptance Criteria
- Direct mobile/runtime pairing still works.
- Relay mobile/runtime pairing works when host and client can only reach the relay.
- Relay server cannot read or log decrypted payloads.
- Clients cannot replace host control/data sockets because host relay sockets require a host-only token that is not in pairing offers.
- Unknown hosts cannot bind arbitrary
serverIdvalues without the relay enrollment token. - Relay host-token bindings survive relay restart and reject wrong-token host sockets before routing state changes.
- A malicious relay control socket cannot make the host exceed the host-side relay data socket cap.
- Device revocation closes active relay-backed sockets.
- Replaying an old encrypted auth frame against a new relay-backed socket fails.
- Replaying a post-auth encrypted frame on the same relay-backed socket fails before duplicate RPC dispatch or duplicate binary-frame delivery.
- Connection floods hit per-host/global relay caps before unbounded host data socket churn or unbounded buffering.
- Mobile method allowlist still applies over relay.
- Terminal streaming works over relay.
- Browser screencast or another binary-frame path works over relay, or the design explicitly blocks release until binary frames are verified.
- All new code is cross-platform and has no provider-specific assumptions.
- No generated artifact, code comment, commit message, or user-facing text names any external reference implementation.