From 137c19429969ed813a29a1af047f58f86eb8bd1c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:59:03 +0200 Subject: [PATCH] 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. --- .../src/daemon-client-relay-e2ee-transport.ts | 15 +++- .../src/daemon-client-transport-utils.ts | 7 ++ packages/client/src/daemon-client.ts | 13 +-- .../protocol/src/websocket-message-limit.ts | 3 + packages/server/package.json | 2 +- .../daemon-e2e/relay-transport.e2e.test.ts | 89 +++++++++++++++++-- packages/server/src/utils/checkout-git.ts | 5 +- 7 files changed, 114 insertions(+), 20 deletions(-) diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 1bc175bf1b..e25779a7bb 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { createClientChannel, type EncryptedChannel, @@ -8,7 +9,11 @@ import type { DaemonTransportFactory, TransportLogger, } from "./daemon-client-transport-types.js"; -import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js"; +import { + extractRelayMessage, + normalizeTransportPayload, + messageTooLargeError, +} from "./daemon-client-transport-utils.js"; type OpenHandler = () => void; type CloseHandler = (event?: unknown) => void; @@ -137,7 +142,13 @@ export function createEncryptedTransport( if (!channel) { throw new Error("Encrypted channel not ready"); } - void channel.send(normalizeTransportPayload(data)).catch((error) => { + 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); }); }, diff --git a/packages/client/src/daemon-client-transport-utils.ts b/packages/client/src/daemon-client-transport-utils.ts index 3b39cf5114..cb767c1d87 100644 --- a/packages/client/src/daemon-client-transport-utils.ts +++ b/packages/client/src/daemon-client-transport-utils.ts @@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array { } return out; } + +export function messageTooLargeError(size: number, limit: number): Error { + const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + return new Error( + `Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`, + ); +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1fc580cd25..2bd19f2974 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import { messageTooLargeError } from "./daemon-client-transport-utils.js"; import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; @@ -1136,20 +1137,14 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void { if (typeof frame !== "string") { if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) - throw messageTooLargeError(frame.byteLength); + throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES); return; } // UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured. if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return; const size = new TextEncoder().encode(frame).byteLength; - if (size > MAX_WEBSOCKET_MESSAGE_BYTES) throw messageTooLargeError(size); -} - -function messageTooLargeError(size: number): Error { - const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); - return new Error( - `Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(MAX_WEBSOCKET_MESSAGE_BYTES)} MiB, so send fewer or smaller attachments.`, - ); + if (size > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES); } function describeInboundTransportFrame( diff --git a/packages/protocol/src/websocket-message-limit.ts b/packages/protocol/src/websocket-message-limit.ts index 05a095003d..9e2bb48e21 100644 --- a/packages/protocol/src/websocket-message-limit.ts +++ b/packages/protocol/src/websocket-message-limit.ts @@ -1,2 +1,5 @@ /** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */ export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024; + +/** Largest encrypted frame the relay accepts. */ +export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024; diff --git a/packages/server/package.json b/packages/server/package.json index 5576a2e713..541cf5eba1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", 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 3edede83d3..d204842e0c 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 @@ -1,4 +1,6 @@ import { describe, expect, test } from "vitest"; +import { withTimeout } from "../../utils/promise-timeout.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; import { WebSocket } from "ws"; import pino from "pino"; import { Writable } from "node:stream"; @@ -202,12 +204,20 @@ async function waitForCapturedLog( if (options.useLocalRelay) { relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:"); } - relayProcess = spawn("npx", relayArgs, { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); + relayProcess = spawn( + process.execPath, + [ + "--experimental-vm-modules", + path.resolve(relayDir, "../../node_modules/wrangler/wrangler-dist/cli.js"), + ...relayArgs.slice(1), + ], + { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); relayProcess.stdout?.on("data", (data: Buffer) => { const lines = data @@ -241,6 +251,73 @@ async function waitForCapturedLog( relayProcess = null; }; + test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { + await startRelay({ useLocalRelay: true }); + const daemon = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, + }); + let client: DaemonClient | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, + }); + await client.connect(); + const statuses: string[] = []; + client.subscribeConnectionStatus((state) => statuses.push(state.status)); + for (const imageMiB of [30, 61]) { + // Attachments are base64 in the JSON, then E2EE base64 expands the frame again. + const images = [ + { data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" }, + ]; + await expect( + withTimeout( + client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images, + }, + }), + 10000, + "Workspace creation did not settle", + ), + ).rejects.toThrow(/too large.*32\.0 MiB/); + expect((await client.fetchAgents()).entries).toEqual([]); + } + const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); + await expect( + client.sendMessage(agent.id, "Describe these screens.", { + images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }], + }), + ).rejects.toThrow(/too large.*32\.0 MiB/); + await client.sendMessage(agent.id, "A small follow-up still works."); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect(statuses).toEqual(["connected"]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, 60000); + test("daemon connects to relay and client ping/pong works through relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index caa7e65cb0..7a53c753b9 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; @@ -2152,13 +2153,13 @@ export async function getCheckoutSnapshotFacts( const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024; const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; + maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { files: ParsedDiffFile[];