diff --git a/CHANGELOG.md b/CHANGELOG.md index b140e2679..bbf9bde26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1. ### Fixes +- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging. - Doctor: surface plugin diagnostics in the report. - CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. - Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 8bf588f10..e6900bdea 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -125,4 +125,47 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); await server.close(); }); + + test("invalid connect params surface in response and close reason", async () => { + const { server, ws } = await startServerWithClient(); + await new Promise((resolve) => ws.once("open", resolve)); + + ws.send( + JSON.stringify({ + type: "req", + id: "h-bad", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "bad-client", + version: "dev", + platform: "web", + mode: "webchat", + }, + }, + }), + ); + + const res = await onceMessage<{ + ok: boolean; + error?: { message?: string }; + }>( + ws, + (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", + ); + expect(res.ok).toBe(false); + expect(String(res.error?.message ?? "")).toContain("invalid connect params"); + + const closeInfo = await new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + }); + expect(closeInfo.code).toBe(1008); + expect(closeInfo.reason).toContain("invalid connect params"); + + await server.close(); + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8fffc48d7..3a90c8e06 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import type { Server as HttpServer } from "node:http"; import os from "node:os"; @@ -380,6 +381,18 @@ let healthCache: HealthSummary | null = null; let healthRefresh: Promise | null = null; let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null; +const CLOSE_REASON_MAX_BYTES = 120; + +function truncateCloseReason( + reason: string, + maxBytes = CLOSE_REASON_MAX_BYTES, +): string { + if (!reason) return "invalid handshake"; + const buf = Buffer.from(reason); + if (buf.length <= maxBytes) return reason; + return buf.subarray(0, maxBytes).toString(); +} + function buildSnapshot(): Snapshot { const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); @@ -1461,6 +1474,11 @@ export async function startGatewayServer( (parsed as RequestFrame).method !== "connect" || !validateConnectParams((parsed as RequestFrame).params) ) { + const handshakeError = validateRequestFrame(parsed) + ? (parsed as RequestFrame).method === "connect" + ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` + : "invalid handshake: first request must be connect" + : "invalid request frame"; if (validateRequestFrame(parsed)) { const req = parsed as RequestFrame; send({ @@ -1469,9 +1487,7 @@ export async function startGatewayServer( ok: false, error: errorShape( ErrorCodes.INVALID_REQUEST, - req.method === "connect" - ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` - : "invalid handshake: first request must be connect", + handshakeError, ), }); } else { @@ -1480,18 +1496,16 @@ export async function startGatewayServer( ); } handshakeState = "failed"; - const handshakeError = validateRequestFrame(parsed) - ? (parsed as RequestFrame).method === "connect" - ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` - : "invalid handshake: first request must be connect" - : "invalid request frame"; setCloseCause("invalid-handshake", { frameType, frameMethod, frameId, handshakeError, }); - socket.close(1008, "invalid handshake"); + const closeReason = truncateCloseReason( + handshakeError || "invalid handshake", + ); + socket.close(1008, closeReason); close(); return; }