Add self-hosted runtime relay

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-28 13:40:12 -07:00
co-authored by Orca
parent 83b97d52a1
commit 6188d7f8bb
44 changed files with 5998 additions and 204 deletions
+1
View File
@@ -81,6 +81,7 @@ docs/**
!docs/reference/
!docs/reference/**
!docs/STYLEGUIDE.md
!docs/runtime-relay-design.md
# Stably CLI (only docs/ are tracked)
.stably/*
+2 -1
View File
@@ -22,7 +22,8 @@
"../src/main/grok/hook-service.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
"../src/main/win32-utils.ts",
"../src/runtime-relay-server/**/*"
],
"compilerOptions": {
"composite": true,
+1
View File
@@ -5,6 +5,7 @@
"../src/main/**/*",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/runtime-relay-server/**/*",
"../src/relay/**/*",
"../src/types/**/*"
],
+853
View File
@@ -0,0 +1,853 @@
# 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
1. Add a self-hostable relay server that forwards WebSocket frames between Orca
runtime hosts and runtime clients without decrypting them.
2. Let mobile, web, and desktop runtime clients pair through the relay without
being on the same LAN or tailnet as the host.
3. Reuse the existing `E2EEChannel`, `DeviceRegistry`, mobile method allowlist,
streaming dispatch, and binary terminal stream handling.
4. Keep relay deployment independent from the Electron app so users can run it on
a VPS, container host, or internal server.
5. 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.ts` defines a pairing offer containing:
- `endpoint`
- `deviceToken`
- `publicKeyB64`
- `src/main/runtime/runtime-rpc.ts` creates pairing offers from the local
WebSocket transport and owns authorization.
- `src/main/runtime/rpc/ws-transport.ts` accepts local-network WebSocket clients.
- `src/main/runtime/rpc/e2ee-channel.ts` performs the E2EE handshake and binds the
authenticated `deviceToken` to the WebSocket.
- `mobile/src/transport/rpc-client.ts`, `src/renderer/src/web/web-runtime-client.ts`,
and `src/shared/remote-runtime-request-connection.ts` connect 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.
```text
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
```text
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 `serverId` must present
the relay enrollment token and then stores `sha256(hostToken)` in the relay
state file before the control socket is accepted
- later host control and host data sockets for that `serverId` must present a
token whose hash matches the stored binding
- client sockets for an unregistered `serverId` are 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 `serverId` binding, after which old pairing
offers for the previous `serverId` stop 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:
```text
wss://relay.example.com/ws?role=server&serverId=<serverId>&v=1
```
The relay sends plaintext control messages on that socket:
```ts
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:
```text
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:
```text
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
`serverId` and 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 `connectionId` closes 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:
```json
{
"v": 2,
"endpoint": "ws://192.168.1.20:6768",
"deviceToken": "...",
"publicKeyB64": "..."
}
```
Relay offer:
```json
{
"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 `/health` and `/ws`
- validate relay query parameters
- route sockets by `serverId`, `role`, and `connectionId`
- 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 `connectionId` values
- reject `sync` lists longer than the host relay data socket cap
- deduplicate `sync` ids before reconciling
- ignore duplicate `connected` events 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
- 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.onClientDisconnected` only 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:
```ts
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:
```text
orca relay serve --host 0.0.0.0 --port 8787
```
Optional flags:
```text
--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:
```json
{
"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
```text
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 `serverId` but 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:
1. first host control enrollment requires the relay enrollment token
2. every host control/data socket requires the host token bound to `serverId`
3. runtime clients require possession of the pairing offer's device token
4. runtime clients complete the E2EE handshake against the host public key
5. the host checks `DeviceRegistry.validateToken`
6. the host applies the method allowlist for `mobile` scoped 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 }`, where `payload` is
the JSON string the direct transport already encrypts
- binary frames encrypt an 8-byte big-endian `seq` prefix 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_ready` frame 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
- `hostToken`
- `deviceToken`
- `publicKeyB64`
- 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:
```text
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:
1. Disable relay in settings or unset the relay endpoint.
2. Existing direct pairing continues to work.
3. 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
1. User opens Settings -> Mobile.
2. User enters relay endpoint and enables relay.
3. UI shows relay status as connecting, then connected.
4. User chooses relay QR.
5. Phone scans QR and connects from any network.
6. 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
1. User opens runtime pairing controls.
2. User copies relay runtime pairing URL.
3. Remote desktop/web client imports the URL.
4. 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.ts` with query parsing and typed control
messages.
- Add `src/runtime-relay-server/server.ts` with HTTP/WebSocket server lifecycle.
- Add host-only relay identity storage containing `serverId` and `hostToken`.
- 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
`E2EEChannel` and to mobile/web/desktop runtime clients.
- Extract WebSocket/E2EE attachment from `OrcaRuntimeRpcServer.start()`.
- Keep `WebSocketTransport` responsible 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 `RuntimeRelayClient` for control/data sockets.
- Start/stop relay client with runtime RPC server lifecycle.
- Add status reporting and tests for reconnect, sync, connected, and disconnected.
- Define `sync` reconciliation as idempotent: open only missing data sockets,
close local data sockets absent from `sync`, and never replace a live local
data socket only because its id appeared again in a later `sync`.
- Add tests for malicious control messages: oversized `sync`, invalid
`connectionId`, duplicate `connected`, 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:
1. Start `orca relay serve` locally.
2. Start Orca runtime with relay enabled.
3. Generate relay pairing offer.
4. Connect a mobile-equivalent or runtime client through the relay from a
separate process.
5. Call `status.get`.
6. Create/list a terminal or subscribe to terminal output through relay.
7. Revoke the paired device and verify active relay sockets close.
## Test Plan
Automated:
- `src/runtime-relay-server/*.test.ts`
- `src/main/runtime/relay-client*.test.ts`
- `src/main/runtime/runtime-rpc*.test.ts`
- `src/main/runtime/rpc/e2ee-channel*.test.ts`
- `src/shared/pairing*.test.ts`
- mobile/web pairing parser tests if endpoint normalization changes
Manual e2e:
```text
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
over `https://` 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
1. 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.
2. Should `orca relay serve` include TLS directly? Recommendation: not initially;
reverse proxy support is simpler and avoids certificate storage UX.
3. 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 `serverId` values 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.
+34
View File
@@ -120,6 +120,11 @@ function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
})
}
async function flushInboundQueue(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('mobile rpc-client connection timeout', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -169,6 +174,35 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('ignores queued handshake messages from sockets replaced during reconnect', async () => {
const client = connect(
'ws://desktop.invalid?role=client&serverId=relay-1',
'token',
'server-key'
)
const firstSocket = mockSockets[0]!
firstSocket.open()
firstSocket.receive(JSON.stringify({ type: 'e2ee_ready', challenge: 'first-challenge' }))
firstSocket.receive('encrypted:{"type":"e2ee_authenticated"}')
await flushInboundQueue()
expect(client.getState()).toBe('connected')
firstSocket.close()
vi.advanceTimersByTime(500)
const secondSocket = mockSockets[1]!
secondSocket.open()
firstSocket.receive(JSON.stringify({ type: 'e2ee_ready', challenge: 'stale-challenge' }))
await flushInboundQueue()
expect(secondSocket.sent).not.toContain(
'encrypted:{"type":"e2ee_auth","deviceToken":"token","challenge":"stale-challenge"}'
)
client.close()
})
it('sends session tabs unsubscribe when a session tab stream is disposed', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
+114 -5
View File
@@ -106,6 +106,8 @@ const HANDSHAKE_TIMEOUT_MS = 5_000
// Why: RN's WebSocket implementation may not expose static readyState
// constants, but the protocol value for CONNECTING is stable across runtimes.
const WEBSOCKET_CONNECTING_STATE = 0
const RELAY_BINARY_SEQUENCE_BYTES = 8
const RELAY_MAX_SEQUENCE = Number.MAX_SAFE_INTEGER
// Why: app-level liveness probe. The server runs its own ping/pong sweep
// at 15s, but RN's WebSocket runtime auto-pongs at the native layer
@@ -120,6 +122,17 @@ const WEBSOCKET_CONNECTING_STATE = 0
// window and well below iOS's typical background-disconnect window.
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
function getRuntimeWebSocketTransportKind(endpoint: string): 'direct' | 'relay' {
try {
const url = new URL(endpoint)
return url.searchParams.get('role') === 'client' && url.searchParams.has('serverId')
? 'relay'
: 'direct'
} catch {
return 'direct'
}
}
export type ConnectOptions = {
onStateChange?: (state: ConnectionState) => void
// Fires for every observable lifecycle event so the UI can render a
@@ -141,6 +154,7 @@ export function connect(
: (optionsOrLegacy ?? {})
const onStateChange = options.onStateChange
const onLog = options.onLog
const transportKind = getRuntimeWebSocketTransportKind(endpoint)
let logCounter = 0
function emitLog(level: ConnectionLogLevel, message: string, detail?: string) {
if (!onLog) return
@@ -179,6 +193,9 @@ export function connect(
// The shared key is derived from our ephemeral secret + server's static public key.
let sharedKey: Uint8Array | null = null
const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64)
let inboundRelaySeq = 0
let outboundRelaySeq = 0
let inboundMessageQueue = Promise.resolve()
const pending = new Map<string, PendingRequest>()
const streamListeners = new Map<string, StreamRequest>()
@@ -268,6 +285,8 @@ export function connect(
})
setState('connecting')
sharedKey = null
inboundRelaySeq = 0
outboundRelaySeq = 0
currentWsOpenedAt = now
emitLog(
@@ -336,7 +355,17 @@ export function connect(
}
ws.onmessage = (event) => {
void handleSocketMessage(event.data)
// Why: relay sequencing is shared across text and binary frames. RN may
// convert binary payloads asynchronously, so process wire events in order.
const messageWs = openingWs
inboundMessageQueue = inboundMessageQueue
.then(() => {
if (messageWs !== ws) {
return
}
return handleSocketMessage(event.data)
})
.catch(() => {})
}
async function handleSocketMessage(rawData: unknown) {
@@ -355,7 +384,11 @@ export function connect(
const msg = JSON.parse(raw)
if (msg.type === 'e2ee_ready') {
emitLog('success', 'Received e2ee_ready', 'Sending device token')
sendEncrypted({ type: 'e2ee_auth', deviceToken })
sendEncrypted({
type: 'e2ee_auth',
deviceToken,
challenge: typeof msg.challenge === 'string' ? msg.challenge : undefined
})
return
}
} catch {
@@ -438,11 +471,19 @@ export function connect(
if (!plaintextBytes) {
return
}
handleBinaryFrame(plaintextBytes)
const binaryPayload = unwrapRelayBinaryFrame(plaintextBytes)
if (!binaryPayload) {
return
}
handleBinaryFrame(binaryPayload)
return
}
const plaintext = decrypt(raw, sharedKey)
const decrypted = decrypt(raw, sharedKey)
if (decrypted === null) {
return
}
const plaintext = unwrapRelayTextFrame(decrypted)
if (plaintext === null) {
return
}
@@ -927,7 +968,7 @@ export function connect(
function sendEncrypted(request: unknown): boolean {
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
ws.send(encrypt(JSON.stringify(request), sharedKey))
ws.send(encrypt(wrapRelayTextFrame(JSON.stringify(request)), sharedKey))
return true
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
@@ -950,6 +991,74 @@ export function connect(
return false
}
function nextOutboundRelaySeq(): number | null {
if (outboundRelaySeq >= RELAY_MAX_SEQUENCE) {
ws?.close()
return null
}
outboundRelaySeq += 1
return outboundRelaySeq
}
function acceptInboundRelaySeq(seq: number): boolean {
if (!Number.isSafeInteger(seq) || seq !== inboundRelaySeq + 1) {
ws?.close()
return false
}
inboundRelaySeq = seq
return true
}
function wrapRelayTextFrame(payload: string): string {
if (transportKind !== 'relay' || state !== 'connected') {
return payload
}
const seq = nextOutboundRelaySeq()
if (seq === null) {
return payload
}
return JSON.stringify({ type: 'e2ee_frame', seq, payload })
}
function unwrapRelayTextFrame(plaintext: string): string | null {
if (transportKind !== 'relay') {
return plaintext
}
let frame: { type?: unknown; seq?: unknown; payload?: unknown }
try {
frame = JSON.parse(plaintext) as { type?: unknown; seq?: unknown; payload?: unknown }
} catch {
ws?.close()
return null
}
if (
frame.type !== 'e2ee_frame' ||
typeof frame.seq !== 'number' ||
typeof frame.payload !== 'string' ||
!acceptInboundRelaySeq(frame.seq)
) {
ws?.close()
return null
}
return frame.payload
}
function unwrapRelayBinaryFrame(plaintext: Uint8Array): Uint8Array | null {
if (transportKind !== 'relay') {
return plaintext
}
if (plaintext.length < RELAY_BINARY_SEQUENCE_BYTES) {
ws?.close()
return null
}
const view = new DataView(plaintext.buffer, plaintext.byteOffset, RELAY_BINARY_SEQUENCE_BYTES)
const seq = Number(view.getBigUint64(0, false))
if (!acceptInboundRelaySeq(seq)) {
return null
}
return plaintext.slice(RELAY_BINARY_SEQUENCE_BYTES)
}
function sendBrowserScreencastUnsubscribe(subscriptionId: string): void {
sendEncrypted({
id: nextId(),
+176
View File
@@ -1,6 +1,9 @@
import { homedir } from 'os'
import { join } from 'path'
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
import { startRuntimeRelayServer } from '../../runtime-relay-server/server'
export const CORE_HANDLERS: Record<string, CommandHandler> = {
open: async ({ client, json }) => {
@@ -33,6 +36,83 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
})
process.exitCode = exitCode
},
'relay serve': async ({ flags, json }) => {
const rawHost = flags.get('host')
if (flags.has('host') && (!isNonBlankString(rawHost) || rawHost.trim() !== rawHost)) {
throw new RuntimeClientError('invalid_argument', 'Missing required --host value.')
}
const host = typeof rawHost === 'string' ? rawHost : '127.0.0.1'
const rawPort = flags.get('port')
if (flags.has('port') && !isNonBlankString(rawPort)) {
throw new RuntimeClientError('invalid_argument', 'Missing required --port value.')
}
const port = typeof rawPort === 'string' ? Number(rawPort) : 8787
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${String(rawPort)}`)
}
const rawStatePath = flags.get('state-path')
if (flags.has('state-path') && !isNonBlankString(rawStatePath)) {
throw new RuntimeClientError('invalid_argument', 'Missing required --state-path value.')
}
const rawEnrollmentToken = flags.get('enrollment-token')
if (flags.has('enrollment-token') && !isNonBlankString(rawEnrollmentToken)) {
throw new RuntimeClientError('invalid_argument', 'Missing required --enrollment-token value.')
}
const rawPublicUrl = flags.get('public-url')
if (
flags.has('public-url') &&
(!isNonBlankString(rawPublicUrl) || rawPublicUrl.trim() !== rawPublicUrl)
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --public-url value.')
}
const advertisedWebSocketUrl =
typeof rawPublicUrl === 'string' ? normalizeRelayPublicWebSocketUrl(rawPublicUrl) : null
try {
const relay = await startRuntimeRelayServer({
host,
port,
statePath:
typeof rawStatePath === 'string' ? rawStatePath : defaultRuntimeRelayServerStatePath(),
enrollmentToken: typeof rawEnrollmentToken === 'string' ? rawEnrollmentToken : undefined,
log: (message) => {
const output = `${message}\n`
if (json) {
process.stderr.write(output)
} else {
process.stdout.write(output)
}
}
})
const startup = {
ok: true,
kind: 'runtime_relay_server',
protocolVersion: relay.protocolVersion,
httpUrl: relay.httpUrl,
webSocketUrl: relay.webSocketUrl,
advertisedWebSocketUrl:
advertisedWebSocketUrl ?? (isWildcardBindHost(host) ? null : relay.webSocketUrl),
publicUrlRequired: advertisedWebSocketUrl === null && isWildcardBindHost(host),
enrollmentToken: relay.enrollmentToken
}
process.stdout.write(json ? `${JSON.stringify(startup)}\n` : formatRelayStartup(startup))
await waitForRelayShutdown(relay.stop)
} catch (error) {
if (json) {
const code = error instanceof RuntimeClientError ? error.code : 'runtime_error'
const message = error instanceof Error ? error.message : String(error)
process.stdout.write(
`${JSON.stringify({
ok: false,
kind: 'runtime_relay_server',
error: { code, message }
})}\n`
)
process.exitCode = 1
return
}
throw error
}
},
status: async ({ client, json }) => {
const result = await client.getCliStatus()
if (!json && !result.result.runtime.reachable) {
@@ -41,3 +121,99 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatStatus)
}
}
function isNonBlankString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function normalizeRelayPublicWebSocketUrl(rawUrl: string): string {
let url: URL
try {
url = new URL(rawUrl)
} catch {
throw new RuntimeClientError('invalid_argument', `Invalid --public-url value: ${rawUrl}`)
}
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
throw new RuntimeClientError('invalid_argument', 'Use a ws:// or wss:// URL for --public-url.')
}
if (url.username || url.password || url.search || url.hash) {
throw new RuntimeClientError(
'invalid_argument',
'Pass --public-url without credentials, query parameters, or fragments.'
)
}
if (url.pathname === '/' || url.pathname === '') {
url.pathname = '/ws'
}
if (url.pathname !== '/ws') {
throw new RuntimeClientError('invalid_argument', 'Use a --public-url ending in /ws.')
}
return url.toString()
}
function isWildcardBindHost(host: string): boolean {
return ['0.0.0.0', '::', '[::]'].includes(host)
}
function defaultRuntimeRelayServerStatePath(): string {
const envPath = process.env.ORCA_USER_DATA_PATH
const userDataPath =
envPath && envPath.trim()
? envPath
: process.platform === 'darwin'
? join(homedir(), 'Library', 'Application Support', 'orca')
: process.platform === 'win32'
? join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'orca')
: join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'orca')
return join(userDataPath, 'runtime-relay-server-state.json')
}
function formatRelayStartup(startup: {
httpUrl: string
webSocketUrl: string
advertisedWebSocketUrl: string | null
publicUrlRequired: boolean
enrollmentToken: string
protocolVersion: number
}): string {
const lines = [
`Orca runtime relay listening on ${startup.httpUrl}`,
startup.advertisedWebSocketUrl
? `Configure Orca/mobile with: ${startup.advertisedWebSocketUrl}`
: 'Configure Orca/mobile with your public wss://.../ws endpoint; 0.0.0.0 and :: are bind addresses only.',
startup.advertisedWebSocketUrl === startup.webSocketUrl
? null
: `Bind-only WebSocket endpoint: ${startup.webSocketUrl}`,
`Enrollment token: ${startup.enrollmentToken}`,
`Protocol version: ${startup.protocolVersion}`,
'Stop it with Ctrl+C.',
''
]
if (startup.publicUrlRequired) {
lines.splice(
3,
0,
'For self-hosting, pass --public-url wss://relay.example.com/ws or configure that public URL in Orca settings.'
)
}
return lines.filter((line): line is string => line !== null).join('\n')
}
function waitForRelayShutdown(stop: () => Promise<void>): Promise<void> {
return new Promise((resolve) => {
let stopping = false
const shutdown = () => {
if (stopping) {
return
}
stopping = true
void stop().finally(() => {
process.off('SIGINT', shutdown)
process.off('SIGTERM', shutdown)
resolve()
})
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
})
}
+184
View File
@@ -5,12 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
callMock,
serveOrcaAppMock,
startRuntimeRelayServerMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
startRuntimeRelayServerMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
@@ -79,6 +81,10 @@ vi.mock('./runtime/environments', () => ({
resolveEnvironment: vi.fn()
}))
vi.mock('../runtime-relay-server/server', () => ({
startRuntimeRelayServer: startRuntimeRelayServerMock
}))
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@@ -110,6 +116,7 @@ describe('orca cli worktree awareness', () => {
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_USER_DATA_PATH
serveOrcaAppMock.mockReset()
startRuntimeRelayServerMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
@@ -179,6 +186,31 @@ describe('orca cli worktree awareness', () => {
)
})
it('lists the relay group from root help', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['--help'], '/tmp/repo')
const output = logSpy.mock.calls.flat().join('\n')
expect(output).toContain('Relay:')
expect(output).toContain('relay serve')
expect(output).toContain('Start a self-hosted Orca runtime relay server')
expect(callMock).not.toHaveBeenCalled()
})
it('prints relay group help', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['relay', '--help'], '/tmp/repo')
const output = logSpy.mock.calls.flat().join('\n')
expect(output).toContain('orca relay')
expect(output).toContain('Usage: orca relay <command> [options]')
expect(output).toContain('serve')
expect(output).toContain('Run `orca relay <command> --help`')
expect(callMock).not.toHaveBeenCalled()
})
it('shows the enclosing worktree for `worktree current`', async () => {
queueFixtures(
callMock,
@@ -710,6 +742,142 @@ describe('orca cli worktree awareness', () => {
})
})
it('does not present wildcard relay bind hosts as client endpoints', async () => {
const stopMock = vi.fn().mockResolvedValue(undefined)
startRuntimeRelayServerMock.mockResolvedValue({
httpUrl: 'http://0.0.0.0:8787',
webSocketUrl: 'ws://0.0.0.0:8787/ws',
enrollmentToken: 'enroll-token',
protocolVersion: 1,
stop: stopMock
})
let resolveStartupWritten: () => void = () => {}
const startupWritten = new Promise<void>((resolve) => {
resolveStartupWritten = resolve
})
const stdoutSpy = vi.spyOn(process.stdout, 'write')
stdoutSpy.mockImplementation(((chunk: unknown) => {
if (String(chunk).includes('Orca runtime relay listening')) {
resolveStartupWritten()
}
return true
}) as never)
const run = main(['relay', 'serve', '--host', '0.0.0.0'], '/tmp/repo')
await startupWritten
await new Promise((resolve) => setTimeout(resolve, 0))
process.emit('SIGINT')
await run
const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join('')
expect(output).toContain('Configure Orca/mobile with your public wss://.../ws endpoint')
expect(output).toContain('0.0.0.0 and :: are bind addresses only')
expect(output).toContain('Bind-only WebSocket endpoint: ws://0.0.0.0:8787/ws')
expect(output).not.toMatch(/^WebSocket endpoint: ws:\/\/0\.0\.0\.0:8787\/ws$/m)
expect(stopMock).toHaveBeenCalled()
})
it('emits the configured public relay URL in json startup output', async () => {
const stopMock = vi.fn().mockResolvedValue(undefined)
startRuntimeRelayServerMock.mockResolvedValue({
httpUrl: 'http://0.0.0.0:8787',
webSocketUrl: 'ws://0.0.0.0:8787/ws',
enrollmentToken: 'enroll-token',
protocolVersion: 1,
stop: stopMock
})
let resolveStartupWritten: () => void = () => {}
const startupWritten = new Promise<void>((resolve) => {
resolveStartupWritten = resolve
})
const stdoutSpy = vi.spyOn(process.stdout, 'write')
stdoutSpy.mockImplementation(((chunk: unknown) => {
if (String(chunk).includes('"kind":"runtime_relay_server"')) {
resolveStartupWritten()
}
return true
}) as never)
const run = main(
[
'relay',
'serve',
'--host',
'0.0.0.0',
'--public-url',
'wss://relay.example.com/ws',
'--json'
],
'/tmp/repo'
)
await startupWritten
await new Promise((resolve) => setTimeout(resolve, 0))
process.emit('SIGINT')
await run
const startup = JSON.parse(String(stdoutSpy.mock.calls[0]?.[0]))
expect(startup).toMatchObject({
ok: true,
kind: 'runtime_relay_server',
httpUrl: 'http://0.0.0.0:8787',
webSocketUrl: 'ws://0.0.0.0:8787/ws',
advertisedWebSocketUrl: 'wss://relay.example.com/ws',
publicUrlRequired: false,
enrollmentToken: 'enroll-token'
})
expect(stopMock).toHaveBeenCalled()
})
it('rejects invalid public relay URLs before starting the relay', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
const invalidUrls = [
'http://relay.example.com/ws',
'wss://user:pass@relay.example.com/ws',
'wss://relay.example.com/ws?enrollmentToken=secret',
'wss://relay.example.com/ws#secret',
'wss://relay.example.com/not-ws'
]
for (const invalidUrl of invalidUrls) {
process.exitCode = undefined
await main(['relay', 'serve', '--public-url', invalidUrl, '--json'], '/tmp/repo')
}
expect(startRuntimeRelayServerMock).not.toHaveBeenCalled()
const output = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
expect(output).toContain('Use a ws:// or wss:// URL for --public-url.')
expect(output).toContain(
'Pass --public-url without credentials, query parameters, or fragments.'
)
expect(output).toContain('Use a --public-url ending in /ws.')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('emits structured json when relay startup fails', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const priorExitCode = process.exitCode
startRuntimeRelayServerMock.mockRejectedValue(new Error('listen EADDRINUSE'))
await main(['relay', 'serve', '--json'], '/tmp/repo')
const output = JSON.parse(String(stdoutSpy.mock.calls[0]?.[0]))
expect(output).toMatchObject({
ok: false,
kind: 'runtime_relay_server',
error: {
code: 'runtime_error',
message: 'listen EADDRINUSE'
}
})
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects contradictory serve pairing flags', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -742,6 +910,22 @@ describe('orca cli worktree awareness', () => {
process.exitCode = priorExitCode
})
it('rejects blank relay serve flag values before starting the relay', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(['relay', 'serve', '--port', '', '--json'], '/tmp/repo')
expect(startRuntimeRelayServerMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Missing required --port value.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
process.env.ORCA_ENVIRONMENT = 'stale-env'
listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
+42 -5
View File
@@ -18,7 +18,10 @@ export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selec
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return (
commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent'
commandPath[0] === 'environment' ||
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
(commandPath[0] === 'relay' && commandPath[1] === 'serve')
)
}
@@ -26,18 +29,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(COMMAND_SPECS, helpPath)
printCliHelp(helpPath)
if (
helpPath.length > 0 &&
!findCommandSpec(COMMAND_SPECS, helpPath) &&
!isCommandGroup(helpPath)
!isCommandGroup(helpPath) &&
!isSpecBackedCommandGroup(helpPath)
) {
process.exitCode = 1
}
return
}
if (parsed.commandPath.length === 0) {
printHelp(COMMAND_SPECS, [])
printCliHelp([])
return
}
const json = parsed.flags.has('json')
@@ -53,7 +57,7 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
// Why: pass `null` (not `undefined`) when remote selection is suppressed
// so the RuntimeClient default parameter does not re-activate the
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
// that must run locally (environment / serve).
// that must run locally.
const client = new RuntimeClient(
undefined,
undefined,
@@ -79,3 +83,36 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
if (require.main === module) {
void main()
}
function printCliHelp(commandPath: string[]): void {
if (isSpecBackedCommandGroup(commandPath)) {
console.log(formatSpecBackedGroupHelp(commandPath[0]))
return
}
printHelp(COMMAND_SPECS, commandPath)
if (commandPath.length === 0) {
console.log(
[
'',
'Relay:',
' relay serve Start a self-hosted Orca runtime relay server'
].join('\n')
)
}
}
function isSpecBackedCommandGroup(commandPath: string[]): boolean {
return (
commandPath.length === 1 &&
COMMAND_SPECS.some((spec) => spec.path.length > 1 && spec.path[0] === commandPath[0])
)
}
function formatSpecBackedGroupHelp(group: string): string {
const lines = [`orca ${group}`, '', `Usage: orca ${group} <command> [options]`, '', 'Commands:']
for (const spec of COMMAND_SPECS.filter((entry) => entry.path[0] === group)) {
lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`)
}
lines.push('', `Run \`orca ${group} <command> --help\` for command-specific usage.`)
return lines.join('\n')
}
+19
View File
@@ -28,6 +28,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'orca serve --pairing-address 100.64.1.20 --mobile-pairing'
]
},
{
path: ['relay', 'serve'],
summary: 'Start a self-hosted Orca runtime relay server',
usage:
'orca relay serve [--host <host>] [--port <port>] [--public-url <url>] [--state-path <path>] [--enrollment-token <token>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'host', 'port', 'public-url', 'state-path', 'enrollment-token'],
notes: [
'Runs in the foreground and forwards encrypted runtime WebSocket frames between hosts and clients.',
'Use --public-url wss://relay.example.com/ws when binding to 0.0.0.0, ::, or a private interface.',
'Host token bindings persist under the Orca user-data directory by default; pass --state-path for an explicit server state file.',
'First-time host enrollment requires the startup enrollment token. Add it to the saved Orca relay URL as ?enrollmentToken=<token>.',
'Use a TLS reverse proxy and configure Orca with the public wss:// endpoint for internet use.',
'When --json is passed, stdout contains exactly one startup object; later logs go to stderr.'
],
examples: [
'orca relay serve --host 0.0.0.0 --port 8787 --public-url wss://relay.example.com/ws',
'orca relay serve --json'
]
},
{
path: ['status'],
summary: 'Show app/runtime/graph readiness',
+87
View File
@@ -44,6 +44,20 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function parseRelayPairingScope(value: unknown): 'mobile' | 'runtime' {
if (value === undefined) {
return 'mobile'
}
if (value === 'mobile' || value === 'runtime') {
return value
}
throw new Error('Invalid relay pairing scope')
}
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
// device management, and WebSocket readiness status. They depend on the
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
@@ -127,6 +141,79 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
}
)
ipcMain.handle(
'mobile:getRelayPairingQR',
async (_event, args?: { rotate?: boolean; scope?: 'mobile' | 'runtime' }) => {
const relayScope = parseRelayPairingScope(isRecord(args) ? args.scope : undefined)
const offer = rpcServer.createRelayPairingOffer({
rotate: isRecord(args) ? args.rotate === true : false,
name: `${relayScope === 'runtime' ? 'Runtime' : 'Mobile'} ${new Date().toLocaleDateString()}`,
scope: relayScope
})
if (!offer.available) {
return { available: false as const, reason: offer.reason }
}
const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, {
errorCorrectionLevel: 'M',
margin: 2,
width: 256
})
return {
available: true as const,
qrDataUrl,
pairingUrl: offer.pairingUrl,
endpoint: offer.endpoint,
deviceId: offer.deviceId,
webClientUrl: offer.webClientUrl
}
}
)
ipcMain.handle(
'mobile:getRelayRuntimePairingUrl',
async (_event, args?: { rotate?: boolean }) => {
const offer = rpcServer.createRelayPairingOffer({
rotate: isRecord(args) ? args.rotate === true : false,
name: `Runtime ${new Date().toLocaleDateString()}`,
scope: 'runtime'
})
if (!offer.available) {
return { available: false as const, reason: offer.reason }
}
return {
available: true as const,
pairingUrl: offer.pairingUrl,
webClientUrl: offer.webClientUrl,
endpoint: offer.endpoint,
deviceId: offer.deviceId
}
}
)
ipcMain.handle('mobile:getRelayConfig', () => ({
config: rpcServer.getRelayConfig(),
status: rpcServer.getRelayStatus()
}))
ipcMain.handle(
'mobile:updateRelayConfig',
(_event, args: { enabled?: boolean; endpoint?: string }) => {
if (!isRecord(args)) {
throw new Error('Invalid relay config update')
}
const current = rpcServer.getRelayConfig()
return {
config: rpcServer.updateRelayConfig({
enabled: typeof args.enabled === 'boolean' ? args.enabled : current.enabled,
endpoint: typeof args.endpoint === 'string' ? args.endpoint : current.endpoint
}),
status: rpcServer.getRelayStatus()
}
}
)
ipcMain.handle('mobile:getRelayStatus', () => ({ status: rpcServer.getRelayStatus() }))
ipcMain.handle('mobile:listDevices', () => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../../shared/runtime-relay-limits'
import { RuntimeRelayClient } from './relay-client'
const { FakeWebSocket, createdSockets } = vi.hoisted(() => {
class HoistedFakeWebSocket {
static OPEN = 1
readonly OPEN = 1
readyState = 1
readonly endpoint: URL
readonly options: unknown
readonly closeCalls: { code?: number; reason?: string }[] = []
private listeners = new Map<string, ((...args: unknown[]) => void)[]>()
constructor(endpoint: URL, options: unknown) {
this.endpoint = endpoint
this.options = options
sockets.push(this)
}
on(event: string, listener: (...args: unknown[]) => void): this {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener])
return this
}
emit(event: string, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(...args)
}
}
close(code?: number, reason?: string): void {
this.closeCalls.push({ code, reason })
}
}
const sockets: HoistedFakeWebSocket[] = []
return { FakeWebSocket: HoistedFakeWebSocket, createdSockets: sockets }
})
vi.mock('ws', () => ({
default: FakeWebSocket
}))
describe('RuntimeRelayClient', () => {
it('ignores stale control socket events after config rotation', () => {
createdSockets.length = 0
const attachDataSocket = vi.fn()
const client = new RuntimeRelayClient({
config: { enabled: true, endpoint: 'ws://relay-a.test/ws' },
identity: { serverId: 'serverIdForRelayClientTest01', hostToken: 'host-token' },
attachDataSocket
})
client.start()
const oldControl = createdSockets[0]!
oldControl.emit('open')
client.updateConfig({ enabled: true, endpoint: 'ws://relay-b.test/ws' })
expect(createdSockets).toHaveLength(2)
oldControl.emit(
'message',
JSON.stringify({ type: 'connected', connectionId: 'abc123abc123abcd' })
)
expect(createdSockets).toHaveLength(2)
expect(attachDataSocket).not.toHaveBeenCalled()
})
it('closes the control socket on invalid JSON values from the relay', () => {
createdSockets.length = 0
const attachDataSocket = vi.fn()
const client = new RuntimeRelayClient({
config: { enabled: true, endpoint: 'ws://relay-a.test/ws' },
identity: { serverId: 'serverIdForRelayClientTest02', hostToken: 'host-token' },
attachDataSocket
})
client.start()
const control = createdSockets[0]!
control.emit('open')
expect(() => control.emit('message', 'null')).not.toThrow()
expect(control.closeCalls).toContainEqual({
code: 1008,
reason: 'Invalid relay control message'
})
})
it('sends enrollment tokens as a host-only header', () => {
createdSockets.length = 0
const client = new RuntimeRelayClient({
config: {
enabled: true,
endpoint: 'ws://relay-a.test/ws?enrollmentToken=enrollment-secret'
},
identity: { serverId: 'serverIdForRelayClientTest03', hostToken: 'host-token' },
attachDataSocket: vi.fn()
})
client.start()
const control = createdSockets[0]!
expect(control.endpoint.searchParams.has('enrollmentToken')).toBe(false)
expect((control.options as { headers: Record<string, string> }).headers).toMatchObject({
Authorization: 'Bearer host-token',
'X-Orca-Relay-Enrollment': 'enrollment-secret'
})
})
it('caps received relay frames on control and data sockets', () => {
createdSockets.length = 0
const client = new RuntimeRelayClient({
config: { enabled: true, endpoint: 'ws://relay-a.test/ws' },
identity: { serverId: 'serverIdForRelayClientTest04', hostToken: 'host-token' },
attachDataSocket: vi.fn()
})
client.start()
const control = createdSockets[0]!
control.emit('open')
control.emit('message', JSON.stringify({ type: 'connected', connectionId: 'abc123abc123abcd' }))
expect(createdSockets).toHaveLength(2)
expect((control.options as { maxPayload: number }).maxPayload).toBe(
RUNTIME_RELAY_MAX_FRAME_BYTES
)
expect((createdSockets[1]!.options as { maxPayload: number }).maxPayload).toBe(
RUNTIME_RELAY_MAX_FRAME_BYTES
)
})
})
+358
View File
@@ -0,0 +1,358 @@
/* eslint-disable max-lines -- Why: the host relay client is one socket lifecycle state machine; splitting control/data cleanup would obscure stale-socket guards. */
import WebSocket from 'ws'
import { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../../shared/runtime-relay-limits'
import type { RuntimeRelayIdentity } from './relay-identity'
export type RuntimeRelayConfig = {
enabled: boolean
endpoint: string
}
export type RuntimeRelayStatus =
| { state: 'disabled'; activeDataSockets: 0; error: null }
| { state: 'connecting'; activeDataSockets: number; error: string | null }
| { state: 'connected'; activeDataSockets: number; error: null }
| { state: 'error'; activeDataSockets: number; error: string }
type RuntimeRelayClientOptions = {
config: RuntimeRelayConfig
identity: RuntimeRelayIdentity
attachDataSocket: (ws: WebSocket, onAuthenticated: () => void) => void
onStatusChange?: (status: RuntimeRelayStatus) => void
reconnectBaseMs?: number
maxDataSockets?: number
maxSyncIds?: number
dataAttachDeadlineMs?: number
}
type RelayControlMessage =
| { type: 'sync'; connectionIds: string[] }
| { type: 'connected'; connectionId: string }
| { type: 'disconnected'; connectionId: string }
const DEFAULT_RECONNECT_BASE_MS = 1_000
const DEFAULT_MAX_DATA_SOCKETS = 32
const DEFAULT_DATA_ATTACH_DEADLINE_MS = 15_000
const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{16,128}$/
export class RuntimeRelayClient {
private config: RuntimeRelayConfig
private readonly identity: RuntimeRelayIdentity
private readonly attachDataSocket: (ws: WebSocket, onAuthenticated: () => void) => void
private readonly onStatusChange: ((status: RuntimeRelayStatus) => void) | undefined
private readonly reconnectBaseMs: number
private readonly maxDataSockets: number
private readonly maxSyncIds: number
private readonly dataAttachDeadlineMs: number
private control: WebSocket | null = null
private stopped = true
private reconnectAttempt = 0
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private dataSockets = new Map<string, WebSocket>()
private dataAttachTimers = new Map<string, ReturnType<typeof setTimeout>>()
private status: RuntimeRelayStatus = { state: 'disabled', activeDataSockets: 0, error: null }
constructor(options: RuntimeRelayClientOptions) {
this.config = options.config
this.identity = options.identity
this.attachDataSocket = options.attachDataSocket
this.onStatusChange = options.onStatusChange
this.reconnectBaseMs = options.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS
this.maxDataSockets = options.maxDataSockets ?? DEFAULT_MAX_DATA_SOCKETS
this.maxSyncIds = options.maxSyncIds ?? this.maxDataSockets
this.dataAttachDeadlineMs = options.dataAttachDeadlineMs ?? DEFAULT_DATA_ATTACH_DEADLINE_MS
}
start(): void {
this.stopped = false
if (!this.config.enabled) {
this.setStatus({ state: 'disabled', activeDataSockets: 0, error: null })
return
}
this.connectControl()
}
stop(): void {
this.stopped = true
this.clearReconnectTimer()
this.control?.close(1000, 'Relay client stopped')
this.control = null
for (const ws of this.dataSockets.values()) {
ws.close(1000, 'Relay client stopped')
}
this.dataSockets.clear()
for (const timer of this.dataAttachTimers.values()) {
clearTimeout(timer)
}
this.dataAttachTimers.clear()
this.setStatus({ state: 'disabled', activeDataSockets: 0, error: null })
}
updateConfig(config: RuntimeRelayConfig): void {
this.config = config
this.stop()
this.start()
}
getStatus(): RuntimeRelayStatus {
return this.status
}
isConnected(): boolean {
return this.status.state === 'connected'
}
createClientEndpoint(): string | null {
if (!this.config.enabled || !this.isConnected()) {
return null
}
const endpoint = normalizeRelayEndpoint(this.config.endpoint)
if (!endpoint) {
return null
}
endpoint.searchParams.set('role', 'client')
endpoint.searchParams.set('serverId', this.identity.serverId)
endpoint.searchParams.set('v', '1')
return endpoint.toString()
}
private connectControl(): void {
if (this.stopped || !this.config.enabled) {
return
}
const endpoint = normalizeRelayEndpoint(this.config.endpoint)
if (!endpoint) {
this.setStatus({
state: 'error',
activeDataSockets: this.dataSockets.size,
error: 'Invalid relay endpoint'
})
return
}
endpoint.searchParams.set('role', 'server')
endpoint.searchParams.set('serverId', this.identity.serverId)
endpoint.searchParams.set('v', '1')
const enrollmentToken = extractRelayEnrollmentToken(this.config.endpoint)
this.setStatus({ state: 'connecting', activeDataSockets: this.dataSockets.size, error: null })
const ws = new WebSocket(endpoint, {
maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES,
headers: {
Authorization: `Bearer ${this.identity.hostToken}`,
...(enrollmentToken ? { 'X-Orca-Relay-Enrollment': enrollmentToken } : {})
}
})
this.control = ws
ws.on('open', () => {
if (!this.isCurrentControl(ws)) {
return
}
this.reconnectAttempt = 0
this.setStatus({ state: 'connected', activeDataSockets: this.dataSockets.size, error: null })
})
ws.on('message', (data) => {
if (!this.isCurrentControl(ws)) {
return
}
this.handleControlMessage(data.toString())
})
ws.on('close', () => this.handleControlClosed(ws))
ws.on('error', (error) => {
if (!this.isCurrentControl(ws)) {
return
}
this.setStatus({
state: 'error',
activeDataSockets: this.dataSockets.size,
error: error instanceof Error ? error.message : String(error)
})
})
}
private isCurrentControl(ws: WebSocket): boolean {
return this.control === ws && !this.stopped && this.config.enabled
}
private handleControlMessage(raw: string): void {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
this.control?.close(1008, 'Invalid relay control message')
return
}
if (!parsed || typeof parsed !== 'object') {
this.control?.close(1008, 'Invalid relay control message')
return
}
const message = parsed as RelayControlMessage
if (message.type === 'sync') {
this.handleSync(message.connectionIds)
return
}
if (message.type === 'connected') {
this.openDataSocket(message.connectionId)
return
}
if (message.type === 'disconnected') {
this.closeDataSocket(message.connectionId)
return
}
this.control?.close(1008, 'Unknown relay control message')
}
private handleSync(connectionIds: string[]): void {
if (!Array.isArray(connectionIds) || connectionIds.length > this.maxSyncIds) {
this.control?.close(1008, 'Relay sync too large')
return
}
const ids = new Set<string>()
for (const id of connectionIds) {
if (!CONNECTION_ID_RE.test(id)) {
this.control?.close(1008, 'Invalid relay connection id')
return
}
ids.add(id)
}
for (const id of ids) {
this.openDataSocket(id)
}
for (const id of Array.from(this.dataSockets.keys())) {
if (!ids.has(id)) {
this.closeDataSocket(id)
}
}
}
private openDataSocket(connectionId: string): void {
if (!CONNECTION_ID_RE.test(connectionId) || this.dataSockets.has(connectionId)) {
return
}
if (this.dataSockets.size >= this.maxDataSockets) {
this.control?.close(1013, 'Relay data socket cap reached')
return
}
const endpoint = normalizeRelayEndpoint(this.config.endpoint)
if (!endpoint) {
return
}
endpoint.searchParams.set('role', 'server')
endpoint.searchParams.set('serverId', this.identity.serverId)
endpoint.searchParams.set('connectionId', connectionId)
endpoint.searchParams.set('v', '1')
const ws = new WebSocket(endpoint, {
maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES,
headers: { Authorization: `Bearer ${this.identity.hostToken}` }
})
this.dataSockets.set(connectionId, ws)
const timer = setTimeout(() => {
ws.close(1008, 'Relay data E2EE attach timeout')
}, this.dataAttachDeadlineMs)
if (typeof timer.unref === 'function') {
timer.unref()
}
this.dataAttachTimers.set(connectionId, timer)
// Why: the relay may flush client frames immediately when the data socket
// upgrades, so attach the encrypted RPC channel before the open callback.
this.attachDataSocket(ws, () => {
const current = this.dataAttachTimers.get(connectionId)
if (current) {
clearTimeout(current)
this.dataAttachTimers.delete(connectionId)
}
})
ws.on('open', () => {
this.emitCurrentStatus()
})
ws.on('close', () => this.closeDataSocket(connectionId, ws))
ws.on('error', () => this.closeDataSocket(connectionId, ws))
this.emitCurrentStatus()
}
private closeDataSocket(connectionId: string, expected?: WebSocket): void {
const ws = this.dataSockets.get(connectionId)
if (!ws || (expected && ws !== expected)) {
return
}
this.dataSockets.delete(connectionId)
const timer = this.dataAttachTimers.get(connectionId)
if (timer) {
clearTimeout(timer)
this.dataAttachTimers.delete(connectionId)
}
ws.close()
this.emitCurrentStatus()
}
private handleControlClosed(ws: WebSocket): void {
if (this.control !== ws) {
return
}
this.control = null
for (const id of Array.from(this.dataSockets.keys())) {
this.closeDataSocket(id)
}
if (!this.stopped && this.config.enabled) {
this.scheduleReconnect()
}
}
private scheduleReconnect(): void {
this.clearReconnectTimer()
this.reconnectAttempt += 1
const delay = Math.min(this.reconnectBaseMs * 2 ** (this.reconnectAttempt - 1), 30_000)
this.setStatus({
state: 'connecting',
activeDataSockets: this.dataSockets.size,
error: 'Relay disconnected'
})
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connectControl()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
private emitCurrentStatus(): void {
if (this.status.state === 'connected') {
this.setStatus({ state: 'connected', activeDataSockets: this.dataSockets.size, error: null })
}
}
private setStatus(status: RuntimeRelayStatus): void {
this.status = status
this.onStatusChange?.(status)
}
}
export function normalizeRelayEndpoint(endpoint: string): URL | null {
try {
const url = new URL(endpoint)
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
return null
}
url.pathname = url.pathname.replace(/\/$/, '') || '/ws'
if (url.pathname !== '/ws') {
url.pathname = `${url.pathname}/ws`
}
url.search = ''
url.hash = ''
return url
} catch {
return null
}
}
function extractRelayEnrollmentToken(endpoint: string): string | null {
try {
const url = new URL(endpoint)
return url.searchParams.get('enrollmentToken') || null
} catch {
return null
}
}
+46
View File
@@ -0,0 +1,46 @@
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
import type { RuntimeRelayConfig } from './relay-client'
const CONFIG_FILE = 'runtime-relay-config.json'
export const DEFAULT_RUNTIME_RELAY_CONFIG: RuntimeRelayConfig = {
enabled: false,
endpoint: ''
}
export function loadRuntimeRelayConfig(userDataPath: string): RuntimeRelayConfig {
const path = runtimeRelayConfigPath(userDataPath)
if (!existsSync(path)) {
return DEFAULT_RUNTIME_RELAY_CONFIG
}
try {
hardenExistingSecureFile(path)
const parsed = JSON.parse(readFileSync(path, 'utf8')) as RuntimeRelayConfig
return normalizeRuntimeRelayConfig(parsed)
} catch {
return DEFAULT_RUNTIME_RELAY_CONFIG
}
}
export function saveRuntimeRelayConfig(
userDataPath: string,
config: RuntimeRelayConfig
): RuntimeRelayConfig {
const normalized = normalizeRuntimeRelayConfig(config)
const path = runtimeRelayConfigPath(userDataPath)
writeSecureJsonFile(path, normalized)
return normalized
}
function runtimeRelayConfigPath(userDataPath: string): string {
return join(userDataPath, CONFIG_FILE)
}
function normalizeRuntimeRelayConfig(config: RuntimeRelayConfig): RuntimeRelayConfig {
return {
enabled: config.enabled === true,
endpoint: typeof config.endpoint === 'string' ? config.endpoint.trim() : ''
}
}
+56
View File
@@ -0,0 +1,56 @@
import { randomBytes } from 'crypto'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
export type RuntimeRelayIdentity = {
serverId: string
hostToken: string
}
const IDENTITY_FILE = 'runtime-relay-identity.json'
export function loadOrCreateRuntimeRelayIdentity(userDataPath: string): RuntimeRelayIdentity {
const path = runtimeRelayIdentityPath(userDataPath)
if (existsSync(path)) {
try {
hardenExistingSecureFile(path)
const parsed = JSON.parse(readFileSync(path, 'utf8')) as RuntimeRelayIdentity
if (isValidRelayIdentity(parsed)) {
return parsed
}
} catch {
// Fall through and replace corrupt identity with a fresh host-only secret.
}
}
const identity = createRuntimeRelayIdentity()
writeSecureJsonFile(path, identity)
return identity
}
export function rotateRuntimeRelayIdentity(userDataPath: string): RuntimeRelayIdentity {
const identity = createRuntimeRelayIdentity()
const path = runtimeRelayIdentityPath(userDataPath)
writeSecureJsonFile(path, identity)
return identity
}
function runtimeRelayIdentityPath(userDataPath: string): string {
return join(userDataPath, IDENTITY_FILE)
}
function createRuntimeRelayIdentity(): RuntimeRelayIdentity {
return {
serverId: randomBytes(24).toString('base64url'),
hostToken: randomBytes(32).toString('base64url')
}
}
function isValidRelayIdentity(value: RuntimeRelayIdentity): boolean {
return (
typeof value.serverId === 'string' &&
value.serverId.length >= 16 &&
typeof value.hostToken === 'string' &&
value.hostToken.length >= 32
)
}
+114 -3
View File
@@ -1,7 +1,21 @@
/* eslint-disable max-lines -- Why: these protocol tests cover direct and relay E2EE compatibility as one matrix so handshake/frame regressions are easier to audit. */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type { WebSocket } from 'ws'
import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel'
import { generateKeyPair, deriveSharedKey, encrypt, decrypt, encryptBytes } from './e2ee-crypto'
import {
generateKeyPair,
deriveSharedKey,
encrypt,
decrypt,
encryptBytes,
decryptBytes
} from './e2ee-crypto'
import {
createRelayTextFrame,
decodeRelayBinaryFrame,
encodeRelayBinaryFrame,
parseRelayTextFrame
} from '../../../shared/runtime-relay-transport'
function publicKeyToBase64(key: Uint8Array): string {
return Buffer.from(key).toString('base64')
@@ -49,6 +63,27 @@ function doHandshake(ctx: ReturnType<typeof setup>) {
return sharedKey
}
function doRelayHandshake(ctx: ReturnType<typeof setup>) {
const hello = JSON.stringify({
type: 'e2ee_hello',
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
})
ctx.channel.handleRawMessage(hello)
const ready = JSON.parse(ctx.ws.sent[0]!) as { challenge: string }
const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
ctx.channel.handleRawMessage(
encrypt(
JSON.stringify({
type: 'e2ee_auth',
deviceToken: 'valid-token',
challenge: ready.challenge
}),
sharedKey
)
)
return sharedKey
}
describe('E2EEChannel', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -68,7 +103,8 @@ describe('E2EEChannel', () => {
expect(ctx.channel.deviceToken).toBe('valid-token')
const readyMsg = JSON.parse(ctx.ws.sent[0]!)
expect(readyMsg).toEqual({ type: 'e2ee_ready' })
expect(readyMsg).toMatchObject({ type: 'e2ee_ready' })
expect(typeof readyMsg.challenge).toBe('string')
const authMsg = decrypt(
ctx.ws.sent[1]!,
deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey)
@@ -86,7 +122,7 @@ describe('E2EEChannel', () => {
)
expect(ctx.onReady).not.toHaveBeenCalled()
expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' })
expect(JSON.parse(ctx.ws.sent[0]!)).toMatchObject({ type: 'e2ee_ready' })
})
it('rejects invalid encrypted token', () => {
@@ -246,6 +282,81 @@ describe('E2EEChannel', () => {
}
expect(ctx.onError).not.toHaveBeenCalled()
})
it('requires relay auth challenge on relay sockets', () => {
const ctx = setup({ transportKind: 'relay' })
const sharedKey = doHandshake(ctx)
expect(sharedKey).toBeTruthy()
expect(ctx.onReady).not.toHaveBeenCalled()
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Missing relay auth challenge')
})
it('rejects duplicate relay text frame sequence before dispatch', () => {
const ctx = setup({ transportKind: 'relay' })
const sharedKey = doRelayHandshake(ctx)
const received: string[] = []
ctx.channel.onMessage((plaintext) => {
received.push(plaintext)
})
const request = '{"id":"rpc-1","method":"status.get"}'
const frame = encrypt(createRelayTextFrame(1, request), sharedKey)
ctx.channel.handleRawMessage(frame)
ctx.channel.handleRawMessage(frame)
expect(received).toEqual([request])
expect(ctx.onError).toHaveBeenCalledWith(4007, 'Invalid relay frame sequence')
})
it('wraps relay replies with outbound sequence numbers', () => {
const ctx = setup({ transportKind: 'relay' })
const sharedKey = doRelayHandshake(ctx)
ctx.channel.onMessage((_plaintext, encryptedReply) => {
encryptedReply('{"id":"rpc-1","ok":true}')
})
ctx.channel.handleRawMessage(
encrypt(createRelayTextFrame(1, '{"id":"rpc-1","method":"status.get"}'), sharedKey)
)
const replyPlain = decrypt(ctx.ws.sent[2]!, sharedKey)
const frame = parseRelayTextFrame(replyPlain!)
expect(frame).toMatchObject({ seq: 1, payload: '{"id":"rpc-1","ok":true}' })
})
it('rejects duplicate relay binary frame sequence before dispatch', () => {
const ctx = setup({ transportKind: 'relay' })
const sharedKey = doRelayHandshake(ctx)
const received: Uint8Array<ArrayBufferLike>[] = []
ctx.channel.onBinaryMessage((plaintext) => {
received.push(plaintext)
})
const frame = encryptBytes(encodeRelayBinaryFrame(1, new Uint8Array([4, 5])), sharedKey)
ctx.channel.handleRawMessage(frame)
ctx.channel.handleRawMessage(frame)
expect([...received[0]!]).toEqual([4, 5])
expect(ctx.onError).toHaveBeenCalledWith(4007, 'Invalid relay frame sequence')
})
it('wraps relay binary replies with outbound sequence numbers', () => {
const ctx = setup({ transportKind: 'relay' })
const sharedKey = doRelayHandshake(ctx)
ctx.channel.onMessage((_plaintext, _encryptedReply, encryptedBinaryReply) => {
encryptedBinaryReply(new Uint8Array([9, 8, 7]))
})
ctx.channel.handleRawMessage(
encrypt(createRelayTextFrame(1, '{"id":"rpc-1","method":"browser.screencast"}'), sharedKey)
)
const replyPlain = decryptBytes(ctx.ws.sent[2]! as unknown as Uint8Array, sharedKey)
const frame = decodeRelayBinaryFrame(replyPlain!)
expect(frame?.seq).toBe(1)
expect([...(frame?.payload ?? [])]).toEqual([9, 8, 7])
})
})
describe('cross-compatibility', () => {
+103 -5
View File
@@ -1,8 +1,17 @@
// Why: the E2EE channel sits between the WebSocket transport and the RPC handler.
// It owns the handshake state machine and transparent encrypt/decrypt so the RPC
// handler only sees plaintext JSON, identical to the Unix socket path.
import { randomBytes } from 'crypto'
import type { WebSocket } from 'ws'
import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto'
import {
RELAY_MAX_SEQUENCE,
createRelayTextFrame,
decodeRelayBinaryFrame,
encodeRelayBinaryFrame,
parseRelayTextFrame,
type RuntimeWebSocketTransportKind
} from '../../../shared/runtime-relay-transport'
type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready'
@@ -18,10 +27,12 @@ type E2EEHello = {
type E2EEAuth = {
type: 'e2ee_auth'
deviceToken: string
challenge?: string
}
export type E2EEChannelOptions = {
serverSecretKey: Uint8Array
transportKind?: RuntimeWebSocketTransportKind
validateToken: (token: string) => boolean
onReady: (channel: E2EEChannel) => void
onError: (code: number, reason: string) => void
@@ -34,9 +45,13 @@ export class E2EEChannel {
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private readonly ws: WebSocket
private readonly serverSecretKey: Uint8Array
private readonly transportKind: RuntimeWebSocketTransportKind
private readonly validateToken: (token: string) => boolean
private readonly onReady: (channel: E2EEChannel) => void
private readonly onError: (code: number, reason: string) => void
private challenge: string | null = null
private inboundRelaySeq = 0
private outboundRelaySeq = 0
// Why: the RPC handler is set after the channel is ready, so the channel
// can forward decrypted messages. Kept as a callback rather than constructor
// param because the handler needs the encrypt function for replies.
@@ -54,6 +69,7 @@ export class E2EEChannel {
constructor(ws: WebSocket, options: E2EEChannelOptions) {
this.ws = ws
this.serverSecretKey = options.serverSecretKey
this.transportKind = options.transportKind ?? 'direct'
this.validateToken = options.validateToken
this.onReady = options.onReady
this.onError = options.onError
@@ -102,7 +118,14 @@ export class E2EEChannel {
this.onError(4001, 'Invalid binary message before authentication')
return
}
this.binaryMessageHandler?.(plaintextBytes)
const binaryPayload =
this.transportKind === 'relay'
? this.unwrapRelayBinaryFrame(plaintextBytes)
: plaintextBytes
if (!binaryPayload) {
return
}
this.binaryMessageHandler?.(binaryPayload)
return
}
@@ -118,6 +141,14 @@ export class E2EEChannel {
return
}
const messagePlaintext =
this.state === 'ready' && this.transportKind === 'relay'
? this.unwrapRelayTextFrame(plaintext)
: plaintext
if (messagePlaintext === null) {
return
}
// Why: streaming RPC handlers (e.g. terminal.subscribe) retain this
// closure and may fire emits long after the inbound message handled
// here. If destroy() runs in between (mobile disconnect, handshake
@@ -128,7 +159,7 @@ export class E2EEChannel {
if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) {
return
}
this.ws.send(encrypt(response, this.sharedKey))
this.ws.send(encrypt(this.wrapRelayTextFrame(response), this.sharedKey))
}
const encryptedBinaryReply = (response: Uint8Array<ArrayBufferLike>): boolean => {
if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) {
@@ -137,10 +168,12 @@ export class E2EEChannel {
if (this.ws.bufferedAmount > MAX_BINARY_BUFFERED_AMOUNT) {
return false
}
this.ws.send(Buffer.from(encryptBytes(response, this.sharedKey)), { binary: true })
this.ws.send(Buffer.from(encryptBytes(this.wrapRelayBinaryFrame(response), this.sharedKey)), {
binary: true
})
return true
}
this.messageHandler?.(plaintext, encryptedReply, encryptedBinaryReply)
this.messageHandler?.(messagePlaintext, encryptedReply, encryptedBinaryReply)
}
private trackDecryptFailure(): void {
@@ -174,11 +207,13 @@ export class E2EEChannel {
this.sharedKey = deriveSharedKey(this.serverSecretKey, clientPublicKey)
this.state = 'awaiting_auth'
this.challenge = randomBytes(24).toString('base64url')
// Why: send e2ee_ready as plaintext — the client needs it to know the
// key exchange succeeded before it can send encrypted authentication.
// The challenge is echoed inside encrypted auth to make relay handshakes fresh.
if (this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify({ type: 'e2ee_ready' }))
this.ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: this.challenge }))
}
}
@@ -197,6 +232,11 @@ export class E2EEChannel {
this.onError(4001, 'Invalid e2ee_auth')
return
}
if (this.transportKind === 'relay' && auth.challenge !== this.challenge) {
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'bad_auth' } })
this.onError(4001, 'Missing relay auth challenge')
return
}
if (!this.validateToken(auth.deviceToken)) {
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: 'unauthorized' } })
this.onError(4001, 'Unauthorized')
@@ -221,12 +261,70 @@ export class E2EEChannel {
}
}
private nextOutboundRelaySeq(): number {
if (this.transportKind !== 'relay') {
return 0
}
if (this.outboundRelaySeq >= RELAY_MAX_SEQUENCE) {
this.onError(4008, 'Relay frame sequence exhausted')
return 0
}
this.outboundRelaySeq += 1
return this.outboundRelaySeq
}
private wrapRelayTextFrame(payload: string): string {
if (this.transportKind !== 'relay') {
return payload
}
const seq = this.nextOutboundRelaySeq()
return createRelayTextFrame(seq, payload)
}
private wrapRelayBinaryFrame(payload: Uint8Array<ArrayBufferLike>): Uint8Array {
if (this.transportKind !== 'relay') {
return payload
}
return encodeRelayBinaryFrame(this.nextOutboundRelaySeq(), payload)
}
private acceptRelaySeq(seq: unknown): boolean {
if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq !== this.inboundRelaySeq + 1) {
this.onError(4007, 'Invalid relay frame sequence')
return false
}
this.inboundRelaySeq = seq
return true
}
private unwrapRelayTextFrame(plaintext: string): string | null {
const frame = parseRelayTextFrame(plaintext)
if (!frame) {
this.onError(4007, 'Invalid relay text frame')
return null
}
return this.acceptRelaySeq(frame.seq) ? frame.payload : null
}
private unwrapRelayBinaryFrame(plaintext: Uint8Array<ArrayBufferLike>): Uint8Array | null {
const frame = decodeRelayBinaryFrame(plaintext)
if (!frame) {
this.onError(4007, 'Invalid relay binary frame')
return null
}
if (!this.acceptRelaySeq(frame.seq)) {
return null
}
return frame.payload
}
destroy(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
this.sharedKey = null
this.challenge = null
this.messageHandler = null
this.binaryMessageHandler = null
}
@@ -75,7 +75,7 @@ describe('E2EE integration (simulated mobile ↔ desktop)', () => {
// Desktop should respond with plaintext ready, then encrypted auth ack.
expect(onReady).toHaveBeenCalled()
expect(wsSent).toHaveLength(2)
expect(JSON.parse(wsSent[0]!)).toEqual({ type: 'e2ee_ready' })
expect(JSON.parse(wsSent[0]!)).toMatchObject({ type: 'e2ee_ready' })
expect(JSON.parse(decrypt(wsSent[1]!, mobileShared)!)).toEqual({
type: 'e2ee_authenticated'
})
+47 -2
View File
@@ -11,6 +11,8 @@ import {
} from '../../shared/runtime-environment-store'
import { DeviceRegistry } from './device-registry'
import { loadOrCreateE2EEKeypair } from './e2ee-keypair'
import { loadRuntimeRelayConfig, saveRuntimeRelayConfig } from './relay-config'
import { loadOrCreateRuntimeRelayIdentity } from './relay-identity'
import {
clearRuntimeMetadata,
clearRuntimeMetadataIfOwned,
@@ -178,6 +180,11 @@ describe('runtime metadata', () => {
new DeviceRegistry(userDataPath).addDevice('phone')
loadOrCreateE2EEKeypair(userDataPath)
loadOrCreateRuntimeRelayIdentity(userDataPath)
saveRuntimeRelayConfig(userDataPath, {
enabled: true,
endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret'
})
addEnvironmentFromPairingCode(userDataPath, {
name: 'desk',
pairingCode: encodePairingOffer({
@@ -191,6 +198,8 @@ describe('runtime metadata', () => {
for (const path of [
join(userDataPath, 'orca-devices.json'),
join(userDataPath, 'orca-e2ee-keypair.json'),
join(userDataPath, 'runtime-relay-identity.json'),
join(userDataPath, 'runtime-relay-config.json'),
getEnvironmentStorePath(userDataPath)
]) {
expect(statSync(path).mode & 0o777).toBe(0o600)
@@ -219,6 +228,8 @@ describe('runtime metadata', () => {
const devicesPath = join(userDataPath, 'orca-devices.json')
const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json')
const relayIdentityPath = join(userDataPath, 'runtime-relay-identity.json')
const relayConfigPath = join(userDataPath, 'runtime-relay-config.json')
const environmentsPath = getEnvironmentStorePath(userDataPath)
writeFileSync(
devicesPath,
@@ -236,7 +247,27 @@ describe('runtime metadata', () => {
keypairPath,
JSON.stringify({ v: 1, publicKeyB64: keyMaterial, secretKeyB64: keyMaterial })
)
for (const path of [devicesPath, keypairPath, environmentsPath]) {
writeFileSync(
relayIdentityPath,
JSON.stringify({
serverId: 'relay-server-id-0001',
hostToken: 'relay-host-token-000000000000000000'
})
)
writeFileSync(
relayConfigPath,
JSON.stringify({
enabled: true,
endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret'
})
)
for (const path of [
devicesPath,
keypairPath,
relayIdentityPath,
relayConfigPath,
environmentsPath
]) {
chmodSync(path, 0o644)
}
chmodSync(userDataPath, 0o755)
@@ -246,9 +277,23 @@ describe('runtime metadata', () => {
scope: 'mobile'
})
expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(keyMaterial)
expect(loadOrCreateRuntimeRelayIdentity(userDataPath)).toMatchObject({
serverId: 'relay-server-id-0001',
hostToken: 'relay-host-token-000000000000000000'
})
expect(loadRuntimeRelayConfig(userDataPath)).toEqual({
enabled: true,
endpoint: 'ws://127.0.0.1:6768/ws?enrollmentToken=secret'
})
expect(listEnvironments(userDataPath)[0]?.id).toBe(environment.id)
for (const path of [devicesPath, keypairPath, environmentsPath]) {
for (const path of [
devicesPath,
keypairPath,
relayIdentityPath,
relayConfigPath,
environmentsPath
]) {
expect(statSync(path).mode & 0o777).toBe(0o600)
}
expect(statSync(userDataPath).mode & 0o777).toBe(0o700)
+101
View File
@@ -0,0 +1,101 @@
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
import { startRuntimeRelayServer } from '../../runtime-relay-server/server'
import { parsePairingCode } from '../../shared/pairing'
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
import { saveRuntimeRelayConfig } from './relay-config'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import type { OrcaRuntimeService } from './orca-runtime'
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('timed out waiting for condition')
}
await sleep(20)
}
}
describe('runtime relay end-to-end', () => {
it('serves encrypted runtime RPC over a self-hosted relay', async () => {
const relayDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-e2e-relay-'))
const runtimeDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-e2e-runtime-'))
const relay = await startRuntimeRelayServer({
port: 0,
statePath: join(relayDataPath, 'relay-state.json')
})
saveRuntimeRelayConfig(runtimeDataPath, {
enabled: true,
endpoint: `${relay.webSocketUrl}?enrollmentToken=${relay.enrollmentToken}`
})
const runtime = {
getRuntimeId: () => 'runtime-relay-e2e',
getStartedAt: () => 1_700_000_000_000,
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ready' }),
cleanupSubscriptionsForConnection: vi.fn(),
cancelMobileDictationForConnection: vi.fn(),
onClientDisconnected: vi.fn()
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath: runtimeDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
await waitFor(() => server.getRelayStatus().state === 'connected')
expect(
server.createRelayPairingOffer({
name: 'Invalid Scope',
scope: 'admin' as never
})
).toEqual({ available: false, reason: 'invalid_scope' })
const offer = server.createRelayPairingOffer({
name: 'Relay E2E',
rotate: true,
scope: 'runtime'
})
expect(offer.available).toBe(true)
if (!offer.available) {
throw new Error('Relay pairing unavailable')
}
expect(offer.endpoint).toContain('role=client')
const pairing = parsePairingCode(offer.pairingUrl)
expect(pairing).toBeTruthy()
if (!pairing) {
throw new Error('Relay pairing parse failed')
}
const connection = new RemoteRuntimeRequestConnection(pairing)
try {
const response = await connection.request<{ graphStatus: string }>(
'status.get',
undefined,
2_000
)
expect(response).toMatchObject({
ok: true,
result: { graphStatus: 'ready' },
_meta: { runtimeId: 'runtime-relay-e2e' }
})
expect(runtime.getStatus).toHaveBeenCalledTimes(1)
} finally {
connection.close()
}
} finally {
await server.stop()
await relay.stop()
}
})
})
+34 -2
View File
@@ -159,7 +159,7 @@ async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64')
})
)
expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' })
expect(JSON.parse(await nextWsMessage(ws))).toMatchObject({ type: 'e2ee_ready' })
ws.send(
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: parsed!.deviceToken }), sharedKey)
@@ -305,6 +305,37 @@ describe('OrcaRuntimeRpcServer', () => {
}
})
it('omits web client URLs from relay pairing offers even with a web bundle configured', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0,
webClientRoot: userDataPath
})
await server.start()
try {
server['relayClient'] = {
createClientEndpoint: () => 'wss://relay.example.test/runtime?role=client',
stop: vi.fn()
} as never
const offer = server.createRelayPairingOffer({ name: 'Relay web test' })
expect(offer.available).toBe(true)
if (offer.available) {
expect(offer.endpoint).toBe('wss://relay.example.test/runtime?role=client')
expect(parsePairingCode(offer.pairingUrl)?.endpoint).toBe(offer.endpoint)
expect(offer.webClientUrl).toBeNull()
}
} finally {
await server.stop()
}
})
it('preserves proxy path prefixes in web client URLs', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
@@ -440,7 +471,7 @@ describe('OrcaRuntimeRpcServer', () => {
publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64')
})
)
expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' })
expect(JSON.parse(await nextWsMessage(ws))).toMatchObject({ type: 'e2ee_ready' })
expect(server['e2eeChannels'].size).toBe(1)
expect(server['wsConnectionIds'].size).toBe(1)
@@ -1894,6 +1925,7 @@ describe('OrcaRuntimeRpcServer', () => {
expect(existsSync(endpoint)).toBe(false)
expect(server['transports']).toEqual([])
expect(server['activeTransports']).toEqual([])
expect(server['relayClient']).toBeNull()
writeMetadataSpy.mockRestore()
})
+204 -74
View File
@@ -20,6 +20,14 @@ import type { WebSocket } from 'ws'
import { DeviceRegistry, type DeviceScope } from './device-registry'
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
import { E2EEChannel } from './rpc/e2ee-channel'
import { loadRuntimeRelayConfig, saveRuntimeRelayConfig } from './relay-config'
import {
RuntimeRelayClient,
type RuntimeRelayConfig,
type RuntimeRelayStatus
} from './relay-client'
import { loadOrCreateRuntimeRelayIdentity } from './relay-identity'
import type { RuntimeWebSocketTransportKind } from '../../shared/runtime-relay-transport'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
import {
decodeTerminalStreamFrame,
@@ -311,7 +319,8 @@ export class OrcaRuntimeRpcServer {
private deviceRegistry: DeviceRegistry | null = null
private e2eeKeypair: E2EEKeypair | null = null
private tlsFingerprint: string | null = null
private wsTransport: WebSocketTransport | null = null
private relayClient: RuntimeRelayClient | null = null
private relayConfig: RuntimeRelayConfig | null = null
private activeTransports: RpcTransport[] = []
private transports: RuntimeTransportMetadata[] = []
// Why: each WebSocket connection has its own E2EE channel that manages the
@@ -321,6 +330,7 @@ export class OrcaRuntimeRpcServer {
// subscriptions, so the server can reap a closing socket's subscriptions
// without affecting other live sockets that share the same deviceToken.
private wsConnectionIds = new Map<WebSocket, string>()
private runtimeWsClientIds = new Map<WebSocket, string>()
private readonly binaryStreamHandlers = new Map<
string,
Map<number, (frame: TerminalStreamFrame) => void>
@@ -378,7 +388,7 @@ export class OrcaRuntimeRpcServer {
if (device?.scope !== 'mobile' || !this.deviceRegistry?.removeDevice(deviceId)) {
return false
}
this.wsTransport?.terminateClientConnections(device.token)
this.terminateRuntimeWebSocketConnections(device.token)
return true
}
@@ -387,15 +397,98 @@ export class OrcaRuntimeRpcServer {
if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) {
return false
}
this.wsTransport?.terminateClientConnections(device.token)
this.terminateRuntimeWebSocketConnections(device.token)
return true
}
attachExternalWebSocket(
ws: WebSocket,
transportKind: RuntimeWebSocketTransportKind = 'relay',
onAuthenticated?: (deviceToken: string) => void
): void {
this.attachRuntimeWebSocket(ws, transportKind, onAuthenticated)
ws.on('message', (data, isBinary) => {
const msg =
typeof data === 'string'
? data
: isBinary
? new Uint8Array(data as Buffer)
: data.toString()
this.e2eeChannels.get(ws)?.handleRawMessage(msg)
})
ws.on('close', () => this.cleanupRuntimeWebSocket(ws))
ws.on('error', () => {
this.cleanupRuntimeWebSocket(ws)
ws.close()
})
}
getWebSocketEndpoint(): string | null {
const ws = this.transports.find((t) => t.kind === 'websocket')
return ws?.endpoint ?? null
}
getRelayConfig(): RuntimeRelayConfig {
return this.relayConfig ?? loadRuntimeRelayConfig(this.userDataPath)
}
updateRelayConfig(config: RuntimeRelayConfig): RuntimeRelayConfig {
const saved = saveRuntimeRelayConfig(this.userDataPath, config)
this.relayConfig = saved
this.relayClient?.updateConfig(saved)
return saved
}
getRelayStatus(): RuntimeRelayStatus {
return (
this.relayClient?.getStatus() ?? {
state: 'disabled',
activeDataSockets: 0,
error: null
}
)
}
createRelayPairingOffer(args: { name?: string; rotate?: boolean; scope?: DeviceScope }):
| { available: false; reason: string }
| {
available: true
pairingUrl: string
endpoint: string
deviceId: string
webClientUrl: string | null
} {
const endpoint = this.relayClient?.createClientEndpoint()
const publicKeyB64 = this.getE2EEPublicKey()
if (!endpoint || !this.deviceRegistry || !publicKeyB64) {
return { available: false, reason: 'relay_unavailable' }
}
const rawScope: unknown = args.scope
if (rawScope !== undefined && rawScope !== 'mobile' && rawScope !== 'runtime') {
return { available: false, reason: 'invalid_scope' }
}
const scope: DeviceScope = rawScope ?? 'runtime'
const deviceName = args.name ?? `Relay ${new Date().toLocaleDateString()}`
const device = args.rotate
? this.deviceRegistry.rotatePendingDevice(deviceName, scope)
: this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope)
const pairingUrl = encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: device.token,
publicKeyB64
})
return {
available: true,
pairingUrl,
endpoint,
deviceId: device.deviceId,
// Why: relay origins are user-configured and may be untrusted; pairing
// URLs carry credentials, so only direct trusted web roots may embed them.
webClientUrl: null
}
}
createPairingOffer(args: {
address?: string | null
name?: string
@@ -418,7 +511,11 @@ export class OrcaRuntimeRpcServer {
const endpoint = resolvePairingEndpoint(rawEndpoint, args.address)
const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}`
const scope = args.scope ?? 'runtime'
const rawScope: unknown = args.scope
if (rawScope !== undefined && rawScope !== 'mobile' && rawScope !== 'runtime') {
return { available: false }
}
const scope: DeviceScope = rawScope ?? 'runtime'
const device = args.rotate
? this.deviceRegistry.rotatePendingDevice(deviceName, scope)
: this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope)
@@ -476,6 +573,90 @@ export class OrcaRuntimeRpcServer {
this.binaryStreamHandlers.get(connectionId)?.get(frame.streamId)?.(frame)
}
private attachRuntimeWebSocket(
ws: WebSocket,
transportKind: RuntimeWebSocketTransportKind,
onAuthenticated?: (deviceToken: string) => void
): E2EEChannel {
let channel = this.e2eeChannels.get(ws)
if (channel) {
return channel
}
if (!this.e2eeKeypair || !this.deviceRegistry) {
ws.close(1011, 'Runtime WebSocket auth unavailable')
throw new Error('Runtime WebSocket auth unavailable')
}
// Why: stable per-ws id used as the cleanup-index key for streaming
// subscriptions, so direct and relay sockets reap identical runtime state.
this.wsConnectionIds.set(ws, randomBytes(8).toString('hex'))
channel = new E2EEChannel(ws, {
serverSecretKey: this.e2eeKeypair.secretKey,
transportKind,
validateToken: (token) => this.deviceRegistry?.validateToken(token) != null,
onReady: (ch) => {
if (!ch.deviceToken) {
return
}
this.runtimeWsClientIds.set(ws, ch.deviceToken)
onAuthenticated?.(ch.deviceToken)
const device = this.deviceRegistry?.validateToken(ch.deviceToken)
if (device) {
this.deviceRegistry?.updateLastSeen(device.deviceId)
}
},
onError: (code, reason) => {
this.e2eeChannels.get(ws)?.destroy()
this.e2eeChannels.delete(ws)
ws.close(code, reason)
}
})
channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => {
const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null
void this.handleWebSocketMessage(
plaintext,
encryptedReply,
encryptedBinaryReply,
undefined,
ws,
authenticatedDeviceToken
)
})
channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws))
this.e2eeChannels.set(ws, channel)
return channel
}
private cleanupRuntimeWebSocket(ws: WebSocket, fallbackClientId: string | null = null): void {
this.abortWebSocketDispatches(ws)
const connectionId = this.wsConnectionIds.get(ws)
if (connectionId) {
this.runtime.cleanupSubscriptionsForConnection(connectionId)
this.runtime.cancelMobileDictationForConnection(connectionId)
this.binaryStreamHandlers.delete(connectionId)
this.wsConnectionIds.delete(ws)
}
const channel = this.e2eeChannels.get(ws)
if (channel) {
channel.destroy()
this.e2eeChannels.delete(ws)
}
const clientId = this.runtimeWsClientIds.get(ws) ?? fallbackClientId
this.runtimeWsClientIds.delete(ws)
if (clientId && !Array.from(this.runtimeWsClientIds.values()).includes(clientId)) {
this.runtime.onClientDisconnected(clientId)
}
}
private terminateRuntimeWebSocketConnections(clientId: string): number {
const sockets = Array.from(this.runtimeWsClientIds.entries())
.filter(([, candidateClientId]) => candidateClientId === clientId)
.map(([ws]) => ws)
for (const ws of sockets) {
ws.terminate()
}
return sockets.length
}
private registerWebSocketDispatchAbort(ws: WebSocket): {
signal: AbortSignal
dispose: () => void
@@ -611,54 +792,10 @@ export class OrcaRuntimeRpcServer {
port: this.wsPort,
staticRoot: this.webClientRoot
})
this.wsTransport = wsTransport
// Why: each WebSocket connection gets an E2EE channel that handles the
// handshake before any RPC messages are processed. The channel decrypts
// inbound messages and encrypts outbound replies transparently.
wsTransport.onMessage((msg, _reply, ws) => {
let channel = this.e2eeChannels.get(ws)
if (!channel) {
// Why: stable per-ws id used as the cleanup-index key for
// streaming subscriptions, so the server can reap them exactly
// when this socket closes (without affecting other live sockets
// that share the same deviceToken).
this.wsConnectionIds.set(ws, randomBytes(8).toString('hex'))
channel = new E2EEChannel(ws, {
serverSecretKey: this.e2eeKeypair!.secretKey,
validateToken: (token) => this.deviceRegistry?.validateToken(token) != null,
onReady: (ch) => {
if (ch.deviceToken) {
wsTransport.setClientId(ws, ch.deviceToken)
// Why: mark the device as actually connected so it appears
// in the "Paired Devices" list. Devices that were only
// generated as QR codes but never scanned stay hidden.
const device = this.deviceRegistry?.validateToken(ch.deviceToken)
if (device) {
this.deviceRegistry?.updateLastSeen(device.deviceId)
}
}
},
onError: (code, reason) => {
this.e2eeChannels.get(ws)?.destroy()
this.e2eeChannels.delete(ws)
ws.close(code, reason)
}
})
channel.onMessage((plaintext, encryptedReply, encryptedBinaryReply) => {
const authenticatedDeviceToken = this.e2eeChannels.get(ws)?.deviceToken ?? null
void this.handleWebSocketMessage(
plaintext,
encryptedReply,
encryptedBinaryReply,
wsTransport,
ws,
authenticatedDeviceToken
)
})
channel.onBinaryMessage((bytes) => this.handleWebSocketBinaryMessage(bytes, ws))
this.e2eeChannels.set(ws, channel)
}
const channel = this.attachRuntimeWebSocket(ws, 'direct', (token) =>
wsTransport.setClientId(ws, token)
)
channel.handleRawMessage(msg)
})
@@ -668,27 +805,8 @@ export class OrcaRuntimeRpcServer {
// multiple concurrent sockets (host screen + accounts screen, etc.),
// so destroy the channel for THIS exact ws and skip the per-client
// teardown when other sockets for the same token are still alive.
wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => {
this.abortWebSocketDispatches(ws)
// Why: sweep streaming subscriptions for THIS ws regardless of
// hasOtherConnections, so per-ws listeners (notifications,
// accounts, terminal) don't leak across reconnects. This is
// independent of the deviceToken-scoped onClientDisconnected.
const connectionId = this.wsConnectionIds.get(ws)
if (connectionId) {
this.runtime.cleanupSubscriptionsForConnection(connectionId)
this.runtime.cancelMobileDictationForConnection(connectionId)
this.binaryStreamHandlers.delete(connectionId)
this.wsConnectionIds.delete(ws)
}
const channel = this.e2eeChannels.get(ws)
if (channel) {
channel.destroy()
this.e2eeChannels.delete(ws)
}
if (clientId && !hasOtherConnections) {
this.runtime.onClientDisconnected(clientId)
}
wsTransport.onConnectionClose((clientId, ws, _hasOtherConnections) => {
this.cleanupRuntimeWebSocket(ws, clientId)
})
await wsTransport.start()
@@ -697,12 +815,21 @@ export class OrcaRuntimeRpcServer {
kind: 'websocket',
endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}`
})
this.relayConfig = loadRuntimeRelayConfig(this.userDataPath)
const relayIdentity = loadOrCreateRuntimeRelayIdentity(this.userDataPath)
this.relayClient = new RuntimeRelayClient({
config: this.relayConfig,
identity: relayIdentity,
attachDataSocket: (ws, onAuthenticated) => {
this.attachExternalWebSocket(ws, 'relay', () => onAuthenticated())
}
})
this.relayClient.start()
} catch (error) {
// Why: WebSocket transport is supplementary — the runtime must still
// function if it fails to start (e.g., port in use). Log and continue
// with Unix socket only.
console.error('[runtime] Failed to start WebSocket transport:', error)
this.wsTransport = null
}
}
@@ -718,6 +845,8 @@ export class OrcaRuntimeRpcServer {
// Why: a runtime that cannot publish bootstrap metadata is invisible to
// the `orca` CLI. Close all transports immediately instead of leaving
// behind a live but undiscoverable control plane.
this.relayClient?.stop()
this.relayClient = null
this.activeTransports = []
this.transports = []
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
@@ -729,7 +858,8 @@ export class OrcaRuntimeRpcServer {
const transports = this.activeTransports
this.activeTransports = []
this.transports = []
this.wsTransport = null
this.relayClient?.stop()
this.relayClient = null
if (transports.length === 0) {
return
}
+32
View File
@@ -2140,6 +2140,38 @@ export type PreloadApi = {
deviceId: string
}
>
getRelayPairingQR: (args?: { rotate?: boolean; scope?: 'mobile' | 'runtime' }) => Promise<
| { available: false; reason: string }
| {
available: true
qrDataUrl: string
pairingUrl: string
webClientUrl: string | null
endpoint: string
deviceId: string
}
>
getRelayRuntimePairingUrl: (args?: { rotate?: boolean }) => Promise<
| { available: false; reason: string }
| {
available: true
pairingUrl: string
webClientUrl: string | null
endpoint: string
deviceId: string
}
>
getRelayConfig: () => Promise<{
config: { enabled: boolean; endpoint: string }
status: { state: string; activeDataSockets: number; error: string | null }
}>
updateRelayConfig: (args: { enabled?: boolean; endpoint?: string }) => Promise<{
config: { enabled: boolean; endpoint: string }
status: { state: string; activeDataSockets: number; error: string | null }
}>
getRelayStatus: () => Promise<{
status: { state: string; activeDataSockets: number; error: string | null }
}>
listDevices: () => Promise<{
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
}>
+45
View File
@@ -3253,6 +3253,51 @@ const api = {
}
> => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args),
getRelayPairingQR: (args?: {
rotate?: boolean
scope?: 'mobile' | 'runtime'
}): Promise<
| { available: false; reason: string }
| {
available: true
qrDataUrl: string
pairingUrl: string
webClientUrl: string | null
endpoint: string
deviceId: string
}
> => ipcRenderer.invoke('mobile:getRelayPairingQR', args),
getRelayRuntimePairingUrl: (args?: {
rotate?: boolean
}): Promise<
| { available: false; reason: string }
| {
available: true
pairingUrl: string
webClientUrl: string | null
endpoint: string
deviceId: string
}
> => ipcRenderer.invoke('mobile:getRelayRuntimePairingUrl', args),
getRelayConfig: (): Promise<{
config: { enabled: boolean; endpoint: string }
status: { state: string; activeDataSockets: number; error: string | null }
}> => ipcRenderer.invoke('mobile:getRelayConfig'),
updateRelayConfig: (args: {
enabled?: boolean
endpoint?: string
}): Promise<{
config: { enabled: boolean; endpoint: string }
status: { state: string; activeDataSockets: number; error: string | null }
}> => ipcRenderer.invoke('mobile:updateRelayConfig', args),
getRelayStatus: (): Promise<{
status: { state: string; activeDataSockets: number; error: string | null }
}> => ipcRenderer.invoke('mobile:getRelayStatus'),
listDevices: (): Promise<{
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
}> => ipcRenderer.invoke('mobile:listDevices'),
@@ -14,6 +14,7 @@ type MobileNetworkInterfaceSectionProps = {
refreshingNetworkInterfaces: boolean
onRefreshNetworkInterfaces: () => void
loading: boolean
disabled?: boolean
hasQrCode: boolean
onGenerateQr: () => void
}
@@ -29,6 +30,7 @@ export function MobileNetworkInterfaceSection({
refreshingNetworkInterfaces,
onRefreshNetworkInterfaces,
loading,
disabled = false,
hasQrCode,
onGenerateQr
}: MobileNetworkInterfaceSectionProps): React.JSX.Element {
@@ -36,12 +38,11 @@ export function MobileNetworkInterfaceSection({
<div className="rounded-lg border border-border/60 p-4">
<div className="mb-3 flex items-center gap-2">
<Wifi className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Network Interface</span>
<span className="text-sm font-medium">Direct pairing</span>
</div>
<p className="text-muted-foreground mb-3 text-xs">
Choose which network address to advertise in the QR code. Use your LAN address for
same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network
access.
Your phone connects straight to this computer. Use your LAN address on the same Wi-Fi, or a
Tailscale or ZeroTier address to reach it from another network.
</p>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
@@ -80,7 +81,7 @@ export function MobileNetworkInterfaceSection({
</div>
<Button
onClick={onGenerateQr}
disabled={loading || !selectedAddress}
disabled={disabled || loading || !selectedAddress}
size="sm"
className="gap-1.5"
>
@@ -91,19 +92,19 @@ export function MobileNetworkInterfaceSection({
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode ? 'Regenerate' : 'Generate QR Code'}
{loading ? 'Generating…' : hasQrCode ? 'Regenerate' : 'Generate QR Code'}
</Button>
</div>
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
<AccordionItem value="remote-pairing-guide">
<AccordionTrigger className="py-2 text-xs">
Connect outside your Wi-Fi with a tailnet
Pair across networks with a tailnet
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
Orca Mobile connects directly to this computer. To use it away from the same local
network, put your computer and phone on the same private overlay network, then
generate the QR code with that network address selected.
Put your computer and phone on the same private overlay network, then generate the QR
code with that network address selected. If you can&apos;t install a tailnet on both
devices, use a self-hosted relay below instead.
</p>
<ol className="list-decimal space-y-1 pl-4">
<li>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
/* eslint-disable max-lines -- Why: direct and relay mobile pairing share QR/device state; keeping the pane together prevents credential-rotation drift. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Check, Copy, Maximize2, Smartphone, Trash2 } from 'lucide-react'
import { Button } from '../ui/button'
@@ -11,6 +12,7 @@ import {
type MobileNetworkInterface
} from './mobile-network-interface-selection'
import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection'
import { RuntimeRelaySettingsSection } from './RuntimeRelaySettingsSection'
export { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search'
// Why: the section heading "When you leave the mobile app" carries the
@@ -44,6 +46,7 @@ export function MobilePane(): React.JSX.Element {
const updateSettings = useAppStore((s) => s.updateSettings)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
const [pairingSource, setPairingSource] = useState<'direct' | 'relay' | null>(null)
const [endpoint, setEndpoint] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<PairedDevice[]>([])
@@ -52,6 +55,37 @@ export function MobilePane(): React.JSX.Element {
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
const [mobilePairingGenerationSource, setMobilePairingGenerationSource] = useState<
'direct' | 'relay' | null
>(null)
const mobilePairingGenerationIdRef = useRef(0)
const activeMobilePairingGenerationIdRef = useRef<number | null>(null)
const tryBeginMobilePairingGeneration = useCallback(
(source: 'direct' | 'relay'): number | null => {
if (activeMobilePairingGenerationIdRef.current !== null) {
return null
}
const requestId = mobilePairingGenerationIdRef.current + 1
mobilePairingGenerationIdRef.current = requestId
activeMobilePairingGenerationIdRef.current = requestId
setMobilePairingGenerationSource(source)
return requestId
},
[]
)
const isCurrentMobilePairingGeneration = useCallback((requestId: number): boolean => {
return activeMobilePairingGenerationIdRef.current === requestId
}, [])
const finishMobilePairingGeneration = useCallback((requestId: number): void => {
if (activeMobilePairingGenerationIdRef.current !== requestId) {
return
}
activeMobilePairingGenerationIdRef.current = null
setMobilePairingGenerationSource(null)
}, [])
const loadDevices = useCallback(async () => {
try {
@@ -81,6 +115,10 @@ export function MobilePane(): React.JSX.Element {
const generateQR = useCallback(
async (opts: { rotate?: boolean } = {}) => {
const requestId = tryBeginMobilePairingGeneration('direct')
if (requestId === null) {
return
}
setLoading(true)
try {
// Why: pass rotate=true on explicit Regenerate clicks so the runtime
@@ -90,9 +128,13 @@ export function MobilePane(): React.JSX.Element {
...(selectedAddress ? { address: selectedAddress } : {}),
...(opts.rotate ? { rotate: true } : {})
})
if (!isCurrentMobilePairingGeneration(requestId)) {
return
}
if (result.available) {
setQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
setPairingSource('direct')
setEndpoint(result.endpoint)
setCodeCopied(false)
void loadDevices()
@@ -100,12 +142,21 @@ export function MobilePane(): React.JSX.Element {
toast.error('WebSocket transport is not running')
}
} catch {
toast.error('Failed to generate QR code')
if (isCurrentMobilePairingGeneration(requestId)) {
toast.error('Failed to generate QR code')
}
} finally {
setLoading(false)
finishMobilePairingGeneration(requestId)
}
},
[loadDevices, selectedAddress]
[
finishMobilePairingGeneration,
isCurrentMobilePairingGeneration,
loadDevices,
selectedAddress,
tryBeginMobilePairingGeneration
]
)
useEffect(() => {
@@ -156,6 +207,51 @@ export function MobilePane(): React.JSX.Element {
}
}
const clearPairingCode = useCallback(() => {
setQrDataUrl(null)
setPairingUrl(null)
setPairingSource(null)
setEndpoint(null)
setCodeCopied(false)
}, [])
const pairingCodeDisplay = qrDataUrl ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
<button
type="button"
onClick={() => setQrEnlarged(true)}
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
>
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
<p className="text-muted-foreground max-w-xs text-center text-xs">
Scan this code with the Orca mobile app. Each code creates a unique device token.
</p>
{pairingUrl && (
<div className="flex w-full max-w-lg flex-col gap-1.5 px-4">
<div className="text-muted-foreground text-center text-xs">
Or paste this code in the mobile app:
</div>
<Button
variant="outline"
size="sm"
onClick={() => void copyPairingCode()}
className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3"
>
<span className="flex-1 text-left">{pairingUrl}</span>
{codeCopied ? (
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
) : (
<Copy className="ml-2 size-3.5 shrink-0" />
)}
</Button>
</div>
)}
</div>
) : null
return (
<div className="space-y-6">
<MobileNetworkInterfaceSection
@@ -165,47 +261,34 @@ export function MobilePane(): React.JSX.Element {
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })}
loading={loading}
hasQrCode={qrDataUrl != null}
onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })}
disabled={mobilePairingGenerationSource !== null}
hasQrCode={pairingSource === 'direct' && qrDataUrl != null}
onGenerateQr={() =>
void generateQR({ rotate: pairingSource === 'direct' && qrDataUrl != null })
}
/>
{pairingSource === 'direct' ? pairingCodeDisplay : null}
{/* QR code display */}
{qrDataUrl && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
<button
type="button"
onClick={() => setQrEnlarged(true)}
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
>
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
<p className="text-muted-foreground max-w-xs text-center text-xs">
Scan this code with the Orca mobile app. Each code creates a unique device token.
</p>
{pairingUrl && (
<div className="flex w-full max-w-lg flex-col gap-1.5 px-4">
<div className="text-muted-foreground text-center text-xs">
Or paste this code in the mobile app:
</div>
<Button
variant="outline"
size="sm"
onClick={() => void copyPairingCode()}
className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3"
>
<span className="flex-1 text-left">{pairingUrl}</span>
{codeCopied ? (
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
) : (
<Copy className="ml-2 size-3.5 shrink-0" />
)}
</Button>
</div>
)}
</div>
)}
<RuntimeRelaySettingsSection
onMobilePairingGenerated={(result) => {
setQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
setPairingSource('relay')
setEndpoint(result.endpoint)
setCodeCopied(false)
void loadDevices()
}}
onRelayConfigChanged={() => {
if (pairingSource === 'relay') {
clearPairingCode()
}
}}
mobilePairingBusy={mobilePairingGenerationSource !== null}
tryBeginMobilePairingGeneration={() => tryBeginMobilePairingGeneration('relay')}
isCurrentMobilePairingGeneration={isCurrentMobilePairingGeneration}
finishMobilePairingGeneration={finishMobilePairingGeneration}
/>
{pairingSource === 'relay' ? pairingCodeDisplay : null}
{/* Paired devices */}
<div>
@@ -1,4 +1,5 @@
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
/* eslint-disable max-lines -- Why: this settings component keeps direct, relay, and grant-management state together so generated access links and revocation stay in sync. */
import { Check, Copy, Loader2, RadioTower, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants'
@@ -95,6 +96,7 @@ export function RuntimePairingUrlGenerator({
)
const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress)
const [customAddress, setCustomAddress] = useState(runtimePairingUrlCache.customAddress)
const [relayConnected, setRelayConnected] = useState(false)
const [runtimePairingUrl, setRuntimePairingUrl] = useState<string | null>(
runtimePairingUrlCache.runtimePairingUrl
)
@@ -109,9 +111,13 @@ export function RuntimePairingUrlGenerator({
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(null)
const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
const [runtimePairingGenerationSource, setRuntimePairingGenerationSource] = useState<
'direct' | 'relay' | null
>(null)
const networkInterfaceLoadIdRef = useRef(0)
const accessGrantLoadIdRef = useRef(0)
const runtimePairingGenerationIdRef = useRef(0)
const activeRuntimePairingGenerationIdRef = useRef<number | null>(null)
const loadRuntimeAccessGrants = useCallback(
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
@@ -175,6 +181,37 @@ export function RuntimePairingUrlGenerator({
}
}, [loadRuntimeAccessGrants])
const loadRelayConnectionStatus = useCallback(async (): Promise<void> => {
try {
const result = await window.api.mobile.getRelayStatus()
setRelayConnected(result.status.state === 'connected')
} catch {
setRelayConnected(false)
}
}, [])
// Why: the relay can be enabled from the Mobile pane while this generator is
// already mounted, so keep the relay action's disabled state current.
useEffect(() => {
if (!showGeneratorForm) {
return
}
let cancelled = false
const refresh = async (): Promise<void> => {
if (!cancelled) {
await loadRelayConnectionStatus()
}
}
void refresh()
const interval = window.setInterval(() => {
void refresh()
}, 2_000)
return () => {
cancelled = true
window.clearInterval(interval)
}
}, [loadRelayConnectionStatus, showGeneratorForm])
const clearGeneratedUrls = (): void => {
runtimePairingUrlCache.runtimePairingUrl = null
runtimePairingUrlCache.webClientUrl = null
@@ -184,14 +221,43 @@ export function RuntimePairingUrlGenerator({
setRuntimePairingDeviceId(null)
}
const tryBeginRuntimePairingGeneration = (source: 'direct' | 'relay'): number | null => {
if (activeRuntimePairingGenerationIdRef.current !== null) {
return null
}
const requestId = runtimePairingGenerationIdRef.current + 1
runtimePairingGenerationIdRef.current = requestId
activeRuntimePairingGenerationIdRef.current = requestId
setRuntimePairingGenerationSource(source)
return requestId
}
const isCurrentRuntimePairingGeneration = (requestId: number): boolean => {
return activeRuntimePairingGenerationIdRef.current === requestId
}
const finishRuntimePairingGeneration = (requestId: number): void => {
if (activeRuntimePairingGenerationIdRef.current !== requestId) {
return
}
activeRuntimePairingGenerationIdRef.current = null
setRuntimePairingGenerationSource(null)
}
const generateRuntimePairingUrl = async (): Promise<void> => {
setIsGeneratingPairing(true)
const requestId = tryBeginRuntimePairingGeneration('direct')
if (requestId === null) {
return
}
try {
const advertiseAddress = customAddress.trim() || selectedAddress
const result = await window.api.mobile.getRuntimePairingUrl({
address: advertiseAddress,
rotate: true
})
if (!isCurrentRuntimePairingGeneration(requestId)) {
return
}
if (!result.available) {
clearGeneratedUrls()
toast.error('Runtime pairing is unavailable.')
@@ -206,9 +272,47 @@ export function RuntimePairingUrlGenerator({
await loadRuntimeAccessGrants()
toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.')
if (isCurrentRuntimePairingGeneration(requestId)) {
toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.')
}
} finally {
setIsGeneratingPairing(false)
finishRuntimePairingGeneration(requestId)
}
}
const generateRelayRuntimePairingUrl = async (): Promise<void> => {
const requestId = tryBeginRuntimePairingGeneration('relay')
if (requestId === null) {
return
}
try {
const result = await window.api.mobile.getRelayRuntimePairingUrl({ rotate: true })
if (!isCurrentRuntimePairingGeneration(requestId)) {
return
}
if (!result.available) {
clearGeneratedUrls()
toast.error('Relay access link is unavailable.')
return
}
runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
runtimePairingUrlCache.webClientUrl = result.webClientUrl
runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId
setRuntimePairingUrl(result.pairingUrl)
setWebClientUrl(result.webClientUrl)
setRuntimePairingDeviceId(result.deviceId)
await loadRuntimeAccessGrants()
toast.success(
result.webClientUrl ? 'Generated relay web client URL.' : 'Generated relay pairing URL.'
)
} catch (error) {
if (isCurrentRuntimePairingGeneration(requestId)) {
toast.error(
error instanceof Error ? error.message : 'Failed to generate relay pairing URL.'
)
}
} finally {
finishRuntimePairingGeneration(requestId)
}
}
@@ -252,6 +356,9 @@ export function RuntimePairingUrlGenerator({
? 'space-y-3 rounded-lg border border-border/50 bg-muted/25 p-3'
: 'space-y-4'
const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : ''
const isGeneratingPairing = runtimePairingGenerationSource === 'direct'
const isGeneratingRelayPairing = runtimePairingGenerationSource === 'relay'
const isGeneratingAnyPairing = runtimePairingGenerationSource !== null
const updateSelectedAddress = (address: string): void => {
runtimePairingUrlCache.selectedAddress = address
@@ -342,18 +449,46 @@ export function RuntimePairingUrlGenerator({
127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for
another device.
</p>
<div className="flex justify-end">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void generateRuntimePairingUrl()}
disabled={isGeneratingPairing}
disabled={isGeneratingAnyPairing}
>
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Generate Access Link
{isGeneratingPairing ? 'Generating…' : 'Generate direct link'}
</Button>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: wrap in span so the tooltip still surfaces while the
relay button is disabled — Radix can't fire on a disabled
button itself. */}
<span tabIndex={relayConnected ? -1 : 0}>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void generateRelayRuntimePairingUrl()}
disabled={isGeneratingAnyPairing || !relayConnected}
>
{isGeneratingRelayPairing ? (
<Loader2 className="animate-spin" />
) : (
<RadioTower />
)}
{isGeneratingRelayPairing ? 'Generating…' : 'Generate relay link'}
</Button>
</span>
</TooltipTrigger>
{!relayConnected ? (
<TooltipContent side="bottom" sideOffset={6}>
Connect a relay in Mobile settings to share this server through it.
</TooltipContent>
) : null}
</Tooltip>
</div>
</div>
@@ -0,0 +1,311 @@
import { Loader2, QrCode, RadioTower, RefreshCw, Save } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SettingsSwitch } from './SettingsFormControls'
import { cn } from '@/lib/utils'
type RuntimeRelayConfig = {
enabled: boolean
endpoint: string
}
type RuntimeRelayStatus = {
state: string
activeDataSockets: number
error: string | null
}
type RelayMobilePairingResult = {
qrDataUrl: string
pairingUrl: string
endpoint: string
deviceId: string
}
type RuntimeRelaySettingsSectionProps = {
onMobilePairingGenerated: (result: RelayMobilePairingResult) => void
onRelayConfigChanged?: () => void
mobilePairingBusy?: boolean
tryBeginMobilePairingGeneration?: () => number | null
isCurrentMobilePairingGeneration?: (requestId: number) => boolean
finishMobilePairingGeneration?: (requestId: number) => void
}
function relayStatusLabel(status: RuntimeRelayStatus): string {
if (status.state === 'connected') {
return status.activeDataSockets > 0
? `Connected · ${status.activeDataSockets} active`
: 'Connected'
}
if (status.state === 'connecting') {
return 'Connecting…'
}
if (status.state === 'error') {
return 'Error'
}
return 'Off'
}
function relayStatusToneClass(status: RuntimeRelayStatus): string {
if (status.state === 'error') {
return 'border-destructive/40 text-destructive'
}
if (status.state === 'connected') {
return 'border-border/60 text-foreground/80'
}
return 'border-border/60 text-muted-foreground'
}
export function RuntimeRelaySettingsSection({
onMobilePairingGenerated,
onRelayConfigChanged,
mobilePairingBusy = false,
tryBeginMobilePairingGeneration,
isCurrentMobilePairingGeneration,
finishMobilePairingGeneration
}: RuntimeRelaySettingsSectionProps): React.JSX.Element {
const [config, setConfig] = useState<RuntimeRelayConfig>({ enabled: false, endpoint: '' })
const [endpointInput, setEndpointInput] = useState('')
const [enabled, setEnabled] = useState(false)
const [status, setStatus] = useState<RuntimeRelayStatus>({
state: 'disabled',
activeDataSockets: 0,
error: null
})
const [loadingConfig, setLoadingConfig] = useState(true)
const [saving, setSaving] = useState(false)
const [refreshingStatus, setRefreshingStatus] = useState(false)
const [generatingQr, setGeneratingQr] = useState(false)
const refreshingStatusRef = useRef(false)
const loadRelayConfig = useCallback(async (): Promise<void> => {
setLoadingConfig(true)
try {
const result = await window.api.mobile.getRelayConfig()
setConfig(result.config)
setEndpointInput(result.config.endpoint)
setEnabled(result.config.enabled)
setStatus(result.status)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load relay settings.')
} finally {
setLoadingConfig(false)
}
}, [])
const refreshRelayStatus = useCallback(
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
// Why: interval and manual refresh can fire together; one in-flight read
// keeps older relay status responses from replacing newer UI state.
if (refreshingStatusRef.current) {
return
}
refreshingStatusRef.current = true
setRefreshingStatus(true)
try {
const result = await window.api.mobile.getRelayStatus()
setStatus(result.status)
} catch (error) {
if (options.showToastOnError) {
toast.error(error instanceof Error ? error.message : 'Failed to refresh relay status.')
}
} finally {
refreshingStatusRef.current = false
setRefreshingStatus(false)
}
},
[]
)
useEffect(() => {
void loadRelayConfig()
}, [loadRelayConfig])
useEffect(() => {
if (!enabled) {
return
}
const interval = window.setInterval(() => {
void refreshRelayStatus()
}, 2_000)
return () => window.clearInterval(interval)
}, [enabled, refreshRelayStatus])
const saveRelayConfig = async (): Promise<void> => {
setSaving(true)
try {
const result = await window.api.mobile.updateRelayConfig({
enabled,
endpoint: endpointInput.trim()
})
setConfig(result.config)
setEndpointInput(result.config.endpoint)
setEnabled(result.config.enabled)
setStatus(result.status)
onRelayConfigChanged?.()
toast.success(result.config.enabled ? 'Relay enabled.' : 'Relay disabled.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save relay settings.')
} finally {
setSaving(false)
}
}
const generateRelayQr = async (): Promise<void> => {
const pairingRequestId = tryBeginMobilePairingGeneration?.()
if (pairingRequestId === null) {
return
}
const requestId = pairingRequestId ?? 0
setGeneratingQr(true)
try {
const result = await window.api.mobile.getRelayPairingQR({ rotate: true, scope: 'mobile' })
if (isCurrentMobilePairingGeneration && !isCurrentMobilePairingGeneration(requestId)) {
return
}
if (!result.available) {
toast.error('Relay pairing is unavailable.')
await refreshRelayStatus()
return
}
onMobilePairingGenerated(result)
toast.success('Generated relay pairing code.')
} catch (error) {
if (!isCurrentMobilePairingGeneration || isCurrentMobilePairingGeneration(requestId)) {
toast.error(error instanceof Error ? error.message : 'Failed to generate relay QR code.')
}
} finally {
setGeneratingQr(false)
finishMobilePairingGeneration?.(requestId)
}
}
const hasUnsavedChanges = config.enabled !== enabled || config.endpoint !== endpointInput.trim()
const relayConnected = status.state === 'connected'
const relayConfigLocked = loadingConfig || saving || generatingQr || mobilePairingBusy
return (
<div className="rounded-lg border border-border/60 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<RadioTower className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Self-hosted relay (fallback)</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[11px]',
relayStatusToneClass(status)
)}
>
{relayStatusLabel(status)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void refreshRelayStatus({ showToastOnError: true })}
disabled={refreshingStatus}
aria-label="Refresh relay status"
className="text-muted-foreground"
>
<RefreshCw className={refreshingStatus ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh relay status
</TooltipContent>
</Tooltip>
</div>
</div>
<p className="mb-3 text-xs text-muted-foreground">
If direct pairing can&apos;t reach this computer, route traffic through a relay you trust.
Pairing data and runtime traffic stay end-to-end encrypted; the relay only sees ciphertext.
</p>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 space-y-1">
<Label htmlFor="runtime-relay-endpoint">Relay endpoint</Label>
<Input
id="runtime-relay-endpoint"
value={endpointInput}
onChange={(event) => setEndpointInput(event.target.value)}
placeholder="wss://relay.example.com/ws?enrollmentToken=..."
disabled={relayConfigLocked}
className="h-8 font-mono text-xs"
/>
</div>
<div className="flex items-end gap-2">
<SettingsSwitch
checked={enabled}
onChange={() => setEnabled((current) => !current)}
ariaLabel="Enable self-hosted relay"
disabled={relayConfigLocked}
/>
<Button
type="button"
variant={hasUnsavedChanges ? 'default' : 'outline'}
size="sm"
className="gap-1.5"
onClick={() => void saveRelayConfig()}
disabled={relayConfigLocked || !hasUnsavedChanges}
>
{saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
{status.error ? <p className="text-xs text-destructive">{status.error}</p> : null}
<div className="flex flex-wrap items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
{/* Why: wrap in span so the tooltip still surfaces while the
button is disabled (Radix triggers can't fire on the disabled
button itself). */}
<span tabIndex={relayConnected ? -1 : 0}>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void generateRelayQr()}
disabled={
!relayConnected || generatingQr || mobilePairingBusy || hasUnsavedChanges
}
>
{generatingQr ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<QrCode className="size-3.5" />
)}
{generatingQr ? 'Generating…' : 'Generate pairing QR'}
</Button>
</span>
</TooltipTrigger>
{!relayConnected || hasUnsavedChanges ? (
<TooltipContent side="bottom" sideOffset={6}>
{hasUnsavedChanges
? 'Save changes before generating a relay code.'
: 'Connect to the relay to generate a relay code.'}
</TooltipContent>
) : null}
</Tooltip>
<p className="text-xs text-muted-foreground">
Paste a relay URL with its enrollment token, save, then generate a code for the mobile
app.
</p>
</div>
</div>
</div>
)
}
@@ -28,6 +28,12 @@ export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'remote'
]
},
{
title: 'Self-hosted relay',
description:
'Connect mobile and remote clients through a relay when direct pairing cannot reach this computer.',
keywords: ['mobile', 'relay', 'remote', 'pair', 'websocket', 'server', 'client', 'self-hosted']
},
{
title: 'When you leave the mobile app',
description:
+14
View File
@@ -448,6 +448,20 @@ function createWebPreloadApi(): Partial<PreloadApi> {
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
getPairingQR: () => Promise.resolve({ available: false }),
getRuntimePairingUrl: () => Promise.resolve({ available: false }),
getRelayPairingQR: () => Promise.resolve({ available: false, reason: 'web_client' }),
getRelayRuntimePairingUrl: () => Promise.resolve({ available: false, reason: 'web_client' }),
getRelayConfig: () =>
Promise.resolve({
config: { enabled: false, endpoint: '' },
status: { state: 'disabled', activeDataSockets: 0, error: null }
}),
updateRelayConfig: () =>
Promise.resolve({
config: { enabled: false, endpoint: '' },
status: { state: 'disabled', activeDataSockets: 0, error: null }
}),
getRelayStatus: () =>
Promise.resolve({ status: { state: 'disabled', activeDataSockets: 0, error: null } }),
listDevices: () => Promise.resolve({ devices: [] }),
revokeDevice: () => Promise.resolve({ revoked: false }),
listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }),
+106 -1
View File
@@ -14,16 +14,24 @@ import {
encryptBytes as encryptSharedBytes
} from '../../../shared/e2ee-crypto'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
createRelayTextFrame,
encodeRelayBinaryFrame
} from '../../../shared/runtime-relay-transport'
class FakeWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static instances: FakeWebSocket[] = []
readyState = FakeWebSocket.CONNECTING
binaryType = 'arraybuffer'
close = vi.fn()
send = vi.fn()
onmessage?: (event: { data: unknown }) => void
constructor(readonly _url: string) {}
constructor(readonly _url: string) {
FakeWebSocket.instances.push(this)
}
}
describe('WebRuntimeClient', () => {
@@ -35,6 +43,7 @@ describe('WebRuntimeClient', () => {
btoa: (value: string) => Buffer.from(value, 'binary').toString('base64')
})
vi.stubGlobal('WebSocket', FakeWebSocket)
FakeWebSocket.instances = []
})
afterEach(() => {
@@ -378,6 +387,102 @@ describe('WebRuntimeClient', () => {
client.close()
})
it('ignores handshake messages from sockets that are no longer current', () => {
const serverKeys = generateKeyPair()
const client = new WebRuntimeClient({
v: 2,
endpoint: 'wss://relay.orca.test/runtime?role=client&serverId=server-1',
deviceToken: 'token',
publicKeyB64: publicKeyToBase64(serverKeys.publicKey)
})
const staleSocket = FakeWebSocket.instances[0]
const currentSocket = new FakeWebSocket('wss://relay.orca.test/runtime')
currentSocket.readyState = FakeWebSocket.OPEN
const internals = client as unknown as {
ws: WebSocket
state: 'handshaking'
sharedKey: Uint8Array
}
internals.ws = currentSocket as unknown as WebSocket
internals.state = 'handshaking'
internals.sharedKey = new Uint8Array(32).fill(7)
staleSocket?.onmessage?.({
data: JSON.stringify({ type: 'e2ee_ready', challenge: 'old-challenge' })
})
expect(currentSocket.send).not.toHaveBeenCalled()
client.close()
})
it('serializes delayed relay Blob frames before later text frames', async () => {
let releaseBlob!: () => void
const blobReleased = new Promise<void>((resolve) => {
releaseBlob = resolve
})
class DelayedBlob extends Blob {
override async arrayBuffer(): Promise<ArrayBuffer> {
await blobReleased
return super.arrayBuffer()
}
}
const client = new WebRuntimeClient({
v: 2,
endpoint: 'wss://relay.orca.test/runtime?role=client&serverId=server-1',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const socket = FakeWebSocket.instances[0]
const sharedKey = new Uint8Array(32).fill(7)
const calls: string[] = []
const onResponse = vi.fn(() => calls.push('text'))
const onBinary = vi.fn(() => calls.push('binary'))
const internals = client as unknown as {
state: 'connected'
sharedKey: Uint8Array
subscriptions: Map<
string,
{ callbacks: { onResponse: typeof onResponse; onBinary: typeof onBinary } }
>
}
internals.state = 'connected'
internals.sharedKey = sharedKey
internals.subscriptions.set('stream-1', { callbacks: { onResponse, onBinary } })
const binaryFrame = encryptSharedBytes(
encodeRelayBinaryFrame(1, new Uint8Array([9, 8, 7])),
sharedKey
)
const textFrame = encrypt(
createRelayTextFrame(
2,
JSON.stringify({
id: 'stream-1',
ok: true,
streaming: true,
result: { type: 'ready' },
_meta: { runtimeId: 'runtime-web-test' }
})
),
sharedKey
)
socket?.onmessage?.({ data: new DelayedBlob([Uint8Array.from(binaryFrame)]) })
socket?.onmessage?.({ data: textFrame })
await Promise.resolve()
expect(onResponse).not.toHaveBeenCalled()
expect(socket?.close).not.toHaveBeenCalled()
releaseBlob()
await vi.waitFor(() => expect(onResponse).toHaveBeenCalledTimes(1))
expect(onBinary).toHaveBeenCalledWith(new Uint8Array([9, 8, 7]))
expect(calls).toEqual(['binary', 'text'])
expect(socket?.close).not.toHaveBeenCalled()
client.close()
})
it('receives encrypted subscription binary frames over a paired web socket', async () => {
vi.stubGlobal('WebSocket', WebSocket)
const serverKeys = generateKeyPair()
+102 -5
View File
@@ -3,6 +3,15 @@
binary frame forwarding as one transport boundary. */
import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope'
import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
import {
RelayFrameSequencer,
createRelayTextFrame,
decodeRelayBinaryFrame,
encodeRelayBinaryFrame,
getRuntimeWebSocketTransportKind,
parseRelayTextFrame,
type RuntimeWebSocketTransportKind
} from '../../../shared/runtime-relay-transport'
import type { WebPairingOffer } from './web-pairing'
import {
decrypt,
@@ -69,9 +78,12 @@ export class WebRuntimeClient {
private readonly childClients = new Set<WebRuntimeClient>()
private readonly waiters: { resolve: () => void; reject: (error: Error) => void }[] = []
private readonly serverPublicKey: Uint8Array
private readonly transportKind: RuntimeWebSocketTransportKind
private relaySequencer = new RelayFrameSequencer()
constructor(private readonly pairing: WebPairingOffer) {
this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64)
this.transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint)
this.openConnection()
}
@@ -313,6 +325,7 @@ export class WebRuntimeClient {
ws.binaryType = 'arraybuffer'
this.ws = ws
this.sharedKey = null
this.relaySequencer = new RelayFrameSequencer()
this.setState('connecting')
this.connectTimer = window.setTimeout(() => {
@@ -343,7 +356,27 @@ export class WebRuntimeClient {
}, HANDSHAKE_TIMEOUT_MS)
}
let relayInboundQueue = Promise.resolve()
ws.onmessage = (event) => {
if (this.ws !== ws) {
return
}
if (this.transportKind === 'relay') {
const rawData = event.data
// Why: browser Blob decoding is async, so relay frames must be
// processed in socket arrival order before sequence validation.
relayInboundQueue = relayInboundQueue
.catch(() => undefined)
.then(() => {
if (this.ws !== ws) {
return
}
return this.handleSocketMessage(rawData)
})
.catch(() => undefined)
void relayInboundQueue
return
}
void this.handleSocketMessage(event.data)
}
@@ -364,7 +397,14 @@ export class WebRuntimeClient {
try {
const control = JSON.parse(raw) as { type?: unknown }
if (control.type === 'e2ee_ready') {
this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken })
this.sendEncrypted({
type: 'e2ee_auth',
deviceToken: this.pairing.deviceToken,
challenge:
typeof (control as { challenge?: unknown }).challenge === 'string'
? (control as { challenge: string }).challenge
: undefined
})
return
}
} catch {
@@ -410,13 +450,21 @@ export class WebRuntimeClient {
if (!plaintext) {
return
}
const payload = this.unwrapRelayBinaryFrame(plaintext)
if (!payload) {
return
}
for (const subscription of this.subscriptions.values()) {
subscription.callbacks.onBinary?.(plaintext)
subscription.callbacks.onBinary?.(payload)
}
return
}
const plaintext = decrypt(raw, this.sharedKey)
const decrypted = decrypt(raw, this.sharedKey)
if (decrypted === null) {
return
}
const plaintext = this.unwrapRelayTextFrame(decrypted)
if (plaintext === null) {
return
}
@@ -466,7 +514,8 @@ export class WebRuntimeClient {
if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) {
return false
}
ws.send(encrypt(JSON.stringify(message), this.sharedKey))
const plaintext = JSON.stringify(message)
ws.send(encrypt(this.wrapRelayTextFrame(plaintext), this.sharedKey))
return true
}
@@ -475,10 +524,58 @@ export class WebRuntimeClient {
if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) {
return false
}
ws.send(encryptBytes(bytes, this.sharedKey))
ws.send(encryptBytes(this.wrapRelayBinaryFrame(bytes), this.sharedKey))
return true
}
private wrapRelayTextFrame(payload: string): string {
if (this.transportKind !== 'relay' || this.state !== 'connected') {
return payload
}
const seq = this.relaySequencer.nextOutboundSeq()
if (seq === null) {
this.ws?.close()
return payload
}
return createRelayTextFrame(seq, payload)
}
private wrapRelayBinaryFrame(payload: Uint8Array<ArrayBufferLike>): Uint8Array {
if (this.transportKind !== 'relay' || this.state !== 'connected') {
return payload
}
const seq = this.relaySequencer.nextOutboundSeq()
if (seq === null) {
this.ws?.close()
return payload
}
return encodeRelayBinaryFrame(seq, payload)
}
private unwrapRelayTextFrame(plaintext: string): string | null {
if (this.transportKind !== 'relay') {
return plaintext
}
const frame = parseRelayTextFrame(plaintext)
if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) {
this.ws?.close()
return null
}
return frame.payload
}
private unwrapRelayBinaryFrame(plaintext: Uint8Array<ArrayBufferLike>): Uint8Array | null {
if (this.transportKind !== 'relay') {
return plaintext
}
const frame = decodeRelayBinaryFrame(plaintext)
if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) {
this.ws?.close()
return null
}
return frame.payload
}
private waitForConnected(timeoutMs = REQUEST_TIMEOUT_MS): Promise<void> {
if (this.state === 'connected') {
return Promise.resolve()
+131
View File
@@ -0,0 +1,131 @@
import { randomBytes, randomUUID } from 'crypto'
import type { IncomingMessage } from 'http'
export { RUNTIME_RELAY_MAX_FRAME_BYTES } from '../shared/runtime-relay-limits'
export const RUNTIME_RELAY_PROTOCOL_VERSION = 1
export const RUNTIME_RELAY_WS_PATH = '/ws'
export const RUNTIME_RELAY_DEFAULT_PORT = 8787
export const RUNTIME_RELAY_ENROLLMENT_TOKEN_BYTES = 32
export const RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER = 64
export const RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER = 64
export const RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS = 1024
export const RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS = 1024
export const RUNTIME_RELAY_MAX_BOUND_SERVERS = 10_000
export const RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION = 4
export const RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION = 4 * 1024 * 1024
export const RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER = 16 * 1024 * 1024
export const RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES = 64 * 1024 * 1024
export const RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES = 8 * 1024 * 1024
export const RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES = 1024 * 1024
export const RUNTIME_RELAY_ATTACH_DEADLINE_MS = 15_000
export const RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS = 15_000
export const RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS = 45_000
const SERVER_ID_RE = /^[A-Za-z0-9_-]{16,128}$/
const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{16,128}$/
const HOST_TOKEN_HASH_RE = /^[a-f0-9]{64}$/
const MIN_HOST_TOKEN_LENGTH = 32
export type RuntimeRelayRole = 'server' | 'client'
export type RuntimeRelayClientRequest = {
role: 'client'
serverId: string
}
export type RuntimeRelayServerRequest =
| {
role: 'server'
serverId: string
hostToken: string
enrollmentToken: string | null
connectionId: null
}
| {
role: 'server'
serverId: string
hostToken: string
enrollmentToken: string | null
connectionId: string
}
export type RuntimeRelayRequest = RuntimeRelayClientRequest | RuntimeRelayServerRequest
export type RelayControlMessage =
| { type: 'sync'; connectionIds: string[] }
| { type: 'connected'; connectionId: string }
| { type: 'disconnected'; connectionId: string }
export function createRelayConnectionId(): string {
return randomUUID().replace(/-/g, '')
}
export function createRelayEnrollmentToken(): string {
return randomBytes(RUNTIME_RELAY_ENROLLMENT_TOKEN_BYTES).toString('base64url')
}
export function isRuntimeRelayServerId(value: unknown): value is string {
return typeof value === 'string' && SERVER_ID_RE.test(value)
}
export function isRuntimeRelayHostTokenHash(value: unknown): value is string {
return typeof value === 'string' && HOST_TOKEN_HASH_RE.test(value)
}
export function parseRuntimeRelayRequest(
request: IncomingMessage
): { ok: true; value: RuntimeRelayRequest } | { ok: false; statusCode: number; reason: string } {
const host = request.headers.host ?? '127.0.0.1'
let url: URL
try {
url = new URL(request.url ?? '/', `http://${host}`)
} catch {
return { ok: false, statusCode: 400, reason: 'invalid_url' }
}
if (url.pathname !== RUNTIME_RELAY_WS_PATH) {
return { ok: false, statusCode: 404, reason: 'not_found' }
}
if (url.searchParams.get('v') !== String(RUNTIME_RELAY_PROTOCOL_VERSION)) {
return { ok: false, statusCode: 400, reason: 'invalid_version' }
}
const role = url.searchParams.get('role')
const serverId = url.searchParams.get('serverId')
if ((role !== 'server' && role !== 'client') || !isRuntimeRelayServerId(serverId)) {
return { ok: false, statusCode: 400, reason: 'invalid_relay_request' }
}
if (role === 'client') {
return { ok: true, value: { role, serverId } }
}
const hostToken = parseHostAuthorization(request)
if (!hostToken || hostToken.length < MIN_HOST_TOKEN_LENGTH) {
return { ok: false, statusCode: 401, reason: 'invalid_host_token' }
}
const connectionId = url.searchParams.get('connectionId')
if (connectionId !== null && !CONNECTION_ID_RE.test(connectionId)) {
return { ok: false, statusCode: 400, reason: 'invalid_connection_id' }
}
return {
ok: true,
value: {
role,
serverId,
hostToken,
enrollmentToken: parseEnrollmentToken(request),
connectionId
}
}
}
function parseHostAuthorization(request: IncomingMessage): string | null {
const authorization = request.headers.authorization
if (!authorization) {
return null
}
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim())
return match?.[1] ?? null
}
function parseEnrollmentToken(request: IncomingMessage): string | null {
const value = request.headers['x-orca-relay-enrollment']
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
@@ -0,0 +1,157 @@
import { randomBytes } from 'crypto'
import { connect, type Socket } from 'net'
import WebSocket, { type RawData } from 'ws'
const OPEN_TIMEOUT_MS = 1_000
export function openRelaySocket(
baseUrl: string,
options:
| { role: 'client'; serverId: string }
| {
role: 'server'
serverId: string
token: string
connectionId?: string
enrollmentToken?: string
}
): Promise<WebSocket> {
const url = new URL(baseUrl)
url.searchParams.set('role', options.role)
url.searchParams.set('serverId', options.serverId)
url.searchParams.set('v', '1')
if (options.role === 'server' && options.connectionId) {
url.searchParams.set('connectionId', options.connectionId)
}
const ws = new WebSocket(url, {
headers:
options.role === 'server'
? {
Authorization: `Bearer ${options.token}`,
...(options.enrollmentToken
? { 'X-Orca-Relay-Enrollment': options.enrollmentToken }
: {})
}
: undefined
})
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
ws.close()
reject(new Error('Timed out opening relay socket'))
}, OPEN_TIMEOUT_MS)
ws.once('open', () => {
clearTimeout(timer)
resolve(ws)
})
ws.once('error', (error) => {
clearTimeout(timer)
reject(error)
})
ws.once('close', () => {
clearTimeout(timer)
reject(new Error('Relay socket closed before open'))
})
})
}
export function waitForMessage(ws: WebSocket): Promise<string> {
return new Promise((resolve) => {
ws.once('message', (data) => {
resolve(data.toString())
})
})
}
export function waitForControlMessage(
ws: WebSocket,
type: string
): Promise<Record<string, string>> {
return new Promise((resolve) => {
const onMessage = (data: RawData) => {
const parsed = JSON.parse(data.toString()) as Record<string, string>
if (parsed.type !== type) {
return
}
ws.off('message', onMessage)
resolve(parsed)
}
ws.on('message', onMessage)
})
}
export function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timed out waiting for relay socket close'))
}, OPEN_TIMEOUT_MS)
ws.once('close', (code, reason) => {
clearTimeout(timer)
resolve({ code, reason: reason.toString() })
})
})
}
export function openRawRelaySocket(
baseUrl: string,
options: {
role: 'server'
serverId: string
token: string
enrollmentToken: string
}
): Promise<Socket> {
const url = new URL(baseUrl)
url.searchParams.set('role', options.role)
url.searchParams.set('serverId', options.serverId)
url.searchParams.set('v', '1')
return new Promise((resolve, reject) => {
const socket = connect({ host: url.hostname, port: Number(url.port) })
const timer = setTimeout(() => {
socket.destroy()
reject(new Error('Timed out opening raw relay socket'))
}, OPEN_TIMEOUT_MS)
socket.once('connect', () => {
socket.write(
[
`GET ${url.pathname}${url.search} HTTP/1.1`,
`Host: ${url.host}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${randomBytes(16).toString('base64')}`,
'Sec-WebSocket-Version: 13',
`Authorization: Bearer ${options.token}`,
`X-Orca-Relay-Enrollment: ${options.enrollmentToken}`,
'\r\n'
].join('\r\n')
)
})
socket.once('error', (error) => {
clearTimeout(timer)
reject(error)
})
socket.on('data', (chunk) => {
if (!chunk.toString('utf8').includes('\r\n\r\n')) {
return
}
clearTimeout(timer)
if (chunk.toString('utf8').startsWith('HTTP/1.1 101')) {
resolve(socket)
return
}
socket.destroy()
reject(new Error(`Unexpected relay upgrade response: ${chunk.toString('utf8')}`))
})
})
}
export function waitForRawSocketClose(socket: Socket): Promise<void> {
return new Promise((resolve) => {
socket.once('close', () => resolve())
})
}
export function delay(ms: number): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve('timeout'), ms)
})
}
@@ -0,0 +1,264 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import WebSocket, { type RawData } from 'ws'
import { startRuntimeRelayServer, type RuntimeRelayServer } from './server'
const OPEN_TIMEOUT_MS = 1_000
let relay: RuntimeRelayServer | null = null
let tempDir: string | null = null
afterEach(async () => {
await relay?.stop()
relay = null
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
tempDir = null
}
})
describe('runtime relay server limits', () => {
it('enforces the relay-wide pending client cap', async () => {
relay = await startTestRelay({ maxGlobalPendingClients: 1 })
const serverId = 'serverGlobalPend01'
const token = 'f'.repeat(43)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
enrollmentToken: relay.enrollmentToken
})
const firstClient = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const secondClient = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
await expect(waitForClose(secondClient)).resolves.toMatchObject({
code: 1013,
reason: 'Relay unavailable'
})
control.close()
firstClient.close()
})
it('fails closed when a control message would exceed the control buffer cap', async () => {
relay = await startTestRelay({ maxControlBufferedBytes: 48 })
const serverId = 'serverControlCap01'
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token: 'o'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
const controlClosed = waitForClose(control)
const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const clientClosed = waitForClose(client)
await expect(clientClosed).resolves.toMatchObject({
code: 1013,
reason: 'Relay control buffer full'
})
await expect(controlClosed).resolves.toMatchObject({
code: 1013,
reason: 'Relay control buffer full'
})
})
it('enforces the relay-wide active data socket cap', async () => {
relay = await startTestRelay({ maxGlobalActiveDataSockets: 1 })
const firstServerId = 'serverGlobalData01'
const secondServerId = 'serverGlobalData02'
const firstToken = 'g'.repeat(43)
const secondToken = 'h'.repeat(43)
const firstControl = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: firstServerId,
token: firstToken,
enrollmentToken: relay.enrollmentToken
})
const firstConnected = waitForControlMessage(firstControl, 'connected')
const firstClient = await openRelaySocket(relay.webSocketUrl, {
role: 'client',
serverId: firstServerId
})
const firstConnection = await firstConnected
const firstServerData = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: firstServerId,
token: firstToken,
connectionId: firstConnection.connectionId
})
const secondControl = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: secondServerId,
token: secondToken,
enrollmentToken: relay.enrollmentToken
})
const secondConnected = waitForControlMessage(secondControl, 'connected')
const secondClient = await openRelaySocket(relay.webSocketUrl, {
role: 'client',
serverId: secondServerId
})
const secondConnection = await secondConnected
const secondServerData = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: secondServerId,
token: secondToken,
connectionId: secondConnection.connectionId
})
await expect(waitForClose(secondServerData)).resolves.toMatchObject({
code: 1008,
reason: 'Unknown relay connection'
})
firstControl.close()
firstClient.close()
firstServerData.close()
secondControl.close()
secondClient.close()
})
it('enforces the relay-wide early frame byte cap', async () => {
relay = await startTestRelay({ maxGlobalEarlyBytes: 4 })
const serverId = 'serverGlobalEarly01'
const token = 'i'.repeat(43)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
enrollmentToken: relay.enrollmentToken
})
const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const closed = waitForClose(client)
client.send('12345')
await expect(closed).resolves.toMatchObject({
code: 1013,
reason: 'Relay early buffer full'
})
control.close()
})
it('terminates sockets that miss relay heartbeat liveness checks', async () => {
relay = await startTestRelay({ heartbeatIntervalMs: 5, staleSocketTimeoutMs: 0 })
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: 'serverHeartbeat01',
token: 'j'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
await expect(waitForClose(control)).resolves.toMatchObject({
code: 1006,
reason: ''
})
})
})
type TestRelayOptions = {
maxGlobalPendingClients?: number
maxGlobalActiveDataSockets?: number
maxGlobalEarlyBytes?: number
maxControlBufferedBytes?: number
heartbeatIntervalMs?: number
staleSocketTimeoutMs?: number
}
async function startTestRelay(options: TestRelayOptions = {}): Promise<RuntimeRelayServer> {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
return startRuntimeRelayServer({
host: '127.0.0.1',
port: 0,
statePath: join(tempDir, 'relay-state.json'),
maxGlobalPendingClients: options.maxGlobalPendingClients,
maxGlobalActiveDataSockets: options.maxGlobalActiveDataSockets,
maxGlobalEarlyBytes: options.maxGlobalEarlyBytes,
maxControlBufferedBytes: options.maxControlBufferedBytes,
heartbeatIntervalMs: options.heartbeatIntervalMs,
staleSocketTimeoutMs: options.staleSocketTimeoutMs
})
}
function openRelaySocket(
baseUrl: string,
options:
| { role: 'client'; serverId: string }
| {
role: 'server'
serverId: string
token: string
connectionId?: string
enrollmentToken?: string
}
): Promise<WebSocket> {
const url = new URL(baseUrl)
url.searchParams.set('role', options.role)
url.searchParams.set('serverId', options.serverId)
url.searchParams.set('v', '1')
if (options.role === 'server' && options.connectionId) {
url.searchParams.set('connectionId', options.connectionId)
}
const ws = new WebSocket(url, {
headers:
options.role === 'server'
? {
Authorization: `Bearer ${options.token}`,
...(options.enrollmentToken
? { 'X-Orca-Relay-Enrollment': options.enrollmentToken }
: {})
}
: undefined
})
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
ws.close()
reject(new Error('Timed out opening relay socket'))
}, OPEN_TIMEOUT_MS)
ws.once('open', () => {
clearTimeout(timer)
resolve(ws)
})
ws.once('error', (error) => {
clearTimeout(timer)
reject(error)
})
ws.once('close', () => {
clearTimeout(timer)
reject(new Error('Relay socket closed before open'))
})
})
}
function waitForControlMessage(
ws: WebSocket,
type: string
): Promise<{ type: string; connectionId: string }> {
return new Promise((resolve) => {
const onMessage = (data: RawData) => {
const parsed = JSON.parse(data.toString()) as { type: string; connectionId: string }
if (parsed.type !== type) {
return
}
ws.off('message', onMessage)
resolve(parsed)
}
ws.on('message', onMessage)
})
}
function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Timed out waiting for relay socket close'))
}, OPEN_TIMEOUT_MS)
ws.once('close', (code, reason) => {
clearTimeout(timer)
resolve({ code, reason: reason.toString() })
})
})
}
+294
View File
@@ -0,0 +1,294 @@
import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
delay,
openRawRelaySocket,
openRelaySocket,
waitForClose,
waitForControlMessage,
waitForMessage,
waitForRawSocketClose
} from './relay-server-test-sockets'
import { startRuntimeRelayServer, type RuntimeRelayServer } from './server'
let relay: RuntimeRelayServer | null = null
let tempDir: string | null = null
afterEach(async () => {
await relay?.stop()
relay = null
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
tempDir = null
}
})
describe('runtime relay server', () => {
it('enrolls host control token and rejects later wrong host token', async () => {
relay = await startTestRelay()
const serverId = 'serverTokenBinding01'
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token: 'a'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
await expect(
openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token: 'b'.repeat(43)
})
).rejects.toThrow()
control.close()
})
it('forwards opaque frames between client and host data sockets', async () => {
relay = await startTestRelay()
const serverId = 'serverForwarding01'
const token = 'c'.repeat(43)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
enrollmentToken: relay.enrollmentToken
})
const connectedControl = waitForControlMessage(control, 'connected')
const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const connectedMessage = (await connectedControl) as {
type: string
connectionId: string
}
expect(connectedMessage.type).toBe('connected')
const serverData = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
connectionId: connectedMessage.connectionId
})
const serverReceived = waitForMessage(serverData)
client.send('client-frame')
expect(await serverReceived).toBe('client-frame')
const clientReceived = waitForMessage(client)
serverData.send('server-frame')
expect(await clientReceived).toBe('server-frame')
control.close()
client.close()
serverData.close()
})
it('advertises bracketed URLs for IPv6 hosts', async () => {
relay = await startTestRelay({ host: '::1' })
expect(relay.httpUrl).toMatch(/^http:\/\/\[::1\]:\d+$/)
expect(relay.webSocketUrl).toMatch(/^ws:\/\/\[::1\]:\d+\/ws$/)
})
it('rejects clients for unknown server ids', async () => {
relay = await startTestRelay()
await expect(
openRelaySocket(relay.webSocketUrl, { role: 'client', serverId: 'unknownServerId01' })
).rejects.toThrow()
})
it('rejects first host enrollment without the relay enrollment token', async () => {
relay = await startTestRelay()
await expect(
openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: 'serverNoEnrollment',
token: 'e'.repeat(43)
})
).rejects.toThrow()
})
it('fails closed when persisted relay bindings are unreadable', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
const statePath = join(tempDir, 'relay-state.json')
writeFileSync(statePath, '{not-valid-json')
await expect(
startRuntimeRelayServer({
host: '127.0.0.1',
port: 0,
statePath
})
).rejects.toThrow('Failed to read runtime relay state')
})
it('fails closed when persisted relay bindings have an invalid shape', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
const statePath = join(tempDir, 'relay-state.json')
writeFileSync(statePath, JSON.stringify({ hostTokenHashes: [] }))
await expect(
startRuntimeRelayServer({
host: '127.0.0.1',
port: 0,
statePath
})
).rejects.toThrow('Failed to read runtime relay state')
})
it('fails closed when persisted relay state is not an object', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
const statePath = join(tempDir, 'relay-state.json')
writeFileSync(statePath, JSON.stringify([]))
await expect(
startRuntimeRelayServer({
host: '127.0.0.1',
port: 0,
statePath
})
).rejects.toThrow('Failed to read runtime relay state')
})
it('rejects first host enrollment and rolls back binding when persistence fails', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
const blockerPath = join(tempDir, 'relay-state-parent')
const statePath = join(blockerPath, 'relay-state.json')
const logs: string[] = []
writeFileSync(blockerPath, 'not a directory')
relay = await startRuntimeRelayServer({
host: '127.0.0.1',
port: 0,
statePath,
log: (message) => logs.push(message)
})
const serverId = 'serverPersistFail1'
await expect(
openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token: 'm'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
).rejects.toThrow()
unlinkSync(blockerPath)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token: 'n'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
expect(logs.some((message) => message.includes('failed to persist host binding'))).toBe(true)
control.close()
})
it('closes a connection instead of buffering indefinitely when forwarding stalls', async () => {
relay = await startTestRelay({ maxForwardBufferedBytes: 0 })
const serverId = 'serverBackpressure1'
const token = 'd'.repeat(43)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
enrollmentToken: relay.enrollmentToken
})
const connectedControl = waitForControlMessage(control, 'connected')
const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const connectedMessage = (await connectedControl) as {
type: string
connectionId: string
}
const serverData = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
connectionId: connectedMessage.connectionId
})
const clientClosed = waitForClose(client)
client.send('client-frame')
await expect(clientClosed).resolves.toMatchObject({
code: 1013,
reason: 'Relay forward buffer full'
})
control.close()
serverData.close()
})
it('closes before enqueueing a frame that would exceed the forward buffer cap', async () => {
relay = await startTestRelay({ maxForwardBufferedBytes: 4 })
const serverId = 'serverFrameCap0001'
const token = 'k'.repeat(43)
const control = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
enrollmentToken: relay.enrollmentToken
})
const connectedControl = waitForControlMessage(control, 'connected')
const client = await openRelaySocket(relay.webSocketUrl, { role: 'client', serverId })
const connectedMessage = (await connectedControl) as {
type: string
connectionId: string
}
const serverData = await openRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId,
token,
connectionId: connectedMessage.connectionId
})
const clientClosed = waitForClose(client)
client.send('12345')
await expect(clientClosed).resolves.toMatchObject({
code: 1013,
reason: 'Relay forward buffer full'
})
control.close()
serverData.close()
})
it('force-terminates relay sockets that do not finish shutdown close', async () => {
relay = await startTestRelay({ shutdownGraceMs: 10 })
const rawSocket = await openRawRelaySocket(relay.webSocketUrl, {
role: 'server',
serverId: 'serverShutdown001',
token: 'l'.repeat(43),
enrollmentToken: relay.enrollmentToken
})
const rawSocketClosed = waitForRawSocketClose(rawSocket)
const stopPromise = relay.stop()
relay = null
await expect(Promise.race([stopPromise.then(() => 'stopped'), delay(500)])).resolves.toBe(
'stopped'
)
await expect(rawSocketClosed).resolves.toBeUndefined()
})
})
type TestRelayOptions = {
host?: string
maxForwardBufferedBytes?: number
shutdownGraceMs?: number
}
async function startTestRelay(options: TestRelayOptions = {}): Promise<RuntimeRelayServer> {
tempDir = mkdtempSync(join(tmpdir(), 'orca-runtime-relay-'))
return startRuntimeRelayServer({
host: options.host ?? '127.0.0.1',
port: 0,
statePath: join(tempDir, 'relay-state.json'),
maxForwardBufferedBytes: options.maxForwardBufferedBytes,
shutdownGraceMs: options.shutdownGraceMs
})
}
+796
View File
@@ -0,0 +1,796 @@
/* eslint-disable max-lines -- Why: the relay server keeps the HTTP upgrade, host binding, and socket-forwarding lifecycle in one protocol module so invariants are visible together. */
import { createHash } from 'crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { createServer, type Server as HttpServer } from 'http'
import { isIP } from 'net'
import { dirname, join } from 'path'
import { WebSocket, WebSocketServer, type RawData } from 'ws'
import {
RUNTIME_RELAY_ATTACH_DEADLINE_MS,
RUNTIME_RELAY_DEFAULT_PORT,
RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS,
RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER,
RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES,
RUNTIME_RELAY_MAX_BOUND_SERVERS,
RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION,
RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION,
RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES,
RUNTIME_RELAY_MAX_FRAME_BYTES,
RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS,
RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES,
RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS,
RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER,
RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER,
RUNTIME_RELAY_PROTOCOL_VERSION,
RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS,
RUNTIME_RELAY_WS_PATH,
createRelayEnrollmentToken,
createRelayConnectionId,
isRuntimeRelayHostTokenHash,
isRuntimeRelayServerId,
parseRuntimeRelayRequest,
type RelayControlMessage
} from './protocol'
type RuntimeRelayServerOptions = {
host?: string
port?: number
statePath?: string
log?: (message: string) => void
maxForwardBufferedBytes?: number
enrollmentToken?: string
maxGlobalPendingClients?: number
maxGlobalActiveDataSockets?: number
maxGlobalEarlyBytes?: number
maxTotalForwardBufferedBytesPerServer?: number
maxGlobalForwardBufferedBytes?: number
maxControlBufferedBytes?: number
heartbeatIntervalMs?: number
staleSocketTimeoutMs?: number
shutdownGraceMs?: number
}
export type RuntimeRelayServer = {
httpUrl: string
webSocketUrl: string
enrollmentToken: string
protocolVersion: number
stop: () => Promise<void>
}
type RelayHostState = {
control: WebSocket | null
pendingClients: Map<string, RelayConnectionState>
serverDataSockets: Map<string, WebSocket>
totalBufferedBytes: number
forwardedBufferedBytes: number
}
type RelayServerState = {
hosts: Map<string, RelayHostState>
hostTokenHashes: Map<string, string>
statePath: string
globalPendingClients: number
globalActiveDataSockets: number
globalEarlyBytes: number
globalForwardedBytes: number
limits: RelayServerLimits
}
type RelayConnectionState = {
connectionId: string
client: WebSocket
server: WebSocket | null
earlyFrames: { data: RawData; isBinary: boolean; byteLength: number }[]
bufferedBytes: number
forwardedBufferedBytes: number
pendingForwardedFrames: Map<number, { target: WebSocket; byteLength: number }>
forwardedCloseHandlers: Map<WebSocket, () => void>
nextForwardedFrameId: number
attachTimer: ReturnType<typeof setTimeout>
}
type RelayStateFile = {
hostTokenHashes?: Record<string, string>
}
type RelayServerLimits = {
maxForwardBufferedBytes: number
maxGlobalPendingClients: number
maxGlobalActiveDataSockets: number
maxGlobalEarlyBytes: number
maxTotalForwardBufferedBytesPerServer: number
maxGlobalForwardBufferedBytes: number
maxControlBufferedBytes: number
staleSocketTimeoutMs: number
shutdownGraceMs: number
}
type RelayHeartbeatSocket = WebSocket & {
lastPongAt?: number
}
const DEFAULT_RELAY_STATE_PATH = join(process.cwd(), '.orca-runtime-relay-state.json')
const DEFAULT_SHUTDOWN_GRACE_MS = 1_000
export async function startRuntimeRelayServer(
options: RuntimeRelayServerOptions = {}
): Promise<RuntimeRelayServer> {
const host = options.host ?? '127.0.0.1'
const port = options.port ?? RUNTIME_RELAY_DEFAULT_PORT
const statePath = options.statePath ?? DEFAULT_RELAY_STATE_PATH
const log = options.log ?? (() => {})
const limits: RelayServerLimits = {
maxForwardBufferedBytes:
options.maxForwardBufferedBytes ?? RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES,
maxGlobalPendingClients:
options.maxGlobalPendingClients ?? RUNTIME_RELAY_MAX_GLOBAL_PENDING_CLIENTS,
maxGlobalActiveDataSockets:
options.maxGlobalActiveDataSockets ?? RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS,
maxGlobalEarlyBytes: options.maxGlobalEarlyBytes ?? RUNTIME_RELAY_MAX_GLOBAL_EARLY_BYTES,
maxTotalForwardBufferedBytesPerServer:
options.maxTotalForwardBufferedBytesPerServer ??
RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER * RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES,
maxGlobalForwardBufferedBytes:
options.maxGlobalForwardBufferedBytes ??
(options.maxGlobalActiveDataSockets ?? RUNTIME_RELAY_MAX_GLOBAL_ACTIVE_DATA_SOCKETS) *
RUNTIME_RELAY_MAX_FORWARD_BUFFERED_BYTES,
maxControlBufferedBytes:
options.maxControlBufferedBytes ?? RUNTIME_RELAY_MAX_CONTROL_BUFFERED_BYTES,
staleSocketTimeoutMs: options.staleSocketTimeoutMs ?? RUNTIME_RELAY_STALE_SOCKET_TIMEOUT_MS,
shutdownGraceMs: options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS
}
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? RUNTIME_RELAY_HEARTBEAT_INTERVAL_MS
const enrollmentToken = options.enrollmentToken ?? createRelayEnrollmentToken()
const state: RelayServerState = {
hosts: new Map(),
hostTokenHashes: loadRelayBindings(statePath),
statePath,
globalPendingClients: 0,
globalActiveDataSockets: 0,
globalEarlyBytes: 0,
globalForwardedBytes: 0,
limits
}
const httpServer = createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ ok: true, kind: 'runtime_relay_server' }))
return
}
response.writeHead(404)
response.end('not found')
})
const wss = new WebSocketServer({
noServer: true,
maxPayload: RUNTIME_RELAY_MAX_FRAME_BYTES
})
httpServer.on('upgrade', (request, socket, head) => {
const parsed = parseRuntimeRelayRequest(request)
if (!parsed.ok) {
socket.write(`HTTP/1.1 ${parsed.statusCode} ${parsed.reason}\r\n\r\n`)
socket.destroy()
return
}
if (parsed.value.role === 'server') {
const accepted = acceptHostToken(
state,
parsed.value.serverId,
parsed.value.hostToken,
parsed.value.connectionId === null,
parsed.value.enrollmentToken,
enrollmentToken,
log
)
if (!accepted) {
socket.write('HTTP/1.1 401 invalid_host_token\r\n\r\n')
socket.destroy()
return
}
} else if (!state.hostTokenHashes.has(parsed.value.serverId)) {
socket.write('HTTP/1.1 404 unknown_server\r\n\r\n')
socket.destroy()
return
}
wss.handleUpgrade(request, socket, head, (ws) => {
markSocketAlive(ws)
ws.on('pong', () => markSocketAlive(ws))
if (parsed.value.role === 'server') {
if (parsed.value.connectionId === null) {
attachControlSocket(state, parsed.value.serverId, ws, log)
} else {
attachServerDataSocket(state, parsed.value.serverId, parsed.value.connectionId, ws, log)
}
return
}
attachClientSocket(state, parsed.value.serverId, ws, log)
})
})
const heartbeatTimer = setInterval(() => {
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
if (isSocketStale(client, limits.staleSocketTimeoutMs)) {
client.terminate()
continue
}
client.ping()
}
}
}, heartbeatIntervalMs)
if (typeof heartbeatTimer.unref === 'function') {
heartbeatTimer.unref()
}
await new Promise<void>((resolve, reject) => {
httpServer.once('error', reject)
httpServer.listen(port, host, () => {
httpServer.off('error', reject)
resolve()
})
})
const resolved = httpServer.address()
const resolvedPort = typeof resolved === 'object' && resolved ? resolved.port : port
const urlHost = formatRelayUrlHost(host)
return {
httpUrl: `http://${urlHost}:${resolvedPort}`,
webSocketUrl: `ws://${urlHost}:${resolvedPort}${RUNTIME_RELAY_WS_PATH}`,
enrollmentToken,
protocolVersion: RUNTIME_RELAY_PROTOCOL_VERSION,
stop: async () => {
clearInterval(heartbeatTimer)
await Promise.all([
closeWebSocketServer(wss, limits.shutdownGraceMs),
closeHttpServer(httpServer)
])
}
}
}
function attachControlSocket(
state: RelayServerState,
serverId: string,
ws: WebSocket,
log: (message: string) => void
): void {
const host = getHostState(state.hosts, serverId)
host.control?.close(1012, 'Control socket replaced')
host.control = ws
if (
!sendControl(ws, {
message: { type: 'sync', connectionIds: Array.from(host.pendingClients.keys()) },
maxBufferedBytes: state.limits.maxControlBufferedBytes
})
) {
failControlSocket(state, host, ws, 1013, 'Relay control buffer full')
return
}
log(`[runtime-relay] control connected serverId=${serverId}`)
const cleanupControl = () => {
if (host.control === ws) {
host.control = null
for (const connectionId of Array.from(host.pendingClients.keys())) {
closeConnection(state, host, connectionId, 1011, 'Host control disconnected')
}
}
}
ws.on('close', cleanupControl)
ws.on('error', cleanupControl)
}
function attachClientSocket(
state: RelayServerState,
serverId: string,
ws: WebSocket,
log: (message: string) => void
): void {
const host = getHostState(state.hosts, serverId)
if (
!host.control ||
host.pendingClients.size >= RUNTIME_RELAY_MAX_PENDING_CLIENTS_PER_SERVER ||
state.globalPendingClients >= state.limits.maxGlobalPendingClients
) {
ws.close(1013, 'Relay unavailable')
return
}
const connectionId = createRelayConnectionId()
const attachTimer = setTimeout(() => {
closeConnection(state, host, connectionId, 1013, 'Host data attach timeout')
}, RUNTIME_RELAY_ATTACH_DEADLINE_MS)
if (typeof attachTimer.unref === 'function') {
attachTimer.unref()
}
const connection: RelayConnectionState = {
connectionId,
client: ws,
server: null,
earlyFrames: [],
bufferedBytes: 0,
forwardedBufferedBytes: 0,
pendingForwardedFrames: new Map(),
forwardedCloseHandlers: new Map(),
nextForwardedFrameId: 0,
attachTimer
}
host.pendingClients.set(connectionId, connection)
state.globalPendingClients += 1
if (
!sendControl(host.control, {
message: { type: 'connected', connectionId },
maxBufferedBytes: state.limits.maxControlBufferedBytes
})
) {
failControlSocket(state, host, host.control, 1013, 'Relay control buffer full')
return
}
log(`[runtime-relay] client connected serverId=${serverId} connectionId=${connectionId}`)
ws.on('message', (data, isBinary) => forwardClientFrame(state, host, connection, data, isBinary))
ws.on('close', () => closeConnection(state, host, connectionId, 1000, 'Client disconnected'))
ws.on('error', () => closeConnection(state, host, connectionId, 1011, 'Client socket error'))
}
function attachServerDataSocket(
state: RelayServerState,
serverId: string,
connectionId: string,
ws: WebSocket,
log: (message: string) => void
): void {
const host = getHostState(state.hosts, serverId)
const connection = host.pendingClients.get(connectionId)
const hadServerDataSocket = host.serverDataSockets.has(connectionId)
if (
!connection ||
(!hadServerDataSocket &&
(host.serverDataSockets.size >= RUNTIME_RELAY_MAX_ACTIVE_DATA_SOCKETS_PER_SERVER ||
state.globalActiveDataSockets >= state.limits.maxGlobalActiveDataSockets))
) {
ws.close(1008, 'Unknown relay connection')
return
}
connection.server?.close(1012, 'Server data socket replaced')
connection.server = ws
host.serverDataSockets.set(connectionId, ws)
if (!hadServerDataSocket) {
state.globalActiveDataSockets += 1
}
clearTimeout(connection.attachTimer)
for (const frame of connection.earlyFrames.splice(0)) {
if (!forwardFrame(state, host, connection, ws, frame.data, frame.isBinary)) {
closeConnection(state, host, connectionId, 1013, 'Relay forward buffer full')
return
}
host.totalBufferedBytes = Math.max(0, host.totalBufferedBytes - frame.byteLength)
state.globalEarlyBytes = Math.max(0, state.globalEarlyBytes - frame.byteLength)
connection.bufferedBytes = Math.max(0, connection.bufferedBytes - frame.byteLength)
}
connection.bufferedBytes = 0
log(`[runtime-relay] server data connected serverId=${serverId} connectionId=${connectionId}`)
ws.on('message', (data, isBinary) => {
if (connection.client.readyState === connection.client.OPEN) {
if (!forwardFrame(state, host, connection, connection.client, data, isBinary)) {
closeConnection(state, host, connectionId, 1013, 'Relay forward buffer full')
}
}
})
ws.on('close', () => {
if (connection.server === ws) {
closeConnection(state, host, connectionId, 1011, 'Host data disconnected')
}
})
ws.on('error', () => {
if (connection.server === ws) {
closeConnection(state, host, connectionId, 1011, 'Host data socket error')
}
})
}
function forwardClientFrame(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
data: RawData,
isBinary: boolean
): void {
const server = connection.server
if (server && server.readyState === WebSocket.OPEN) {
if (!forwardFrame(state, host, connection, server, data, isBinary)) {
closeConnection(state, host, connection.connectionId, 1013, 'Relay forward buffer full')
}
return
}
const byteLength = relayFrameByteLength(data)
if (
connection.earlyFrames.length >= RUNTIME_RELAY_MAX_EARLY_FRAMES_PER_CONNECTION ||
connection.bufferedBytes + byteLength > RUNTIME_RELAY_MAX_EARLY_BYTES_PER_CONNECTION ||
host.totalBufferedBytes + byteLength > RUNTIME_RELAY_MAX_TOTAL_EARLY_BYTES_PER_SERVER ||
state.globalEarlyBytes + byteLength > state.limits.maxGlobalEarlyBytes
) {
closeConnection(state, host, connection.connectionId, 1013, 'Relay early buffer full')
return
}
connection.earlyFrames.push({ data, isBinary, byteLength })
connection.bufferedBytes += byteLength
host.totalBufferedBytes += byteLength
state.globalEarlyBytes += byteLength
}
function closeConnection(
state: RelayServerState,
host: RelayHostState,
connectionId: string,
code: number,
reason: string
): void {
const connection = host.pendingClients.get(connectionId)
if (!connection) {
return
}
releaseAllForwardedFrames(state, host, connection)
host.pendingClients.delete(connectionId)
state.globalPendingClients = Math.max(0, state.globalPendingClients - 1)
if (host.serverDataSockets.delete(connectionId)) {
state.globalActiveDataSockets = Math.max(0, state.globalActiveDataSockets - 1)
}
clearTimeout(connection.attachTimer)
host.totalBufferedBytes = Math.max(0, host.totalBufferedBytes - connection.bufferedBytes)
state.globalEarlyBytes = Math.max(0, state.globalEarlyBytes - connection.bufferedBytes)
connection.client.close(code, reason)
connection.server?.close(code, reason)
const control = host.control
if (control && control.readyState === WebSocket.OPEN) {
if (
!sendControl(control, {
message: { type: 'disconnected', connectionId },
maxBufferedBytes: state.limits.maxControlBufferedBytes
})
) {
failControlSocket(state, host, control, 1013, 'Relay control buffer full')
}
}
}
function markSocketAlive(ws: WebSocket): void {
;(ws as RelayHeartbeatSocket).lastPongAt = Date.now()
}
function isSocketStale(ws: WebSocket, staleSocketTimeoutMs: number): boolean {
const lastPongAt = (ws as RelayHeartbeatSocket).lastPongAt
return typeof lastPongAt === 'number' && Date.now() - lastPongAt > staleSocketTimeoutMs
}
function getHostState(hosts: Map<string, RelayHostState>, serverId: string): RelayHostState {
let host = hosts.get(serverId)
if (!host) {
host = {
control: null,
pendingClients: new Map(),
serverDataSockets: new Map(),
totalBufferedBytes: 0,
forwardedBufferedBytes: 0
}
hosts.set(serverId, host)
}
return host
}
function sendControl(
ws: WebSocket,
options: { message: RelayControlMessage; maxBufferedBytes: number }
): boolean {
if (ws.readyState !== ws.OPEN) {
return false
}
const payload = JSON.stringify(options.message)
const byteLength = Buffer.byteLength(payload)
if (ws.bufferedAmount + byteLength > options.maxBufferedBytes) {
return false
}
try {
ws.send(payload)
return true
} catch {
return false
}
}
function failControlSocket(
state: RelayServerState,
host: RelayHostState,
ws: WebSocket,
code: number,
reason: string
): void {
if (host.control === ws) {
// Clear first so fail-closed connection cleanup cannot enqueue more control frames.
host.control = null
for (const connectionId of Array.from(host.pendingClients.keys())) {
closeConnection(state, host, connectionId, code, reason)
}
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(code, reason)
}
}
function forwardFrame(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
target: WebSocket,
data: RawData,
isBinary: boolean
): boolean {
const byteLength = relayFrameByteLength(data)
if (
target.bufferedAmount + byteLength > state.limits.maxForwardBufferedBytes ||
connection.forwardedBufferedBytes + byteLength > state.limits.maxForwardBufferedBytes ||
host.forwardedBufferedBytes + byteLength > state.limits.maxTotalForwardBufferedBytesPerServer ||
state.globalForwardedBytes + byteLength > state.limits.maxGlobalForwardBufferedBytes
) {
return false
}
const frameId = trackForwardedFrame(state, host, connection, target, byteLength)
try {
target.send(data, { binary: isBinary }, () => {
releaseForwardedFrame(state, host, connection, frameId)
})
} catch {
releaseForwardedFrame(state, host, connection, frameId)
return false
}
return true
}
function trackForwardedFrame(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
target: WebSocket,
byteLength: number
): number {
const frameId = connection.nextForwardedFrameId
connection.nextForwardedFrameId += 1
connection.pendingForwardedFrames.set(frameId, { target, byteLength })
connection.forwardedBufferedBytes += byteLength
host.forwardedBufferedBytes += byteLength
state.globalForwardedBytes += byteLength
ensureForwardedSocketCloseHandler(state, host, connection, target)
return frameId
}
function ensureForwardedSocketCloseHandler(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
target: WebSocket
): void {
if (connection.forwardedCloseHandlers.has(target)) {
return
}
const releaseTargetFrames = () => {
releaseForwardedFramesForSocket(state, host, connection, target)
}
connection.forwardedCloseHandlers.set(target, releaseTargetFrames)
target.once('close', releaseTargetFrames)
}
function releaseForwardedFrame(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
frameId: number
): void {
const frame = connection.pendingForwardedFrames.get(frameId)
if (!frame) {
return
}
connection.pendingForwardedFrames.delete(frameId)
subtractForwardedBytes(state, host, connection, frame.byteLength)
clearForwardedSocketCloseHandlerIfIdle(connection, frame.target)
}
function releaseForwardedFramesForSocket(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
target: WebSocket
): void {
for (const [frameId, frame] of connection.pendingForwardedFrames) {
if (frame.target === target) {
connection.pendingForwardedFrames.delete(frameId)
subtractForwardedBytes(state, host, connection, frame.byteLength)
}
}
clearForwardedSocketCloseHandler(connection, target)
}
function releaseAllForwardedFrames(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState
): void {
for (const frame of connection.pendingForwardedFrames.values()) {
subtractForwardedBytes(state, host, connection, frame.byteLength)
}
connection.pendingForwardedFrames.clear()
for (const [target, releaseTargetFrames] of connection.forwardedCloseHandlers) {
target.off('close', releaseTargetFrames)
}
connection.forwardedCloseHandlers.clear()
}
function subtractForwardedBytes(
state: RelayServerState,
host: RelayHostState,
connection: RelayConnectionState,
byteLength: number
): void {
connection.forwardedBufferedBytes = Math.max(0, connection.forwardedBufferedBytes - byteLength)
host.forwardedBufferedBytes = Math.max(0, host.forwardedBufferedBytes - byteLength)
state.globalForwardedBytes = Math.max(0, state.globalForwardedBytes - byteLength)
}
function clearForwardedSocketCloseHandlerIfIdle(
connection: RelayConnectionState,
target: WebSocket
): void {
for (const frame of connection.pendingForwardedFrames.values()) {
if (frame.target === target) {
return
}
}
clearForwardedSocketCloseHandler(connection, target)
}
function clearForwardedSocketCloseHandler(
connection: RelayConnectionState,
target: WebSocket
): void {
const releaseTargetFrames = connection.forwardedCloseHandlers.get(target)
if (!releaseTargetFrames) {
return
}
target.off('close', releaseTargetFrames)
connection.forwardedCloseHandlers.delete(target)
}
function relayFrameByteLength(data: RawData): number {
if (typeof data === 'string') {
return Buffer.byteLength(data)
}
if (Array.isArray(data)) {
return data.reduce((sum, buffer) => sum + buffer.byteLength, 0)
}
return data.byteLength
}
function acceptHostToken(
state: RelayServerState,
serverId: string,
hostToken: string,
allowEnroll: boolean,
providedEnrollmentToken: string | null,
enrollmentToken: string,
log: (message: string) => void
): boolean {
const hash = hashHostToken(hostToken)
const existing = state.hostTokenHashes.get(serverId)
if (existing) {
return existing === hash
}
if (
!allowEnroll ||
providedEnrollmentToken !== enrollmentToken ||
state.hostTokenHashes.size >= RUNTIME_RELAY_MAX_BOUND_SERVERS
) {
return false
}
state.hostTokenHashes.set(serverId, hash)
try {
writeRelayBindings(state.statePath, state.hostTokenHashes)
} catch (error) {
// Failed first enrollment must not pin an in-memory token that was never persisted.
state.hostTokenHashes.delete(serverId)
log(
`[runtime-relay] failed to persist host binding serverId=${serverId}: ${
error instanceof Error ? error.message : String(error)
}`
)
return false
}
return true
}
function hashHostToken(hostToken: string): string {
return createHash('sha256').update(hostToken).digest('hex')
}
function loadRelayBindings(statePath: string): Map<string, string> {
if (!existsSync(statePath)) {
return new Map()
}
try {
const parsed = JSON.parse(readFileSync(statePath, 'utf8')) as RelayStateFile
if (!isPlainRecord(parsed)) {
throw new Error('relay state must be an object')
}
const hostTokenHashes = parsed.hostTokenHashes ?? {}
if (!isPlainRecord(hostTokenHashes)) {
throw new Error('hostTokenHashes must be an object')
}
const entries = Object.entries(hostTokenHashes)
for (const [serverId, hash] of entries) {
if (!isRuntimeRelayServerId(serverId) || !isRuntimeRelayHostTokenHash(hash)) {
throw new Error('hostTokenHashes contains an invalid binding')
}
}
return new Map(entries)
} catch (error) {
throw new Error(
`Failed to read runtime relay state at ${statePath}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function writeRelayBindings(statePath: string, bindings: Map<string, string>): void {
mkdirSync(dirname(statePath), { recursive: true })
writeFileSync(
statePath,
JSON.stringify({ hostTokenHashes: Object.fromEntries(bindings) }, null, 2),
{ mode: 0o600 }
)
}
function formatRelayUrlHost(host: string): string {
return isIP(host) === 6 ? `[${host}]` : host
}
function closeWebSocketServer(wss: WebSocketServer, shutdownGraceMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const forceCloseTimer = setTimeout(() => {
for (const client of wss.clients) {
if (client.readyState !== WebSocket.CLOSED) {
client.terminate()
}
}
}, shutdownGraceMs)
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.close(1001, 'Relay shutting down')
} else if (client.readyState !== WebSocket.CLOSED) {
client.terminate()
}
}
wss.close((error) => {
clearTimeout(forceCloseTimer)
if (error) {
reject(error)
return
}
resolve()
})
})
}
function closeHttpServer(httpServer: HttpServer): Promise<void> {
return new Promise((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
@@ -0,0 +1,411 @@
/* oxlint-disable max-lines -- Why: relay E2EE tests keep paired client/server fixtures in one file. */
import type { AddressInfo } from 'net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WebSocketServer, type WebSocket } from 'ws'
import {
decrypt,
decryptBytes,
deriveSharedKey,
encrypt,
encryptBytes,
generateKeyPair,
publicKeyFromBase64,
publicKeyToBase64
} from './e2ee-crypto'
import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing'
import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client'
import {
decodeRelayBinaryFrame,
encodeRelayBinaryFrame,
parseRelayTextFrame
} from './runtime-relay-transport'
const servers: WebSocketServer[] = []
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
for (const client of server.clients) {
client.close()
}
server.close(() => resolve())
})
)
)
})
describe('legacy remote runtime relay transport', () => {
it('echoes relay challenges and sequences relay one-shot requests', async () => {
const server = await createOneShotRelayServer()
const response = await sendRemoteRuntimeRequest<{ satisfied: boolean }>(
server.pairing,
'terminal.wait',
{ terminal: 't1', for: 'tui-idle', timeoutMs: 550 },
300
)
expect(response).toMatchObject({
ok: true,
result: { satisfied: true }
})
})
it('echoes relay challenges and sequences subscription text and binary frames', async () => {
const server = await createSubscriptionRelayServer()
const onResponse = vi.fn()
const onError = vi.fn()
const subscription = await subscribeRemoteRuntimeRequest(
server.pairing,
'terminal.subscribe',
{ terminal: 't1' },
1000,
{
onResponse,
onError
}
)
await vi.waitFor(() =>
expect(onResponse).toHaveBeenCalledWith(
expect.objectContaining({ ok: true, result: { type: 'subscribed' } })
)
)
const bytes = new Uint8Array([4, 5, 6])
expect(subscription.sendBinary(bytes)).toBe(true)
await expect(server.nextBinary).resolves.toEqual(bytes)
expect(onError).not.toHaveBeenCalled()
subscription.close()
})
it('closes an established subscription after a replayed relay text frame', async () => {
const server = await createTamperedSubscriptionRelayServer('text')
const onError = vi.fn()
const subscription = await subscribeRemoteRuntimeRequest(
server.pairing,
'terminal.subscribe',
{ terminal: 't1' },
1000,
{
onResponse: vi.fn(),
onError
}
)
await vi.waitFor(() =>
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'invalid_runtime_response' })
)
)
await vi.waitFor(() => expect(server.onClose).toHaveBeenCalled())
subscription.close()
})
it('closes an established subscription after a replayed relay binary frame', async () => {
const server = await createTamperedSubscriptionRelayServer('binary')
const onError = vi.fn()
const subscription = await subscribeRemoteRuntimeRequest(
server.pairing,
'terminal.subscribe',
{ terminal: 't1' },
1000,
{
onResponse: vi.fn(),
onError
}
)
await vi.waitFor(() =>
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'invalid_runtime_response' })
)
)
await vi.waitFor(() => expect(server.onClose).toHaveBeenCalled())
subscription.close()
})
})
async function createSubscriptionRelayServer(): Promise<{
pairing: PairingOffer
nextBinary: Promise<Uint8Array>
}> {
const serverKeyPair = generateKeyPair()
let resolveBinary: (bytes: Uint8Array) => void = () => {}
const nextBinary = new Promise<Uint8Array>((resolve) => {
resolveBinary = resolve
})
const wss = new WebSocketServer({ port: 0 })
servers.push(wss)
wss.on('connection', (ws) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
let inboundSeq = 0
let outboundSeq = 0
ws.on('message', (data, isBinary) => {
if (isBinary) {
if (!sharedKey) {
return
}
const decrypted = decryptBytes(new Uint8Array(data as Buffer), sharedKey)
if (!decrypted) {
return
}
const plaintext = unwrapRelayBinary(decrypted, ++inboundSeq)
if (plaintext) {
resolveBinary(plaintext)
}
return
}
const frame = data.toString()
if (!sharedKey) {
const hello = JSON.parse(frame) as { publicKeyB64: string }
sharedKey = deriveSharedKey(
serverKeyPair.secretKey,
publicKeyFromBase64(hello.publicKeyB64)
)
ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' }))
return
}
const plaintext = decrypt(frame, sharedKey)
if (!plaintext) {
return
}
if (!authenticated) {
expect(JSON.parse(plaintext)).toMatchObject({
type: 'e2ee_auth',
deviceToken: 'device-token',
challenge: 'relay-challenge'
})
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null)
return
}
const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq)
if (!requestPlaintext) {
return
}
const request = JSON.parse(requestPlaintext) as { id: string }
sendEncrypted(
ws,
sharedKey,
{
id: request.id,
ok: true,
streaming: true,
result: { type: 'subscribed' },
_meta: { runtimeId: 'runtime-test' }
},
++outboundSeq
)
})
})
return {
pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient01'),
nextBinary
}
}
async function createTamperedSubscriptionRelayServer(kind: 'text' | 'binary'): Promise<{
pairing: PairingOffer
onClose: ReturnType<typeof vi.fn>
}> {
const serverKeyPair = generateKeyPair()
const onClose = vi.fn()
const wss = new WebSocketServer({ port: 0 })
servers.push(wss)
wss.on('connection', (ws) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
let inboundSeq = 0
ws.once('close', onClose)
ws.on('message', (data, isBinary) => {
if (isBinary) {
return
}
const frame = data.toString()
if (!sharedKey) {
const hello = JSON.parse(frame) as { publicKeyB64: string }
sharedKey = deriveSharedKey(
serverKeyPair.secretKey,
publicKeyFromBase64(hello.publicKeyB64)
)
ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' }))
return
}
const plaintext = decrypt(frame, sharedKey)
if (!plaintext) {
return
}
if (!authenticated) {
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null)
return
}
const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq)
if (!requestPlaintext) {
return
}
const request = JSON.parse(requestPlaintext) as { id: string }
if (kind === 'text') {
sendEncrypted(
ws,
sharedKey,
{
id: request.id,
ok: true,
streaming: true,
result: { type: 'subscribed' },
_meta: { runtimeId: 'runtime-test' }
},
2
)
return
}
ws.send(Buffer.from(encryptBytes(encodeRelayBinaryFrame(2, new Uint8Array([9])), sharedKey)))
})
})
return {
pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient03'),
onClose
}
}
async function createOneShotRelayServer(): Promise<{ pairing: PairingOffer }> {
const serverKeyPair = generateKeyPair()
const wss = new WebSocketServer({ port: 0 })
servers.push(wss)
wss.on('connection', (ws) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
let inboundSeq = 0
let outboundSeq = 0
ws.on('message', (data, isBinary) => {
if (isBinary) {
return
}
const frame = data.toString()
if (!sharedKey) {
const hello = JSON.parse(frame) as { publicKeyB64: string }
sharedKey = deriveSharedKey(
serverKeyPair.secretKey,
publicKeyFromBase64(hello.publicKeyB64)
)
ws.send(JSON.stringify({ type: 'e2ee_ready', challenge: 'relay-challenge' }))
return
}
const plaintext = decrypt(frame, sharedKey)
if (!plaintext) {
return
}
if (!authenticated) {
expect(JSON.parse(plaintext)).toMatchObject({
type: 'e2ee_auth',
deviceToken: 'device-token',
challenge: 'relay-challenge'
})
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }, null)
return
}
const requestPlaintext = unwrapRelayText(plaintext, ++inboundSeq)
if (!requestPlaintext) {
return
}
const request = JSON.parse(requestPlaintext) as { id: string }
const key = sharedKey
const keepalive = setInterval(() => {
sendEncrypted(ws, key, { _keepalive: true }, ++outboundSeq)
}, 100)
ws.once('close', () => clearInterval(keepalive))
setTimeout(() => {
clearInterval(keepalive)
sendEncrypted(
ws,
key,
{
id: request.id,
ok: true,
result: { satisfied: true },
_meta: { runtimeId: 'runtime-test' }
},
++outboundSeq
)
}, 550)
})
})
return {
pairing: await createRelayPairing(wss, serverKeyPair.publicKey, 'serverRelayClient02')
}
}
async function createRelayPairing(
wss: WebSocketServer,
publicKey: Uint8Array,
serverId: string
): Promise<PairingOffer> {
await new Promise<void>((resolve) => wss.once('listening', resolve))
const address = wss.address() as AddressInfo
const pairing = parsePairingCode(
encodePairingOffer({
v: 2,
endpoint: `ws://127.0.0.1:${address.port}?role=client&serverId=${serverId}&v=1`,
deviceToken: 'device-token',
publicKeyB64: publicKeyToBase64(publicKey)
})
)
if (!pairing) {
throw new Error('Failed to create test pairing')
}
return pairing
}
function sendEncrypted(
ws: WebSocket,
sharedKey: Uint8Array,
message: unknown,
relaySeq: number | null
): void {
const payload = JSON.stringify(message)
ws.send(
encrypt(
relaySeq === null ? payload : JSON.stringify({ type: 'e2ee_frame', seq: relaySeq, payload }),
sharedKey
)
)
}
function unwrapRelayText(plaintext: string, expectedSeq: number): string | null {
const frame = parseRelayTextFrame(plaintext)
expect(frame?.seq).toBe(expectedSeq)
return frame?.payload ?? null
}
function unwrapRelayBinary(
plaintext: Uint8Array<ArrayBufferLike>,
expectedSeq: number
): Uint8Array<ArrayBufferLike> | null {
const frame = decodeRelayBinaryFrame(plaintext)
expect(frame?.seq).toBe(expectedSeq)
return frame?.payload ?? null
}
+24 -6
View File
@@ -98,10 +98,11 @@ async function createSubscriptionServer(): Promise<{
if (!sharedKey) {
return
}
const plaintext = decryptBytes(new Uint8Array(data as Buffer), sharedKey)
if (plaintext) {
resolveBinary(plaintext)
const decrypted = decryptBytes(new Uint8Array(data as Buffer), sharedKey)
if (!decrypted) {
return
}
resolveBinary(decrypted)
return
}
@@ -112,7 +113,11 @@ async function createSubscriptionServer(): Promise<{
serverKeyPair.secretKey,
publicKeyFromBase64(hello.publicKeyB64)
)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
ws.send(
JSON.stringify({
type: 'e2ee_ready'
})
)
return
}
@@ -121,6 +126,10 @@ async function createSubscriptionServer(): Promise<{
return
}
if (!authenticated) {
expect(JSON.parse(plaintext)).toMatchObject({
type: 'e2ee_auth',
deviceToken: 'device-token'
})
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' })
return
@@ -154,7 +163,8 @@ async function createSubscriptionServer(): Promise<{
}
function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void {
ws.send(encrypt(JSON.stringify(message), sharedKey))
const payload = JSON.stringify(message)
ws.send(encrypt(payload, sharedKey))
}
async function createOneShotServer(): Promise<{ pairing: PairingOffer }> {
@@ -177,7 +187,11 @@ async function createOneShotServer(): Promise<{ pairing: PairingOffer }> {
serverKeyPair.secretKey,
publicKeyFromBase64(hello.publicKeyB64)
)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
ws.send(
JSON.stringify({
type: 'e2ee_ready'
})
)
return
}
@@ -186,6 +200,10 @@ async function createOneShotServer(): Promise<{ pairing: PairingOffer }> {
return
}
if (!authenticated) {
expect(JSON.parse(plaintext)).toMatchObject({
type: 'e2ee_auth',
deviceToken: 'device-token'
})
authenticated = true
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' })
return
+154 -18
View File
@@ -20,6 +20,15 @@ import {
RuntimeRpcEnvelopeSchema,
type RuntimeRpcResponse
} from './runtime-rpc-envelope'
import {
RelayFrameSequencer,
createRelayTextFrame,
decodeRelayBinaryFrame,
encodeRelayBinaryFrame,
getRuntimeWebSocketTransportKind,
parseRelayTextFrame,
type RuntimeWebSocketTransportKind
} from './runtime-relay-transport'
type HandshakeState = 'awaiting_ready' | 'awaiting_authenticated' | 'ready'
@@ -57,6 +66,8 @@ export async function sendRemoteRuntimeRequest<TResult>(
const keyPair = generateKeyPair()
const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64)
const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey)
const transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint)
const relaySequencer = new RelayFrameSequencer()
let state: HandshakeState = 'awaiting_ready'
let settled = false
let ws: WebSocket | null = null
@@ -174,7 +185,18 @@ export async function sendRemoteRuntimeRequest<TResult>(
return
}
handleRpcFrame(plaintext)
const rpcPlaintext = unwrapRelayTextFrame(plaintext, transportKind, relaySequencer)
if (rpcPlaintext === null) {
finish({
ok: false,
error: new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime replayed a relay frame.'
)
})
return
}
handleRpcFrame(rpcPlaintext)
})
function handleReadyFrame(frame: string): void {
@@ -206,8 +228,16 @@ export async function sendRemoteRuntimeRequest<TResult>(
return
}
state = 'awaiting_authenticated'
const challenge = (ready as { challenge?: unknown }).challenge
ws?.send(
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey)
encrypt(
JSON.stringify({
type: 'e2ee_auth',
deviceToken: pairing.deviceToken,
challenge: typeof challenge === 'string' ? challenge : undefined
}),
sharedKey
)
)
}
@@ -245,12 +275,16 @@ export async function sendRemoteRuntimeRequest<TResult>(
state = 'ready'
ws?.send(
encrypt(
JSON.stringify({
id: requestId,
deviceToken: pairing.deviceToken,
method,
params
}),
wrapRelayTextFrame(
JSON.stringify({
id: requestId,
deviceToken: pairing.deviceToken,
method,
params
}),
transportKind,
relaySequencer
),
sharedKey
)
)
@@ -313,6 +347,8 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
const keyPair = generateKeyPair()
const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64)
const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey)
const transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint)
const relaySequencer = new RelayFrameSequencer()
let state: HandshakeState = 'awaiting_ready'
let settled = false
let ws: WebSocket | null = null
@@ -338,7 +374,12 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) {
return false
}
ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true })
const payload = wrapRelayBinaryFrame(bytes, transportKind, relaySequencer)
if (payload === null) {
close()
return false
}
ws.send(Buffer.from(encryptBytes(payload, sharedKey)), { binary: true })
return true
}
@@ -362,6 +403,12 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
callbacks.onError(error)
}
const failClosed = (error: RemoteRuntimeClientError): void => {
// Relay sequence failures invalidate the session; force the next attempt through a fresh handshake.
close()
fail(error)
}
try {
ws = new WebSocket(pairing.endpoint)
} catch (error) {
@@ -430,7 +477,17 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
return
}
handleRpcFrame(plaintext)
const rpcPlaintext = unwrapRelayTextFrame(plaintext, transportKind, relaySequencer)
if (rpcPlaintext === null) {
failClosed(
new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime replayed a relay frame.'
)
)
return
}
handleRpcFrame(rpcPlaintext)
})
function handleReadyFrame(frame: string): void {
@@ -460,8 +517,16 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
return
}
state = 'awaiting_authenticated'
const challenge = (ready as { challenge?: unknown }).challenge
ws?.send(
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey)
encrypt(
JSON.stringify({
type: 'e2ee_auth',
deviceToken: pairing.deviceToken,
challenge: typeof challenge === 'string' ? challenge : undefined
}),
sharedKey
)
)
}
@@ -492,12 +557,16 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
state = 'ready'
ws?.send(
encrypt(
JSON.stringify({
id: requestId,
deviceToken: pairing.deviceToken,
method,
params
}),
wrapRelayTextFrame(
JSON.stringify({
id: requestId,
deviceToken: pairing.deviceToken,
method,
params
}),
transportKind,
relaySequencer
),
sharedKey
)
)
@@ -554,7 +623,74 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
)
return
}
callbacks.onBinary?.(plaintext)
const binaryPayload = unwrapRelayBinaryFrame(plaintext, transportKind, relaySequencer)
if (binaryPayload === null) {
failClosed(
new RemoteRuntimeClientError(
'invalid_runtime_response',
'Remote Orca runtime replayed a relay binary frame.'
)
)
return
}
callbacks.onBinary?.(binaryPayload)
}
})
}
function wrapRelayTextFrame(
payload: string,
transportKind: RuntimeWebSocketTransportKind,
relaySequencer: RelayFrameSequencer
): string {
if (transportKind !== 'relay') {
return payload
}
const seq = relaySequencer.nextOutboundSeq()
if (seq === null) {
return payload
}
return createRelayTextFrame(seq, payload)
}
function unwrapRelayTextFrame(
plaintext: string,
transportKind: RuntimeWebSocketTransportKind,
relaySequencer: RelayFrameSequencer
): string | null {
if (transportKind !== 'relay') {
return plaintext
}
const frame = parseRelayTextFrame(plaintext)
if (!frame || !relaySequencer.acceptInboundSeq(frame.seq)) {
return null
}
return frame.payload
}
function wrapRelayBinaryFrame(
payload: Uint8Array<ArrayBufferLike>,
transportKind: RuntimeWebSocketTransportKind,
relaySequencer: RelayFrameSequencer
): Uint8Array<ArrayBufferLike> | null {
if (transportKind !== 'relay') {
return payload
}
const seq = relaySequencer.nextOutboundSeq()
return seq === null ? null : encodeRelayBinaryFrame(seq, payload)
}
function unwrapRelayBinaryFrame(
plaintext: Uint8Array<ArrayBufferLike>,
transportKind: RuntimeWebSocketTransportKind,
relaySequencer: RelayFrameSequencer
): Uint8Array<ArrayBufferLike> | null {
if (transportKind !== 'relay') {
return plaintext
}
const frame = decodeRelayBinaryFrame(plaintext)
if (!frame || !relaySequencer.acceptInboundSeq(frame.seq)) {
return null
}
return frame.payload
}
+60 -13
View File
@@ -1,7 +1,15 @@
/* eslint-disable max-lines -- Why: the remote runtime client owns WebSocket lifecycle, E2EE handshake, relay sequencing, and request fan-out as one state machine. */
import { randomUUID } from 'crypto'
import WebSocket from 'ws'
import type { PairingOffer } from './pairing'
import { decrypt, encrypt } from './e2ee-crypto'
import {
RelayFrameSequencer,
createRelayTextFrame,
getRuntimeWebSocketTransportKind,
parseRelayTextFrame,
type RuntimeWebSocketTransportKind
} from './runtime-relay-transport'
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
import { RemoteRuntimeClientError } from './remote-runtime-client'
import {
@@ -34,12 +42,15 @@ export class RemoteRuntimeRequestConnection {
private state: ConnectionState = 'closed'
private ws: WebSocket | null = null
private sharedKey: Uint8Array | null = null
private readonly transportKind: RuntimeWebSocketTransportKind
private relaySequencer = new RelayFrameSequencer()
private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
private readonly readyWaiters: ReadyWaiter[] = []
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null
constructor(pairing: PairingOffer) {
this.pairing = pairing
this.transportKind = getRuntimeWebSocketTransportKind(pairing.endpoint)
}
request<TResult>(
@@ -77,6 +88,7 @@ export class RemoteRuntimeRequestConnection {
const ws = this.ws
this.ws = null
this.sharedKey = null
this.relaySequencer = new RelayFrameSequencer()
this.state = 'closed'
this.clearIdleCloseTimer()
@@ -136,6 +148,7 @@ export class RemoteRuntimeRequestConnection {
}
this.ws = opened.socket.ws
this.sharedKey = opened.socket.sharedKey
this.relaySequencer = new RelayFrameSequencer()
this.state = 'awaiting_ready'
}
@@ -149,8 +162,8 @@ export class RemoteRuntimeRequestConnection {
if (!sharedKey) {
return
}
const plaintext = decrypt(frame, sharedKey)
if (plaintext === null) {
const decrypted = decrypt(frame, sharedKey)
if (decrypted === null) {
this.close(
invalidRemoteRuntimeResponseError('Remote Orca runtime returned an undecryptable frame.')
)
@@ -158,17 +171,21 @@ export class RemoteRuntimeRequestConnection {
}
if (this.state === 'awaiting_authenticated') {
this.handleAuthenticatedFrame(plaintext)
this.handleAuthenticatedFrame(decrypted)
return
}
const plaintext = this.unwrapRelayTextFrame(decrypted)
if (plaintext === null) {
return
}
this.handleRpcFrame(plaintext)
}
private handleReadyFrame(frame: string): void {
const error = parseReadyFrame(frame)
if (error) {
this.close(error)
const ready = parseReadyFrame(frame)
if (ready instanceof RemoteRuntimeClientError) {
this.close(ready)
return
}
this.state = 'awaiting_authenticated'
@@ -178,7 +195,11 @@ export class RemoteRuntimeRequestConnection {
}
this.ws?.send(
encrypt(
JSON.stringify({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }),
JSON.stringify({
type: 'e2ee_auth',
deviceToken: this.pairing.deviceToken,
challenge: ready.challenge
}),
sharedKey
)
)
@@ -229,17 +250,43 @@ export class RemoteRuntimeRequestConnection {
}
ws.send(
encrypt(
JSON.stringify({
id: requestId,
deviceToken: this.pairing.deviceToken,
method,
params
}),
this.wrapRelayTextFrame(
JSON.stringify({
id: requestId,
deviceToken: this.pairing.deviceToken,
method,
params
})
),
sharedKey
)
)
}
private wrapRelayTextFrame(payload: string): string {
if (this.transportKind !== 'relay' || this.state !== 'ready') {
return payload
}
const seq = this.relaySequencer.nextOutboundSeq()
if (seq === null) {
this.close(remoteRuntimeUnavailableError('Relay frame sequence exhausted.'))
return payload
}
return createRelayTextFrame(seq, payload)
}
private unwrapRelayTextFrame(plaintext: string): string | null {
if (this.transportKind !== 'relay') {
return plaintext
}
const frame = parseRelayTextFrame(plaintext)
if (!frame || !this.relaySequencer.acceptInboundSeq(frame.seq)) {
this.close(invalidRemoteRuntimeResponseError('Remote Orca runtime replayed a relay frame.'))
return null
}
return frame.payload
}
private rejectPendingRequest(requestId: string, error: Error): void {
const pending = this.pendingRequests.get(requestId)
if (!pending) {
+3 -2
View File
@@ -27,7 +27,7 @@ export function invalidRemoteRuntimeResponseError(message: string): RemoteRuntim
return new RemoteRuntimeClientError('invalid_runtime_response', message)
}
export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null {
export function parseReadyFrame(frame: string): { challenge?: string } | RemoteRuntimeClientError {
let ready: unknown
try {
ready = JSON.parse(frame)
@@ -45,7 +45,8 @@ export function parseReadyFrame(frame: string): RemoteRuntimeClientError | null
'Remote Orca runtime returned an unexpected E2EE handshake frame.'
)
}
return null
const challenge = (ready as { challenge?: unknown }).challenge
return typeof challenge === 'string' ? { challenge } : {}
}
export function parseAuthenticatedFrame(plaintext: string): RemoteRuntimeClientError | null {
+1
View File
@@ -0,0 +1 @@
export const RUNTIME_RELAY_MAX_FRAME_BYTES = 1024 * 1024
+97
View File
@@ -0,0 +1,97 @@
export const RELAY_BINARY_SEQUENCE_BYTES = 8
export const RELAY_MAX_SEQUENCE = Number.MAX_SAFE_INTEGER
export type RuntimeWebSocketTransportKind = 'direct' | 'relay'
export type RelayTextFrame = {
type: 'e2ee_frame'
seq: number
payload: string
}
export function getRuntimeWebSocketTransportKind(endpoint: string): RuntimeWebSocketTransportKind {
try {
const url = new URL(endpoint)
return url.searchParams.get('role') === 'client' && url.searchParams.has('serverId')
? 'relay'
: 'direct'
} catch {
return 'direct'
}
}
export function createRelayTextFrame(seq: number, payload: string): string {
return JSON.stringify({ type: 'e2ee_frame', seq, payload })
}
export function parseRelayTextFrame(plaintext: string): RelayTextFrame | null {
let parsed: unknown
try {
parsed = JSON.parse(plaintext)
} catch {
return null
}
if (
typeof parsed !== 'object' ||
parsed === null ||
(parsed as { type?: unknown }).type !== 'e2ee_frame' ||
typeof (parsed as { seq?: unknown }).seq !== 'number' ||
typeof (parsed as { payload?: unknown }).payload !== 'string'
) {
return null
}
const frame = parsed as RelayTextFrame
if (!Number.isSafeInteger(frame.seq) || frame.seq < 1) {
return null
}
return frame
}
export function encodeRelayBinaryFrame(
seq: number,
payload: Uint8Array<ArrayBufferLike>
): Uint8Array {
const framed = new Uint8Array(RELAY_BINARY_SEQUENCE_BYTES + payload.length)
const view = new DataView(framed.buffer, framed.byteOffset, RELAY_BINARY_SEQUENCE_BYTES)
view.setBigUint64(0, BigInt(seq), false)
framed.set(payload, RELAY_BINARY_SEQUENCE_BYTES)
return framed
}
export function decodeRelayBinaryFrame(
plaintext: Uint8Array<ArrayBufferLike>
): { seq: number; payload: Uint8Array<ArrayBufferLike> } | null {
if (plaintext.length < RELAY_BINARY_SEQUENCE_BYTES) {
return null
}
const view = new DataView(plaintext.buffer, plaintext.byteOffset, RELAY_BINARY_SEQUENCE_BYTES)
const seq = Number(view.getBigUint64(0, false))
if (!Number.isSafeInteger(seq) || seq < 1) {
return null
}
return {
seq,
payload: plaintext.slice(RELAY_BINARY_SEQUENCE_BYTES)
}
}
export class RelayFrameSequencer {
private inboundSeq = 0
private outboundSeq = 0
nextOutboundSeq(): number | null {
if (this.outboundSeq >= RELAY_MAX_SEQUENCE) {
return null
}
this.outboundSeq += 1
return this.outboundSeq
}
acceptInboundSeq(seq: number): boolean {
if (!Number.isSafeInteger(seq) || seq !== this.inboundSeq + 1) {
return false
}
this.inboundSeq = seq
return true
}
}