diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 343bdbd116..7049274519 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -37,6 +37,16 @@ export function createRelayE2eeTransportFactory(args: { }; } +/** + * Relay upgrades carry routing only. Daemon credentials and subprotocols must + * never reach the relay; they travel inside the encrypted hello. + */ +export function createRelayUpgradeTransportFactory( + baseFactory: DaemonTransportFactory, +): DaemonTransportFactory { + return ({ url }) => baseFactory({ url }); +} + export function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index 05bd08bbc6..02a3360c52 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -17,6 +17,7 @@ export { export { createEncryptedTransport, createRelayE2eeTransportFactory, + createRelayUpgradeTransportFactory, } from "./daemon-client-relay-e2ee-transport.js"; export { bindWsHandler, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6fef9fea40..c35f96da80 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -919,6 +919,29 @@ test("keeps relay upgrade credentials out of the socket request", async () => { await expect(connecting).rejects.toThrow("Daemon client closed"); }); +test("keeps relay upgrade credentials out of the socket request without E2EE", async () => { + const mock = createMockTransport(); + const requests: Array<{ url: string; headers?: Record; protocols?: string[] }> = + []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_relay_header_test", + authHeader: "Bearer synthetic-review-token", + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return mock.transport; + }, + }); + clients.push(client); + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); +}); + test("refuses relay password auth without an encrypted hello", async () => { const requests: unknown[] = []; const client = new DaemonClient({ diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 563d1ee151..31eed9e38b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -162,6 +162,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { createRelayE2eeTransportFactory, + createRelayUpgradeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, @@ -1449,8 +1450,11 @@ export class DaemonClient { const isRelayTransport = isRelayClientWebSocketUrl(this.config.url); this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport); + // Relay upgrades are routing only: credentials never reach the relay socket. + let transportFactory = isRelayTransport + ? createRelayUpgradeTransportFactory(baseTransportFactory) + : baseTransportFactory; // Endpoint security is selected by configuration, never inferred from the URL. - let transportFactory = baseTransportFactory; if (this.config.e2ee?.enabled === true) { const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64; if (!daemonPublicKeyB64) {