mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
* fix(client): reject a message over the daemon's size limit instead of reconnecting in a loop The daemon closes the WebSocket with 1009 when a message exceeds its 100 MiB limit. A workspace or agent creation that loses its connection mid-request stays pending and, after reconnecting, finds no record of itself on the daemon (the oversized frame was never read), so it resubmits the same frame and the connection drops again every couple of seconds until the app reloads. The client now checks each outbound frame against the same limit, which protocol exports and the daemon sets explicitly, and fails that request with a size error while the connection stays up. Refs #5501 * test(client): cover the UTF-8 byte count at the message size limit * fix(client): reject oversized relay messages without disconnecting Check the encrypted wire size synchronously before submission, so a frame below the direct limit cannot close the relay and leave workspace creation resubmitting forever. Keep the relay limit shared with the existing checkout diff budget. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
215 lines
5.9 KiB
TypeScript
215 lines
5.9 KiB
TypeScript
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
|
import {
|
|
createClientChannel,
|
|
type EncryptedChannel,
|
|
type Transport as RelayTransport,
|
|
} from "@getpaseo/relay/e2ee";
|
|
import type {
|
|
DaemonTransport,
|
|
DaemonTransportFactory,
|
|
TransportLogger,
|
|
} from "./daemon-client-transport-types.js";
|
|
import {
|
|
extractRelayMessage,
|
|
normalizeTransportPayload,
|
|
messageTooLargeError,
|
|
} from "./daemon-client-transport-utils.js";
|
|
|
|
type OpenHandler = () => void;
|
|
type CloseHandler = (event?: unknown) => void;
|
|
type ErrorHandler = (event?: unknown) => void;
|
|
type MessageHandler = (data: unknown, isBinary: boolean) => void;
|
|
|
|
export function createRelayE2eeTransportFactory(args: {
|
|
baseFactory: DaemonTransportFactory;
|
|
daemonPublicKeyB64: string;
|
|
logger: TransportLogger;
|
|
}): DaemonTransportFactory {
|
|
return ({ url }) => {
|
|
const base = args.baseFactory({ url });
|
|
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
|
|
};
|
|
}
|
|
|
|
export function createRelayTransportFactory(
|
|
baseFactory: DaemonTransportFactory,
|
|
): DaemonTransportFactory {
|
|
// The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials.
|
|
return ({ url }) => baseFactory({ url });
|
|
}
|
|
|
|
export function createEncryptedTransport(
|
|
base: DaemonTransport,
|
|
daemonPublicKeyB64: string,
|
|
logger: TransportLogger,
|
|
): DaemonTransport {
|
|
let channel: EncryptedChannel | null = null;
|
|
let opened = false;
|
|
let closed = false;
|
|
|
|
const openHandlers = new Set<OpenHandler>();
|
|
const closeHandlers = new Set<CloseHandler>();
|
|
const errorHandlers = new Set<ErrorHandler>();
|
|
const messageHandlers = new Set<MessageHandler>();
|
|
|
|
const emitOpen = () => {
|
|
if (opened || closed) {
|
|
return;
|
|
}
|
|
opened = true;
|
|
emitHandlers(openHandlers);
|
|
};
|
|
|
|
const emitClose = (event?: unknown) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
emitHandlers(closeHandlers, event);
|
|
};
|
|
|
|
const emitError = (event?: unknown) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
emitHandlers(errorHandlers, event);
|
|
};
|
|
|
|
const emitMessage = (data: unknown) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
emitHandlers(messageHandlers, data, data instanceof ArrayBuffer);
|
|
};
|
|
|
|
const relayTransport: RelayTransport = {
|
|
send: (data) => {
|
|
if (typeof data === "string") {
|
|
base.send(data);
|
|
return;
|
|
}
|
|
if (ArrayBuffer.isView(data)) {
|
|
base.send(normalizeTransportPayload(data));
|
|
return;
|
|
}
|
|
if (data instanceof ArrayBuffer) {
|
|
base.send(data);
|
|
return;
|
|
}
|
|
base.send(String(data));
|
|
},
|
|
close: (code?: number, reason?: string) => base.close(code, reason),
|
|
onmessage: null,
|
|
onclose: null,
|
|
onerror: null,
|
|
};
|
|
|
|
const startHandshake = async () => {
|
|
try {
|
|
channel = await createClientChannel(relayTransport, daemonPublicKeyB64, {
|
|
onopen: emitOpen,
|
|
onmessage: (data) => emitMessage(data),
|
|
onclose: (code, reason) => emitClose({ code, reason }),
|
|
onerror: (error) => emitError(error),
|
|
});
|
|
} catch (error) {
|
|
logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed");
|
|
emitError(error);
|
|
// Browser WebSocket.close only accepts 1000 or 3000-4999.
|
|
// Use an app-defined code so this path works in browser and Node runtimes.
|
|
base.close(4001, "E2EE handshake failed");
|
|
}
|
|
};
|
|
|
|
base.onOpen(() => {
|
|
void startHandshake();
|
|
});
|
|
base.onMessage((data, isBinary) => {
|
|
relayTransport.onmessage?.(extractRelayMessage(data, isBinary));
|
|
});
|
|
base.onClose((event) => {
|
|
const record = event as { code?: number; reason?: string } | undefined;
|
|
relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? "");
|
|
emitClose(event);
|
|
});
|
|
base.onError((event) => {
|
|
relayTransport.onerror?.(event instanceof Error ? event : new Error(String(event)));
|
|
emitError(event);
|
|
});
|
|
|
|
return {
|
|
send: (data) => {
|
|
if (!channel) {
|
|
throw new Error("Encrypted channel not ready");
|
|
}
|
|
const payload = normalizeTransportPayload(data);
|
|
// Reject synchronously so the requesting operation fails without closing the transport.
|
|
const wireBytes = channel.outboundWireByteLength(payload);
|
|
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
|
|
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
|
|
}
|
|
void channel.send(payload).catch((error) => {
|
|
emitError(error);
|
|
});
|
|
},
|
|
close: (code?: number, reason?: string) => {
|
|
if (channel) {
|
|
channel.close(code, reason);
|
|
} else {
|
|
base.close(code, reason);
|
|
}
|
|
emitClose({ code, reason });
|
|
},
|
|
onMessage: (handler) => {
|
|
messageHandlers.add(handler);
|
|
return () => messageHandlers.delete(handler);
|
|
},
|
|
onOpen: (handler) => {
|
|
openHandlers.add(handler);
|
|
if (opened) {
|
|
invokeHandler(handler);
|
|
}
|
|
return () => openHandlers.delete(handler);
|
|
},
|
|
onClose: (handler) => {
|
|
closeHandlers.add(handler);
|
|
if (closed) {
|
|
invokeHandler(handler);
|
|
}
|
|
return () => closeHandlers.delete(handler);
|
|
},
|
|
onError: (handler) => {
|
|
errorHandlers.add(handler);
|
|
return () => errorHandlers.delete(handler);
|
|
},
|
|
};
|
|
}
|
|
|
|
function emitHandlers<TArgs extends unknown[]>(
|
|
handlers: Set<(...args: TArgs) => void>,
|
|
...args: TArgs
|
|
) {
|
|
for (const handler of handlers) {
|
|
invokeHandler(handler, ...args);
|
|
}
|
|
}
|
|
|
|
function invokeHandler<TArgs extends unknown[]>(handler: (...args: TArgs) => void, ...args: TArgs) {
|
|
try {
|
|
handler(...args);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
function normalizeTransportError(error: unknown): Record<string, string> {
|
|
if (error instanceof Error) {
|
|
return {
|
|
name: error.name,
|
|
message: error.message,
|
|
...(typeof error.stack === "string" ? { stack: error.stack } : {}),
|
|
};
|
|
}
|
|
return { message: String(error) };
|
|
}
|