From 859b4529da603515990dd7c69080301737b8b476 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 8 Jul 2026 03:44:02 +0300 Subject: [PATCH] feat: add private relay for end-to-end-encrypted remote access (#2087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenChamber Relay — an opt-in way to reach an instance from a phone, browser, or another desktop from anywhere, with no open inbound ports, no tunnel, and no shared LAN. The instance dials outbound to a relay; all app traffic (HTTP, the event stream, terminal, dictation) is multiplexed and encrypted through a single connection per client, so the relay only ever forwards opaque ciphertext. Transport - End-to-end-encrypted channel over WebCrypto (ECDH P-256 -> HKDF -> AES-256-GCM) with a capability-negotiated handshake and a small HTTP/SSE/WebSocket multiplexing protocol. A byte-compatible JS host mirror is cross-checked by tests. - Host: outbound connection manager, per-client tunnel dispatcher to the local server over loopback, reuse of the existing instance identity key, and management routes. Disabled by default; explicit opt-in. - Client: plugs into the existing runtime layer (runtime-fetch/-url/-switch/ -auth, event pipeline, terminal, dictation) so features work over the relay unchanged; direct-URL and Electron realtime-proxy paths are untouched. Pairing & UX - Relay section in Settings -> Remote Instances (live status, QR/link pairing, revocation via the existing client-token list) and the mobile connect flow. - Frame batching and idle-gated keepalive keep tunnel message volume low without affecting streaming smoothness. Security - The tunnel is transport only; the server authenticates every tunneled request exactly as for a direct remote client. fragments only. The relay stores no keys, tokens, or payloads. Operability - The endpoint can be pinned to a self-hosted rel paired clients inherit it from the offer automatically. - Relay module DOCUMENTATION.md and a relay-trans invariants that future WebSocket/streaming changes must follow. The relay transport is complete and tested; the UI for enabling and pairing is gated behind openchamber_relay_gate and stays --- .agents/skills/relay-transport/SKILL.md | 60 + AGENTS.md | 7 + CHANGELOG.md | 6 + packages/ui/src/apps/MobileApp.tsx | 35 +- .../ui/src/apps/mobileConnections.test.ts | 101 +- packages/ui/src/apps/mobileConnections.ts | 392 ++++++- packages/ui/src/apps/mobileQrScan.test.ts | 66 ++ packages/ui/src/apps/mobileQrScan.ts | 26 + .../remote-instances/RelaySection.tsx | 314 +++++ .../remote-instances/RemoteInstancesPage.tsx | 4 + .../ui/src/lib/dictation/dictation-client.ts | 22 +- .../ui/src/lib/i18n/messages/en.settings.ts | 31 + packages/ui/src/lib/i18n/messages/en.ts | 1 + .../ui/src/lib/i18n/messages/es.settings.ts | 31 + packages/ui/src/lib/i18n/messages/es.ts | 1 + .../ui/src/lib/i18n/messages/fr.settings.ts | 31 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + .../ui/src/lib/i18n/messages/ja.settings.ts | 31 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + .../ui/src/lib/i18n/messages/ko.settings.ts | 31 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + .../ui/src/lib/i18n/messages/pl.settings.ts | 31 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + .../src/lib/i18n/messages/pt-BR.settings.ts | 31 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + .../ui/src/lib/i18n/messages/uk.settings.ts | 31 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + .../src/lib/i18n/messages/zh-CN.settings.ts | 31 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + .../src/lib/i18n/messages/zh-TW.settings.ts | 31 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/lib/opencode/client.test.ts | 21 + packages/ui/src/lib/opencode/client.ts | 9 +- packages/ui/src/lib/relay/crypto.test.ts | 136 +++ packages/ui/src/lib/relay/crypto.ts | 223 ++++ packages/ui/src/lib/relay/gate.ts | 21 + packages/ui/src/lib/relay/handshake.test.ts | 171 +++ packages/ui/src/lib/relay/handshake.ts | 234 ++++ packages/ui/src/lib/relay/offer.test.ts | 130 +++ packages/ui/src/lib/relay/offer.ts | 102 ++ packages/ui/src/lib/relay/protocol.ts | 139 +++ packages/ui/src/lib/relay/runtime-socket.ts | 20 + packages/ui/src/lib/relay/runtime-tunnel.ts | 47 + .../ui/src/lib/relay/tunnel-client.test.ts | 576 ++++++++++ packages/ui/src/lib/relay/tunnel-client.ts | 1024 +++++++++++++++++ .../ui/src/lib/relay/tunnel-codec.test.ts | 178 +++ packages/ui/src/lib/relay/tunnel-codec.ts | 384 +++++++ packages/ui/src/lib/relay/tunnel-payloads.ts | 143 +++ packages/ui/src/lib/runtime-auth.ts | 17 +- packages/ui/src/lib/runtime-fetch.ts | 102 +- packages/ui/src/lib/runtime-switch.ts | 18 +- packages/ui/src/lib/settings/search.ts | 10 + packages/ui/src/lib/terminalApi.ts | 14 +- packages/ui/src/sync/event-pipeline.ts | 15 +- packages/ui/src/types/bun-test.d.ts | 8 +- packages/web/bin/cli.test.js | 8 + packages/web/bin/lib/DOCUMENTATION.md | 1 + packages/web/bin/lib/cli-args.js | 9 + packages/web/bin/lib/commands-connect-url.js | 106 ++ packages/web/server/index.js | 22 + .../server/lib/notifications/apns-runtime.js | 25 +- .../web/server/lib/relay/DOCUMENTATION.md | 77 ++ .../web/server/lib/relay/cross-compat.test.js | 135 +++ packages/web/server/lib/relay/e2ee.js | 341 ++++++ packages/web/server/lib/relay/e2ee.test.js | 124 ++ packages/web/server/lib/relay/host-client.js | 329 ++++++ .../web/server/lib/relay/host-client.test.js | 280 +++++ packages/web/server/lib/relay/identity.js | 73 ++ .../web/server/lib/relay/identity.test.js | 78 ++ packages/web/server/lib/relay/service.js | 221 ++++ packages/web/server/lib/relay/signing-key.js | 50 + packages/web/server/lib/relay/tunnel-codec.js | 373 ++++++ .../web/server/lib/relay/tunnel-codec.test.js | 58 + packages/web/server/lib/relay/tunnel-host.js | 462 ++++++++ 74 files changed, 7768 insertions(+), 99 deletions(-) create mode 100644 .agents/skills/relay-transport/SKILL.md create mode 100644 packages/ui/src/apps/mobileQrScan.test.ts create mode 100644 packages/ui/src/components/sections/remote-instances/RelaySection.tsx create mode 100644 packages/ui/src/lib/relay/crypto.test.ts create mode 100644 packages/ui/src/lib/relay/crypto.ts create mode 100644 packages/ui/src/lib/relay/gate.ts create mode 100644 packages/ui/src/lib/relay/handshake.test.ts create mode 100644 packages/ui/src/lib/relay/handshake.ts create mode 100644 packages/ui/src/lib/relay/offer.test.ts create mode 100644 packages/ui/src/lib/relay/offer.ts create mode 100644 packages/ui/src/lib/relay/protocol.ts create mode 100644 packages/ui/src/lib/relay/runtime-socket.ts create mode 100644 packages/ui/src/lib/relay/runtime-tunnel.ts create mode 100644 packages/ui/src/lib/relay/tunnel-client.test.ts create mode 100644 packages/ui/src/lib/relay/tunnel-client.ts create mode 100644 packages/ui/src/lib/relay/tunnel-codec.test.ts create mode 100644 packages/ui/src/lib/relay/tunnel-codec.ts create mode 100644 packages/ui/src/lib/relay/tunnel-payloads.ts create mode 100644 packages/web/server/lib/relay/DOCUMENTATION.md create mode 100644 packages/web/server/lib/relay/cross-compat.test.js create mode 100644 packages/web/server/lib/relay/e2ee.js create mode 100644 packages/web/server/lib/relay/e2ee.test.js create mode 100644 packages/web/server/lib/relay/host-client.js create mode 100644 packages/web/server/lib/relay/host-client.test.js create mode 100644 packages/web/server/lib/relay/identity.js create mode 100644 packages/web/server/lib/relay/identity.test.js create mode 100644 packages/web/server/lib/relay/service.js create mode 100644 packages/web/server/lib/relay/signing-key.js create mode 100644 packages/web/server/lib/relay/tunnel-codec.js create mode 100644 packages/web/server/lib/relay/tunnel-codec.test.js create mode 100644 packages/web/server/lib/relay/tunnel-host.js diff --git a/.agents/skills/relay-transport/SKILL.md b/.agents/skills/relay-transport/SKILL.md new file mode 100644 index 00000000..9857a9eb --- /dev/null +++ b/.agents/skills/relay-transport/SKILL.md @@ -0,0 +1,60 @@ +--- +name: relay-transport +description: Use when adding or changing any WebSocket, SSE, or streaming endpoint (terminal, dictation/voice, event stream, notifications), opening a WebSocket in shared UI, refactoring the runtime transport (runtime-fetch/runtime-url/runtime-switch/runtime-auth), touching anything under packages/ui/src/lib/relay or packages/web/server/lib/relay, or porting a realtime feature. These changes silently break OpenChamber's private relay (mobile→desktop over an E2EE tunnel) in ways that pass local/desktop testing and only fail over the relay on a real device. Load this before such work to know the invariants and the traps already hit. +license: MIT +compatibility: opencode +--- + +## Overview + +OpenChamber has a private relay: a client (mobile app, browser, another desktop) reaches a user's instance through an OpenChamber-hosted relay over an **end-to-end encrypted tunnel**. All of the app's traffic — many HTTP requests, the event stream (SSE), and WebSockets (terminal, dictation) — is multiplexed and encrypted through **one** connection per client. + +Architecture overview: `packages/web/server/lib/relay/DOCUMENTATION.md`. Code: `packages/ui/src/lib/relay/` (client + shared, TS) and `packages/web/server/lib/relay/` (host, JS). + +**Why this skill exists:** relay bugs do not show up in normal testing. The event stream is SSE (which behaves differently from WebSockets), so a new WebSocket feature is often the *first* real WebSocket to cross the tunnel on mobile — and it fails there while working everywhere else. We have fixed the same class of bug across several iterations. The rules below are those lessons. + +## The core mental model + +- **The tunnel is transparent.** A feature should reach the server through the shared runtime transport (`runtimeFetch`, `openRuntimeWebSocket`) and never know whether it is direct or relayed. If a feature constructs its own `fetch`/`WebSocket` against a runtime URL, it bypasses the tunnel and breaks in relay mode. +- **Three transports behave differently over the tunnel:** + - HTTP and SSE authenticate with the client's **bearer token** (a header). They "just work" through the tunnel for any allowlisted `/api/*`, `/auth/*`, `/health` path. + - **WebSockets cannot send headers.** They authenticate with a short-lived **URL-scoped token** (`oc_url_token`) that must be minted first and passed as a query parameter. This is the source of most relay WS bugs. + +## Rules for adding or changing a WebSocket endpoint + +Adding a new WS endpoint (or porting one, e.g. the planned terminal port) requires ALL of these, or it breaks over the relay: + +1. **Open it via `openRuntimeWebSocket`** (`packages/ui/src/lib/relay/runtime-socket.ts`), never `new WebSocket(...)` directly. A raw `new WebSocket` against a runtime URL fails in relay mode (the resolver yields a tunnel-virtual/custom-scheme URL the platform rejects — surfaced as "The string did not match the expected pattern"). +2. **Add the path to BOTH allowlists** (they are separate and both required): + - Host tunnel dispatcher: `ALLOWED_WS_PATHS` in `packages/web/server/lib/relay/tunnel-host.js`. + - URL-token auth gate: `isUrlAuthWebSocketPath` in `packages/web/server/lib/ui-auth/ui-auth.js` (otherwise the `oc_url_token` is refused for that path → 401). +3. **Mint the URL token before connecting.** Call `refreshRuntimeUrlAuthToken()` and build the URL through the resolver's `websocket(...)` so `oc_url_token` is appended. SSE/HTTP do not need this; WS does. +4. **Do not touch origin handling.** The server rejects WS upgrades whose `Origin` it does not trust. Over the tunnel the host dials loopback and presents the loopback origin (`http://127.0.0.1:`), which the server trusts as same-origin — this already covers every allowlisted WS path. **Never reintroduce reliance on `window.location.origin`**: in the iOS WKWebView it is `"null"`/empty for the custom scheme, so forwarding it produces a 403. +5. **Test over the relay, not just direct/desktop.** A new WS may be the first WebSocket the mobile client runs through the tunnel (events are SSE-locked on Capacitor). Passing on desktop or a direct connection proves nothing about the relay path. + +## Rules for the tunnel/crypto/codec internals + +- **Two implementations must stay byte-compatible.** The E2EE and framing exist as TS (`packages/ui/src/lib/relay/{crypto,handshake,tunnel-codec}.ts`, normative) and a JS host mirror (`packages/web/server/lib/relay/{e2ee,tunnel-codec}.js`). Any wire-format, frame-type, handshake, or batching change must update **both** and keep `packages/web/server/lib/relay/cross-compat.test.js` green. +- **Frame types live in `protocol.ts`** and must match across `protocol.ts`, `tunnel-codec.ts`, and `tunnel-codec.js`. Adding a frame type without mirroring it corrupts the stream on one side. +- **Frame batching is capability-negotiated** in the handshake with a legacy fallback, so mixed client/host app versions still interoperate. Preserve the negotiation and the single-frame fallback; do not make batching unconditional. +- **The encrypted-frame counter/IV is per-direction and strictly increasing.** One encrypted WS message = one encrypt call = one counter tick. Keep encrypt+send serialized per direction; do not reorder or parallelize it. + +## Rules for the runtime transport layer + +- Relay mode routes through `runtime-switch` (activates the tunnel singleton), `runtime-fetch` (routes runtime requests through it), `runtime-url`/`runtime-socket` (tunnel-backed URLs/sockets), and `runtime-auth` (mints the URL token through the tunnel). When refactoring any of these, preserve the relay branch and the direct-URL/Electron-realtime-proxy branches — they must remain byte-identical in behavior for non-relay runtimes. +- **The host dispatcher never injects credentials.** Tunneled requests carry the client's own token; the server authenticates them. Do not add host-side auth shortcuts, and do not trust loopback source address as authentication (relay traffic arrives at loopback but represents remote clients). + +## Testing guidance (a stub that skips auth/origin hides the exact bugs) + +- Exercise the real auth and origin gates. An end-to-end test whose stub server accepts any WS upgrade will pass while the real server rejects it — this is precisely how the origin-check bug shipped. When writing a relay integration test, mirror the real gates (`ensureSessionToken` via `oc_url_token`, `isRequestOriginAllowed`) or run against the real server pieces. +- Run relay tests per file (`bun test `); the suite has order sensitivity. +- Validate both sides: `packages/ui` `type-check`/`lint`, and `node --check` on changed JS host files. + +## Quick checklist before finishing relay-adjacent work + +- [ ] New WS endpoint added to `ALLOWED_WS_PATHS` AND `isUrlAuthWebSocketPath`? +- [ ] UI opens it via `openRuntimeWebSocket`, not `new WebSocket`? +- [ ] URL token minted before the WS connects? +- [ ] No new dependence on `window.location.origin`? +- [ ] Wire/codec/handshake change mirrored in TS and JS, cross-compat test green? +- [ ] Direct and relay paths both still work; verified over the relay on the transport that actually uses it? diff --git a/AGENTS.md b/AGENTS.md index 9d21944a..14da1901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,12 @@ Server-side text-to-speech services and summarization helpers for `/api/tts/*` e - Module docs: `packages/web/server/lib/tts/DOCUMENTATION.md` +##### relay + +Host side of the private relay: outbound E2EE tunnel that lets remote clients reach this instance through OpenChamber-hosted relay infrastructure without inbound exposure. Load the `relay-transport` skill before changing it or any WebSocket/streaming endpoint that rides it. + +- Module docs: `packages/web/server/lib/relay/DOCUMENTATION.md` + ##### tunnels Tunnel provider setup and runtime helpers for exposing OpenChamber over remote URLs. @@ -341,6 +347,7 @@ Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents ** | Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` | | Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` | | iOS Simulator preview/control for the mobile app, `serve-sim`, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode | `skill({ name: "serve-sim" })` | +| WebSocket/SSE/streaming endpoints (terminal, dictation/voice, event stream, notifications), opening a WebSocket in shared UI, runtime transport refactors (`runtime-fetch`/`runtime-url`/`runtime-switch`/`runtime-auth`), the private relay tunnel, or anything under `packages/ui/src/lib/relay` or `packages/web/server/lib/relay` | `skill({ name: "relay-transport" })` | Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 03084e89..4d76f530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Diff/Chat: added a Last turn mode to the Diff view, and latest-turn changed-file chips in chat now open that snapshot while older turn chips stay read-only. +- Chat: if sending a message times out or loses the connection after OpenCode accepted it, the app now keeps the sent message instead of rolling it back as failed. +- Mobile: the native app can now check for OpenChamber app updates; Android shows a persistent download toast when an update is available. +- Mobile: selecting local files from the composer now attaches the picked files even if the composer switches between compact and expanded layouts while the file picker is open. +- Browser: links clicked inside an embedded browser tab now keep the tab on the navigated page instead of remounting the frame. +- Context Panel: raw message rows now keep token and time columns aligned without showing shortened message IDs. +- UI: closing the right sidebar after resizing no longer leaves stale width constraints behind. ## [1.14.1] - 2026-07-07 diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 3d5432fd..9ac0f421 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -32,7 +32,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@ import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -58,7 +58,7 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections'; +import { autoConnectLastInstance, isSameConnectionUrl, relayConnectionRuntimeKey, useMobileConnection, validateActiveRuntimeSession } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; @@ -644,7 +644,9 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn

{pendingConnection.label}

-

{pendingConnection.url}

+

+ {pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url} +

void }> = ({ onConn key={connection.id} type="button" className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" - onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label })} + onClick={() => void conn.connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })} > {connection.label} - {connection.url} + + {connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url} + @@ -882,7 +886,12 @@ const MobileInstancesSurface: React.FC<{ setConfirmingDeleteId(null); if (editingId === id) resetForm(); void removeConnection(id).then((removed) => { - if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { + if (!removed) return; + // Relay entries have no reachable URL — the runtime key is their identity. + const isActive = removed.relay + ? getRuntimeKey() === relayConnectionRuntimeKey(removed.relay) + : isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl()); + if (isActive) { onActiveConnectionDeleted(); } }); @@ -901,7 +910,9 @@ const MobileInstancesSurface: React.FC<{

{pendingConnection.label}

-

{pendingConnection.url}

+

+ {pendingConnection.relay ? t('mobile.connect.relay.badge') : pendingConnection.url} +

void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label })} + onClick={() => void connect({ url: connection.url, clientToken: connection.clientToken, label: connection.label, relay: connection.relay })} disabled={isBusy || confirming} > @@ -954,7 +965,9 @@ const MobileInstancesSurface: React.FC<{ {connection.label} - {connection.url} + + {connection.mode === 'relay' ? t('mobile.connect.relay.badge') : connection.url} +
@@ -969,7 +982,7 @@ const MobileInstancesSurface: React.FC<{ {t('mobile.instances.delete')} - ) : ( + ) : connection.mode === 'relay' ? null : ( +
+ ) : ( + <> +
+
+
+ +

{t(stateLabelKey(state))}

+
+

+ {(status?.connectedClients ?? 0) === 1 + ? t('settings.remoteInstances.relay.status.clientsOne', { count: 1 }) + : t('settings.remoteInstances.relay.status.clientsMany', { count: status?.connectedClients ?? 0 })} +

+ {state === 'error' && status?.lastError ? ( +

{status.lastError}

+ ) : null} +
+ +
+ +
+

{t('settings.remoteInstances.relay.pair.title')}

+
+ setPairLabel(event.target.value)} + placeholder={t('settings.remoteInstances.relay.pair.labelPlaceholder')} + disabled={isPairing} + /> + +
+ + {!includeToken ? ( +

{t('settings.remoteInstances.relay.pair.noTokenHint')}

+ ) : null} + {!isConnected ? ( +

{t('settings.remoteInstances.relay.pair.requiresConnected')}

+ ) : null} + {offerUrl ? ( +
+

{t('settings.remoteInstances.relay.pair.linkLabel')}

+ {offerUrl} +
+ + {offerQrDataUrl ? ( + + ) : null} +
+

{t('settings.remoteInstances.relay.pair.warning')}

+
+ ) : null} +

{t('settings.remoteInstances.relay.pair.manageHint')}

+
+ + )} + + + + + {t('settings.remoteInstances.relay.pair.qrDialogTitle')} + {t('settings.remoteInstances.relay.pair.qrDialogDescription')} + + {offerQrDataUrl ? ( +
+ {t('settings.remoteInstances.relay.pair.qrAlt')} +
+ ) : null} +
+
+ + ); +}; diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 5e38a383..c3464cfb 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -21,6 +21,8 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { RelaySection } from '@/components/sections/remote-instances/RelaySection'; +import { RELAY_UI_ENABLED } from '@/lib/relay/gate'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; @@ -1115,6 +1117,8 @@ export const RemoteInstancesPage: React.FC = () => { ) : null} + {clientAuth && RELAY_UI_ENABLED ? : null} + {showInstanceManagement ?

{t('settings.remoteInstances.direct.title')}

diff --git a/packages/ui/src/lib/dictation/dictation-client.ts b/packages/ui/src/lib/dictation/dictation-client.ts index 3163db54..446d9496 100644 --- a/packages/ui/src/lib/dictation/dictation-client.ts +++ b/packages/ui/src/lib/dictation/dictation-client.ts @@ -8,6 +8,8 @@ import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; +import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket'; +import { type RelayTunnelWebSocket } from '@/lib/relay/tunnel-client'; export interface DictationStartOptions { provider?: 'local' | 'openai-compatible'; @@ -67,7 +69,7 @@ interface PendingFinish { } export class DictationClient { - private socket: WebSocket | null = null; + private socket: RelayTunnelWebSocket | null = null; private connectPromise: Promise | null = null; private idleCloseTimer: ReturnType | null = null; private readonly pendingStarts = new Map(); @@ -116,10 +118,10 @@ export class DictationClient { this.connectPromise = new Promise((resolve, reject) => { let settled = false; - let socket: WebSocket; + let socket: RelayTunnelWebSocket; try { const url = getRuntimeUrlResolver().websocket('/api/dictation/ws'); - socket = new WebSocket(url); + socket = openRuntimeWebSocket(url); } catch (error) { this.connectPromise = null; reject(error instanceof Error ? error : new Error(String(error))); @@ -163,20 +165,26 @@ export class DictationClient { }; socket.onerror = () => { - if (!settled) { + // Prefer onclose, which follows with the real reason (e.g. + // "Unexpected server response: 403"). But if a socket ever errors + // without a prompt onclose, fail fast here rather than hanging for + // the full connect timeout. onclose still wins if it arrives first. + window.setTimeout(() => { + if (settled) return; settled = true; clearTimeout(timeout); this.connectPromise = null; reject(new Error('Dictation connection failed')); - } + }, 250); }; - socket.onclose = () => { + socket.onclose = (event) => { if (!settled) { settled = true; clearTimeout(timeout); this.connectPromise = null; - reject(new Error('Dictation connection closed')); + const detail = event?.reason ? `: ${event.reason}` : ''; + reject(new Error(`Dictation connection failed${detail}`)); return; } if (this.socket === socket) { diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 00513280..846ce624 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -287,6 +287,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': 'This device', 'settings.remoteInstances.clientAuth.lastUsed': 'Last used {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Never used', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': 'Let your other devices connect from anywhere without opening ports. Traffic is end-to-end encrypted — the relay cannot read it.', + 'settings.remoteInstances.relay.enableHint': 'Nothing is shared until you enable the relay on this server.', + 'settings.remoteInstances.relay.actions.enable': 'Enable Relay', + 'settings.remoteInstances.relay.actions.disable': 'Disable', + 'settings.remoteInstances.relay.confirm.disable': 'Disable the relay? Devices connected through it will be disconnected immediately.', + 'settings.remoteInstances.relay.state.loading': 'Checking relay status...', + 'settings.remoteInstances.relay.state.disabled': 'Disabled', + 'settings.remoteInstances.relay.state.connecting': 'Connecting', + 'settings.remoteInstances.relay.state.connected': 'Connected', + 'settings.remoteInstances.relay.state.reconnecting': 'Reconnecting', + 'settings.remoteInstances.relay.state.error': 'Error', + 'settings.remoteInstances.relay.status.clientsOne': '{count} device connected', + 'settings.remoteInstances.relay.status.clientsMany': '{count} devices connected', + 'settings.remoteInstances.relay.pair.title': 'Pair a device', + 'settings.remoteInstances.relay.pair.labelPlaceholder': 'Device name (optional)', + 'settings.remoteInstances.relay.pair.includeToken': 'Include access token (one-scan pairing)', + 'settings.remoteInstances.relay.pair.noTokenHint': 'Without a token, the device signs in with this server’s UI password after connecting.', + 'settings.remoteInstances.relay.pair.generate': 'Create pairing link', + 'settings.remoteInstances.relay.pair.requiresConnected': 'Pairing becomes available once the relay is connected.', + 'settings.remoteInstances.relay.pair.linkLabel': 'Pairing link', + 'settings.remoteInstances.relay.pair.warning': 'This link grants access to this server. Do not share it.', + 'settings.remoteInstances.relay.pair.qrAlt': 'Relay pairing QR code', + 'settings.remoteInstances.relay.pair.showQr': 'Show QR code', + 'settings.remoteInstances.relay.pair.qrDialogTitle': 'Scan to pair', + 'settings.remoteInstances.relay.pair.qrDialogDescription': 'Scan this QR code with the OpenChamber app on your other device.', + 'settings.remoteInstances.relay.pair.manageHint': 'Manage or revoke paired devices in the “Connect to this server” list above.', + 'settings.remoteInstances.relay.toast.enableFailed': 'Failed to enable relay', + 'settings.remoteInstances.relay.toast.disableFailed': 'Failed to disable relay', + 'settings.remoteInstances.relay.toast.offerFailed': 'Failed to create pairing link', + 'settings.remoteInstances.relay.toast.linkCopied': 'Pairing link copied', 'settings.remoteInstances.sidebar.phase.ready': 'Ready', 'settings.remoteInstances.sidebar.phase.error': 'Error', 'settings.remoteInstances.sidebar.phase.reconnect': 'Reconnect', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index eff6d89e..13d1480d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -58,6 +58,7 @@ export const dict = { 'mobile.connect.scan.unsupported': 'QR scanning is only available in the installed mobile app.', 'mobile.connect.saved.title': 'Saved connections', 'mobile.connect.saved.empty': 'No saved connections yet.', + 'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Enter a server URL.', 'mobile.connect.error.invalidUrl': 'That server URL is not valid.', 'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 654832c4..a943f7ea 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -254,6 +254,37 @@ export const settingsDict = { "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", "settings.remoteInstances.clientAuth.lastUsed": "Último uso {date}", "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", + "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.description": "Permite que tus otros dispositivos se conecten desde cualquier lugar sin abrir puertos. El tráfico está cifrado de extremo a extremo: el relay no puede leerlo.", + "settings.remoteInstances.relay.enableHint": "No se comparte nada hasta que actives el relay en este servidor.", + "settings.remoteInstances.relay.actions.enable": "Activar Relay", + "settings.remoteInstances.relay.actions.disable": "Desactivar", + "settings.remoteInstances.relay.confirm.disable": "¿Desactivar el relay? Los dispositivos conectados a través de él se desconectarán de inmediato.", + "settings.remoteInstances.relay.state.loading": "Comprobando el estado del relay...", + "settings.remoteInstances.relay.state.disabled": "Desactivado", + "settings.remoteInstances.relay.state.connecting": "Conectando", + "settings.remoteInstances.relay.state.connected": "Conectado", + "settings.remoteInstances.relay.state.reconnecting": "Reconectando", + "settings.remoteInstances.relay.state.error": "Error", + "settings.remoteInstances.relay.status.clientsOne": "{count} dispositivo conectado", + "settings.remoteInstances.relay.status.clientsMany": "{count} dispositivos conectados", + "settings.remoteInstances.relay.pair.title": "Emparejar un dispositivo", + "settings.remoteInstances.relay.pair.labelPlaceholder": "Nombre del dispositivo (opcional)", + "settings.remoteInstances.relay.pair.includeToken": "Incluir token de acceso (emparejamiento con un solo escaneo)", + "settings.remoteInstances.relay.pair.noTokenHint": "Sin token, el dispositivo inicia sesión con la contraseña de la interfaz de este servidor tras conectarse.", + "settings.remoteInstances.relay.pair.generate": "Crear enlace de emparejamiento", + "settings.remoteInstances.relay.pair.requiresConnected": "El emparejamiento estará disponible cuando el relay esté conectado.", + "settings.remoteInstances.relay.pair.linkLabel": "Enlace de emparejamiento", + "settings.remoteInstances.relay.pair.warning": "Este enlace concede acceso a este servidor. No lo compartas.", + "settings.remoteInstances.relay.pair.qrAlt": "Código QR de emparejamiento del relay", + "settings.remoteInstances.relay.pair.showQr": "Mostrar código QR", + "settings.remoteInstances.relay.pair.qrDialogTitle": "Escanear para emparejar", + "settings.remoteInstances.relay.pair.qrDialogDescription": "Escanea este código QR con la app de OpenChamber en tu otro dispositivo.", + "settings.remoteInstances.relay.pair.manageHint": "Gestiona o revoca los dispositivos emparejados en la lista «Conexión a este servidor» de arriba.", + "settings.remoteInstances.relay.toast.enableFailed": "No se pudo activar el relay", + "settings.remoteInstances.relay.toast.disableFailed": "No se pudo desactivar el relay", + "settings.remoteInstances.relay.toast.offerFailed": "No se pudo crear el enlace de emparejamiento", + "settings.remoteInstances.relay.toast.linkCopied": "Enlace de emparejamiento copiado", "settings.remoteInstances.sidebar.phase.ready": "Listo", "settings.remoteInstances.sidebar.phase.error": "Error", "settings.remoteInstances.sidebar.phase.reconnect": "Reconectar", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3dfecad7..35358c5a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -59,6 +59,7 @@ export const dict: Record = { "mobile.connect.scan.unsupported": "El escaneo de QR solo está disponible en la app móvil instalada.", "mobile.connect.saved.title": "Conexiones guardadas", "mobile.connect.saved.empty": "Aún no hay conexiones guardadas.", + "mobile.connect.relay.badge": "a través de OpenChamber Relay", "mobile.connect.error.urlRequired": "Introduce una URL de servidor.", "mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.", "mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index ac682324..65d29ab1 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1789,6 +1789,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': 'Cet appareil', 'settings.remoteInstances.clientAuth.lastUsed': 'Dernière utilisation le {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Jamais utilisé', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': 'Permettez à vos autres appareils de se connecter depuis n’importe où sans ouvrir de ports. Le trafic est chiffré de bout en bout — le relais ne peut pas le lire.', + 'settings.remoteInstances.relay.enableHint': 'Rien n’est partagé tant que vous n’activez pas le relais sur ce serveur.', + 'settings.remoteInstances.relay.actions.enable': 'Activer le relais', + 'settings.remoteInstances.relay.actions.disable': 'Désactiver', + 'settings.remoteInstances.relay.confirm.disable': 'Désactiver le relais ? Les appareils connectés via celui-ci seront déconnectés immédiatement.', + 'settings.remoteInstances.relay.state.loading': 'Vérification de l’état du relais...', + 'settings.remoteInstances.relay.state.disabled': 'Désactivé', + 'settings.remoteInstances.relay.state.connecting': 'Connexion', + 'settings.remoteInstances.relay.state.connected': 'Connecté', + 'settings.remoteInstances.relay.state.reconnecting': 'Reconnexion', + 'settings.remoteInstances.relay.state.error': 'Erreur', + 'settings.remoteInstances.relay.status.clientsOne': '{count} appareil connecté', + 'settings.remoteInstances.relay.status.clientsMany': '{count} appareils connectés', + 'settings.remoteInstances.relay.pair.title': 'Associer un appareil', + 'settings.remoteInstances.relay.pair.labelPlaceholder': 'Nom de l’appareil (facultatif)', + 'settings.remoteInstances.relay.pair.includeToken': 'Inclure le jeton d’accès (association en un seul scan)', + 'settings.remoteInstances.relay.pair.noTokenHint': 'Sans jeton, l’appareil se connecte avec le mot de passe de l’interface de ce serveur.', + 'settings.remoteInstances.relay.pair.generate': 'Créer un lien d’association', + 'settings.remoteInstances.relay.pair.requiresConnected': 'L’association devient disponible une fois le relais connecté.', + 'settings.remoteInstances.relay.pair.linkLabel': 'Lien d’association', + 'settings.remoteInstances.relay.pair.warning': 'Ce lien donne accès à ce serveur. Ne le partagez pas.', + 'settings.remoteInstances.relay.pair.qrAlt': 'Code QR d’association du relais', + 'settings.remoteInstances.relay.pair.showQr': 'Afficher le code QR', + 'settings.remoteInstances.relay.pair.qrDialogTitle': 'Scanner pour associer', + 'settings.remoteInstances.relay.pair.qrDialogDescription': 'Scannez ce code QR avec l’application OpenChamber sur votre autre appareil.', + 'settings.remoteInstances.relay.pair.manageHint': 'Gérez ou révoquez les appareils associés dans la liste « Connexion à ce serveur » ci-dessus.', + 'settings.remoteInstances.relay.toast.enableFailed': 'Échec de l’activation du relais', + 'settings.remoteInstances.relay.toast.disableFailed': 'Échec de la désactivation du relais', + 'settings.remoteInstances.relay.toast.offerFailed': 'Échec de la création du lien d’association', + 'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié', 'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode', 'settings.openchamber.about.state.unknown': 'inconnue', 'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2042f531..ccf31fa8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2503,6 +2503,7 @@ export const dict = { 'mobile.connect.scan.unsupported': 'Le scan QR est disponible uniquement dans l\'app mobile installée.', 'mobile.connect.saved.title': 'Connexions enregistrées', 'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.', + 'mobile.connect.relay.badge': 'via OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.', 'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.', 'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 0234e01c..04cdefd9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -287,6 +287,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': 'このデバイス', 'settings.remoteInstances.clientAuth.lastUsed': '最終使用 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '未使用', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': 'ポートを開放せずに、他のデバイスからどこからでも接続できます。通信はエンドツーエンドで暗号化され、リレーは内容を読めません。', + 'settings.remoteInstances.relay.enableHint': 'このサーバーでリレーを有効にするまで、何も共有されません。', + 'settings.remoteInstances.relay.actions.enable': 'リレーを有効にする', + 'settings.remoteInstances.relay.actions.disable': '無効にする', + 'settings.remoteInstances.relay.confirm.disable': 'リレーを無効にしますか?リレー経由で接続中のデバイスは即座に切断されます。', + 'settings.remoteInstances.relay.state.loading': 'リレーの状態を確認中...', + 'settings.remoteInstances.relay.state.disabled': '無効', + 'settings.remoteInstances.relay.state.connecting': '接続中', + 'settings.remoteInstances.relay.state.connected': '接続済み', + 'settings.remoteInstances.relay.state.reconnecting': '再接続中', + 'settings.remoteInstances.relay.state.error': 'エラー', + 'settings.remoteInstances.relay.status.clientsOne': '{count} 台のデバイスが接続中', + 'settings.remoteInstances.relay.status.clientsMany': '{count} 台のデバイスが接続中', + 'settings.remoteInstances.relay.pair.title': 'デバイスをペアリング', + 'settings.remoteInstances.relay.pair.labelPlaceholder': 'デバイス名(任意)', + 'settings.remoteInstances.relay.pair.includeToken': 'アクセストークンを含める(1回のスキャンでペアリング)', + 'settings.remoteInstances.relay.pair.noTokenHint': 'トークンなしの場合、デバイスは接続後にこのサーバーのUIパスワードでサインインします。', + 'settings.remoteInstances.relay.pair.generate': 'ペアリングリンクを作成', + 'settings.remoteInstances.relay.pair.requiresConnected': 'ペアリングはリレーの接続後に利用できます。', + 'settings.remoteInstances.relay.pair.linkLabel': 'ペアリングリンク', + 'settings.remoteInstances.relay.pair.warning': 'このリンクはこのサーバーへのアクセスを許可します。共有しないでください。', + 'settings.remoteInstances.relay.pair.qrAlt': 'リレーペアリング用QRコード', + 'settings.remoteInstances.relay.pair.showQr': 'QRコードを表示', + 'settings.remoteInstances.relay.pair.qrDialogTitle': 'スキャンしてペアリング', + 'settings.remoteInstances.relay.pair.qrDialogDescription': '他のデバイスのOpenChamberアプリでこのQRコードをスキャンします。', + 'settings.remoteInstances.relay.pair.manageHint': 'ペアリング済みデバイスの管理や取り消しは、上の「このサーバーへの接続」一覧で行えます。', + 'settings.remoteInstances.relay.toast.enableFailed': 'リレーを有効にできませんでした', + 'settings.remoteInstances.relay.toast.disableFailed': 'リレーを無効にできませんでした', + 'settings.remoteInstances.relay.toast.offerFailed': 'ペアリングリンクを作成できませんでした', + 'settings.remoteInstances.relay.toast.linkCopied': 'ペアリングリンクをコピーしました', 'settings.remoteInstances.sidebar.phase.ready': '準備完了', 'settings.remoteInstances.sidebar.phase.error': 'エラー', 'settings.remoteInstances.sidebar.phase.reconnect': '再接続', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 616b16c3..dfba9e49 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -58,6 +58,7 @@ export const dict: Record = { 'mobile.connect.cancelPassword': '別のサーバーを使用', 'mobile.connect.saved.title': '保存された接続', 'mobile.connect.saved.empty': '保存された接続はまだありません。', + 'mobile.connect.relay.badge': 'OpenChamber Relay 経由', 'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。', 'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。', 'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 7b48d10e..2e4b00cf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -254,6 +254,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': '이 기기', 'settings.remoteInstances.clientAuth.lastUsed': '마지막 사용 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '사용한 적 없음', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': '포트를 열지 않고도 다른 기기가 어디서든 연결할 수 있습니다. 트래픽은 종단 간 암호화되어 릴레이는 내용을 읽을 수 없습니다.', + 'settings.remoteInstances.relay.enableHint': '이 서버에서 릴레이를 켜기 전까지는 아무것도 공유되지 않습니다.', + 'settings.remoteInstances.relay.actions.enable': '릴레이 켜기', + 'settings.remoteInstances.relay.actions.disable': '끄기', + 'settings.remoteInstances.relay.confirm.disable': '릴레이를 끄시겠습니까? 릴레이를 통해 연결된 기기는 즉시 연결이 끊어집니다.', + 'settings.remoteInstances.relay.state.loading': '릴레이 상태 확인 중...', + 'settings.remoteInstances.relay.state.disabled': '꺼짐', + 'settings.remoteInstances.relay.state.connecting': '연결 중', + 'settings.remoteInstances.relay.state.connected': '연결됨', + 'settings.remoteInstances.relay.state.reconnecting': '재연결 중', + 'settings.remoteInstances.relay.state.error': '오류', + 'settings.remoteInstances.relay.status.clientsOne': '기기 {count}대 연결됨', + 'settings.remoteInstances.relay.status.clientsMany': '기기 {count}대 연결됨', + 'settings.remoteInstances.relay.pair.title': '기기 페어링', + 'settings.remoteInstances.relay.pair.labelPlaceholder': '기기 이름 (선택 사항)', + 'settings.remoteInstances.relay.pair.includeToken': '액세스 토큰 포함 (한 번 스캔으로 페어링)', + 'settings.remoteInstances.relay.pair.noTokenHint': '토큰이 없으면 기기는 연결 후 이 서버의 UI 비밀번호로 로그인합니다.', + 'settings.remoteInstances.relay.pair.generate': '페어링 링크 만들기', + 'settings.remoteInstances.relay.pair.requiresConnected': '페어링은 릴레이가 연결된 후 사용할 수 있습니다.', + 'settings.remoteInstances.relay.pair.linkLabel': '페어링 링크', + 'settings.remoteInstances.relay.pair.warning': '이 링크는 이 서버에 대한 접근 권한을 부여합니다. 공유하지 마세요.', + 'settings.remoteInstances.relay.pair.qrAlt': '릴레이 페어링 QR 코드', + 'settings.remoteInstances.relay.pair.showQr': 'QR 코드 표시', + 'settings.remoteInstances.relay.pair.qrDialogTitle': '스캔하여 페어링', + 'settings.remoteInstances.relay.pair.qrDialogDescription': '다른 기기의 OpenChamber 앱으로 이 QR 코드를 스캔하세요.', + 'settings.remoteInstances.relay.pair.manageHint': '페어링된 기기는 위의 “이 서버에 연결” 목록에서 관리하거나 철회할 수 있습니다.', + 'settings.remoteInstances.relay.toast.enableFailed': '릴레이를 켤 수 없습니다', + 'settings.remoteInstances.relay.toast.disableFailed': '릴레이를 끔 수 없습니다', + 'settings.remoteInstances.relay.toast.offerFailed': '페어링 링크를 만들지 못했습니다', + 'settings.remoteInstances.relay.toast.linkCopied': '페어링 링크를 복사했습니다', 'settings.remoteInstances.sidebar.phase.ready': '준비됨', 'settings.remoteInstances.sidebar.phase.error': '오류', 'settings.remoteInstances.sidebar.phase.reconnect': '재연결', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 41746993..06d84b75 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -59,6 +59,7 @@ export const dict: Record = { 'mobile.connect.scan.unsupported': 'QR 스캔은 설치된 모바일 앱에서만 사용할 수 있습니다.', 'mobile.connect.saved.title': '저장된 연결', 'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.', + 'mobile.connect.relay.badge': 'OpenChamber Relay 경유', 'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.', 'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.', 'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b0be44bc..bf2a2145 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1477,6 +1477,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': 'To urządzenie', 'settings.remoteInstances.clientAuth.lastUsed': 'Ostatnio użyto {date}', 'settings.remoteInstances.clientAuth.neverUsed': 'Nigdy nie użyto', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': 'Pozwól swoim innym urządzeniom łączyć się z dowolnego miejsca bez otwierania portów. Ruch jest szyfrowany od końca do końca — relay nie może go odczytać.', + 'settings.remoteInstances.relay.enableHint': 'Nic nie jest udostępniane, dopóki nie włączysz relay na tym serwerze.', + 'settings.remoteInstances.relay.actions.enable': 'Włącz Relay', + 'settings.remoteInstances.relay.actions.disable': 'Wyłącz', + 'settings.remoteInstances.relay.confirm.disable': 'Wyłączyć relay? Urządzenia połączone przez niego zostaną natychmiast rozłączone.', + 'settings.remoteInstances.relay.state.loading': 'Sprawdzanie stanu relay...', + 'settings.remoteInstances.relay.state.disabled': 'Wyłączony', + 'settings.remoteInstances.relay.state.connecting': 'Łączenie', + 'settings.remoteInstances.relay.state.connected': 'Połączono', + 'settings.remoteInstances.relay.state.reconnecting': 'Ponowne łączenie', + 'settings.remoteInstances.relay.state.error': 'Błąd', + 'settings.remoteInstances.relay.status.clientsOne': '{count} urządzenie połączone', + 'settings.remoteInstances.relay.status.clientsMany': 'Połączone urządzenia: {count}', + 'settings.remoteInstances.relay.pair.title': 'Sparuj urządzenie', + 'settings.remoteInstances.relay.pair.labelPlaceholder': 'Nazwa urządzenia (opcjonalnie)', + 'settings.remoteInstances.relay.pair.includeToken': 'Dołącz token dostępu (parowanie jednym skanem)', + 'settings.remoteInstances.relay.pair.noTokenHint': 'Bez tokenu urządzenie zaloguje się po połączeniu hasłem interfejsu tego serwera.', + 'settings.remoteInstances.relay.pair.generate': 'Utwórz link parowania', + 'settings.remoteInstances.relay.pair.requiresConnected': 'Parowanie będzie dostępne, gdy relay zostanie połączony.', + 'settings.remoteInstances.relay.pair.linkLabel': 'Link parowania', + 'settings.remoteInstances.relay.pair.warning': 'Ten link daje dostęp do tego serwera. Nie udostępniaj go.', + 'settings.remoteInstances.relay.pair.qrAlt': 'Kod QR parowania relay', + 'settings.remoteInstances.relay.pair.showQr': 'Pokaż kod QR', + 'settings.remoteInstances.relay.pair.qrDialogTitle': 'Zeskanuj, aby sparować', + 'settings.remoteInstances.relay.pair.qrDialogDescription': 'Zeskanuj ten kod QR aplikacją OpenChamber na drugim urządzeniu.', + 'settings.remoteInstances.relay.pair.manageHint': 'Zarządzaj sparowanymi urządzeniami lub odbieraj im dostęp na liście „Połączenie z tym serwerem” powyżej.', + 'settings.remoteInstances.relay.toast.enableFailed': 'Nie udało się włączyć relay', + 'settings.remoteInstances.relay.toast.disableFailed': 'Nie udało się wyłączyć relay', + 'settings.remoteInstances.relay.toast.offerFailed': 'Nie udało się utworzyć linku parowania', + 'settings.remoteInstances.relay.toast.linkCopied': 'Skopiowano link parowania', 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Ponowiono próbę z losowym lokalnym portem', 'settings.remoteInstances.sidebar.toast.retryFailed': 'Nie udało się ponowić połączenia', 'settings.remoteInstances.sidebar.total': 'Suma: {count}', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 80df1a15..b1f00c03 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -60,6 +60,7 @@ export const dict: Record = { 'mobile.connect.scan.unsupported': 'Skanowanie QR jest dostępne tylko w zainstalowanej aplikacji mobilnej.', 'mobile.connect.saved.title': 'Zapisane połączenia', 'mobile.connect.saved.empty': 'Brak zapisanych połączeń.', + 'mobile.connect.relay.badge': 'przez OpenChamber Relay', 'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.', 'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.', 'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 2a6a8e6b..b696fd14 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -254,6 +254,37 @@ export const settingsDict = { "settings.remoteInstances.clientAuth.state.thisDevice": "Este dispositivo", "settings.remoteInstances.clientAuth.lastUsed": "Último uso em {date}", "settings.remoteInstances.clientAuth.neverUsed": "Nunca usado", + "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.description": "Permita que seus outros dispositivos se conectem de qualquer lugar sem abrir portas. O tráfego é criptografado de ponta a ponta — o relay não consegue lê-lo.", + "settings.remoteInstances.relay.enableHint": "Nada é compartilhado até você ativar o relay neste servidor.", + "settings.remoteInstances.relay.actions.enable": "Ativar Relay", + "settings.remoteInstances.relay.actions.disable": "Desativar", + "settings.remoteInstances.relay.confirm.disable": "Desativar o relay? Os dispositivos conectados por ele serão desconectados imediatamente.", + "settings.remoteInstances.relay.state.loading": "Verificando o status do relay...", + "settings.remoteInstances.relay.state.disabled": "Desativado", + "settings.remoteInstances.relay.state.connecting": "Conectando", + "settings.remoteInstances.relay.state.connected": "Conectado", + "settings.remoteInstances.relay.state.reconnecting": "Reconectando", + "settings.remoteInstances.relay.state.error": "Erro", + "settings.remoteInstances.relay.status.clientsOne": "{count} dispositivo conectado", + "settings.remoteInstances.relay.status.clientsMany": "{count} dispositivos conectados", + "settings.remoteInstances.relay.pair.title": "Parear um dispositivo", + "settings.remoteInstances.relay.pair.labelPlaceholder": "Nome do dispositivo (opcional)", + "settings.remoteInstances.relay.pair.includeToken": "Incluir token de acesso (pareamento com um único escaneamento)", + "settings.remoteInstances.relay.pair.noTokenHint": "Sem token, o dispositivo faz login com a senha da interface deste servidor após conectar.", + "settings.remoteInstances.relay.pair.generate": "Criar link de pareamento", + "settings.remoteInstances.relay.pair.requiresConnected": "O pareamento fica disponível quando o relay estiver conectado.", + "settings.remoteInstances.relay.pair.linkLabel": "Link de pareamento", + "settings.remoteInstances.relay.pair.warning": "Este link concede acesso a este servidor. Não o compartilhe.", + "settings.remoteInstances.relay.pair.qrAlt": "Código QR de pareamento do relay", + "settings.remoteInstances.relay.pair.showQr": "Mostrar código QR", + "settings.remoteInstances.relay.pair.qrDialogTitle": "Escanear para parear", + "settings.remoteInstances.relay.pair.qrDialogDescription": "Escaneie este código QR com o app OpenChamber no seu outro dispositivo.", + "settings.remoteInstances.relay.pair.manageHint": "Gerencie ou revogue dispositivos pareados na lista “Conectar a este servidor” acima.", + "settings.remoteInstances.relay.toast.enableFailed": "Falha ao ativar o relay", + "settings.remoteInstances.relay.toast.disableFailed": "Falha ao desativar o relay", + "settings.remoteInstances.relay.toast.offerFailed": "Falha ao criar o link de pareamento", + "settings.remoteInstances.relay.toast.linkCopied": "Link de pareamento copiado", "settings.remoteInstances.sidebar.phase.ready": "Pronto", "settings.remoteInstances.sidebar.phase.error": "Erro", "settings.remoteInstances.sidebar.phase.reconnect": "Reconectar", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index c0d785b1..fc7dec50 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -59,6 +59,7 @@ export const dict: Record = { "mobile.connect.scan.unsupported": "A leitura de QR só está disponível no app móvel instalado.", "mobile.connect.saved.title": "Conexões salvas", "mobile.connect.saved.empty": "Nenhuma conexão salva ainda.", + "mobile.connect.relay.badge": "via OpenChamber Relay", "mobile.connect.error.urlRequired": "Informe a URL de um servidor.", "mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.", "mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 24488a18..f245ef5d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -254,6 +254,37 @@ export const settingsDict = { "settings.remoteInstances.clientAuth.state.thisDevice": "Цей пристрій", "settings.remoteInstances.clientAuth.lastUsed": "Останнє використання {date}", "settings.remoteInstances.clientAuth.neverUsed": "Ще не використовувався", + "settings.remoteInstances.relay.title": "OpenChamber Relay", + "settings.remoteInstances.relay.description": "Дозволяє вашим іншим пристроям підключатися звідки завгодно без відкриття портів. Трафік шифрується наскрізно — релей не може його прочитати.", + "settings.remoteInstances.relay.enableHint": "Нічого не передається, доки ви не увімкнете релей на цьому сервері.", + "settings.remoteInstances.relay.actions.enable": "Увімкнути Relay", + "settings.remoteInstances.relay.actions.disable": "Вимкнути", + "settings.remoteInstances.relay.confirm.disable": "Вимкнути релей? Пристрої, підключені через нього, будуть негайно відключені.", + "settings.remoteInstances.relay.state.loading": "Перевірка стану релею...", + "settings.remoteInstances.relay.state.disabled": "Вимкнено", + "settings.remoteInstances.relay.state.connecting": "Підключення", + "settings.remoteInstances.relay.state.connected": "Підключено", + "settings.remoteInstances.relay.state.reconnecting": "Повторне підключення", + "settings.remoteInstances.relay.state.error": "Помилка", + "settings.remoteInstances.relay.status.clientsOne": "Підключено пристроїв: {count}", + "settings.remoteInstances.relay.status.clientsMany": "Підключено пристроїв: {count}", + "settings.remoteInstances.relay.pair.title": "Спарувати пристрій", + "settings.remoteInstances.relay.pair.labelPlaceholder": "Назва пристрою (необов'язково)", + "settings.remoteInstances.relay.pair.includeToken": "Додати токен доступу (спарювання одним скануванням)", + "settings.remoteInstances.relay.pair.noTokenHint": "Без токена пристрій після підключення входить за паролем інтерфейсу цього сервера.", + "settings.remoteInstances.relay.pair.generate": "Створити посилання для спарювання", + "settings.remoteInstances.relay.pair.requiresConnected": "Спарювання стане доступним після підключення релею.", + "settings.remoteInstances.relay.pair.linkLabel": "Посилання для спарювання", + "settings.remoteInstances.relay.pair.warning": "Це посилання надає доступ до цього сервера. Не діліться ним.", + "settings.remoteInstances.relay.pair.qrAlt": "QR-код спарювання релею", + "settings.remoteInstances.relay.pair.showQr": "Показати QR-код", + "settings.remoteInstances.relay.pair.qrDialogTitle": "Скануйте для спарювання", + "settings.remoteInstances.relay.pair.qrDialogDescription": "Відскануйте цей QR-код застосунком OpenChamber на іншому пристрої.", + "settings.remoteInstances.relay.pair.manageHint": "Керуйте спареними пристроями або відкликайте їх у списку «Підключення до цього сервера» вище.", + "settings.remoteInstances.relay.toast.enableFailed": "Не вдалося увімкнути релей", + "settings.remoteInstances.relay.toast.disableFailed": "Не вдалося вимкнути релей", + "settings.remoteInstances.relay.toast.offerFailed": "Не вдалося створити посилання для спарювання", + "settings.remoteInstances.relay.toast.linkCopied": "Посилання для спарювання скопійовано", "settings.remoteInstances.sidebar.phase.ready": "Готово", "settings.remoteInstances.sidebar.phase.error": "Помилка", "settings.remoteInstances.sidebar.phase.reconnect": "Повторне підключення", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 03faa282..563ba33e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -59,6 +59,7 @@ export const dict: Record = { "mobile.connect.scan.unsupported": "Сканування QR доступне лише у встановленій мобільній апці.", "mobile.connect.saved.title": "Збережені підключення", "mobile.connect.saved.empty": "Збережених підключень ще немає.", + "mobile.connect.relay.badge": "через OpenChamber Relay", "mobile.connect.error.urlRequired": "Введи адресу сервера.", "mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.", "mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index e5e99b9d..52b65166 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -254,6 +254,37 @@ export const settingsDict = { 'settings.remoteInstances.clientAuth.state.thisDevice': '此设备', 'settings.remoteInstances.clientAuth.lastUsed': '上次使用 {date}', 'settings.remoteInstances.clientAuth.neverUsed': '从未使用', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': '无需开放端口,即可让你的其他设备从任何地方连接。流量端到端加密,中继无法读取内容。', + 'settings.remoteInstances.relay.enableHint': '在此服务器上启用中继之前,不会共享任何内容。', + 'settings.remoteInstances.relay.actions.enable': '启用中继', + 'settings.remoteInstances.relay.actions.disable': '停用', + 'settings.remoteInstances.relay.confirm.disable': '停用中继?通过它连接的设备将立即断开。', + 'settings.remoteInstances.relay.state.loading': '正在检查中继状态...', + 'settings.remoteInstances.relay.state.disabled': '已停用', + 'settings.remoteInstances.relay.state.connecting': '连接中', + 'settings.remoteInstances.relay.state.connected': '已连接', + 'settings.remoteInstances.relay.state.reconnecting': '重新连接中', + 'settings.remoteInstances.relay.state.error': '错误', + 'settings.remoteInstances.relay.status.clientsOne': '已连接 {count} 台设备', + 'settings.remoteInstances.relay.status.clientsMany': '已连接 {count} 台设备', + 'settings.remoteInstances.relay.pair.title': '配对设备', + 'settings.remoteInstances.relay.pair.labelPlaceholder': '设备名称(可选)', + 'settings.remoteInstances.relay.pair.includeToken': '包含访问令牌(扫一次即完成配对)', + 'settings.remoteInstances.relay.pair.noTokenHint': '不包含令牌时,设备连接后需使用此服务器的界面密码登录。', + 'settings.remoteInstances.relay.pair.generate': '创建配对链接', + 'settings.remoteInstances.relay.pair.requiresConnected': '中继连接后即可配对。', + 'settings.remoteInstances.relay.pair.linkLabel': '配对链接', + 'settings.remoteInstances.relay.pair.warning': '此链接可访问此服务器,请勿分享。', + 'settings.remoteInstances.relay.pair.qrAlt': '中继配对二维码', + 'settings.remoteInstances.relay.pair.showQr': '显示二维码', + 'settings.remoteInstances.relay.pair.qrDialogTitle': '扫描配对', + 'settings.remoteInstances.relay.pair.qrDialogDescription': '用其他设备上的 OpenChamber 应用扫描此二维码。', + 'settings.remoteInstances.relay.pair.manageHint': '可在上方“连接到此服务器”列表中管理或吊销已配对的设备。', + 'settings.remoteInstances.relay.toast.enableFailed': '无法启用中继', + 'settings.remoteInstances.relay.toast.disableFailed': '无法停用中继', + 'settings.remoteInstances.relay.toast.offerFailed': '无法创建配对链接', + 'settings.remoteInstances.relay.toast.linkCopied': '已复制配对链接', 'settings.remoteInstances.sidebar.phase.ready': '就绪', 'settings.remoteInstances.sidebar.phase.error': '错误', 'settings.remoteInstances.sidebar.phase.reconnect': '重连', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index c9e93f93..04fd66c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -59,6 +59,7 @@ export const dict: Record = { 'mobile.connect.scan.unsupported': '二维码扫描仅在已安装的移动应用中可用。', 'mobile.connect.saved.title': '已保存的连接', 'mobile.connect.saved.empty': '暂无已保存的连接。', + 'mobile.connect.relay.badge': '通过 OpenChamber Relay 连接', 'mobile.connect.error.urlRequired': '请输入服务器 URL。', 'mobile.connect.error.invalidUrl': '该服务器 URL 无效。', 'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c75dd345..5a781ce3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -260,6 +260,37 @@ 'settings.remoteInstances.clientAuth.state.thisDevice': '此裝置', 'settings.remoteInstances.clientAuth.lastUsed': '上次使用:{date}', 'settings.remoteInstances.clientAuth.neverUsed': '從未使用', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.description': '無需開放連接埠,即可讓你的其他裝置從任何地方連線。流量端對端加密,中繼無法讀取內容。', + 'settings.remoteInstances.relay.enableHint': '在此伺服器上啟用中繼之前,不會共享任何內容。', + 'settings.remoteInstances.relay.actions.enable': '啟用中繼', + 'settings.remoteInstances.relay.actions.disable': '停用', + 'settings.remoteInstances.relay.confirm.disable': '停用中繼?透過它連線的裝置將立即中斷連線。', + 'settings.remoteInstances.relay.state.loading': '正在檢查中繼狀態...', + 'settings.remoteInstances.relay.state.disabled': '已停用', + 'settings.remoteInstances.relay.state.connecting': '連線中', + 'settings.remoteInstances.relay.state.connected': '已連線', + 'settings.remoteInstances.relay.state.reconnecting': '重新連線中', + 'settings.remoteInstances.relay.state.error': '錯誤', + 'settings.remoteInstances.relay.status.clientsOne': '已連線 {count} 台裝置', + 'settings.remoteInstances.relay.status.clientsMany': '已連線 {count} 台裝置', + 'settings.remoteInstances.relay.pair.title': '配對裝置', + 'settings.remoteInstances.relay.pair.labelPlaceholder': '裝置名稱(選填)', + 'settings.remoteInstances.relay.pair.includeToken': '包含存取權杖(掃一次即完成配對)', + 'settings.remoteInstances.relay.pair.noTokenHint': '不包含權杖時,裝置連線後需使用此伺服器的介面密碼登入。', + 'settings.remoteInstances.relay.pair.generate': '建立配對連結', + 'settings.remoteInstances.relay.pair.requiresConnected': '中繼連線後即可配對。', + 'settings.remoteInstances.relay.pair.linkLabel': '配對連結', + 'settings.remoteInstances.relay.pair.warning': '此連結可存取此伺服器,請勿分享。', + 'settings.remoteInstances.relay.pair.qrAlt': '中繼配對 QR 碼', + 'settings.remoteInstances.relay.pair.showQr': '顯示 QR 碼', + 'settings.remoteInstances.relay.pair.qrDialogTitle': '掃描配對', + 'settings.remoteInstances.relay.pair.qrDialogDescription': '用另一台裝置上的 OpenChamber 應用程式掃描此 QR 碼。', + 'settings.remoteInstances.relay.pair.manageHint': '可在上方「連線到此伺服器」清單中管理或撤銷已配對的裝置。', + 'settings.remoteInstances.relay.toast.enableFailed': '無法啟用中繼', + 'settings.remoteInstances.relay.toast.disableFailed': '無法停用中繼', + 'settings.remoteInstances.relay.toast.offerFailed': '無法建立配對連結', + 'settings.remoteInstances.relay.toast.linkCopied': '已複製配對連結', 'settings.remoteInstances.page.section.instance': '執行個體', 'settings.remoteInstances.page.section.instanceDescription': '核心 SSH 設定。', 'settings.remoteInstances.page.field.mode': '模式', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 1d6eb173..32d761ce 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -59,6 +59,7 @@ export const dict: Record = { 'mobile.connect.scan.unsupported': 'QR code 掃描僅在已安裝的行動應用程式中可用。', 'mobile.connect.saved.title': '已儲存的連線', 'mobile.connect.saved.empty': '尚未儲存任何連線。', + 'mobile.connect.relay.badge': '透過 OpenChamber Relay 連線', 'mobile.connect.error.urlRequired': '請輸入伺服器網址。', 'mobile.connect.error.invalidUrl': '該伺服器網址無效。', 'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。', diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 71d1c0ec..4bd0d712 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -126,6 +126,27 @@ describe('opencodeClient prompt retry behavior', () => { expect(error instanceof Error ? error.message : String(error)).toContain('Failed to fetch'); }); + test('does not fabricate an HTTP 500 when the SDK swallows a transport failure into result.error', async () => { + // The SDK catches thrown fetch errors and returns { error, response: undefined }. + // That is a transport failure, not a server 500 — it must surface as a + // descriptive transport error, never as "Failed to send message (500): {}". + promptAsyncResults.push({ error: new TypeError('relay tunnel reset: plaintext frame on established channel'), response: undefined }); + + let error: unknown = null; + try { + await sendPrompt('anthropic-transport'); + } catch (caught) { + error = caught; + } + + expect(promptAsyncCalls.length).toBe(1); + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain('Failed to send message (500)'); + expect(message).toContain('transport failure'); + expect(message).toContain('relay tunnel reset'); + expect((error as Error & { status?: number }).status).toBe(undefined); + }); + test('does not retry 503 prompt responses because proxy errors can be ambiguous too', async () => { promptAsyncResults.push({ response: new Response('starting', { status: 503 }) }); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 90ac657d..f5d04273 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -860,7 +860,14 @@ class OpencodeService { if (result.response instanceof Response) { response = result.response; } else if (result.error) { - const status = (result as SdkResult).response?.status || 500; + const status = (result as SdkResult).response?.status; + if (!status) { + // The SDK caught a thrown fetch error (network/tunnel transport + // failure) — there is no HTTP response to report. Never fabricate a + // status: surface it as a transport error so callers treat it like + // any other network failure instead of a server 500. + throw new Error(`Message send transport failure: ${formatSdkError(result.error)}`); + } response = new Response(JSON.stringify(result.error), { status }); } else { response = new Response(JSON.stringify(result.data ?? true), { status: 200 }); diff --git a/packages/ui/src/lib/relay/crypto.test.ts b/packages/ui/src/lib/relay/crypto.test.ts new file mode 100644 index 00000000..0b4c0440 --- /dev/null +++ b/packages/ui/src/lib/relay/crypto.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'bun:test'; + +import { + base64UrlToBytes, + bytesToBase64Url, + createFrameDecryptor, + createFrameEncryptor, + deriveSessionKeys, + exportPublicKeyJwk, + generateEcdhKeyPair, + generateHandshakeNonce, + importEcdhPublicKey, + publicKeyJwkFingerprint, + RelayCryptoError, +} from './crypto'; +import { ENCRYPTED_FRAME_HEADER_BYTES, MAX_PLAINTEXT_FRAME_BYTES } from './protocol'; + +const setupSession = async () => { + const host = await generateEcdhKeyPair(); + const client = await generateEcdhKeyPair(); + const nonce = generateHandshakeNonce(); + const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey)); + const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey)); + const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, nonce); + const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, nonce); + return { clientKeys, hostKeys }; +}; + +describe('relay crypto', () => { + test('both sides derive matching directional keys (round trip both ways)', async () => { + const { clientKeys, hostKeys } = await setupSession(); + + const clientToHost = createFrameEncryptor(clientKeys.clientToHost); + const hostReceives = createFrameDecryptor(hostKeys.clientToHost); + const message = new TextEncoder().encode('hello from client'); + const decrypted = await hostReceives.decrypt(await clientToHost.encrypt(message)); + expect(new TextDecoder().decode(decrypted)).toBe('hello from client'); + + const hostToClient = createFrameEncryptor(hostKeys.hostToClient); + const clientReceives = createFrameDecryptor(clientKeys.hostToClient); + const reply = new TextEncoder().encode('hello from host'); + const decryptedReply = await clientReceives.decrypt(await hostToClient.encrypt(reply)); + expect(new TextDecoder().decode(decryptedReply)).toBe('hello from host'); + }); + + test('different nonce yields incompatible keys', async () => { + const host = await generateEcdhKeyPair(); + const client = await generateEcdhKeyPair(); + const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey)); + const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey)); + const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, generateHandshakeNonce()); + const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, generateHandshakeNonce()); + const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([1, 2, 3])); + await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow(RelayCryptoError); + }); + + test('tampered ciphertext is rejected', async () => { + const { clientKeys, hostKeys } = await setupSession(); + const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([9, 9, 9])); + frame[frame.length - 1] ^= 0x01; + await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow( + 'frame decryption failed', + ); + }); + + test('replayed and reordered frames are rejected (counter regression)', async () => { + const { clientKeys, hostKeys } = await setupSession(); + const encryptor = createFrameEncryptor(clientKeys.clientToHost); + const decryptor = createFrameDecryptor(hostKeys.clientToHost); + const first = await encryptor.encrypt(new Uint8Array([1])); + const second = await encryptor.encrypt(new Uint8Array([2])); + await decryptor.decrypt(first); + await decryptor.decrypt(second); + await expect(decryptor.decrypt(first)).rejects.toThrow('frame counter regression'); + }); + + test('skipped counters are tolerated but never regress', async () => { + const { clientKeys, hostKeys } = await setupSession(); + const encryptor = createFrameEncryptor(clientKeys.clientToHost); + const decryptor = createFrameDecryptor(hostKeys.clientToHost); + const first = await encryptor.encrypt(new Uint8Array([1])); + const second = await encryptor.encrypt(new Uint8Array([2])); + const third = await encryptor.encrypt(new Uint8Array([3])); + await decryptor.decrypt(first); + await decryptor.decrypt(third); + await expect(decryptor.decrypt(second)).rejects.toThrow('frame counter regression'); + }); + + test('oversized plaintext is rejected before encryption', async () => { + const { clientKeys } = await setupSession(); + const encryptor = createFrameEncryptor(clientKeys.clientToHost); + await expect(encryptor.encrypt(new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES + 1))).rejects.toThrow( + 'plaintext frame exceeds maximum size', + ); + }); + + test('truncated and wrong-version frames are rejected', async () => { + const { hostKeys } = await setupSession(); + const decryptor = createFrameDecryptor(hostKeys.clientToHost); + await expect(decryptor.decrypt(new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES))).rejects.toThrow( + 'encrypted frame too short', + ); + const bogus = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + 20); + bogus[0] = 42; + await expect(decryptor.decrypt(bogus)).rejects.toThrow('unsupported encrypted frame version'); + }); + + test('importEcdhPublicKey rejects malformed JWKs', async () => { + await expect(importEcdhPublicKey({ kty: 'RSA' })).rejects.toThrow(RelayCryptoError); + await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-384', x: 'a', y: 'b' })).rejects.toThrow( + RelayCryptoError, + ); + await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-256', x: '!!', y: '!!' })).rejects.toThrow( + RelayCryptoError, + ); + }); + + test('fingerprint is stable across key-order differences and distinct per key', async () => { + const pair = await generateEcdhKeyPair(); + const jwk = await exportPublicKeyJwk(pair.publicKey); + const shuffled: JsonWebKey = { y: jwk.y, x: jwk.x, crv: jwk.crv, kty: jwk.kty }; + expect(publicKeyJwkFingerprint(jwk)).toBe(publicKeyJwkFingerprint(shuffled)); + const other = await exportPublicKeyJwk((await generateEcdhKeyPair()).publicKey); + expect(publicKeyJwkFingerprint(jwk)).not.toBe(publicKeyJwkFingerprint(other)); + }); + + test('base64url round trip and rejection of invalid input', () => { + for (const length of [0, 1, 2, 3, 16, 31, 32]) { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes); + } + expect(() => base64UrlToBytes('a+b/c=')).toThrow(RelayCryptoError); + expect(() => base64UrlToBytes('abcde')).toThrow(RelayCryptoError); + }); +}); diff --git a/packages/ui/src/lib/relay/crypto.ts b/packages/ui/src/lib/relay/crypto.ts new file mode 100644 index 00000000..e5b8ab83 --- /dev/null +++ b/packages/ui/src/lib/relay/crypto.ts @@ -0,0 +1,223 @@ +// E2EE primitives for the private relay (Layer 2 of the protocol spec). +// WebCrypto only — isomorphic across browser, Node >= 20, WKWebView, and Workers. +// Key agreement: ECDH P-256 -> HKDF-SHA-256 -> two AES-256-GCM keys (one per direction). +// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2). + +import { + ENCRYPTED_FRAME_HEADER_BYTES, + ENCRYPTED_FRAME_IV_BYTES, + ENCRYPTED_FRAME_VERSION, + MAX_PLAINTEXT_FRAME_BYTES, + RELAY_HKDF_INFO, +} from './protocol'; + +const subtle = globalThis.crypto.subtle; + +const ECDH_PARAMS: EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-256' }; +const HANDSHAKE_NONCE_BYTES = 16; +const SESSION_KEY_BYTES = 32; +const GCM_TAG_BYTES = 16; +// IV = 4-byte random per-direction prefix || 8-byte big-endian frame counter. +const IV_PREFIX_BYTES = 4; +const IV_COUNTER_BYTES = 8; + +export class RelayCryptoError extends Error { + constructor(message: string) { + super(message); + this.name = 'RelayCryptoError'; + } +} + +export const generateEcdhKeyPair = (): Promise => + subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']); + +export const exportPublicKeyJwk = async (key: CryptoKey): Promise => { + const jwk = await subtle.exportKey('jwk', key); + // Keep only the fields that define the public point so serialized forms compare stably. + return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; +}; + +export const importEcdhPublicKey = async (jwk: JsonWebKey): Promise => { + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { + throw new RelayCryptoError('invalid ECDH public key JWK'); + } + try { + return await subtle.importKey( + 'jwk', + { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true }, + ECDH_PARAMS, + true, + [], + ); + } catch { + throw new RelayCryptoError('invalid ECDH public key JWK'); + } +}; + +// Stable fingerprint of a public key, used to detect rekey attempts on re-hello. +export const publicKeyJwkFingerprint = (jwk: JsonWebKey): string => + JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); + +export const generateHandshakeNonce = (): Uint8Array => { + const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES); + globalThis.crypto.getRandomValues(nonce); + return nonce; +}; + +export interface SessionKeys { + clientToHost: CryptoKey; + hostToClient: CryptoKey; +} + +// Both sides call this with their own private key and the peer's public key; +// ECDH yields the same shared secret, so the derived key pair matches. +export const deriveSessionKeys = async ( + ownPrivateKey: CryptoKey, + peerPublicKey: CryptoKey, + handshakeNonce: Uint8Array, +): Promise => { + if (handshakeNonce.length !== HANDSHAKE_NONCE_BYTES) { + throw new RelayCryptoError('invalid handshake nonce length'); + } + const sharedSecret = await subtle.deriveBits( + { name: 'ECDH', public: peerPublicKey }, + ownPrivateKey, + 256, + ); + const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']); + const keyMaterial = new Uint8Array( + await subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: handshakeNonce as BufferSource, + info: new TextEncoder().encode(RELAY_HKDF_INFO), + }, + hkdfKey, + SESSION_KEY_BYTES * 2 * 8, + ), + ); + const importAesKey = (bytes: Uint8Array, usage: KeyUsage[]) => + subtle.importKey('raw', bytes as BufferSource, { name: 'AES-GCM' }, false, usage); + return { + clientToHost: await importAesKey(keyMaterial.slice(0, SESSION_KEY_BYTES), ['encrypt', 'decrypt']), + hostToClient: await importAesKey(keyMaterial.slice(SESSION_KEY_BYTES), ['encrypt', 'decrypt']), + }; +}; + +export interface FrameEncryptor { + encrypt(plaintext: Uint8Array): Promise; +} + +export interface FrameDecryptor { + decrypt(frame: Uint8Array): Promise; +} + +const writeCounter = (target: Uint8Array, offset: number, counter: bigint): void => { + for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) { + target[offset + i] = Number(counter & 0xffn); + counter >>= 8n; + } +}; + +const readCounter = (source: Uint8Array, offset: number): bigint => { + let value = 0n; + for (let i = 0; i < IV_COUNTER_BYTES; i += 1) { + value = (value << 8n) | BigInt(source[offset + i]); + } + return value; +}; + +export const createFrameEncryptor = (key: CryptoKey): FrameEncryptor => { + const ivPrefix = new Uint8Array(IV_PREFIX_BYTES); + globalThis.crypto.getRandomValues(ivPrefix); + let counter = 0n; + return { + async encrypt(plaintext: Uint8Array): Promise { + if (plaintext.length > MAX_PLAINTEXT_FRAME_BYTES) { + throw new RelayCryptoError('plaintext frame exceeds maximum size'); + } + counter += 1n; + const iv = new Uint8Array(ENCRYPTED_FRAME_IV_BYTES); + iv.set(ivPrefix, 0); + writeCounter(iv, IV_PREFIX_BYTES, counter); + const ciphertext = new Uint8Array( + await subtle.encrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, plaintext as BufferSource), + ); + const frame = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + ciphertext.length); + frame[0] = ENCRYPTED_FRAME_VERSION; + frame.set(iv, 1); + frame.set(ciphertext, ENCRYPTED_FRAME_HEADER_BYTES); + return frame; + }, + }; +}; + +// Enforces strictly increasing per-direction counters: the relay WS preserves +// ordering, so any regression or replay means tampering and must fail closed. +export const createFrameDecryptor = (key: CryptoKey): FrameDecryptor => { + let lastCounter = 0n; + return { + async decrypt(frame: Uint8Array): Promise { + if (frame.length < ENCRYPTED_FRAME_HEADER_BYTES + GCM_TAG_BYTES) { + throw new RelayCryptoError('encrypted frame too short'); + } + if (frame[0] !== ENCRYPTED_FRAME_VERSION) { + throw new RelayCryptoError('unsupported encrypted frame version'); + } + const iv = frame.slice(1, ENCRYPTED_FRAME_HEADER_BYTES); + const counter = readCounter(iv, IV_PREFIX_BYTES); + if (counter <= lastCounter) { + throw new RelayCryptoError('frame counter regression'); + } + let plaintext: ArrayBuffer; + try { + plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + frame.slice(ENCRYPTED_FRAME_HEADER_BYTES) as BufferSource, + ); + } catch { + throw new RelayCryptoError('frame decryption failed'); + } + lastCounter = counter; + return new Uint8Array(plaintext); + }, + }; +}; + +const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +export const bytesToBase64Url = (bytes: Uint8Array): string => { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]; + const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined; + const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined; + out += BASE64URL_ALPHABET[b0 >> 2]; + out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)]; + if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)]; + if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f]; + } + return out; +}; + +export const base64UrlToBytes = (value: string): Uint8Array => { + if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { + throw new RelayCryptoError('invalid base64url input'); + } + const out = new Uint8Array(Math.floor((value.length * 3) / 4)); + let outIndex = 0; + let buffer = 0; + let bits = 0; + for (const char of value) { + buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(char); + bits += 6; + if (bits >= 8) { + bits -= 8; + out[outIndex] = (buffer >> bits) & 0xff; + outIndex += 1; + } + } + return out; +}; diff --git a/packages/ui/src/lib/relay/gate.ts b/packages/ui/src/lib/relay/gate.ts new file mode 100644 index 00000000..ac9534c7 --- /dev/null +++ b/packages/ui/src/lib/relay/gate.ts @@ -0,0 +1,21 @@ +// openchamber_relay_gate +// +// Feature gate for the private-relay UI — the surfaces for enabling the relay and +// pairing devices through it (Settings → Remote Instances "Relay" section and its +// settings-search entry). The relay transport itself is fully implemented and +// tested; this flag only hides the UI entry points until the feature is ready for +// public release (the connect flow is being unified across LAN / tunnels / relay). +// +// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token — +// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the +// gated surfaces read this one constant. Also add a CHANGELOG entry then — the +// relay's changelog note is intentionally held back while this is off. +// +// Note: existing saved relay connections keep working regardless (this gates the +// UI for ADDING/pairing, not the runtime transport). If you also want to hide the +// mobile side of importing a relay link, gate the relay branch in +// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag. +// Typed as boolean (not the literal `false`) so gated call sites don't trip +// "condition always false" / unreachable-code checks — flipping to true is a +// one-word change with no other edits. +export const RELAY_UI_ENABLED: boolean = false; diff --git a/packages/ui/src/lib/relay/handshake.test.ts b/packages/ui/src/lib/relay/handshake.test.ts new file mode 100644 index 00000000..9b8fb99e --- /dev/null +++ b/packages/ui/src/lib/relay/handshake.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; + +import { exportPublicKeyJwk, generateEcdhKeyPair } from './crypto'; +import { + createClientHandshake, + createHostHandshake, + type EstablishedChannelCrypto, + type HandshakeAction, +} from './handshake'; +import { RelayCloseCode } from './protocol'; + +const createHostIdentity = async () => { + const keyPair = await generateEcdhKeyPair(); + return { + privateKey: keyPair.privateKey, + publicJwk: await exportPublicKeyJwk(keyPair.publicKey), + }; +}; + +const expectEstablished = ( + action: HandshakeAction, +): { channel: EstablishedChannelCrypto; replyText?: string } => { + if (action.type !== 'established') { + throw new Error(`expected established, got ${action.type}`); + } + return action; +}; + +const runFullHandshake = async () => { + const host = await createHostIdentity(); + const client = await createClientHandshake(host.publicJwk); + const hostMachine = createHostHandshake(host.privateKey); + + const hostResult = expectEstablished(await hostMachine.handleText(client.helloText)); + expect(hostResult.replyText).toBeDefined(); + const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string)); + return { client, hostMachine, clientChannel: clientResult.channel, hostChannel: hostResult.channel }; +}; + +describe('relay E2EE handshake', () => { + test('full handshake establishes a working bidirectional channel', async () => { + const { clientChannel, hostChannel } = await runFullHandshake(); + + const toHost = await clientChannel.encryptor.encrypt(new TextEncoder().encode('ping')); + expect(new TextDecoder().decode(await hostChannel.decryptor.decrypt(toHost))).toBe('ping'); + + const toClient = await hostChannel.encryptor.encrypt(new TextEncoder().encode('pong')); + expect(new TextDecoder().decode(await clientChannel.decryptor.decrypt(toClient))).toBe('pong'); + }); + + test('negotiates batching only when both peers advertise it', async () => { + const assertNegotiated = async ( + clientBatch: boolean | undefined, + hostBatch: boolean | undefined, + expected: boolean, + ) => { + const host = await createHostIdentity(); + const client = await createClientHandshake(host.publicJwk, { batch: clientBatch }); + const hostMachine = createHostHandshake(host.privateKey, { batch: hostBatch }); + const hostResult = await hostMachine.handleText(client.helloText); + if (hostResult.type !== 'established') throw new Error('host did not establish'); + const clientResult = await client.handleText(hostResult.replyText as string); + if (clientResult.type !== 'established') throw new Error('client did not establish'); + // Symmetric: both sides agree on the same negotiated value. + expect(hostResult.batch).toBe(expected); + expect(clientResult.batch).toBe(expected); + }; + + await assertNegotiated(true, true, true); + await assertNegotiated(undefined, undefined, true); // default is batch-on + await assertNegotiated(false, true, false); // legacy client + await assertNegotiated(true, false, false); // legacy host + await assertNegotiated(false, false, false); // both legacy + }); + + test('host re-sends ready for an identical retried hello', async () => { + const host = await createHostIdentity(); + const client = await createClientHandshake(host.publicJwk); + const hostMachine = createHostHandshake(host.privateKey); + + const first = expectEstablished(await hostMachine.handleText(client.helloText)); + const retry = await hostMachine.handleText(client.helloText); + expect(retry).toEqual({ type: 'send-text', text: first.replyText as string }); + }); + + test('client ignores a duplicate ready after establishment (host re-answers retried hellos)', async () => { + const { client } = await runFullHandshake(); + const action = await client.handleText(JSON.stringify({ t: 'ready', v: 1 })); + expect(action.type).toBe('ignore'); + }); + + test('hello with a different key after establishment fails with rekey mismatch (1008)', async () => { + const host = await createHostIdentity(); + const firstClient = await createClientHandshake(host.publicJwk); + const hostMachine = createHostHandshake(host.privateKey); + expectEstablished(await hostMachine.handleText(firstClient.helloText)); + + const attacker = await createClientHandshake(host.publicJwk); + const action = await hostMachine.handleText(attacker.helloText); + expect(action.type).toBe('fail'); + if (action.type === 'fail') { + expect(action.closeCode).toBe(RelayCloseCode.RekeyMismatch); + } + }); + + test('plaintext after establishment fails closed (1011) on both sides', async () => { + const { client, hostMachine } = await runFullHandshake(); + + const hostAction = await hostMachine.handleText('{"anything":"plaintext"}'); + expect(hostAction.type).toBe('fail'); + if (hostAction.type === 'fail') { + expect(hostAction.closeCode).toBe(RelayCloseCode.ChannelFailure); + } + + const clientAction = await client.handleText('{"anything":"plaintext"}'); + expect(clientAction.type).toBe('fail'); + if (clientAction.type === 'fail') { + expect(clientAction.closeCode).toBe(RelayCloseCode.ChannelFailure); + } + }); + + test('pre-establishment noise is ignored, not fatal', async () => { + const host = await createHostIdentity(); + const client = await createClientHandshake(host.publicJwk); + const hostMachine = createHostHandshake(host.privateKey); + + expect((await client.handleText('not json')).type).toBe('ignore'); + expect((await client.handleText('{"type":"sync","connectionIds":[]}')).type).toBe('ignore'); + expect((await hostMachine.handleText('not json')).type).toBe('ignore'); + expect((await hostMachine.handleText(JSON.stringify({ t: 'ready', v: 1 }))).type).toBe('ignore'); + }); + + test('malformed hello fails closed without corrupting host state', async () => { + const host = await createHostIdentity(); + const hostMachine = createHostHandshake(host.privateKey); + const badHello = JSON.stringify({ + t: 'hello', + v: 1, + clientPubJwk: { kty: 'EC', crv: 'P-256', x: '!!', y: '!!' }, + nonce: 'AAAA', + }); + const action = await hostMachine.handleText(badHello); + expect(action.type).toBe('fail'); + expect(hostMachine.established).toBe(false); + + // A valid client can still complete against a fresh machine after garbage. + const client = await createClientHandshake(host.publicJwk); + expectEstablished(await hostMachine.handleText(client.helloText)); + }); + + test('wrong protocol version hello is ignored', async () => { + const host = await createHostIdentity(); + const client = await createClientHandshake(host.publicJwk); + const hostMachine = createHostHandshake(host.privateKey); + const tampered = JSON.stringify({ ...JSON.parse(client.helloText), v: 99 }); + expect((await hostMachine.handleText(tampered)).type).toBe('ignore'); + }); + + test('client bound to a different host key derives non-matching channel keys', async () => { + const realHost = await createHostIdentity(); + const otherHost = await createHostIdentity(); + // Client trusts otherHost's public key, but realHost answers. + const client = await createClientHandshake(otherHost.publicJwk); + const hostMachine = createHostHandshake(realHost.privateKey); + const hostResult = expectEstablished(await hostMachine.handleText(client.helloText)); + const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string)); + + const frame = await hostResult.channel.encryptor.encrypt(new Uint8Array([1, 2, 3])); + await expect(clientResult.channel.decryptor.decrypt(frame)).rejects.toThrow(); + }); +}); diff --git a/packages/ui/src/lib/relay/handshake.ts b/packages/ui/src/lib/relay/handshake.ts new file mode 100644 index 00000000..35497eac --- /dev/null +++ b/packages/ui/src/lib/relay/handshake.ts @@ -0,0 +1,234 @@ +// E2EE handshake state machines (Layer 2 of the protocol spec). +// Transport-agnostic: callers feed inbound frames in and deliver the returned +// outbound frames; text frames are plaintext handshake JSON, binary frames are +// encrypted traffic. Wire-up to actual WebSockets happens in the host client +// (packages/web/server/lib/relay) and the tunnel client (Phase 3). +// +// Client (initiator): sends `hello` with an ephemeral public key + nonce, +// waits for `ready`. Host (responder): waits for `hello`, derives session +// keys with its long-lived encryption private key, replies `ready`. +// +// Fail-closed rules (adopted from the spec): +// - a repeated identical `hello` re-sends `ready` (client retry race); +// - a `hello` with a DIFFERENT key on an established channel is a rekey +// attack -> close 1008, never rekey in place; +// - plaintext after `ready`, or any decrypt failure -> close 1011. + +import { + createFrameDecryptor, + createFrameEncryptor, + base64UrlToBytes, + bytesToBase64Url, + deriveSessionKeys, + exportPublicKeyJwk, + generateEcdhKeyPair, + generateHandshakeNonce, + importEcdhPublicKey, + publicKeyJwkFingerprint, + type FrameDecryptor, + type FrameEncryptor, +} from './crypto'; +import { + RELAY_PROTOCOL_VERSION, + RelayCloseCode, + type E2eeHelloMessage, + type E2eeReadyMessage, +} from './protocol'; + +export interface EstablishedChannelCrypto { + encryptor: FrameEncryptor; + decryptor: FrameDecryptor; +} + +export type HandshakeAction = + | { type: 'send-text'; text: string } + // `replyText`, when present, must be sent to the peer before any encrypted frame. + // `batch` is the negotiated frame-batching capability for the session. + | { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; replyText?: string } + | { type: 'ignore' } + | { type: 'fail'; closeCode: number; reason: string }; + +const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage | null => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const message = parsed as Record; + if (message.v !== RELAY_PROTOCOL_VERSION) return null; + // Unknown/missing capability flag = false = legacy behavior. + const batch = message.batch === true; + if (message.t === 'ready') { + return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch }; + } + if ( + message.t === 'hello' && + typeof message.nonce === 'string' && + typeof message.clientPubJwk === 'object' && + message.clientPubJwk !== null + ) { + return { + t: 'hello', + v: RELAY_PROTOCOL_VERSION, + clientPubJwk: message.clientPubJwk as JsonWebKey, + nonce: message.nonce, + batch, + }; + } + return null; +}; + +const failClosed = (reason: string): HandshakeAction => ({ + type: 'fail', + closeCode: RelayCloseCode.ChannelFailure, + reason, +}); + +export interface ClientHandshake { + /** The `hello` text frame. Send on open and re-send on a retry interval until established. */ + helloText: string; + /** Feed every inbound text frame received before the channel is established. */ + handleText(raw: string): Promise; + readonly established: boolean; +} + +export interface ClientHandshakeOptions { + /** Advertise frame batching. Default true; set false to force legacy behavior. */ + batch?: boolean; +} + +// hostEncPubJwk comes from the pairing offer (QR / deep link) and is the trust +// anchor: only the real host can derive the same session keys. +export const createClientHandshake = async ( + hostEncPubJwk: JsonWebKey, + options: ClientHandshakeOptions = {}, +): Promise => { + const localBatch = options.batch !== false; + const hostPublicKey = await importEcdhPublicKey(hostEncPubJwk); + const ephemeralKeyPair = await generateEcdhKeyPair(); + const nonce = generateHandshakeNonce(); + const hello: E2eeHelloMessage = { + t: 'hello', + v: RELAY_PROTOCOL_VERSION, + clientPubJwk: await exportPublicKeyJwk(ephemeralKeyPair.publicKey), + nonce: bytesToBase64Url(nonce), + ...(localBatch ? { batch: true } : {}), + }; + let established = false; + return { + helloText: JSON.stringify(hello), + get established() { + return established; + }, + async handleText(raw: string): Promise { + if (established) { + // The host answers every retried `hello` with `ready`, so a duplicate + // `ready` after establishment is protocol-legal (first-connect latency + // exceeding the hello retry interval). Any other plaintext fails closed. + const message = parseHandshakeMessage(raw); + if (message?.t === 'ready') return { type: 'ignore' }; + return failClosed('plaintext frame on established channel'); + } + const message = parseHandshakeMessage(raw); + if (message?.t !== 'ready') { + // Not established yet: tolerate unknown plaintext (relay control noise, + // late frames) rather than tearing down a connection that may recover. + return { type: 'ignore' }; + } + const keys = await deriveSessionKeys(ephemeralKeyPair.privateKey, hostPublicKey, nonce); + established = true; + return { + type: 'established', + // Batching runs only if both peers advertised it. + batch: localBatch && message.batch === true, + channel: { + encryptor: createFrameEncryptor(keys.clientToHost), + decryptor: createFrameDecryptor(keys.hostToClient), + }, + }; + }, + }; +}; + +export interface HostHandshake { + /** Feed every inbound text frame. */ + handleText(raw: string): Promise; + readonly established: boolean; +} + +export interface HostHandshakeOptions { + /** Support frame batching. Default true; set false to force legacy behavior. */ + batch?: boolean; +} + +export const createHostHandshake = ( + hostEncPrivateKey: CryptoKey, + options: HostHandshakeOptions = {}, +): HostHandshake => { + const localBatch = options.batch !== false; + let established = false; + let acceptedClientKeyFingerprint: string | null = null; + let readyText: string | null = null; + let negotiatedBatch = false; + return { + get established() { + return established; + }, + async handleText(raw: string): Promise { + const message = parseHandshakeMessage(raw); + if (message?.t !== 'hello') { + if (established) { + return failClosed('plaintext frame on established channel'); + } + return { type: 'ignore' }; + } + const fingerprint = publicKeyJwkFingerprint(message.clientPubJwk); + if (acceptedClientKeyFingerprint !== null) { + if (fingerprint === acceptedClientKeyFingerprint && readyText !== null) { + // Client retried `hello` before our `ready` arrived — answer again. + return { type: 'send-text', text: readyText }; + } + return { + type: 'fail', + closeCode: RelayCloseCode.RekeyMismatch, + reason: 'rekey mismatch', + }; + } + let clientPublicKey: CryptoKey; + let nonce: Uint8Array; + try { + clientPublicKey = await importEcdhPublicKey(message.clientPubJwk); + nonce = base64UrlToBytes(message.nonce); + } catch { + return failClosed('malformed hello'); + } + let keys; + try { + keys = await deriveSessionKeys(hostEncPrivateKey, clientPublicKey, nonce); + } catch { + return failClosed('key derivation failed'); + } + acceptedClientKeyFingerprint = fingerprint; + // Batching runs only if both peers advertised it. + negotiatedBatch = localBatch && message.batch === true; + const ready: E2eeReadyMessage = { + t: 'ready', + v: RELAY_PROTOCOL_VERSION, + ...(negotiatedBatch ? { batch: true } : {}), + }; + readyText = JSON.stringify(ready); + established = true; + return { + type: 'established', + batch: negotiatedBatch, + replyText: readyText, + channel: { + encryptor: createFrameEncryptor(keys.hostToClient), + decryptor: createFrameDecryptor(keys.clientToHost), + }, + }; + }, + }; +}; diff --git a/packages/ui/src/lib/relay/offer.test.ts b/packages/ui/src/lib/relay/offer.test.ts new file mode 100644 index 00000000..79d818e6 --- /dev/null +++ b/packages/ui/src/lib/relay/offer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test'; + +import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer'; +import type { RelayOfferV1 } from './protocol'; + +const baseOffer: RelayOfferV1 = { + v: 1, + mode: 'relay', + relayUrl: 'wss://relay.example.com/host', + serverId: 'srv_0123456789abcdef', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' }, +}; + +const fullOffer: RelayOfferV1 = { + ...baseOffer, + label: 'My Mac', + token: 'oc_client_secret_token_value', + grant: 'grant-value', +}; + +describe('buildRelayOfferUrl / parseRelayOfferUrl', () => { + test('round-trips a minimal offer', () => { + expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer); + }); + + test('round-trips a full offer with optional fields', () => { + expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer); + }); + + test('URL has the expected shape', () => { + const url = buildRelayOfferUrl(baseOffer); + expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true); + }); + + test('token appears only in the fragment, never in the query string', () => { + const url = buildRelayOfferUrl(fullOffer); + const [beforeFragment, fragment] = url.split('#'); + expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay'); + expect(beforeFragment.includes(fullOffer.token as string)).toBe(false); + expect(fragment.startsWith('offer=')).toBe(true); + // Token round-trips through the fragment payload. + expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string); + }); + + const encodeOffer = (value: unknown): string => { + const json = JSON.stringify(value); + const b64 = Buffer.from(json, 'utf8').toString('base64url'); + return `openchamber://connect?v=1&mode=relay#offer=${b64}`; + }; + + test('rejects wrong scheme, host, version, and mode', () => { + const url = buildRelayOfferUrl(baseOffer); + expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull(); + expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull(); + expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull(); + expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull(); + expect(parseRelayOfferUrl('not a url')).toBeNull(); + expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull(); + expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull(); + expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull(); + }); + + const without = (key: keyof RelayOfferV1): Record => { + const clone: Record = { ...fullOffer }; + delete clone[key]; + return clone; + }; + + test('rejects wholly when any required field is missing or malformed', () => { + const cases: unknown[] = [ + { ...fullOffer, v: 2 }, + without('v'), + { ...fullOffer, mode: 'direct' }, + without('mode'), + without('relayUrl'), + { ...fullOffer, relayUrl: '' }, + { ...fullOffer, relayUrl: 'not-a-url' }, + { ...fullOffer, relayUrl: 'ftp://relay.example.com' }, + without('serverId'), + { ...fullOffer, serverId: '' }, + { ...fullOffer, serverId: 42 }, + without('hostEncPubJwk'), + { ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } }, + { ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } }, + { ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } }, + { ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } }, + { ...fullOffer, hostEncPubJwk: 'jwk' }, + { ...fullOffer, label: '' }, + { ...fullOffer, token: '' }, + { ...fullOffer, token: 123 }, + { ...fullOffer, grant: '' }, + ['array'], + ]; + for (const payload of cases) { + expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull(); + } + }); + + test('parse strips unknown fields', () => { + const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' })); + expect(parsed).toEqual(baseOffer); + }); +}); + +describe('redactOffer', () => { + test('masks token, grant, and host public key coordinates', () => { + const redacted = redactOffer(fullOffer); + expect(redacted.token).toBe('[redacted]'); + expect(redacted.grant).toBe('[redacted]'); + expect(redacted.hostEncPubJwk.x).toBe('[redacted]'); + expect(redacted.hostEncPubJwk.y).toBe('[redacted]'); + const serialized = JSON.stringify(redacted); + expect(serialized.includes(fullOffer.token as string)).toBe(false); + expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false); + }); + + test('keeps non-secret fields and omits absent optionals', () => { + const redacted = redactOffer(baseOffer); + expect(redacted.relayUrl).toBe(baseOffer.relayUrl); + expect(redacted.serverId).toBe(baseOffer.serverId); + expect('token' in redacted).toBe(false); + expect('grant' in redacted).toBe(false); + }); + + test('does not mutate the input offer', () => { + const copy = structuredClone(fullOffer); + redactOffer(fullOffer); + expect(fullOffer).toEqual(copy); + }); +}); diff --git a/packages/ui/src/lib/relay/offer.ts b/packages/ui/src/lib/relay/offer.ts new file mode 100644 index 00000000..ba19b60f --- /dev/null +++ b/packages/ui/src/lib/relay/offer.ts @@ -0,0 +1,102 @@ +// Relay pairing offer URL codec (spec §Pairing payload). +// The offer JSON travels ONLY in the URL fragment so secrets (token) never +// reach servers, logs, or referrer headers via the query string. +// Shared by: settings UI (build), mobile scan (parse), desktop host import +// (parse), CLI (build). + +import { base64UrlToBytes, bytesToBase64Url } from './crypto'; +import type { RelayOfferV1 } from './protocol'; + +const OFFER_SCHEME = 'openchamber:'; +const OFFER_HOST = 'connect'; +const OFFER_FRAGMENT_KEY = 'offer='; + +const REDACTED = '[redacted]'; + +export const buildRelayOfferUrl = (offer: RelayOfferV1): string => { + const json = JSON.stringify(offer); + const encoded = bytesToBase64Url(new TextEncoder().encode(json)); + return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`; +}; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +const isValidHttpOrWsUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +}; + +const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const jwk = value as Record; + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null; + if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null; + return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }; +}; + +// Strict parse: every required field is validated; any malformed or missing +// field rejects the whole offer (returns null, never a partial object). +export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return null; + } + if (parsed.protocol !== OFFER_SCHEME) return null; + // Custom-scheme URLs may surface the authority as hostname or pathname + // depending on the runtime's parser. + const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0]; + if (authority !== OFFER_HOST) return null; + if (parsed.searchParams.get('v') !== '1') return null; + if (parsed.searchParams.get('mode') !== 'relay') return null; + + const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash; + if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null; + const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length); + if (!encoded) return null; + + let raw: unknown; + try { + raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded))); + } catch { + return null; + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; + const candidate = raw as Record; + + if (candidate.v !== 1) return null; + if (candidate.mode !== 'relay') return null; + if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null; + if (!isNonEmptyString(candidate.serverId)) return null; + const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk); + if (!hostEncPubJwk) return null; + if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null; + if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null; + if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null; + + return { + v: 1, + mode: 'relay', + relayUrl: candidate.relayUrl, + serverId: candidate.serverId, + hostEncPubJwk, + ...(candidate.label !== undefined ? { label: candidate.label } : {}), + ...(candidate.token !== undefined ? { token: candidate.token } : {}), + ...(candidate.grant !== undefined ? { grant: candidate.grant } : {}), + }; +}; + +// Safe-for-logging copy: masks the access token and the host public key +// coordinates. Never log a raw offer. +export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({ + ...offer, + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED }, + ...(offer.token !== undefined ? { token: REDACTED } : {}), + ...(offer.grant !== undefined ? { grant: REDACTED } : {}), +}); diff --git a/packages/ui/src/lib/relay/protocol.ts b/packages/ui/src/lib/relay/protocol.ts new file mode 100644 index 00000000..18a12d13 --- /dev/null +++ b/packages/ui/src/lib/relay/protocol.ts @@ -0,0 +1,139 @@ +// OpenChamber private relay protocol constants and shared types. +// Spec: .opencode/plans/private-relay/01-protocol-spec.md +// Three layers: relay routing (Layer 1), E2EE channel (Layer 2), tunnel mux (Layer 3). +// This module is isomorphic: browser, Node, and Cloudflare Workers. + +export const RELAY_PROTOCOL_VERSION = 1; + +export const RELAY_HKDF_INFO = 'openchamber-relay-v1'; + +// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag]. +export const ENCRYPTED_FRAME_VERSION = 1; +export const ENCRYPTED_FRAME_IV_BYTES = 12; +export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES; + +// Max plaintext per encrypted frame. Keeps relay-forwarded WS messages far +// below Cloudflare's 1 MiB cap even after GCM tag + header overhead. +export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024; + +// Tunnel frame layout: [1 byte frameType(+fragment flag)][4 byte BE streamId][payload]. +export const TUNNEL_FRAME_HEADER_BYTES = 5; +export const TUNNEL_FRAGMENT_FLAG = 0x80; + +// Batch envelope (Layer 2 plaintext container, used only when both peers +// negotiated `batch`). Plaintext = [1 byte container tag] then either the raw +// tunnel frame (tag 0x00) or repeated [4 byte BE length][frame] (tag 0x01). +// See tunnel-codec encodeFrameBatch/decodeFrameBatch. +export const BATCH_CONTAINER_TAG_SINGLE = 0x00; +export const BATCH_CONTAINER_TAG_BATCH = 0x01; +export const BATCH_FRAME_LENGTH_BYTES = 4; +// Worst-case per-frame envelope overhead inside a batch (tag + length prefix). +// Reserved from the tunnel payload budget so any single frame — even at the +// maximum size — still fits inside one 64 KiB encrypted plaintext once wrapped. +export const BATCH_ENVELOPE_RESERVED_BYTES = 1 + BATCH_FRAME_LENGTH_BYTES; +export const MAX_TUNNEL_PAYLOAD_BYTES = + MAX_PLAINTEXT_FRAME_BYTES - TUNNEL_FRAME_HEADER_BYTES - BATCH_ENVELOPE_RESERVED_BYTES; + +export const TunnelFrameType = { + HttpRequest: 1, + HttpBody: 2, + HttpResponse: 3, + StreamEnd: 4, + StreamAbort: 5, + WsOpen: 6, + WsOpened: 7, + WsText: 8, + WsBinary: 9, + WsClose: 10, + Ping: 11, + Pong: 12, +} as const; + +export type TunnelFrameTypeValue = (typeof TunnelFrameType)[keyof typeof TunnelFrameType]; + +const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType)); + +export const isTunnelFrameType = (value: number): value is TunnelFrameTypeValue => + TUNNEL_FRAME_TYPE_VALUES.has(value); + +export interface TunnelHttpRequestPayload { + method: string; + path: string; + query: string; + headers: Record; +} + +export interface TunnelHttpResponsePayload { + status: number; + headers: Record; +} + +export interface TunnelStreamAbortPayload { + reason: string; +} + +export interface TunnelWsOpenPayload { + path: string; + query: string; + protocols?: string[]; +} + +export interface TunnelWsOpenedPayload { + protocol?: string; +} + +export interface TunnelWsClosePayload { + code: number; + reason: string; +} + +// Layer 2 handshake messages (plaintext JSON text frames, before encryption starts). +export interface E2eeHelloMessage { + t: 'hello'; + v: typeof RELAY_PROTOCOL_VERSION; + clientPubJwk: JsonWebKey; + nonce: string; // base64url, 16 bytes + // Capability advertisement: the client can pack multiple tunnel frames into + // one encrypted WS message. Missing/false = legacy (one frame per message). + batch?: boolean; +} + +export interface E2eeReadyMessage { + t: 'ready'; + v: typeof RELAY_PROTOCOL_VERSION; + // Host echoes `batch: true` only when it also supports batching AND the client + // advertised it. Batching is enabled for the session only if both agree. + batch?: boolean; +} + +// Layer 1 control messages (relay <-> host control socket). +export type RelayControlMessage = + | { type: 'sync'; connectionIds: string[] } + | { type: 'connected'; connectionId: string } + | { type: 'disconnected'; connectionId: string } + | { type: 'limit'; reason: string }; + +// Relay-assigned WebSocket close codes. +export const RelayCloseCode = { + ControlReplaced: 4001, + DuplicateClient: 4002, + StuckControlReset: 4003, + HostUnavailable: 4008, + AuthFailed: 4010, + LimitExceeded: 4029, + HostWentAway: 1012, + RekeyMismatch: 1008, + ChannelFailure: 1011, +} as const; + +// Pairing payload carried in QR / deep-link URL fragments only. +export interface RelayOfferV1 { + v: 1; + mode: 'relay'; + relayUrl: string; + serverId: string; + hostEncPubJwk: JsonWebKey; + label?: string; + token?: string; + grant?: string; +} diff --git a/packages/ui/src/lib/relay/runtime-socket.ts b/packages/ui/src/lib/relay/runtime-socket.ts new file mode 100644 index 00000000..a4976683 --- /dev/null +++ b/packages/ui/src/lib/relay/runtime-socket.ts @@ -0,0 +1,20 @@ +// Opens a runtime WebSocket the right way for the active runtime: through the +// relay tunnel when relay mode is active, or a native browser WebSocket +// otherwise (wrapped to the same shape). Every runtime WS consumer — the event +// pipeline, dictation, terminal — must go through here so relay mode carries +// ALL socket traffic, not just the main event stream. A raw `new WebSocket(url)` +// against a relay-mode runtime fails: the resolver yields a tunnel-virtual URL +// (or a capacitor:// origin) that the platform WebSocket rejects with +// "The string did not match the expected pattern". + +import { getActiveRelayTunnel } from './runtime-tunnel'; +import { wsUrlToTunnelPath } from './tunnel-payloads'; +import { wrapBrowserWebSocket, type RelayTunnelWebSocket } from './tunnel-client'; + +export const openRuntimeWebSocket = (url: string, protocols?: string[]): RelayTunnelWebSocket => { + const relay = getActiveRelayTunnel(); + if (relay) { + return relay.openWebSocket(wsUrlToTunnelPath(url), protocols); + } + return wrapBrowserWebSocket(protocols ? new WebSocket(url, protocols) : new WebSocket(url)); +}; diff --git a/packages/ui/src/lib/relay/runtime-tunnel.ts b/packages/ui/src/lib/relay/runtime-tunnel.ts new file mode 100644 index 00000000..bc5f3778 --- /dev/null +++ b/packages/ui/src/lib/relay/runtime-tunnel.ts @@ -0,0 +1,47 @@ +// Module-level singleton holding the active relay tunnel client, if the runtime +// is in relay mode. Kept in its own module so runtime-switch, runtime-fetch, +// runtime-url, and the event pipeline can all read it without an import cycle +// (runtime-switch <-> runtime-url). + +import { createRelayTunnelClient, type RelayTunnelClient } from './tunnel-client'; + +export interface RelayRuntimeDescriptor { + relayUrl: string; + serverId: string; + hostEncPubJwk: JsonWebKey; + grant?: string; +} + +let activeTunnel: RelayTunnelClient | null = null; +let activeDescriptor: RelayRuntimeDescriptor | null = null; + +const descriptorsEqual = (a: RelayRuntimeDescriptor, b: RelayRuntimeDescriptor): boolean => + a.relayUrl === b.relayUrl && + a.serverId === b.serverId && + a.grant === b.grant && + JSON.stringify(a.hostEncPubJwk) === JSON.stringify(b.hostEncPubJwk); + +export const getActiveRelayTunnel = (): RelayTunnelClient | null => activeTunnel; + +export const isRelayModeActive = (): boolean => activeTunnel !== null; + +/** + * Activates relay mode with the given descriptor, replacing any previous tunnel. + * Reuses the existing client when the descriptor is unchanged so a redundant + * runtime switch does not tear down a live tunnel. + */ +export const activateRelayTunnel = (descriptor: RelayRuntimeDescriptor): RelayTunnelClient => { + if (activeTunnel && activeDescriptor && descriptorsEqual(activeDescriptor, descriptor)) { + return activeTunnel; + } + activeTunnel?.close(); + activeDescriptor = descriptor; + activeTunnel = createRelayTunnelClient(descriptor); + return activeTunnel; +}; + +export const deactivateRelayTunnel = (): void => { + activeTunnel?.close(); + activeTunnel = null; + activeDescriptor = null; +}; diff --git a/packages/ui/src/lib/relay/tunnel-client.test.ts b/packages/ui/src/lib/relay/tunnel-client.test.ts new file mode 100644 index 00000000..0fd903b8 --- /dev/null +++ b/packages/ui/src/lib/relay/tunnel-client.test.ts @@ -0,0 +1,576 @@ +// Unit tests for the relay tunnel client against an in-memory wire pair whose +// responder side is built from the SAME protocol modules (createHostHandshake + +// the tunnel codec). No network, no real WebSocket. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { + exportPublicKeyJwk, + generateEcdhKeyPair, + type FrameDecryptor, + type FrameEncryptor, +} from './crypto'; +import { createHostHandshake } from './handshake'; +import { TunnelFrameType } from './protocol'; +import { + createFragmentAssembler, + decodeFrameBatch, + decodeJsonPayload, + decodeTunnelFrame, + encodeFrameBatch, + encodeJsonPayload, + encodeTunnelFrame, + type TunnelFrame, +} from './tunnel-codec'; +import { + createRelayTunnelClient, + type RelayTunnelClient, + type TunnelWireSocket, +} from './tunnel-client'; + +const WS_OPEN = 1; +const WS_CLOSED = 3; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const isWsOpenPayload = ( + value: unknown, +): value is { path: string; query: string; protocols?: string[] } => + typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string'; + +const isHttpRequestPayload = ( + value: unknown, +): value is { method: string; path: string; query: string; headers: Record } => + typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string'; + +class FakeEndpoint implements TunnelWireSocket { + readyState = WS_OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + onerror: (() => void) | null = null; + peer: FakeEndpoint | null = null; + closed = false; + // Count binary (encrypted) WS messages that cross this endpoint's send path — + // the billable unit the batching optimization is designed to reduce. + binarySent = 0; + + send(data: string | ArrayBuffer | Uint8Array): void { + if (this.closed) return; + if (typeof data !== 'string') this.binarySent += 1; + const peer = this.peer; + if (!peer) return; + // Copy bytes so the receiver can't observe later mutation. + const payload = typeof data === 'string' ? data : data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0)); + queueMicrotask(() => { + if (peer.closed) return; + peer.onmessage?.({ data: payload }); + }); + } + + close(code = 1000, reason = ''): void { + if (this.closed) return; + this.closed = true; + this.readyState = WS_CLOSED; + const peer = this.peer; + queueMicrotask(() => this.onclose?.({ code, reason })); + if (peer && !peer.closed) { + peer.closed = true; + peer.readyState = WS_CLOSED; + queueMicrotask(() => peer.onclose?.({ code, reason })); + } + } +} + +type MiniHostOptions = { + silent?: boolean; + onConnect?: () => void; + // Delay handling of the first inbound text frame: with a delay longer than + // the client's helloRetryMs this reproduces the first-connect race where the + // client retries `hello` and the host answers every retry with `ready`. + firstHelloDelayMs?: number; + // Advertise batching from the host (default true = matches production). + batch?: boolean; + // Records every tunnel frame the host received, in arrival order. + recordFrame?: (frame: TunnelFrame) => void; +}; + +// A minimal host responder wired to one endpoint. Answers a few routes so the +// client's HTTP/WS/abort paths can be exercised end to end. +const attachMiniHost = (endpoint: FakeEndpoint, hostPrivateKey: CryptoKey, options: MiniHostOptions = {}): void => { + const handshake = createHostHandshake(hostPrivateKey, { batch: options.batch }); + let encryptor: FrameEncryptor | null = null; + let decryptor: FrameDecryptor | null = null; + let batchNegotiated = false; + const assembler = createFragmentAssembler(); + const httpBodies = new Map(); + const aborted = new Set(); + let sendChain: Promise = Promise.resolve(); + let recvChain: Promise = Promise.resolve(); + + const sendFrame = (frame: Uint8Array): void => { + sendChain = sendChain.then(async () => { + if (!encryptor || endpoint.closed) return; + // When batching is negotiated the client always expects a container tag, + // so wrap even single frames (tag 0x00). The host here does not coalesce. + const plaintext = batchNegotiated ? encodeFrameBatch([frame]) : frame; + endpoint.send(await encryptor.encrypt(plaintext)); + }); + }; + + const respondJson = (streamId: number, status: number, body: unknown): void => { + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status, headers: { 'content-type': 'application/json' } }))); + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(JSON.stringify(body)))); + sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0))); + }; + + const handleTunnelFrame = (frame: TunnelFrame): void => { + options.recordFrame?.(frame); + if (options.silent) return; + if (frame.frameType === TunnelFrameType.Ping) { + sendFrame(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, new Uint8Array(0))); + return; + } + if (frame.frameType === TunnelFrameType.HttpRequest) { + const req = decodeJsonPayload(frame.payload, isHttpRequestPayload); + httpBodies.set(frame.streamId, []); + (endpoint as FakeEndpoint & { pendingPath?: Map }).pendingPath ??= new Map(); + (endpoint as FakeEndpoint & { pendingPath: Map }).pendingPath.set(frame.streamId, req.path); + return; + } + if (frame.frameType === TunnelFrameType.HttpBody) { + httpBodies.get(frame.streamId)?.push(frame.payload); + return; + } + if (frame.frameType === TunnelFrameType.StreamAbort) { + aborted.add(frame.streamId); + return; + } + if (frame.frameType === TunnelFrameType.StreamEnd) { + const paths = (endpoint as FakeEndpoint & { pendingPath?: Map }).pendingPath; + const path = paths?.get(frame.streamId) ?? ''; + const bodyChunks = httpBodies.get(frame.streamId) ?? []; + const total = bodyChunks.reduce((sum, c) => sum + c.length, 0); + const body = new Uint8Array(total); + let off = 0; + for (const c of bodyChunks) { + body.set(c, off); + off += c.length; + } + const streamId = frame.streamId; + if (path === '/health') { + respondJson(streamId, 200, { ok: true }); + } else if (path === '/echo-body') { + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} }))); + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, body)); + sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0))); + } else if (path === '/stream') { + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} }))); + const emit = (index: number): void => { + if (aborted.has(streamId)) return; + if (index >= 3) { + sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0))); + return; + } + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(`chunk${index};`))); + setTimeout(() => emit(index + 1), 10); + }; + emit(0); + } else if (path === '/never-ends') { + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} }))); + const pump = (): void => { + if (aborted.has(streamId) || endpoint.closed) return; + sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode('tick;'))); + setTimeout(pump, 10); + }; + pump(); + } else { + respondJson(streamId, 404, { error: 'not found' }); + } + return; + } + if (frame.frameType === TunnelFrameType.WsOpen) { + const open = decodeJsonPayload(frame.payload, isWsOpenPayload); + sendFrame(encodeTunnelFrame(TunnelFrameType.WsOpened, frame.streamId, encodeJsonPayload(open.protocols?.length ? { protocol: open.protocols[0] } : {}))); + return; + } + if (frame.frameType === TunnelFrameType.WsText) { + const complete = assembler.push(frame); + if (!complete) return; + const text = textDecoder.decode(complete); + sendFrame(encodeTunnelFrame(TunnelFrameType.WsText, frame.streamId, textEncoder.encode(`echo:${text}`))); + return; + } + if (frame.frameType === TunnelFrameType.WsClose) { + sendFrame(encodeTunnelFrame(TunnelFrameType.WsClose, frame.streamId, frame.payload)); + } + }; + + let firstHelloDelayed = false; + endpoint.onmessage = (event) => { + const data = event.data; + recvChain = recvChain.then(async () => { + if (typeof data === 'string') { + if (options.firstHelloDelayMs && !firstHelloDelayed) { + firstHelloDelayed = true; + await new Promise((resolve) => setTimeout(resolve, options.firstHelloDelayMs)); + } + const action = await handshake.handleText(data); + if (action.type === 'established') { + encryptor = action.channel.encryptor; + decryptor = action.channel.decryptor; + batchNegotiated = action.batch; + if (action.replyText) endpoint.send(action.replyText); + options.onConnect?.(); + } else if (action.type === 'send-text' && action.text) { + endpoint.send(action.text); + } + return; + } + if (!decryptor) return; + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); + const plaintext = await decryptor.decrypt(bytes); + const frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext]; + for (const frame of frames) handleTunnelFrame(decodeTunnelFrame(frame)); + }); + }; +}; + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const setupClient = async ( + hostOptions: MiniHostOptions = {}, + clientOverrides: Partial[0]> = {}, +): Promise<{ + client: RelayTunnelClient; + connectionCount: () => number; + killWire: () => void; + sendTextToClient: (text: string) => void; + clientBinaryCount: () => number; +}> => { + const hostKeyPair = await generateEcdhKeyPair(); + const hostPubJwk = await exportPublicKeyJwk(hostKeyPair.publicKey); + let count = 0; + let lastClientEndpoint: FakeEndpoint | null = null; + let lastHostEndpoint: FakeEndpoint | null = null; + const client = createRelayTunnelClient({ + relayUrl: 'wss://relay.test/ws', + serverId: 'server-1', + hostEncPubJwk: hostPubJwk, + helloRetryMs: 20, + pingIntervalMs: 40, + pingTimeoutMs: 120, + reconnectBaseDelayMs: 20, + reconnectMaxDelayMs: 80, + ...clientOverrides, + createWireSocket: () => { + count += 1; + const clientEndpoint = new FakeEndpoint(); + const hostEndpoint = new FakeEndpoint(); + clientEndpoint.peer = hostEndpoint; + hostEndpoint.peer = clientEndpoint; + lastClientEndpoint = clientEndpoint; + lastHostEndpoint = hostEndpoint; + attachMiniHost(hostEndpoint, hostKeyPair.privateKey, hostOptions); + queueMicrotask(() => clientEndpoint.onopen?.()); + return clientEndpoint; + }, + }); + return { + client, + connectionCount: () => count, + killWire: () => lastClientEndpoint?.close(1006, 'killed'), + sendTextToClient: (text: string) => lastHostEndpoint?.send(text), + clientBinaryCount: () => lastClientEndpoint?.binarySent ?? 0, + }; +}; + +let openClients: RelayTunnelClient[] = []; +afterEach(() => { + for (const client of openClients) client.close(); + openClients = []; +}); + +const track = (client: RelayTunnelClient): RelayTunnelClient => { + openClients.push(client); + return client; +}; + +describe('createRelayTunnelClient', () => { + test('performs concurrent fetches over one tunnel', async () => { + const { client } = await setupClient(); + track(client); + const [a, b, c] = await Promise.all([ + client.fetch('/health'), + client.fetch('/health'), + client.fetch('/echo-body', { method: 'POST', body: 'payload-xyz' }), + ]); + expect(a.status).toBe(200); + expect(await a.json()).toEqual({ ok: true }); + expect(b.status).toBe(200); + expect(await b.text()).toBe(await new Response('{"ok":true}').text()); + expect(await c.text()).toBe('payload-xyz'); + }); + + test('streams a response body incrementally', async () => { + const { client } = await setupClient(); + track(client); + const response = await client.fetch('/stream'); + expect(response.body).not.toBeNull(); + const reader = response.body!.getReader(); + const chunks: string[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(textDecoder.decode(value)); + } + expect(chunks.join('')).toBe('chunk0;chunk1;chunk2;'); + // The body arrived as multiple frames, not one buffered blob. + expect(chunks.length).toBeGreaterThan(1); + }); + + test('propagates abort to the host and errors the stream', async () => { + const { client } = await setupClient(); + track(client); + const controller = new AbortController(); + const response = await client.fetch('/never-ends', { signal: controller.signal }); + const reader = response.body!.getReader(); + await reader.read(); + controller.abort(); + await expect(reader.read()).rejects.toThrow(); + }); + + test('opens, echoes, and closes a tunneled WebSocket', async () => { + const { client } = await setupClient(); + track(client); + const socket = client.openWebSocket('/api/global/event/ws?x=1'); + const opened = new Promise((resolve) => { + socket.onopen = () => resolve(); + }); + await opened; + expect(socket.readyState).toBe(WS_OPEN); + const message = new Promise((resolve) => { + socket.onmessage = (event) => { + if (typeof event.data === 'string') resolve(event.data); + }; + }); + socket.send('hello'); + expect(await message).toBe('echo:hello'); + const closed = new Promise((resolve) => { + socket.onclose = (event) => resolve(event.code); + }); + socket.close(1000, 'done'); + await closed; + expect(socket.readyState).toBe(WS_CLOSED); + }); + + test('fails open streams on reconnect and recovers on retry', async () => { + const { client, connectionCount, killWire } = await setupClient(); + track(client); + const response = await client.fetch('/never-ends'); + const reader = response.body!.getReader(); + await reader.read(); + const socket = client.openWebSocket('/api/event/ws'); + const socketClosed = new Promise((resolve) => { + socket.onclose = (event) => resolve(event.code); + }); + const firstConnections = connectionCount(); + + // Kill the relay socket: all open streams must fail so callers' retry + // machinery recovers. Tunnel-killed sockets close with 1012. + killWire(); + await expect(reader.read()).rejects.toThrow(); + expect(await socketClosed).toBe(1012); + + // The client reconnects a fresh wire and works again. + const health = await client.fetch('/health'); + expect(health.status).toBe(200); + expect(connectionCount()).toBeGreaterThan(firstConnections); + }); + + test('reconnects when keepalive times out against a silent host', async () => { + const { client, connectionCount } = await setupClient({ silent: true }); + track(client); + // Wait for the first handshake to establish, then for the keepalive timeout + // to fire and trigger a reconnect (a new wire connection). + await wait(400); + expect(connectionCount()).toBeGreaterThan(1); + const status = client.getStatus(); + expect(['reconnecting', 'connecting', 'connected', 'error']).toContain(status.state); + }); + + test('survives duplicate ready frames from a slow first handshake (first-request 500 regression)', async () => { + // firstHelloDelayMs > helloRetryMs (20ms): the client retries `hello` + // several times, and the host answers every retry with `ready`. The + // duplicate `ready` frames arrive after the client established and must + // NOT reset the channel or fail the first in-flight request. + const { client, connectionCount, sendTextToClient } = await setupClient({ firstHelloDelayMs: 70 }); + track(client); + // First request in flight with a streamed response... + const response = await client.fetch('/stream'); + const reader = response.body!.getReader(); + await reader.read(); + // ...when a straggler duplicate `ready` (the host's answer to a retried + // hello) lands on the established channel. Real-world timing: the retry + // answer crosses the relay ~helloRetryMs after the first `ready`. + sendTextToClient(JSON.stringify({ t: 'ready', v: 1 })); + const chunks: string[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(textDecoder.decode(value)); + } + expect(chunks.join('')).toContain('chunk'); + expect(connectionCount()).toBe(1); + expect(client.getStatus().state).toBe('connected'); + const again = await client.fetch('/health'); + expect(again.status).toBe(200); + expect(connectionCount()).toBe(1); + }); + + test('fails closed on non-ready plaintext after establishment', async () => { + const { client, connectionCount, sendTextToClient } = await setupClient(); + track(client); + await client.fetch('/health'); + expect(connectionCount()).toBe(1); + sendTextToClient('{"anything":"plaintext"}'); + // The channel must reset (fail closed) and the client reconnect a new wire. + await wait(150); + expect(connectionCount()).toBeGreaterThan(1); + }); + + test('publishes status transitions to subscribers', async () => { + const { client } = await setupClient(); + track(client); + const seen: string[] = []; + client.subscribeStatus((status) => seen.push(status.state)); + await client.fetch('/health'); + expect(seen).toContain('connected'); + }); + + test('packs a burst of WS messages into far fewer wire messages, preserving order', async () => { + const received: TunnelFrame[] = []; + const { client, clientBinaryCount } = await setupClient( + { recordFrame: (frame) => received.push(frame) }, + { batchWindowMs: 100 }, + ); + track(client); + const socket = client.openWebSocket('/api/event/ws'); + await new Promise((resolve) => { + socket.onopen = () => resolve(); + }); + + const echoes: string[] = []; + socket.onmessage = (event) => { + if (typeof event.data === 'string') echoes.push(event.data); + }; + + const BURST = 50; + const baseline = clientBinaryCount(); // WsOpen etc. before the burst + for (let i = 0; i < BURST; i += 1) socket.send(`m${i}`); + + // Wait for the trailing window to flush and echoes to round-trip. + await wait(250); + + const bodyFrames = received.filter((f) => f.frameType === TunnelFrameType.WsText); + expect(bodyFrames.length).toBe(BURST); + // Order preserved: the host saw m0..m49 in sequence. + expect(bodyFrames.map((f) => textDecoder.decode(f.payload))).toEqual( + Array.from({ length: BURST }, (_, i) => `m${i}`), + ); + // Echoes arrived in order too. + expect(echoes).toEqual(Array.from({ length: BURST }, (_, i) => `echo:m${i}`)); + + // The 50 frames crossed the wire as a handful of encrypted messages, not 50. + const burstWireMessages = clientBinaryCount() - baseline; + expect(burstWireMessages).toBeLessThan(BURST / 3); + expect(burstWireMessages).toBeGreaterThan(0); + }); + + test('leading edge: a single frame after idle is delivered immediately, not a window later', async () => { + const WINDOW = 300; + let firstWsTextAt = 0; + const { client } = await setupClient( + { + recordFrame: (frame) => { + if (frame.frameType === TunnelFrameType.WsText && firstWsTextAt === 0) { + firstWsTextAt = Date.now(); + } + }, + }, + { batchWindowMs: WINDOW }, + ); + track(client); + const socket = client.openWebSocket('/api/event/ws'); + await new Promise((resolve) => { + socket.onopen = () => resolve(); + }); + // Stay idle beyond the window so the next frame takes the leading edge. + await wait(WINDOW + 50); + const sentAt = Date.now(); + socket.send('solo'); + await wait(WINDOW / 2); + expect(firstWsTextAt).toBeGreaterThan(0); + // Delivered well within a full window (leading-edge flush), not delayed. + expect(firstWsTextAt - sentAt).toBeLessThan(WINDOW / 2); + }); + + test('boundary frame (StreamEnd) flushes buffered body immediately', async () => { + // A large batch window would stall a POST body if StreamEnd did not force a + // flush; the request completing quickly proves the boundary flush. + const { client } = await setupClient({}, { batchWindowMs: 1_000 }); + track(client); + const start = Date.now(); + const response = await client.fetch('/echo-body', { method: 'POST', body: 'boundary-body' }); + expect(await response.text()).toBe('boundary-body'); + expect(Date.now() - start).toBeLessThan(500); + }); + + test('keepalive: no ping while frames flow, ping fires after idle', async () => { + const pings: number[] = []; + const { client } = await setupClient( + { + recordFrame: (frame) => { + if (frame.frameType === TunnelFrameType.Ping) pings.push(Date.now()); + }, + }, + { pingIntervalMs: 40, pingTimeoutMs: 5_000, batchWindowMs: 20 }, + ); + track(client); + const socket = client.openWebSocket('/api/event/ws'); + await new Promise((resolve) => { + socket.onopen = () => resolve(); + }); + + // Keep traffic flowing faster than the ping interval for a few intervals. + const busyUntil = Date.now() + 200; + while (Date.now() < busyUntil) { + socket.send('keepbusy'); + await wait(10); + } + expect(pings.length).toBe(0); + + // Now go idle: a ping must appear once we exceed the interval. + await wait(150); + expect(pings.length).toBeGreaterThan(0); + }); + + test('negotiates legacy (no batch) when the host does not advertise batching', async () => { + // Host advertises batch:false -> both directions fall back to one frame per + // encrypted message. Everything still works end to end. + const { client } = await setupClient({ batch: false }); + track(client); + const socket = client.openWebSocket('/api/event/ws'); + await new Promise((resolve) => { + socket.onopen = () => resolve(); + }); + const message = new Promise((resolve) => { + socket.onmessage = (event) => { + if (typeof event.data === 'string') resolve(event.data); + }; + }); + socket.send('legacy'); + expect(await message).toBe('echo:legacy'); + const health = await client.fetch('/health'); + expect(health.status).toBe(200); + }); +}); diff --git a/packages/ui/src/lib/relay/tunnel-client.ts b/packages/ui/src/lib/relay/tunnel-client.ts new file mode 100644 index 00000000..ab24d78f --- /dev/null +++ b/packages/ui/src/lib/relay/tunnel-client.ts @@ -0,0 +1,1024 @@ +// Relay tunnel client: Layer 1 wiring (relay WS, role=client), Layer 2 E2EE +// initiator, and Layer 3 mux (HTTP/SSE/WS streams) on the client side. +// One relay connection per client session carries all app traffic; a tunnel +// reconnect fails every open stream and the app's existing retry machinery +// (runtime-fetch retries, event-pipeline reconnect) recovers. +// Spec: .opencode/plans/private-relay/01-protocol-spec.md + +import { createClientHandshake, type EstablishedChannelCrypto } from './handshake'; +import { + RELAY_PROTOCOL_VERSION, + RelayCloseCode, + TunnelFrameType, + type TunnelHttpRequestPayload, + type TunnelWsOpenPayload, +} from './protocol'; +import { + chunkPayload, + createFragmentAssembler, + createOutboundFrameBatcher, + createStreamIdAllocator, + DEFAULT_BATCH_WINDOW_MS, + decodeFrameBatch, + decodeJsonPayload, + decodeTunnelFrame, + encodeFragmentedMessage, + encodeJsonPayload, + encodeTunnelFrame, + type OutboundFrameBatcher, + type TunnelFrame, +} from './tunnel-codec'; +import { TUNNEL_FRAGMENT_FLAG } from './protocol'; +import { + isHttpResponsePayload, + isStreamAbortPayload, + isWsClosePayload, + normalizeTunnelRequest, +} from './tunnel-payloads'; + +const EMPTY_PAYLOAD = new Uint8Array(0); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const toError = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value))); +const abortError = (): DOMException => new DOMException('The operation was aborted.', 'AbortError'); + +// Minimal wire surface the client needs from the relay WebSocket. Injectable +// so tests can substitute an in-memory transport pair. +export interface TunnelWireSocket { + readonly readyState: number; + send(data: string | ArrayBuffer | Uint8Array): void; + close(code?: number, reason?: string): void; + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: ((event: { code: number; reason: string }) => void) | null; + onerror: (() => void) | null; +} + +const wrapNativeWebSocket = (ws: WebSocket): TunnelWireSocket => { + ws.binaryType = 'arraybuffer'; + const wire: TunnelWireSocket = { + get readyState() { + return ws.readyState; + }, + send(data) { + ws.send(data); + }, + close(code, reason) { + ws.close(code, reason); + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + }; + ws.onopen = () => wire.onopen?.(); + ws.onmessage = (event) => wire.onmessage?.({ data: event.data }); + ws.onclose = (event) => wire.onclose?.({ code: event.code, reason: event.reason }); + ws.onerror = () => wire.onerror?.(); + return wire; +}; + +// Socket-like surface for tunneled WebSockets. Matches exactly what +// packages/ui/src/sync/event-pipeline.ts uses: assignable on* handlers, +// send(), close(), readyState. `wrapBrowserWebSocket` adapts a native +// WebSocket to the same shape so consumers can hold one type for both paths. +export interface RelayTunnelSocketMessageEvent { + data: string | ArrayBuffer; +} + +export interface RelayTunnelSocketCloseEvent { + code: number; + reason: string; +} + +export interface RelayTunnelWebSocket { + readonly readyState: number; + // Native-only hint; the tunnel always delivers binary as ArrayBuffer, so it + // accepts the setter as a no-op to keep the two socket shapes interchangeable. + binaryType?: 'blob' | 'arraybuffer'; + onopen: (() => void) | null; + onmessage: ((event: RelayTunnelSocketMessageEvent) => void) | null; + onerror: (() => void) | null; + onclose: ((event: RelayTunnelSocketCloseEvent) => void) | null; + send(data: string | ArrayBuffer | ArrayBufferView): void; + close(code?: number, reason?: string): void; +} + +export const wrapBrowserWebSocket = (ws: WebSocket): RelayTunnelWebSocket => { + const socket: RelayTunnelWebSocket = { + get readyState() { + return ws.readyState; + }, + get binaryType() { + return ws.binaryType; + }, + set binaryType(value) { + if (value) ws.binaryType = value; + }, + onopen: null, + onmessage: null, + onerror: null, + onclose: null, + send(data) { + ws.send(data); + }, + close(code, reason) { + ws.close(code, reason); + }, + }; + ws.onopen = () => socket.onopen?.(); + ws.onmessage = (event) => { + const data: unknown = event.data; + if (typeof data === 'string' || data instanceof ArrayBuffer) { + socket.onmessage?.({ data }); + } + }; + ws.onerror = () => socket.onerror?.(); + ws.onclose = (event) => socket.onclose?.({ code: event.code, reason: event.reason }); + return socket; +}; + +export type RelayTunnelState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'; + +export interface RelayTunnelStatus { + state: RelayTunnelState; + lastError?: string; +} + +export interface RelayTunnelClientOptions { + relayUrl: string; + serverId: string; + hostEncPubJwk: JsonWebKey; + grant?: string; + /** Test hook: replaces native WebSocket construction with a fake wire. */ + createWireSocket?: (url: string) => TunnelWireSocket; + helloRetryMs?: number; + helloTimeoutMs?: number; + pingIntervalMs?: number; + pingTimeoutMs?: number; + /** Frame-batching flush window in ms (default 150). Only applies once negotiated. */ + batchWindowMs?: number; + /** Advertise frame batching in the handshake. Default true. */ + batch?: boolean; + reconnectBaseDelayMs?: number; + reconnectMaxDelayMs?: number; + hiddenOrOfflineMaxDelayMs?: number; +} + +export interface RelayTunnelClient { + fetch(input: string | URL | Request, init?: RequestInit): Promise; + openWebSocket(pathWithQuery: string, protocols?: string[]): RelayTunnelWebSocket; + getStatus(): RelayTunnelStatus; + subscribeStatus(listener: (status: RelayTunnelStatus) => void): () => void; + close(): void; +} + +const WS_CONNECTING = 0; +const WS_OPEN = 1; +const WS_CLOSING = 2; +const WS_CLOSED = 3; + +// Relay close codes that a reconnect can never resolve — surface a terminal error +// instead of looping forever (auth failed, duplicate client, limit exceeded). +const TERMINAL_RELAY_CLOSE_CODES = new Set([ + RelayCloseCode.AuthFailed, + RelayCloseCode.DuplicateClient, + RelayCloseCode.LimitExceeded, +]); + +const RELAY_CLOSE_MESSAGES: Record = { + [RelayCloseCode.AuthFailed]: 'relay authentication failed', + [RelayCloseCode.DuplicateClient]: 'relay connection replaced by another client', + [RelayCloseCode.LimitExceeded]: 'relay connection limit reached', +}; + +type StreamHandler = { + handleFrame(frameType: number, payload: Uint8Array): void; + fail(error: Error): void; +}; + +type ActiveChannel = { + streams: Map; + assembler: ReturnType; + nextStreamId(): number; + send(frame: Uint8Array): void; + dead: boolean; +}; + +type ChannelWaiter = { + resolve(channel: ActiveChannel): void; + reject(error: Error): void; +}; + +const isOfflineOrHidden = (): boolean => { + const offline = typeof navigator !== 'undefined' && navigator.onLine === false; + const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + return offline || hidden; +}; + +export const createRelayTunnelClient = (options: RelayTunnelClientOptions): RelayTunnelClient => { + const helloRetryMs = options.helloRetryMs ?? 1_000; + const helloTimeoutMs = options.helloTimeoutMs ?? 30_000; + const pingIntervalMs = options.pingIntervalMs ?? 30_000; + // Pong wait after an idle keepalive ping — must be well under the interval so a + // dead socket is caught within one cycle rather than after two. + const pingTimeoutMs = options.pingTimeoutMs ?? 15_000; + const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; + const advertiseBatch = options.batch !== false; + const reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1_000; + const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 30_000; + const hiddenOrOfflineMaxDelayMs = options.hiddenOrOfflineMaxDelayMs ?? 60_000; + + const createWire = options.createWireSocket ?? ((url: string) => wrapNativeWebSocket(new WebSocket(url))); + + let closed = false; + let status: RelayTunnelStatus = { state: 'idle' }; + // Plain listener set — status must not fan out through shared stores. + const statusListeners = new Set<(next: RelayTunnelStatus) => void>(); + let activeChannel: ActiveChannel | null = null; + let currentWire: TunnelWireSocket | null = null; + let currentAttemptCleanup: (() => void) | null = null; + let attemptGeneration = 0; + let consecutiveFailures = 0; + let reconnectTimer: ReturnType | null = null; + let channelWaiters: ChannelWaiter[] = []; + let wakeListenersInstalled = false; + + const setStatus = (next: RelayTunnelStatus): void => { + if (status.state === next.state && status.lastError === next.lastError) return; + status = next; + for (const listener of statusListeners) { + try { + listener(status); + } catch { + // A listener throwing must not break the tunnel. + } + } + }; + + const rejectWaiters = (error: Error): void => { + const waiters = channelWaiters; + channelWaiters = []; + for (const waiter of waiters) waiter.reject(error); + }; + + const resolveWaiters = (channel: ActiveChannel): void => { + const waiters = channelWaiters; + channelWaiters = []; + for (const waiter of waiters) waiter.resolve(channel); + }; + + const failChannelStreams = (channel: ActiveChannel, error: Error): void => { + channel.dead = true; + const handlers = Array.from(channel.streams.values()); + channel.streams.clear(); + for (const handler of handlers) { + try { + handler.fail(error); + } catch { + // Stream teardown must not block the rest. + } + } + }; + + const clearReconnectTimer = (): void => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + const onWake = (): void => { + if (closed || reconnectTimer === null || isOfflineOrHidden()) return; + clearReconnectTimer(); + removeWakeListeners(); + void connect(); + }; + + const onVisibilityWake = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') onWake(); + }; + + const addWakeListeners = (): void => { + if (wakeListenersInstalled || typeof window === 'undefined') return; + wakeListenersInstalled = true; + window.addEventListener('online', onWake); + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityWake); + }; + + const removeWakeListeners = (): void => { + if (!wakeListenersInstalled || typeof window === 'undefined') return; + wakeListenersInstalled = false; + window.removeEventListener('online', onWake); + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityWake); + }; + + const scheduleReconnect = (): void => { + if (closed || reconnectTimer !== null) return; + const attemptIndex = Math.max(0, consecutiveFailures - 1); + const base = reconnectBaseDelayMs * 2 ** Math.min(attemptIndex, 10); + // Per CLAUDE.md reconnect pacing: offline/hidden expect recovery from the + // online/visibility events (interruptible wait below), so back off to the + // long cap instead of probing a dead network. + const cap = isOfflineOrHidden() ? hiddenOrOfflineMaxDelayMs : reconnectMaxDelayMs; + const delay = Math.min(cap, base); + addWakeListeners(); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + removeWakeListeners(); + void connect(); + }, delay); + }; + + const buildRelayWsUrl = (): string => { + const url = new URL(options.relayUrl); + url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION)); + url.searchParams.set('role', 'client'); + url.searchParams.set('serverId', options.serverId); + if (options.grant) url.searchParams.set('grant', options.grant); + return url.toString(); + }; + + const connect = async (): Promise => { + if (closed) return; + clearReconnectTimer(); + attemptGeneration += 1; + const generation = attemptGeneration; + setStatus({ state: consecutiveFailures > 0 ? 'reconnecting' : 'connecting', lastError: status.lastError }); + + let handshake; + try { + handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch }); + } catch (error) { + if (generation !== attemptGeneration || closed) return; + failAttempt(generation, toError(error), true); + return; + } + if (generation !== attemptGeneration || closed) return; + + let wire: TunnelWireSocket; + try { + wire = createWire(buildRelayWsUrl()); + } catch (error) { + failAttempt(generation, toError(error)); + return; + } + currentWire = wire; + + let settled = false; + let helloInterval: ReturnType | null = null; + let helloDeadline: ReturnType | null = null; + let pingTimer: ReturnType | null = null; + // One-shot: armed when an idle-keepalive ping is sent, cleared by any received + // frame (incl. Pong). If it fires, the tunnel is dead. Independent of the ping + // cadence so a dead socket is detected ~pingTimeoutMs after an unanswered ping, + // not on the next full interval. + let pongDeadline: ReturnType | null = null; + let recvChain: Promise = Promise.resolve(); + let channel: ActiveChannel | null = null; + let cryptoChannel: EstablishedChannelCrypto | null = null; + let batchNegotiated = false; + let batcher: OutboundFrameBatcher | null = null; + // Idle tracking: updated on any non-Ping/Pong frame in EITHER direction. + // Ping/Pong are excluded so the keepalive can't sustain itself. + let lastActivityAt = Date.now(); + + const cleanupTimers = (): void => { + if (helloInterval !== null) { + clearInterval(helloInterval); + helloInterval = null; + } + if (helloDeadline !== null) { + clearTimeout(helloDeadline); + helloDeadline = null; + } + if (pingTimer !== null) { + clearInterval(pingTimer); + pingTimer = null; + } + if (pongDeadline !== null) { + clearTimeout(pongDeadline); + pongDeadline = null; + } + if (batcher !== null) { + batcher.dispose(); + batcher = null; + } + }; + currentAttemptCleanup = cleanupTimers; + + function failAttemptLocal(error: Error, asErrorState = false, terminal = false): void { + if (settled || generation !== attemptGeneration) return; + settled = true; + cleanupTimers(); + if (channel) { + activeChannel = null; + failChannelStreams(channel, new Error(`relay tunnel reset: ${error.message}`)); + } + rejectWaiters(error); + try { + wire.close(); + } catch { + // Wire may already be closed. + } + if (currentWire === wire) currentWire = null; + if (closed) return; + consecutiveFailures += 1; + // A permanent rejection (auth failed, duplicate, limit) won't resolve by + // retrying — surface a terminal error instead of reconnecting forever. + if (terminal) { + setStatus({ state: 'error', lastError: error.message }); + return; + } + setStatus({ state: asErrorState ? 'error' : 'reconnecting', lastError: error.message }); + scheduleReconnect(); + } + + const sendHello = (): void => { + try { + wire.send(handshake.helloText); + } catch { + // Socket not ready; the retry interval covers it. + } + }; + + const establish = (crypto: EstablishedChannelCrypto, batch: boolean): void => { + cryptoChannel = crypto; + batchNegotiated = batch; + if (helloInterval !== null) { + clearInterval(helloInterval); + helloInterval = null; + } + if (helloDeadline !== null) { + clearTimeout(helloDeadline); + helloDeadline = null; + } + const streams = new Map(); + const allocator = createStreamIdAllocator(); + const assembler = createFragmentAssembler(); + let sendChain: Promise = Promise.resolve(); + // Serialize encrypt+send: the per-direction IV counter must hit the wire in + // encryption order or the receiver fails closed. One call == one encrypted + // WS message == one counter tick, whether it carries a batch or a lone frame. + const sendEncryptedPlaintext = (plaintext: Uint8Array): void => { + sendChain = sendChain + .then(async () => { + if (channelObj.dead) return; + const encrypted = await crypto.encryptor.encrypt(plaintext); + wire.send(encrypted); + }) + .catch(() => { + // Send failures surface via wire close; do not break the chain. + }); + }; + const localBatcher = batch + ? createOutboundFrameBatcher({ windowMs: batchWindowMs, sendBatch: sendEncryptedPlaintext }) + : null; + batcher = localBatcher; + const channelObj: ActiveChannel = { + streams, + assembler, + nextStreamId: () => allocator.next(), + dead: false, + send(frame: Uint8Array): void { + if (channelObj.dead) return; + const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG; + if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong) { + lastActivityAt = Date.now(); + } + if (localBatcher) localBatcher.enqueue(frame); + else sendEncryptedPlaintext(frame); + }, + }; + channel = channelObj; + activeChannel = channelObj; + consecutiveFailures = 0; + lastActivityAt = Date.now(); + setStatus({ state: 'connected' }); + resolveWaiters(channelObj); + pingTimer = setInterval(() => { + const now = Date.now(); + // Only ping when the tunnel has actually been idle; streaming traffic + // keeps lastActivityAt fresh, so sustained bursts send zero pings. + if (now - lastActivityAt < pingIntervalMs) return; + channelObj.send(encodeTunnelFrame(TunnelFrameType.Ping, 0, EMPTY_PAYLOAD)); + // Expect a Pong (or any frame) before the deadline; otherwise it's dead. + if (pongDeadline === null) { + pongDeadline = setTimeout(() => { + pongDeadline = null; + failAttemptLocal(new Error('relay keepalive timeout')); + }, pingTimeoutMs); + } + }, pingIntervalMs); + }; + + const handleTunnelFrame = (channelObj: ActiveChannel, plaintext: Uint8Array): void => { + let frame: TunnelFrame; + try { + frame = decodeTunnelFrame(plaintext); + } catch (error) { + failAttemptLocal(toError(error)); + return; + } + // Any received frame proves the tunnel is alive — clear the pong deadline. + if (pongDeadline !== null) { + clearTimeout(pongDeadline); + pongDeadline = null; + } + if (frame.frameType === TunnelFrameType.Ping) { + channelObj.send(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, EMPTY_PAYLOAD)); + return; + } + if (frame.frameType === TunnelFrameType.Pong) return; + // Non-keepalive inbound traffic counts as activity (suppresses our ping). + lastActivityAt = Date.now(); + + let payload = frame.payload; + if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) { + let complete: Uint8Array | null; + try { + complete = channelObj.assembler.push(frame); + } catch (error) { + failAttemptLocal(toError(error)); + return; + } + if (complete === null) return; + payload = complete; + } else if (frame.hasMoreFragments) { + failAttemptLocal(new Error('unexpected fragmented tunnel frame')); + return; + } + + const handler = channelObj.streams.get(frame.streamId); + // Late frames for a stream we already dropped (abort race) are expected. + if (!handler) return; + handler.handleFrame(frame.frameType, payload); + }; + + wire.onopen = () => { + if (settled || generation !== attemptGeneration) return; + sendHello(); + helloInterval = setInterval(sendHello, helloRetryMs); + }; + + wire.onmessage = (event) => { + if (settled || generation !== attemptGeneration) return; + const data = event.data; + if (typeof data === 'string') { + recvChain = recvChain + .then(async () => { + if (settled || generation !== attemptGeneration) return; + // Post-establish text frames go through the handshake too: the host + // re-answers retried hellos with duplicate `ready` frames, which the + // handshake ignores; anything else fails closed there. + const action = await handshake.handleText(data); + if (action.type === 'established') { + if (cryptoChannel) return; + establish(action.channel, action.batch); + } else if (action.type === 'fail') { + failAttemptLocal(new Error(`relay handshake failed: ${action.reason}`)); + } + }) + .catch((error: unknown) => { + failAttemptLocal(toError(error)); + }); + return; + } + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null; + if (!bytes) return; + // Decrypt sequentially: the counter check requires wire order. + recvChain = recvChain + .then(async () => { + if (settled || generation !== attemptGeneration) return; + const currentChannel = channel; + const currentCrypto = cryptoChannel; + if (!currentChannel || !currentCrypto) { + failAttemptLocal(new Error('encrypted frame before handshake completed')); + return; + } + let plaintext: Uint8Array; + try { + plaintext = await currentCrypto.decryptor.decrypt(bytes); + } catch (error) { + failAttemptLocal(toError(error)); + return; + } + if (batchNegotiated) { + // One encrypted message may carry several tunnel frames; dispatch + // each in order through the same per-frame handling as legacy. + let frames: Uint8Array[]; + try { + frames = decodeFrameBatch(plaintext); + } catch (error) { + failAttemptLocal(toError(error)); + return; + } + for (const frame of frames) { + if (settled || generation !== attemptGeneration || currentChannel.dead) return; + handleTunnelFrame(currentChannel, frame); + } + return; + } + handleTunnelFrame(currentChannel, plaintext); + }) + .catch((error: unknown) => { + failAttemptLocal(toError(error)); + }); + }; + + wire.onclose = (event) => { + const terminal = TERMINAL_RELAY_CLOSE_CODES.has(event.code); + failAttemptLocal( + new Error(RELAY_CLOSE_MESSAGES[event.code] ?? `relay socket closed (code ${event.code})`), + terminal, + terminal, + ); + }; + + wire.onerror = () => { + // onclose follows with the failure path. + }; + + helloDeadline = setTimeout(() => { + helloDeadline = null; + failAttemptLocal(new Error('relay handshake timeout'), true); + }, helloTimeoutMs); + + function failAttempt(gen: number, error: Error, asErrorState = false): void { + if (gen !== attemptGeneration || closed) return; + rejectWaiters(error); + consecutiveFailures += 1; + setStatus({ state: asErrorState ? 'error' : 'reconnecting', lastError: error.message }); + scheduleReconnect(); + } + }; + + const waitForChannel = (signal?: AbortSignal): Promise => { + if (closed) return Promise.reject(new Error('relay tunnel closed')); + if (signal?.aborted) return Promise.reject(abortError()); + if (activeChannel && !activeChannel.dead) return Promise.resolve(activeChannel); + return new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null; + const waiter: ChannelWaiter = { + resolve(channel) { + if (onAbort && signal) signal.removeEventListener('abort', onAbort); + resolve(channel); + }, + reject(error) { + if (onAbort && signal) signal.removeEventListener('abort', onAbort); + reject(error); + }, + }; + if (signal) { + onAbort = () => { + channelWaiters = channelWaiters.filter((entry) => entry !== waiter); + reject(abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } + channelWaiters.push(waiter); + }); + }; + + const tunnelFetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = await normalizeTunnelRequest(input, init); + const signal = request.signal; + if (signal?.aborted) throw abortError(); + const channel = await waitForChannel(signal); + const streamId = channel.nextStreamId(); + + return await new Promise((resolve, reject) => { + let responseDelivered = false; + let finished = false; + let bodyController: ReadableStreamDefaultController | null = null; + let onAbort: (() => void) | null = null; + + const cleanupStream = (): void => { + channel.streams.delete(streamId); + channel.assembler.dropStream(streamId); + if (onAbort && signal) signal.removeEventListener('abort', onAbort); + }; + + const finishError = (error: Error): void => { + if (finished) return; + finished = true; + cleanupStream(); + if (!responseDelivered) { + reject(error); + return; + } + try { + bodyController?.error(error); + } catch { + // Controller may already be closed. + } + }; + + const sendAbort = (reason: string): void => { + if (!channel.dead) { + channel.send(encodeTunnelFrame(TunnelFrameType.StreamAbort, streamId, encodeJsonPayload({ reason }))); + } + }; + + onAbort = () => { + sendAbort('aborted'); + finishError(abortError()); + }; + + channel.streams.set(streamId, { + handleFrame(frameType, payload) { + if (frameType === TunnelFrameType.HttpResponse) { + if (responseDelivered || finished) return; + let head; + try { + head = decodeJsonPayload(payload, isHttpResponsePayload); + } catch (error) { + sendAbort('malformed response head'); + finishError(toError(error)); + return; + } + const nullBody = head.status === 204 || head.status === 205 || head.status === 304; + let body: ReadableStream | null = null; + if (!nullBody) { + body = new ReadableStream({ + start(controller) { + bodyController = controller; + }, + cancel() { + if (finished) return; + finished = true; + cleanupStream(); + sendAbort('response body cancelled'); + }, + }); + } + responseDelivered = true; + resolve(new Response(body, { status: head.status, headers: head.headers })); + if (nullBody) { + finished = true; + cleanupStream(); + } + return; + } + if (frameType === TunnelFrameType.HttpBody) { + if (!responseDelivered || finished) return; + try { + bodyController?.enqueue(payload); + } catch { + // Consumer already cancelled the stream. + } + return; + } + if (frameType === TunnelFrameType.StreamEnd) { + if (finished) return; + if (!responseDelivered) { + finishError(new Error('tunnel stream ended before response head')); + return; + } + finished = true; + cleanupStream(); + try { + bodyController?.close(); + } catch { + // Consumer already cancelled the stream. + } + return; + } + if (frameType === TunnelFrameType.StreamAbort) { + let reason = 'stream aborted by host'; + try { + reason = decodeJsonPayload(payload, isStreamAbortPayload).reason; + } catch { + // Keep the generic reason. + } + finishError(new Error(reason)); + } + }, + fail(error) { + finishError(error); + }, + }); + + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + + const head: TunnelHttpRequestPayload = { + method: request.method, + path: request.path, + query: request.query, + headers: request.headers, + }; + channel.send(encodeTunnelFrame(TunnelFrameType.HttpRequest, streamId, encodeJsonPayload(head))); + void (async () => { + try { + if (request.body) { + for await (const chunk of request.body) { + if (finished || channel.dead) return; + for (const piece of chunkPayload(chunk)) { + channel.send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece)); + } + } + } + if (!finished && !channel.dead) { + channel.send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, EMPTY_PAYLOAD)); + } + } catch (error) { + sendAbort('request body failed'); + finishError(toError(error)); + } + })(); + }); + }; + + const splitPathQuery = (pathWithQuery: string): { path: string; query: string } => { + const index = pathWithQuery.indexOf('?'); + if (index === -1) return { path: pathWithQuery, query: '' }; + return { path: pathWithQuery.slice(0, index), query: pathWithQuery.slice(index + 1) }; + }; + + const openTunnelWebSocket = (pathWithQuery: string, protocols?: string[]): RelayTunnelWebSocket => { + let readyState = WS_CONNECTING; + let channelRef: ActiveChannel | null = null; + let streamId = 0; + let finished = false; + + const socket: RelayTunnelWebSocket = { + get readyState() { + return readyState; + }, + onopen: null, + onmessage: null, + onerror: null, + onclose: null, + send(data) { + if (readyState !== WS_OPEN || !channelRef || channelRef.dead) { + throw new Error('relay tunnel socket is not open'); + } + if (typeof data === 'string') { + for (const frame of encodeFragmentedMessage(TunnelFrameType.WsText, streamId, textEncoder.encode(data))) { + channelRef.send(frame); + } + return; + } + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data.slice(0)) + : (() => { + const copy = new Uint8Array(data.byteLength); + copy.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + return copy; + })(); + for (const frame of encodeFragmentedMessage(TunnelFrameType.WsBinary, streamId, bytes)) { + channelRef.send(frame); + } + }, + close(code = 1000, reason = '') { + if (readyState === WS_CLOSED || readyState === WS_CLOSING) return; + if (readyState === WS_OPEN && channelRef && !channelRef.dead) { + readyState = WS_CLOSING; + channelRef.send(encodeTunnelFrame(TunnelFrameType.WsClose, streamId, encodeJsonPayload({ code, reason }))); + } + settleClose(code, reason); + }, + }; + + const settleClose = (code: number, reason: string, errored = false): void => { + if (finished) return; + finished = true; + if (channelRef) { + channelRef.streams.delete(streamId); + channelRef.assembler.dropStream(streamId); + } + readyState = WS_CLOSED; + if (errored) { + try { + socket.onerror?.(); + } catch { + // Handler failures must not break teardown. + } + } + try { + socket.onclose?.({ code, reason }); + } catch { + // Handler failures must not break teardown. + } + }; + + void (async () => { + let channel: ActiveChannel; + try { + channel = await waitForChannel(); + } catch (error) { + settleClose(1006, toError(error).message, true); + return; + } + if (finished) return; + channelRef = channel; + streamId = channel.nextStreamId(); + channel.streams.set(streamId, { + handleFrame(frameType, payload) { + if (frameType === TunnelFrameType.WsOpened) { + if (readyState === WS_CONNECTING) { + readyState = WS_OPEN; + try { + socket.onopen?.(); + } catch { + // Handler failures must not break the stream. + } + } + return; + } + if (frameType === TunnelFrameType.WsText) { + try { + socket.onmessage?.({ data: textDecoder.decode(payload) }); + } catch { + // Handler failures must not break the stream. + } + return; + } + if (frameType === TunnelFrameType.WsBinary) { + const buffer = new ArrayBuffer(payload.byteLength); + new Uint8Array(buffer).set(payload); + try { + socket.onmessage?.({ data: buffer }); + } catch { + // Handler failures must not break the stream. + } + return; + } + if (frameType === TunnelFrameType.WsClose) { + let code = 1000; + let reason = ''; + try { + const parsed = decodeJsonPayload(payload, isWsClosePayload); + code = parsed.code; + reason = parsed.reason; + } catch { + // Keep defaults. + } + settleClose(code, reason); + return; + } + if (frameType === TunnelFrameType.StreamAbort) { + let reason = 'stream aborted'; + try { + reason = decodeJsonPayload(payload, isStreamAbortPayload).reason; + } catch { + // Keep the generic reason. + } + settleClose(1006, reason, true); + } + }, + fail(error) { + // Spec: streams killed by a tunnel reset close with 1012 so callers' + // reconnect machinery treats it as "host went away, retry". + settleClose(1012, error.message, true); + }, + }); + const { path, query } = splitPathQuery(pathWithQuery); + // The host sets the WS Origin itself (to the loopback origin it dials); the + // client's window.location.origin is unreliable in WKWebView, so we don't send it. + const openPayload: TunnelWsOpenPayload = protocols && protocols.length > 0 ? { path, query, protocols } : { path, query }; + channel.send(encodeTunnelFrame(TunnelFrameType.WsOpen, streamId, encodeJsonPayload(openPayload))); + })(); + + return socket; + }; + + const close = (): void => { + if (closed) return; + closed = true; + attemptGeneration += 1; + clearReconnectTimer(); + removeWakeListeners(); + currentAttemptCleanup?.(); + currentAttemptCleanup = null; + const channel = activeChannel; + activeChannel = null; + const error = new Error('relay tunnel closed'); + if (channel) failChannelStreams(channel, error); + rejectWaiters(error); + try { + currentWire?.close(); + } catch { + // Wire may already be closed. + } + currentWire = null; + setStatus({ state: 'idle' }); + }; + + void connect(); + + return { + fetch: tunnelFetch, + openWebSocket: openTunnelWebSocket, + getStatus: () => status, + subscribeStatus(listener) { + statusListeners.add(listener); + return () => { + statusListeners.delete(listener); + }; + }, + close, + }; +}; diff --git a/packages/ui/src/lib/relay/tunnel-codec.test.ts b/packages/ui/src/lib/relay/tunnel-codec.test.ts new file mode 100644 index 00000000..18f889ae --- /dev/null +++ b/packages/ui/src/lib/relay/tunnel-codec.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'bun:test'; + +import { + MAX_TUNNEL_PAYLOAD_BYTES, + TunnelFrameType, + type TunnelHttpRequestPayload, +} from './protocol'; +import { + chunkPayload, + createFragmentAssembler, + createStreamIdAllocator, + decodeFrameBatch, + decodeJsonPayload, + decodeTunnelFrame, + encodeFragmentedMessage, + encodeFrameBatch, + encodeJsonPayload, + encodeTunnelFrame, + TunnelCodecError, +} from './tunnel-codec'; +import { MAX_PLAINTEXT_FRAME_BYTES } from './protocol'; + +const randomBytes = (length: number): Uint8Array => { + const bytes = new Uint8Array(length); + // getRandomValues caps at 64 KiB per call. + for (let offset = 0; offset < length; offset += 65536) { + globalThis.crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + 65536, length))); + } + return bytes; +}; + +describe('tunnel codec', () => { + test('frame round trip preserves type, stream id, and payload', () => { + const payload = randomBytes(1024); + for (const streamId of [1, 3, 0x7fffffff, 0xffffffff]) { + const frame = decodeTunnelFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, payload)); + expect(frame.frameType).toBe(TunnelFrameType.HttpBody); + expect(frame.streamId).toBe(streamId); + expect(frame.payload).toEqual(payload); + expect(frame.hasMoreFragments).toBe(false); + } + }); + + test('fragment flag round trips and is separated from the frame type', () => { + const frame = decodeTunnelFrame( + encodeTunnelFrame(TunnelFrameType.WsBinary, 5, new Uint8Array([1]), true), + ); + expect(frame.frameType).toBe(TunnelFrameType.WsBinary); + expect(frame.hasMoreFragments).toBe(true); + }); + + test('rejects invalid stream ids, oversized payloads, short and unknown frames', () => { + const payload = new Uint8Array(1); + expect(() => encodeTunnelFrame(TunnelFrameType.Ping, -1, payload)).toThrow(TunnelCodecError); + expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 2 ** 32, payload)).toThrow(TunnelCodecError); + expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 1.5, payload)).toThrow(TunnelCodecError); + expect(() => + encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES + 1)), + ).toThrow('tunnel payload exceeds maximum size'); + expect(() => decodeTunnelFrame(new Uint8Array(4))).toThrow('tunnel frame too short'); + const unknown = new Uint8Array(5); + unknown[0] = 63; + expect(() => decodeTunnelFrame(unknown)).toThrow('unknown tunnel frame type 63'); + }); + + test('json payload helpers validate shape', () => { + const isHttpRequest = (parsed: unknown): parsed is TunnelHttpRequestPayload => + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as TunnelHttpRequestPayload).method === 'string' && + typeof (parsed as TunnelHttpRequestPayload).path === 'string'; + const payload = encodeJsonPayload({ method: 'GET', path: '/health', query: '', headers: {} }); + const decoded = decodeJsonPayload(payload, isHttpRequest); + expect(decoded.method).toBe('GET'); + expect(() => decodeJsonPayload(new Uint8Array([0x7b]), isHttpRequest)).toThrow( + 'malformed JSON tunnel payload', + ); + expect(() => decodeJsonPayload(encodeJsonPayload({ nope: true }), isHttpRequest)).toThrow( + 'unexpected JSON tunnel payload shape', + ); + }); + + test('chunkPayload splits exactly and yields one empty chunk for empty input', () => { + expect(chunkPayload(new Uint8Array(0))).toEqual([new Uint8Array(0)]); + const bytes = randomBytes(10); + const chunks = chunkPayload(bytes, 4); + expect(chunks.map((c) => c.length)).toEqual([4, 4, 2]); + expect(() => chunkPayload(bytes, 0)).toThrow(TunnelCodecError); + expect(() => chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES + 1)).toThrow(TunnelCodecError); + }); + + test('large message fragments and reassembles byte-identically', () => { + const message = randomBytes(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 12345); + const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 7, message); + expect(frames.length).toBe(3); + const assembler = createFragmentAssembler(); + let result: Uint8Array | null = null; + for (const encoded of frames) { + result = assembler.push(decodeTunnelFrame(encoded)); + } + expect(result).toEqual(message); + }); + + test('assembler keeps interleaved streams separate and passes unfragmented frames through', () => { + const assembler = createFragmentAssembler(); + const a1 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([1]), hasMoreFragments: true }; + const b = { frameType: TunnelFrameType.WsText, streamId: 3, payload: new Uint8Array([9]), hasMoreFragments: false }; + const a2 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([2]), hasMoreFragments: false }; + expect(assembler.push(a1)).toBeNull(); + expect(assembler.push(b)).toEqual(new Uint8Array([9])); + expect(assembler.push(a2)).toEqual(new Uint8Array([1, 2])); + }); + + test('assembler enforces max message size and dropStream clears pending state', () => { + const assembler = createFragmentAssembler(8); + const fragment = (payload: Uint8Array, more: boolean) => ({ + frameType: TunnelFrameType.WsBinary, + streamId: 1, + payload, + hasMoreFragments: more, + }); + expect(assembler.push(fragment(new Uint8Array(6), true))).toBeNull(); + expect(() => assembler.push(fragment(new Uint8Array(6), false))).toThrow( + 'fragmented message exceeds maximum size', + ); + + expect(assembler.push(fragment(new Uint8Array([1]), true))).toBeNull(); + assembler.dropStream(1); + // After drop, a terminal fragment stands alone rather than joining stale chunks. + expect(assembler.push(fragment(new Uint8Array([2]), false))).toEqual(new Uint8Array([2])); + }); + + test('stream id allocator yields odd ascending ids', () => { + const allocator = createStreamIdAllocator(); + expect([allocator.next(), allocator.next(), allocator.next()]).toEqual([1, 3, 5]); + }); + + test('frame batch round-trips N frames byte-identically, in order', () => { + const frames = [ + encodeTunnelFrame(TunnelFrameType.HttpBody, 1, randomBytes(10)), + encodeTunnelFrame(TunnelFrameType.WsText, 3, randomBytes(64)), + encodeTunnelFrame(TunnelFrameType.WsBinary, 5, randomBytes(500)), + ]; + const decoded = decodeFrameBatch(encodeFrameBatch(frames)); + expect(decoded.length).toBe(frames.length); + decoded.forEach((frame, index) => expect(frame).toEqual(frames[index])); + }); + + test('single-frame batch uses the compact tag with 1 byte of overhead', () => { + const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 7, randomBytes(128)); + const encoded = encodeFrameBatch([frame]); + expect(encoded[0]).toBe(0x00); // BATCH_CONTAINER_TAG_SINGLE + expect(encoded.length).toBe(frame.length + 1); + const decoded = decodeFrameBatch(encoded); + expect(decoded.length).toBe(1); + expect(decoded[0]).toEqual(frame); + }); + + test('multi-frame batch uses the length-prefixed tag', () => { + const encoded = encodeFrameBatch([ + encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([1])), + encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([2])), + ]); + expect(encoded[0]).toBe(0x01); // BATCH_CONTAINER_TAG_BATCH + }); + + test('rejects empty input and oversized batches, and truncated/unknown containers', () => { + expect(() => encodeFrameBatch([])).toThrow(TunnelCodecError); + const huge = new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES); // no room for the tag + expect(() => encodeFrameBatch([huge])).toThrow('frame batch exceeds maximum plaintext size'); + expect(() => decodeFrameBatch(new Uint8Array(0))).toThrow('empty batch plaintext'); + expect(() => decodeFrameBatch(new Uint8Array([0x09]))).toThrow('unknown batch container tag 9'); + // tag 0x01 then a length claiming more bytes than present. + expect(() => decodeFrameBatch(new Uint8Array([0x01, 0, 0, 0, 8, 1, 2]))).toThrow( + 'truncated batch frame body', + ); + }); +}); diff --git a/packages/ui/src/lib/relay/tunnel-codec.ts b/packages/ui/src/lib/relay/tunnel-codec.ts new file mode 100644 index 00000000..2c390abe --- /dev/null +++ b/packages/ui/src/lib/relay/tunnel-codec.ts @@ -0,0 +1,384 @@ +// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O. +// Frame layout: [1 byte frameType (high bit = fragment-continues)][4 byte BE streamId][payload]. +// Client-initiated streams use odd streamIds starting at 1; even ids are reserved. +// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3). + +import { + BATCH_CONTAINER_TAG_BATCH, + BATCH_CONTAINER_TAG_SINGLE, + BATCH_FRAME_LENGTH_BYTES, + MAX_PLAINTEXT_FRAME_BYTES, + MAX_TUNNEL_PAYLOAD_BYTES, + TUNNEL_FRAGMENT_FLAG, + TUNNEL_FRAME_HEADER_BYTES, + TunnelFrameType, + isTunnelFrameType, + type TunnelFrameTypeValue, +} from './protocol'; + +const MAX_STREAM_ID = 0xffffffff; + +export class TunnelCodecError extends Error { + constructor(message: string) { + super(message); + this.name = 'TunnelCodecError'; + } +} + +export interface TunnelFrame { + frameType: TunnelFrameTypeValue; + streamId: number; + payload: Uint8Array; + /** True when this frame is a fragment and more fragments of the same message follow. */ + hasMoreFragments: boolean; +} + +export const encodeTunnelFrame = ( + frameType: TunnelFrameTypeValue, + streamId: number, + payload: Uint8Array, + hasMoreFragments = false, +): Uint8Array => { + if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) { + throw new TunnelCodecError('invalid stream id'); + } + if (payload.length > MAX_TUNNEL_PAYLOAD_BYTES) { + throw new TunnelCodecError('tunnel payload exceeds maximum size'); + } + const frame = new Uint8Array(TUNNEL_FRAME_HEADER_BYTES + payload.length); + frame[0] = hasMoreFragments ? frameType | TUNNEL_FRAGMENT_FLAG : frameType; + frame[1] = (streamId >>> 24) & 0xff; + frame[2] = (streamId >>> 16) & 0xff; + frame[3] = (streamId >>> 8) & 0xff; + frame[4] = streamId & 0xff; + frame.set(payload, TUNNEL_FRAME_HEADER_BYTES); + return frame; +}; + +export const decodeTunnelFrame = (frame: Uint8Array): TunnelFrame => { + if (frame.length < TUNNEL_FRAME_HEADER_BYTES) { + throw new TunnelCodecError('tunnel frame too short'); + } + const rawType = frame[0]; + const hasMoreFragments = (rawType & TUNNEL_FRAGMENT_FLAG) !== 0; + const frameType = rawType & ~TUNNEL_FRAGMENT_FLAG; + if (!isTunnelFrameType(frameType)) { + throw new TunnelCodecError(`unknown tunnel frame type ${frameType}`); + } + const streamId = ((frame[1] << 24) | (frame[2] << 16) | (frame[3] << 8) | frame[4]) >>> 0; + return { + frameType, + streamId, + payload: frame.slice(TUNNEL_FRAME_HEADER_BYTES), + hasMoreFragments, + }; +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const encodeJsonPayload = (value: unknown): Uint8Array => textEncoder.encode(JSON.stringify(value)); + +export const decodeJsonPayload = (payload: Uint8Array, validate: (parsed: unknown) => parsed is T): T => { + let parsed: unknown; + try { + parsed = JSON.parse(textDecoder.decode(payload)); + } catch { + throw new TunnelCodecError('malformed JSON tunnel payload'); + } + if (!validate(parsed)) { + throw new TunnelCodecError('unexpected JSON tunnel payload shape'); + } + return parsed; +}; + +/** Split a body/message into payload-sized chunks. Empty input yields one empty chunk. */ +export const chunkPayload = (bytes: Uint8Array, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES): Uint8Array[] => { + if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) { + throw new TunnelCodecError('invalid chunk size'); + } + if (bytes.length === 0) return [new Uint8Array(0)]; + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + chunks.push(bytes.slice(offset, offset + chunkSize)); + } + return chunks; +}; + +/** + * Encode one logical message as one or more frames, setting the fragment flag + * on all but the last. Used for WS messages that exceed the frame budget. + */ +export const encodeFragmentedMessage = ( + frameType: TunnelFrameTypeValue, + streamId: number, + payload: Uint8Array, +): Uint8Array[] => { + const chunks = chunkPayload(payload); + return chunks.map((chunk, index) => + encodeTunnelFrame(frameType, streamId, chunk, index < chunks.length - 1), + ); +}; + +/** Reassembles fragmented messages per (streamId, frameType). Bounded to protect memory. */ +export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => { + const pending = new Map(); + return { + /** + * Returns the complete message payload once all fragments arrived, or null + * while more fragments are expected. + */ + push(frame: TunnelFrame): Uint8Array | null { + const key = `${frame.streamId}:${frame.frameType}`; + const entry = pending.get(key); + if (!frame.hasMoreFragments && !entry) { + return frame.payload; + } + const chunks = entry?.chunks ?? []; + const totalBytes = (entry?.totalBytes ?? 0) + frame.payload.length; + if (totalBytes > maxMessageBytes) { + pending.delete(key); + throw new TunnelCodecError('fragmented message exceeds maximum size'); + } + chunks.push(frame.payload); + if (frame.hasMoreFragments) { + pending.set(key, { chunks, totalBytes }); + return null; + } + pending.delete(key); + const message = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + message.set(chunk, offset); + offset += chunk.length; + } + return message; + }, + dropStream(streamId: number): void { + for (const key of pending.keys()) { + if (key.startsWith(`${streamId}:`)) pending.delete(key); + } + }, + }; +}; + +/** + * Batch envelope encoder (Layer 2 plaintext container). Only used when both + * peers negotiated `batch`. One encrypted WS message still equals one + * encrypt() call — this only changes how many tunnel frames it carries. + * + * - 1 frame -> [0x00][frame bytes] (single, 1-byte overhead) + * - N frames -> [0x01]([4B BE length][frame])×N (batch) + * + * Callers must keep the encoded size within MAX_PLAINTEXT_FRAME_BYTES; the + * outbound batcher flushes before an add would exceed the budget. + */ +export const encodeFrameBatch = (frames: Uint8Array[]): Uint8Array => { + if (frames.length === 0) { + throw new TunnelCodecError('cannot encode an empty frame batch'); + } + if (frames.length === 1) { + const frame = frames[0]; + const out = new Uint8Array(1 + frame.length); + out[0] = BATCH_CONTAINER_TAG_SINGLE; + out.set(frame, 1); + if (out.length > MAX_PLAINTEXT_FRAME_BYTES) { + throw new TunnelCodecError('frame batch exceeds maximum plaintext size'); + } + return out; + } + let total = 1; + for (const frame of frames) total += BATCH_FRAME_LENGTH_BYTES + frame.length; + if (total > MAX_PLAINTEXT_FRAME_BYTES) { + throw new TunnelCodecError('frame batch exceeds maximum plaintext size'); + } + const out = new Uint8Array(total); + out[0] = BATCH_CONTAINER_TAG_BATCH; + let offset = 1; + for (const frame of frames) { + out[offset] = (frame.length >>> 24) & 0xff; + out[offset + 1] = (frame.length >>> 16) & 0xff; + out[offset + 2] = (frame.length >>> 8) & 0xff; + out[offset + 3] = frame.length & 0xff; + offset += BATCH_FRAME_LENGTH_BYTES; + out.set(frame, offset); + offset += frame.length; + } + return out; +}; + +/** Decodes a batch-envelope plaintext into its ordered tunnel frames. */ +export const decodeFrameBatch = (plaintext: Uint8Array): Uint8Array[] => { + if (plaintext.length < 1) { + throw new TunnelCodecError('empty batch plaintext'); + } + const tag = plaintext[0]; + if (tag === BATCH_CONTAINER_TAG_SINGLE) { + return [plaintext.slice(1)]; + } + if (tag !== BATCH_CONTAINER_TAG_BATCH) { + throw new TunnelCodecError(`unknown batch container tag ${tag}`); + } + const frames: Uint8Array[] = []; + let offset = 1; + while (offset < plaintext.length) { + if (offset + BATCH_FRAME_LENGTH_BYTES > plaintext.length) { + throw new TunnelCodecError('truncated batch frame length'); + } + const length = + ((plaintext[offset] << 24) | + (plaintext[offset + 1] << 16) | + (plaintext[offset + 2] << 8) | + plaintext[offset + 3]) >>> + 0; + offset += BATCH_FRAME_LENGTH_BYTES; + if (offset + length > plaintext.length) { + throw new TunnelCodecError('truncated batch frame body'); + } + frames.push(plaintext.slice(offset, offset + length)); + offset += length; + } + if (frames.length === 0) { + throw new TunnelCodecError('empty frame batch'); + } + return frames; +}; + +// Only high-volume body/stream data is buffered; setup/teardown/keepalive +// frames flush immediately so TTFT, terminal echo, and liveness stay snappy. +const BUFFERED_FRAME_TYPES = new Set([ + TunnelFrameType.HttpBody, + TunnelFrameType.WsText, + TunnelFrameType.WsBinary, +]); + +// 150ms: the chat render pipeline already gates visible streaming updates well below this — a +// 100ms input throttle (useStreamingTextThrottle) feeding a ~64ms paced-reveal (usePacedText) that +// buffers-and-smooths arrival bursts, and the app already tolerates 200ms under backpressure. So a +// 150ms batch window is invisible to users while cutting DO messages ~33% more than 100ms. +// Leading-edge flush keeps time-to-first-token and terminal echo instant regardless of this value. +export const DEFAULT_BATCH_WINDOW_MS = 150; +export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024; +export const DEFAULT_BATCH_MAX_FRAMES = 32; + +export interface OutboundFrameBatcherOptions { + /** Trailing flush window in ms. Buffered frames flush no later than this. */ + windowMs?: number; + maxBatchBytes?: number; + maxBatchFrames?: number; + /** Encrypt + write one batched plaintext to the wire. Called in enqueue order. */ + sendBatch: (plaintext: Uint8Array) => void; + // Injectable clock/timer so tests can drive timing deterministically. + now?: () => number; + setTimer?: (fn: () => void, ms: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; +} + +export interface OutboundFrameBatcher { + /** Buffer or immediately flush a tunnel frame per the batching policy. */ + enqueue(frame: Uint8Array): void; + /** Force-flush any buffered frames now. */ + flush(): void; + /** Stop the batcher; drops any un-flushed frames (channel is being torn down). */ + dispose(): void; +} + +/** + * Outbound batching buffer shared by the client and (mirrored in JS) the host + * send paths. Policy: + * - Leading edge: if nothing flushed within windowMs, the frame ships now + * (batch of 1) — keeps time-to-first-token and keystroke echo instant. + * - Trailing window: subsequent body frames buffer and flush when the timer + * fires, buffered bytes >= maxBatchBytes, buffered frames >= maxBatchFrames, + * or the plaintext budget would be exceeded. + * - Non-buffered frame types (setup/teardown/keepalive) flush immediately, and + * flush any pending buffer first so per-stream ordering is preserved. + */ +export const createOutboundFrameBatcher = ( + options: OutboundFrameBatcherOptions, +): OutboundFrameBatcher => { + const windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS; + const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_BATCH_MAX_BYTES; + const maxBatchFrames = options.maxBatchFrames ?? DEFAULT_BATCH_MAX_FRAMES; + const now = options.now ?? (() => Date.now()); + const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms)); + const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); + + let buffer: Uint8Array[] = []; + let bufferedBytes = 0; // conservative multi-envelope size estimate + let timer: ReturnType | null = null; + let lastFlushAt = 0; // 0 => idle, so the first frame takes the leading edge + let disposed = false; + + const clearPendingTimer = (): void => { + if (timer !== null) { + clearTimer(timer); + timer = null; + } + }; + + const flush = (): void => { + clearPendingTimer(); + if (buffer.length === 0) return; + const frames = buffer; + buffer = []; + bufferedBytes = 0; + lastFlushAt = now(); + options.sendBatch(encodeFrameBatch(frames)); + }; + + const enqueue = (frame: Uint8Array): void => { + if (disposed) return; + const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG; + if (!BUFFERED_FRAME_TYPES.has(frameType)) { + // Immediate frame: append then flush so it never overtakes buffered body. + buffer.push(frame); + flush(); + return; + } + const at = now(); + if (buffer.length === 0 && at - lastFlushAt >= windowMs) { + // Leading edge: nothing flushed recently, ship this one right away. + buffer.push(frame); + flush(); + return; + } + const frameCost = BATCH_FRAME_LENGTH_BYTES + frame.length; + if (buffer.length > 0 && 1 + bufferedBytes + frameCost > MAX_PLAINTEXT_FRAME_BYTES) { + flush(); + } + buffer.push(frame); + bufferedBytes += frameCost; + if (bufferedBytes >= maxBatchBytes || buffer.length >= maxBatchFrames) { + flush(); + return; + } + if (timer === null) timer = setTimer(flush, windowMs); + }; + + return { + enqueue, + flush, + dispose(): void { + disposed = true; + clearPendingTimer(); + buffer = []; + bufferedBytes = 0; + }, + }; +}; + +/** Allocates client-initiated stream ids: odd, starting at 1. */ +export const createStreamIdAllocator = () => { + let next = 1; + return { + next(): number { + if (next > MAX_STREAM_ID) { + throw new TunnelCodecError('stream id space exhausted'); + } + const id = next; + next += 2; + return id; + }, + }; +}; diff --git a/packages/ui/src/lib/relay/tunnel-payloads.ts b/packages/ui/src/lib/relay/tunnel-payloads.ts new file mode 100644 index 00000000..01c438e2 --- /dev/null +++ b/packages/ui/src/lib/relay/tunnel-payloads.ts @@ -0,0 +1,143 @@ +// JSON payload guards and HTTP request normalization for the relay tunnel client. +// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3). + +import type { + TunnelHttpResponsePayload, + TunnelStreamAbortPayload, + TunnelWsClosePayload, +} from './protocol'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isStringRecord = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string'); + +export const isHttpResponsePayload = (value: unknown): value is TunnelHttpResponsePayload => + isRecord(value) && typeof value.status === 'number' && isStringRecord(value.headers); + +export const isStreamAbortPayload = (value: unknown): value is TunnelStreamAbortPayload => + isRecord(value) && typeof value.reason === 'string'; + +export const isWsClosePayload = (value: unknown): value is TunnelWsClosePayload => + isRecord(value) && typeof value.code === 'number' && typeof value.reason === 'string'; + +const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i; + +// Placeholder base for parsing origin-relative request paths; never fetched. +// Throwaway base for parsing relative runtime paths — only pathname+search are +// ever read, the host is discarded. Shared so relay modules don't diverge on it. +export const TUNNEL_PARSE_BASE = 'http://tunnel.invalid'; + +/** Extracts `pathname?search` from an absolute or relative WS/HTTP URL. */ +export const wsUrlToTunnelPath = (url: string): string => { + try { + const parsed = ABSOLUTE_URL_PATTERN.test(url) ? new URL(url) : new URL(url, TUNNEL_PARSE_BASE); + return `${parsed.pathname}${parsed.search}`; + } catch { + return url; + } +}; + +export interface NormalizedTunnelRequest { + method: string; + path: string; + query: string; + headers: Record; + body: AsyncIterable | null; + signal?: AbortSignal; +} + +const singleChunk = (bytes: Uint8Array): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + yield bytes; + }, +}); + +const streamChunks = (stream: ReadableStream): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + const reader = stream.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + if (value) yield value; + } + } finally { + reader.releaseLock(); + } + }, +}); + +const copyBytes = (view: ArrayBufferView): Uint8Array => { + const copy = new Uint8Array(view.byteLength); + copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + return copy; +}; + +const resolveBody = async ( + body: BodyInit | ReadableStream | null, +): Promise<{ body: AsyncIterable | null; contentType?: string }> => { + if (body === null || body === undefined) return { body: null }; + if (body instanceof ReadableStream) return { body: streamChunks(body) }; + if (typeof body === 'string') return { body: singleChunk(new TextEncoder().encode(body)) }; + if (body instanceof ArrayBuffer) return { body: singleChunk(new Uint8Array(body.slice(0))) }; + if (ArrayBuffer.isView(body)) return { body: singleChunk(copyBytes(body)) }; + // Blob / FormData / URLSearchParams: let Response serialize the body exactly + // like a native fetch would, and surface the content-type it derives + // (e.g. the multipart boundary for FormData). + const probe = new Response(body); + const contentType = probe.headers.get('content-type') ?? undefined; + const bytes = new Uint8Array(await probe.arrayBuffer()); + return { body: singleChunk(bytes), contentType }; +}; + +/** + * Flattens a fetch-style (input, init) pair into the tunnel HttpRequest shape, + * preserving method, headers, body bytes/stream, and abort signal. + */ +export const normalizeTunnelRequest = async ( + input: string | URL | Request, + init?: RequestInit, +): Promise => { + let urlValue: string; + const headers = new Headers(); + let method = 'GET'; + let bodySource: BodyInit | ReadableStream | null = null; + let signal: AbortSignal | undefined; + + if (input instanceof Request) { + urlValue = input.url; + method = input.method; + input.headers.forEach((value, key) => headers.set(key, value)); + signal = input.signal; + bodySource = input.body; + } else { + urlValue = input.toString(); + } + + if (init) { + if (init.method) method = init.method; + if (init.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value)); + if (init.body !== undefined) bodySource = init.body; + if (init.signal) signal = init.signal; + } + + const url = ABSOLUTE_URL_PATTERN.test(urlValue) ? new URL(urlValue) : new URL(urlValue, TUNNEL_PARSE_BASE); + const { body, contentType } = await resolveBody(bodySource); + if (contentType && !headers.has('content-type')) headers.set('content-type', contentType); + + const headerRecord: Record = {}; + headers.forEach((value, key) => { + headerRecord[key] = value; + }); + + return { + method: method.toUpperCase(), + path: url.pathname, + query: url.search.startsWith('?') ? url.search.slice(1) : url.search, + headers: headerRecord, + body, + signal, + }; +}; diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index 6aa181db..4fc9beda 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -1,3 +1,5 @@ +import { getActiveRelayTunnel } from '@/lib/relay/runtime-tunnel'; + type RuntimeAuthCredential = | { type: 'bearer'; token: string } | null; @@ -201,11 +203,16 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => if (credential?.type === 'bearer') { headers.set('Authorization', `Bearer ${credential.token}`); } - const response = await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), { - method: 'POST', - headers, - credentials: 'include', - }); + // In relay mode the mint must ride the tunnel, not the network: there is no + // reachable network base URL. Same auth headers, same route, tunneled. + const relay = getActiveRelayTunnel(); + const response = relay + ? await relay.fetch('/auth/url-token', { method: 'POST', headers }) + : await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), { + method: 'POST', + headers, + credentials: 'include', + }); if (!response.ok) { if (generation === runtimeAuthGeneration) { clearRuntimeUrlAuthToken(); diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index f46f2e46..e4a4c396 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -1,3 +1,5 @@ +import { getActiveRelayTunnel } from './relay/runtime-tunnel'; +import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads'; import { buildRuntimeAuthHeaders } from './runtime-auth'; import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url'; @@ -150,6 +152,56 @@ const mergeHeaders = async (inputHeaders?: HeadersInit, initHeaders?: HeadersIni return buildRuntimeAuthHeaders(headers); }; +// ── Relay-mode routing ───────────────────────────────────────────────────── +// When the active runtime is a private relay, runtime HTTP does not go to the +// network: it rides the E2EE tunnel. We route exactly the same paths we would +// resolve for a network runtime (/api, /auth, /health) and attach identical +// auth headers; the bearer/url-token semantics are unchanged, only the +// transport differs. Non-runtime requests (external URLs) fall through to the +// real network fetch. +const appendPathQuery = (path: string, query?: RuntimeUrlQuery): string => { + if (!query) return path; + const url = new URL(path, TUNNEL_PARSE_BASE); + appendRuntimeQuery(url, query); + return `${url.pathname}${url.search}`; +}; + +const extractRelayPath = (input: string | URL | Request, query?: RuntimeUrlQuery): string | null => { + const raw = input instanceof Request ? input.url : input.toString(); + if (!isAbsoluteUrl(raw)) { + if (!shouldResolveApiPath(raw)) return null; + return appendPathQuery(raw, query); + } + try { + const url = new URL(raw); + if (!isCurrentWindowUrl(url) || !shouldResolveApiPath(url.pathname)) return null; + appendRuntimeQuery(url, query); + return `${url.pathname}${url.search}`; + } catch { + return null; + } +}; + +const tryRelayFetch = async ( + input: string | URL | Request, + requestInit: RequestInit, + query?: RuntimeUrlQuery, +): Promise => { + const relay = getActiveRelayTunnel(); + if (!relay) return null; + const path = extractRelayPath(input, query); + if (path === null) return null; + const inputHeaders = input instanceof Request ? input.headers : undefined; + const headers = await mergeHeaders(inputHeaders, requestInit.headers, true); + if (input instanceof Request) { + // Forward the Request itself — the tunnel reads its method/body/signal + // natively (incl. stream bodies). Re-wrapping as `new Request(path, input)` + // throws on a stream body without duplex:'half'. + return relay.fetch(input, { ...requestInit, headers }); + } + return relay.fetch(path, { ...requestInit, headers }); +}; + const resolveRuntimeFetchInput = (input: string | URL | Request, query?: RuntimeUrlQuery): string | URL | Request => { if (typeof input === 'string') { return buildRuntimeFetchUrl(input, query); @@ -192,25 +244,43 @@ const coalesceReadKey = (method: string, url: string, hasSignal: boolean): strin export const runtimeFetch = async (input: string | URL | Request, init: RuntimeFetchOptions = {}): Promise => { const { query, ...requestInit } = init; - const resolvedInput = resolveRuntimeFetchInput(input, query); - const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; - const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); - const doFetch = (): Promise => - resolvedInput instanceof Request - ? fetch(new Request(resolvedInput, { ...requestInit, headers })) - : fetch(resolvedInput, { ...requestInit, headers }); + // Resolve the transport once — relay tunnel or network — then apply the SAME + // read-coalescing to both. On a relay the tunnel is bandwidth/latency-bound, so + // deduping concurrent identical GETs matters there most. + const relay = getActiveRelayTunnel(); + const relayPath = relay ? extractRelayPath(input, query) : null; + + let doFetch: () => Promise; + let url: string; + let method: string; + if (relay && relayPath !== null) { + const inputHeaders = input instanceof Request ? input.headers : undefined; + const headers = await mergeHeaders(inputHeaders, requestInit.headers, true); + doFetch = input instanceof Request + ? () => relay.fetch(input, { ...requestInit, headers }) + : () => relay.fetch(relayPath, { ...requestInit, headers }); + url = relayPath; + method = String(requestInit.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + } else { + const resolvedInput = resolveRuntimeFetchInput(input, query); + const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; + const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); + doFetch = resolvedInput instanceof Request + ? () => fetch(new Request(resolvedInput, { ...requestInit, headers })) + : () => fetch(resolvedInput, { ...requestInit, headers }); + url = + resolvedInput instanceof Request ? resolvedInput.url + : resolvedInput instanceof URL ? resolvedInput.toString() + : String(resolvedInput); + method = String( + requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'), + ).toUpperCase(); + } - const url = - resolvedInput instanceof Request ? resolvedInput.url - : resolvedInput instanceof URL ? resolvedInput.toString() - : String(resolvedInput); - const method = String( - requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'), - ).toUpperCase(); // A Request always carries a (possibly default) signal; treat any Request, or // an explicit init.signal, as "has signal" and skip coalescing for safety. - const hasSignal = requestInit.signal != null || resolvedInput instanceof Request; + const hasSignal = requestInit.signal != null || input instanceof Request; const key = coalesceReadKey(method, url, hasSignal); if (!key) return doFetch(); @@ -235,6 +305,8 @@ export const installRuntimeFetchBridge = (): void => { const nativeFetch = window.fetch.bind(window); window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const relayResponse = await tryRelayFetch(input, init ?? {}); + if (relayResponse) return relayResponse; if (typeof input === 'string') { if (!shouldResolveFetchInput(input)) { try { diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index b8cfdada..46ed3b43 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -1,5 +1,13 @@ import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth'; import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; +import { + activateRelayTunnel, + deactivateRelayTunnel, + getActiveRelayTunnel, + type RelayRuntimeDescriptor, +} from '@/lib/relay/runtime-tunnel'; + +export { getActiveRelayTunnel }; export type RuntimeEndpointChangedDetail = { apiBaseUrl: string; @@ -85,7 +93,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl)); }; -export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record | null }): void => { +export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record | null; relay?: RelayRuntimeDescriptor | null }): void => { const apiBaseUrl = options.apiBaseUrl.trim(); const previousApiBaseUrl = getRuntimeApiBaseUrl(); const previousRuntimeKey = getRuntimeKey(); @@ -105,6 +113,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); setRuntimeExtraHeaders(options.requestHeaders || null); setRuntimeBearerToken(options.clientToken || null); + // Relay mode routes runtime HTTP/WS through an E2EE tunnel instead of the + // network. Activate the tunnel BEFORE minting the url token, since the mint + // itself rides the tunnel (runtimeFetch -> tunnel.fetch). + if (options.relay) { + activateRelayTunnel(options.relay); + } else { + deactivateRelayTunnel(); + } void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {}); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(RUNTIME_ENDPOINT_CHANGED_EVENT, { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index c272b203..f6db9686 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -1,6 +1,7 @@ import type { I18nKey } from '@/lib/i18n/store'; import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata'; import { getSettingsPageMeta } from './metadata'; +import { RELAY_UI_ENABLED } from '@/lib/relay/gate'; interface SettingsSearchItem { id: string; @@ -426,6 +427,15 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['pairing link', 'client token', 'connect desktop', 'remote access'], isAvailable: (ctx) => !ctx.isVSCode, }, + { + id: 'remote-instances.relay', + page: 'remote-instances', + titleKey: 'settings.remoteInstances.relay.title', + descriptionKey: 'settings.remoteInstances.relay.description', + keywords: ['relay', 'pairing', 'no ports', 'end-to-end encrypted', 'remote access', 'connect from anywhere'], + // Gated by openchamber_relay_gate until the relay UI ships publicly. + isAvailable: (ctx) => !ctx.isVSCode && RELAY_UI_ENABLED, + }, { id: 'remote-instances.direct-hosts', page: 'remote-instances', diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 8eafe697..d5ec192b 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,5 +1,7 @@ import { getRuntimeUrlResolver } from './runtime-url'; import { runtimeFetch } from './runtime-fetch'; +import { openRuntimeWebSocket } from './relay/runtime-socket'; +import { type RelayTunnelWebSocket } from './relay/tunnel-client'; interface TerminalWebSocketDescriptor { path: string; @@ -132,11 +134,11 @@ const createTransportError = (code: string | undefined): Error => { }; class TerminalTransportManager { - private socket: WebSocket | null = null; + private socket: RelayTunnelWebSocket | null = null; private socketUrl = ''; private boundSessionId: string | null = null; private requestedSessionId: string | null = null; - private openPromise: Promise | null = null; + private openPromise: Promise | null = null; private reconnectTimeout: ReturnType | null = null; private keepaliveInterval: ReturnType | null = null; private closed = false; @@ -311,7 +313,7 @@ class TerminalTransportManager { subscription.connectionTimeoutId = null; } - private async getOpenSocket(waitMs: number): Promise { + private async getOpenSocket(waitMs: number): Promise { if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) { return this.socket; } @@ -355,11 +357,11 @@ class TerminalTransportManager { this.clearReconnectTimeout(); - this.openPromise = new Promise((resolve) => { + this.openPromise = new Promise((resolve) => { let settled = false; let connectTimeout: ReturnType | null = null; - const settle = (value: WebSocket | null) => { + const settle = (value: RelayTunnelWebSocket | null) => { if (settled) { return; } @@ -373,7 +375,7 @@ class TerminalTransportManager { }; try { - const socket = new WebSocket(this.socketUrl); + const socket = openRuntimeWebSocket(this.socketUrl); socket.binaryType = 'arraybuffer'; socket.onopen = () => { diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index 431b617c..fa22c5fa 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -16,6 +16,8 @@ import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/c import { opencodeClient } from "@/lib/opencode/client" import { getRuntimeUrlResolver } from "@/lib/runtime-url" import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from "@/lib/runtime-auth" +import { type RelayTunnelWebSocket } from "@/lib/relay/tunnel-client" +import { openRuntimeWebSocket } from "@/lib/relay/runtime-socket" import { syncDebug } from "./debug" const FLUSH_FRAME_MS = 33 @@ -212,6 +214,17 @@ function buildGlobalEventWsUrl(lastEventId?: string): string { ) } +// In relay mode the global-event WebSocket rides the E2EE tunnel instead of a +// native network socket. The resolver still builds the authenticated URL (it +// carries the oc_url_token the host replays to the loopback origin); we hand +// its path+query to the tunnel, which returns a socket-like with the exact +// on* handler surface this pipeline uses. Direct-URL runtimes keep the native +// WebSocket path, wrapped to the same shape so the caller holds one type. +function openGlobalEventSocket(lastEventId?: string): RelayTunnelWebSocket { + const url = buildGlobalEventWsUrl(lastEventId) + return openRuntimeWebSocket(url) +} + type DirectoryQueue = { queue: Event[] buffer: Event[] @@ -569,7 +582,7 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline { let settled = false let opened = false let readyAt = 0 - const socket = new WebSocket(buildGlobalEventWsUrl(lastEventId)) + const socket: RelayTunnelWebSocket = openGlobalEventSocket(lastEventId) const setFallbackCode = (error: Error, force = false) => { if ((force || !opened) && transport === "auto") { wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 728dcd8d..921149c1 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -10,9 +10,14 @@ declare module "bun:test" { toBeTruthy(): void; toBeFalsy(): void; toBeNull(): void; - toThrow(expected?: string | RegExp): void; + toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): void; toContain(expected: unknown): void; + toBeDefined(): void; + rejects: { + toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): Promise; + }; toBeGreaterThan(expected: number): void; + toBeGreaterThanOrEqual(expected: number): void; toBeLessThan(expected: number): void; toHaveLength(expected: number): void; toBeInstanceOf(expected: unknown): void; @@ -24,6 +29,7 @@ declare module "bun:test" { }; }; export function beforeEach(fn: () => void | Promise): void; + export function afterEach(fn: () => void | Promise): void; export function afterAll(fn: () => void | Promise): void; export function mock unknown>(fn?: T): T; export namespace mock { diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 9faa2100..b4f69a0f 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -204,6 +204,14 @@ describe('cli args', () => { expect(parsed.options.server).toBe('http://homebridge:3002'); }); + it('parses connect-url --relay flag', () => { + const parsed = parseArgs(['connect-url', '--relay', '--name', 'My laptop']); + + expect(parsed.command).toBe('connect-url'); + expect(parsed.options.relay).toBe(true); + expect(parsed.options.name).toBe('My laptop'); + }); + it('parses connect-url api-only help', () => { const parsed = parseArgs(['connect-url', '--api-only', '--help']); diff --git a/packages/web/bin/lib/DOCUMENTATION.md b/packages/web/bin/lib/DOCUMENTATION.md index 1e54b199..09f2a6aa 100644 --- a/packages/web/bin/lib/DOCUMENTATION.md +++ b/packages/web/bin/lib/DOCUMENTATION.md @@ -36,6 +36,7 @@ Command modules implement user-facing commands and preserve output contracts acr - `commands-connect-url.js` - Implements `openchamber connect-url`. - Finds or starts a local instance and prints the browser/connect URL according to the selected output mode. + - `--relay` builds an end-to-end-encrypted relay pairing link instead: it mints a client token and an offer from the instance's local relay identity (no server URL, no auto-start). The relay endpoint follows `OPENCHAMBER_RELAY_URL` / the stored setting / the default, matching the running host; clients read it from the offer. - `commands-update.js` - Implements `openchamber update`. diff --git a/packages/web/bin/lib/cli-args.js b/packages/web/bin/lib/cli-args.js index 5362c75e..c377114f 100644 --- a/packages/web/bin/lib/cli-args.js +++ b/packages/web/bin/lib/cli-args.js @@ -264,6 +264,9 @@ function parseArgs(argv = process.argv.slice(2)) { } break; } + case 'relay': + options.relay = true; + break; case 'qr': options.qr = true; options.explicitQr = true; @@ -384,6 +387,7 @@ OPTIONS: --hostname Alias for --host outside tunnel commands --lan Bind to 0.0.0.0 for LAN access --server Public/server URL for connect-url links + --relay connect-url: generate an end-to-end-encrypted relay pairing link --ui-password Protect browser UI with single password --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) @@ -461,6 +465,10 @@ OPTIONS: --lan Bind to 0.0.0.0 for LAN access when starting --server Public URL saved into the connection link --server-url Alias for --server + --relay Generate an end-to-end-encrypted relay pairing link + (no server URL needed; requires the relay enabled on + this instance). Set OPENCHAMBER_RELAY_URL to use a + self-hosted relay. --name