From 4fb93d92ed42ba59bf69cc29f35101b92abf7017 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 13:57:08 +0700 Subject: [PATCH] fix: stabilize relay E2EE handshake --- CLAUDE.md | 8 +++ packages/relay/src/encrypted-channel.ts | 21 ++++++-- packages/relay/src/node-adapter.ts | 45 ++++++++++++---- .../daemon-e2e/relay-transport.e2e.test.ts | 6 +++ packages/server/src/server/daemon-keypair.ts | 2 +- packages/server/src/server/relay-transport.ts | 53 ++++++++++++++----- .../src/server/websocket-session-bridge.ts | 24 ++++++--- 7 files changed, 125 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 21285a096..21bbe4d0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,14 @@ Get the session ID from the agent JSON file (`persistence.sessionId`), then: Take screenshots like this: `adb exec-out screencap -p > screenshot.png` +## Testing with Playwright MCP + +**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`. + +Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI. + +**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state. + ## Expo troubleshooting Run `npx expo-doctor` to diagnose version mismatches and native module issues. diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 278364795..23ac7a7b5 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -87,19 +87,28 @@ export async function createDaemonChannel( reject(new Error("Handshake timeout")); }, 10000); + const bufferedMessages: Array = []; + transport.onmessage = async (data) => { try { - if (typeof data !== "string") { - throw new Error("Expected string hello message"); - } + const helloText = + typeof data === "string" ? data : new TextDecoder().decode(data); - const msg = JSON.parse(data) as HelloMessage; + const msg = JSON.parse(helloText) as HelloMessage; if (msg.type !== "hello" || !msg.key) { throw new Error("Invalid hello message"); } clearTimeout(timeout); + // Buffer any subsequent messages that arrive while we're doing async + // WebCrypto work to derive the shared key. Without this, it's possible + // for the next message (already encrypted) to be misinterpreted as a + // second hello, causing the handshake to fail. + transport.onmessage = (next) => { + bufferedMessages.push(next); + }; + const clientPublicKey = await importPublicKey(msg.key); const sharedKey = await deriveSharedKey( daemonKeyPair.privateKey, @@ -110,6 +119,10 @@ export async function createDaemonChannel( channel.setState("open"); events.onopen?.(); + for (const buffered of bufferedMessages) { + transport.onmessage?.(buffered); + } + resolve(channel); } catch (error) { clearTimeout(timeout); diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts index f20b5e489..0cf63b768 100644 --- a/packages/relay/src/node-adapter.ts +++ b/packages/relay/src/node-adapter.ts @@ -72,15 +72,36 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer { const sessionId = url.searchParams.get("session")!; const role = url.searchParams.get("role") as ConnectionRole; - const connection = wrapWebSocket(ws); + const connection = wrapWebSocket(ws, role); relay.addConnection(sessionId, role, connection); - ws.on("message", (data) => { - const message = - data instanceof Buffer - ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) - : String(data); - relay.forward(sessionId, role, message as string | ArrayBuffer); + ws.on("message", (data, isBinary) => { + if (isBinary) { + const message = + data instanceof ArrayBuffer + ? data + : bufferToArrayBuffer( + data instanceof Buffer + ? data + : ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(String(data), "utf8") + ); + relay.forward(sessionId, role, message); + return; + } + + const text = + typeof data === "string" + ? data + : data instanceof Buffer + ? data.toString("utf8") + : data instanceof ArrayBuffer + ? Buffer.from(data).toString("utf8") + : ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8") + : String(data); + relay.forward(sessionId, role, text); }); ws.on("close", () => { @@ -120,9 +141,9 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer { }; } -function wrapWebSocket(ws: NodeWebSocket): RelayConnection { +function wrapWebSocket(ws: NodeWebSocket, role: ConnectionRole): RelayConnection { return { - role: "server", + role, send: (data) => { if (ws.readyState === NodeWebSocket.OPEN) { ws.send(data); @@ -133,3 +154,9 @@ function wrapWebSocket(ws: NodeWebSocket): RelayConnection { }, }; } + +function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + const out = new Uint8Array(buffer.byteLength); + out.set(buffer); + return out.buffer; +} diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index cd54e78cf..731ecc488 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -152,6 +152,12 @@ describe("Relay transport (E2EE) - daemon E2E", () => { }); expect(received).toEqual({ type: "pong" }); + } catch (err) { + const tail = lines.slice(-50).join(""); + // Only prints on failure to help diagnose relay handshake issues. + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", tail); + throw err; } finally { await daemon.close(); await relay.stop(); diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts index 3d2a4b9af..a1a5c58c3 100644 --- a/packages/server/src/server/daemon-keypair.ts +++ b/packages/server/src/server/daemon-keypair.ts @@ -66,7 +66,7 @@ export async function loadOrCreateDaemonKeyPair( privateKeyJwk: privateKeyJwk as Record, }; - writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n"); + writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 }); log?.info({ filePath }, "Saved daemon keypair"); return { keyPair, publicKeyB64 }; diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 984b7a2f0..7a0012c46 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -149,8 +149,8 @@ function createRelayTransportAdapter(socket: WebSocket): RelayTransport { onerror: null, }; - socket.on("message", (data) => { - relayTransport.onmessage?.(normalizeMessageData(data)); + socket.on("message", (data, isBinary) => { + relayTransport.onmessage?.(normalizeMessageData(data, isBinary)); }); socket.on("close", (code, reason) => { relayTransport.onclose?.(code, reason.toString()); @@ -200,24 +200,53 @@ function createEncryptedSocket( }; } -function normalizeMessageData(data: unknown): string | ArrayBuffer { - if (typeof data === "string") return data; +function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (!isBinary) { + if (typeof data === "string") return data; + const buffer = bufferFromWsData(data); + if (buffer) return buffer.toString("utf8"); + return String(data); + } + if (data instanceof ArrayBuffer) return data; - if (ArrayBuffer.isView(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - if (Buffer.isBuffer(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + const buffer = bufferFromWsData(data); + if (buffer) { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); const out = new Uint8Array(view.byteLength); out.set(view); return out.buffer; } + return String(data); } +function bufferFromWsData(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) { + const buffers: Buffer[] = []; + for (const part of data) { + if (Buffer.isBuffer(part)) { + buffers.push(part); + } else if (part instanceof ArrayBuffer) { + buffers.push(Buffer.from(part)); + } else if (ArrayBuffer.isView(part)) { + buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); + } else if (typeof part === "string") { + buffers.push(Buffer.from(part, "utf8")); + } else { + return null; + } + } + return Buffer.concat(buffers); + } + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} + function buildRelayWebSocketUrl( relayEndpoint: string, sessionId: string, diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index 9a70e1e66..e4757d682 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -21,6 +21,19 @@ import type pino from "pino"; export type AgentMcpTransportFactory = () => Promise; +function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer { + if (typeof data === "string") return Buffer.from(data, "utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((item) => + Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer) + ) + ); + } + if (Buffer.isBuffer(data)) return data; + return Buffer.from(data as ArrayBuffer); +} + type WebSocketLike = { readyState: number; send: (data: string) => void; @@ -156,7 +169,8 @@ export class WebSocketSessionBridge { data: Buffer | ArrayBuffer | Buffer[] | string ): Promise { try { - const parsed = JSON.parse(data.toString()); + const buffer = bufferFromWsData(data); + const parsed = JSON.parse(buffer.toString()); const message = WSInboundMessageSchema.parse(parsed); const messageSummary = { @@ -203,13 +217,7 @@ export class WebSocketSessionBridge { let parsedPayload: unknown = null; try { - const buffer = Array.isArray(data) - ? Buffer.concat( - data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer))) - ) - : Buffer.isBuffer(data) - ? data - : Buffer.from(data as ArrayBuffer); + const buffer = bufferFromWsData(data); rawPayload = buffer.toString(); parsedPayload = JSON.parse(rawPayload); } catch (payloadError) {