fix: stabilize relay E2EE handshake
This commit is contained in:
@@ -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`
|
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
|
## Expo troubleshooting
|
||||||
|
|
||||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||||
|
|||||||
@@ -87,19 +87,28 @@ export async function createDaemonChannel(
|
|||||||
reject(new Error("Handshake timeout"));
|
reject(new Error("Handshake timeout"));
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
const bufferedMessages: Array<string | ArrayBuffer> = [];
|
||||||
|
|
||||||
transport.onmessage = async (data) => {
|
transport.onmessage = async (data) => {
|
||||||
try {
|
try {
|
||||||
if (typeof data !== "string") {
|
const helloText =
|
||||||
throw new Error("Expected string hello message");
|
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) {
|
if (msg.type !== "hello" || !msg.key) {
|
||||||
throw new Error("Invalid hello message");
|
throw new Error("Invalid hello message");
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
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 clientPublicKey = await importPublicKey(msg.key);
|
||||||
const sharedKey = await deriveSharedKey(
|
const sharedKey = await deriveSharedKey(
|
||||||
daemonKeyPair.privateKey,
|
daemonKeyPair.privateKey,
|
||||||
@@ -110,6 +119,10 @@ export async function createDaemonChannel(
|
|||||||
channel.setState("open");
|
channel.setState("open");
|
||||||
events.onopen?.();
|
events.onopen?.();
|
||||||
|
|
||||||
|
for (const buffered of bufferedMessages) {
|
||||||
|
transport.onmessage?.(buffered);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(channel);
|
resolve(channel);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
@@ -72,15 +72,36 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
|
|||||||
const sessionId = url.searchParams.get("session")!;
|
const sessionId = url.searchParams.get("session")!;
|
||||||
const role = url.searchParams.get("role") as ConnectionRole;
|
const role = url.searchParams.get("role") as ConnectionRole;
|
||||||
|
|
||||||
const connection = wrapWebSocket(ws);
|
const connection = wrapWebSocket(ws, role);
|
||||||
relay.addConnection(sessionId, role, connection);
|
relay.addConnection(sessionId, role, connection);
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
ws.on("message", (data, isBinary) => {
|
||||||
const message =
|
if (isBinary) {
|
||||||
data instanceof Buffer
|
const message =
|
||||||
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
data instanceof ArrayBuffer
|
||||||
: String(data);
|
? data
|
||||||
relay.forward(sessionId, role, message as string | ArrayBuffer);
|
: 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", () => {
|
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 {
|
return {
|
||||||
role: "server",
|
role,
|
||||||
send: (data) => {
|
send: (data) => {
|
||||||
if (ws.readyState === NodeWebSocket.OPEN) {
|
if (ws.readyState === NodeWebSocket.OPEN) {
|
||||||
ws.send(data);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(received).toEqual({ type: "pong" });
|
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 {
|
} finally {
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
await relay.stop();
|
await relay.stop();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function loadOrCreateDaemonKeyPair(
|
|||||||
privateKeyJwk: privateKeyJwk as Record<string, unknown>,
|
privateKeyJwk: privateKeyJwk as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n");
|
writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
||||||
log?.info({ filePath }, "Saved daemon keypair");
|
log?.info({ filePath }, "Saved daemon keypair");
|
||||||
|
|
||||||
return { keyPair, publicKeyB64 };
|
return { keyPair, publicKeyB64 };
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ function createRelayTransportAdapter(socket: WebSocket): RelayTransport {
|
|||||||
onerror: null,
|
onerror: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on("message", (data) => {
|
socket.on("message", (data, isBinary) => {
|
||||||
relayTransport.onmessage?.(normalizeMessageData(data));
|
relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
|
||||||
});
|
});
|
||||||
socket.on("close", (code, reason) => {
|
socket.on("close", (code, reason) => {
|
||||||
relayTransport.onclose?.(code, reason.toString());
|
relayTransport.onclose?.(code, reason.toString());
|
||||||
@@ -200,24 +200,53 @@ function createEncryptedSocket(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessageData(data: unknown): string | ArrayBuffer {
|
function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
|
||||||
if (typeof data === "string") return data;
|
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 (data instanceof ArrayBuffer) return data;
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
const buffer = bufferFromWsData(data);
|
||||||
const out = new Uint8Array(view.byteLength);
|
if (buffer) {
|
||||||
out.set(view);
|
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||||
return out.buffer;
|
|
||||||
}
|
|
||||||
if (Buffer.isBuffer(data)) {
|
|
||||||
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
const out = new Uint8Array(view.byteLength);
|
const out = new Uint8Array(view.byteLength);
|
||||||
out.set(view);
|
out.set(view);
|
||||||
return out.buffer;
|
return out.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(data);
|
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(
|
function buildRelayWebSocketUrl(
|
||||||
relayEndpoint: string,
|
relayEndpoint: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ import type pino from "pino";
|
|||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
|
|
||||||
|
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 = {
|
type WebSocketLike = {
|
||||||
readyState: number;
|
readyState: number;
|
||||||
send: (data: string) => void;
|
send: (data: string) => void;
|
||||||
@@ -156,7 +169,8 @@ export class WebSocketSessionBridge {
|
|||||||
data: Buffer | ArrayBuffer | Buffer[] | string
|
data: Buffer | ArrayBuffer | Buffer[] | string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data.toString());
|
const buffer = bufferFromWsData(data);
|
||||||
|
const parsed = JSON.parse(buffer.toString());
|
||||||
const message = WSInboundMessageSchema.parse(parsed);
|
const message = WSInboundMessageSchema.parse(parsed);
|
||||||
|
|
||||||
const messageSummary = {
|
const messageSummary = {
|
||||||
@@ -203,13 +217,7 @@ export class WebSocketSessionBridge {
|
|||||||
let parsedPayload: unknown = null;
|
let parsedPayload: unknown = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Array.isArray(data)
|
const buffer = bufferFromWsData(data);
|
||||||
? Buffer.concat(
|
|
||||||
data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)))
|
|
||||||
)
|
|
||||||
: Buffer.isBuffer(data)
|
|
||||||
? data
|
|
||||||
: Buffer.from(data as ArrayBuffer);
|
|
||||||
rawPayload = buffer.toString();
|
rawPayload = buffer.toString();
|
||||||
parsedPayload = JSON.parse(rawPayload);
|
parsedPayload = JSON.parse(rawPayload);
|
||||||
} catch (payloadError) {
|
} catch (payloadError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user