diff --git a/package.json b/package.json index d2e5f0d..e2d4d22 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js", + "test": "node --test --test-concurrency=1 dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js dist/test/broadcast-window.integration.test.js dist/test/identity-restart.integration.test.js dist/test/mesh-e2e.integration.test.js dist/test/ws-broadcast-window.integration.test.js dist/test/tls-transport.integration.test.js dist/test/peer-id-verification.integration.test.js dist/test/become-coordinator-actual-port.integration.test.js dist/test/state-sync-convergence.test.js dist/test/downtime-replay.test.js dist/test/downtime-replay.integration.test.js dist/test/filestore.test.js dist/test/handshake.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", "test:federation": "node dist/test/federation.integration.test.js", @@ -94,7 +94,9 @@ "typescript-eslint": "8.69.0" }, "dependencies": { + "@exadev/wire-mesh-core": "github:ExaDev/wire-mesh#path:ts/packages/core", "@modelcontextprotocol/sdk": "1.30.0", + "cbor2": "2.3.0", "preact": "10.29.7", "typebox": "1.3.6", "ws": "8.21.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b14a21d..d452fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: .: dependencies: + '@exadev/wire-mesh-core': + specifier: github:ExaDev/wire-mesh#path:ts/packages/core + version: https://codeload.github.com/ExaDev/wire-mesh/tar.gz/7c9445b750debb0e1e5272b6084e2f76ffe33a10#path:ts/packages/core '@modelcontextprotocol/sdk': specifier: 1.30.0 version: 1.30.0(zod@4.4.3) '@sinclair/typebox': specifier: '*' version: 0.34.49 + cbor2: + specifier: 2.3.0 + version: 2.3.0 preact: specifier: 10.29.7 version: 10.29.7 @@ -333,6 +339,10 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} + '@cto.af/wtf8@0.0.5': + resolution: {integrity: sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==} + engines: {node: '>=20'} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -533,6 +543,10 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exadev/wire-mesh-core@https://codeload.github.com/ExaDev/wire-mesh/tar.gz/7c9445b750debb0e1e5272b6084e2f76ffe33a10#path:ts/packages/core': + resolution: {path: ts/packages/core, tarball: https://codeload.github.com/ExaDev/wire-mesh/tar.gz/7c9445b750debb0e1e5272b6084e2f76ffe33a10} + version: 0.0.0 + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1162,9 +1176,21 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@9.0.0: + resolution: {integrity: sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==} + engines: {node: '>=20'} + + cbor2@2.3.0: + resolution: {integrity: sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==} + engines: {node: '>=20'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cddl.js@https://codeload.github.com/ExaDev/cddl.js/tar.gz/8de0f0cf0e8eb14e347894a55bdb655d5f332bc5: + resolution: {tarball: https://codeload.github.com/ExaDev/cddl.js/tar.gz/8de0f0cf0e8eb14e347894a55bdb655d5f332bc5} + version: 0.0.0 + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3293,6 +3319,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3629,6 +3658,8 @@ snapshots: '@conventional-changelog/template@1.2.1': {} + '@cto.af/wtf8@0.0.5': {} + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -3765,6 +3796,12 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exadev/wire-mesh-core@https://codeload.github.com/ExaDev/wire-mesh/tar.gz/7c9445b750debb0e1e5272b6084e2f76ffe33a10#path:ts/packages/core': + dependencies: + cbor2: 2.3.0 + cddl.js: https://codeload.github.com/ExaDev/cddl.js/tar.gz/8de0f0cf0e8eb14e347894a55bdb655d5f332bc5 + zod: 4.5.4 + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))': dependencies: google-auth-library: 10.9.0 @@ -4509,8 +4546,20 @@ snapshots: callsites@3.1.0: {} + camelcase@9.0.0: {} + + cbor2@2.3.0: + dependencies: + '@cto.af/wtf8': 0.0.5 + ccount@2.0.1: {} + cddl.js@https://codeload.github.com/ExaDev/cddl.js/tar.gz/8de0f0cf0e8eb14e347894a55bdb655d5f332bc5: + dependencies: + camelcase: 9.0.0 + cbor2: 2.3.0 + zod: 4.5.4 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -6807,4 +6856,6 @@ snapshots: zod@4.4.3: {} + zod@4.5.4: {} + zwitch@2.0.4: {} diff --git a/src/core/handshake.ts b/src/core/handshake.ts new file mode 100644 index 0000000..d0ed2fa --- /dev/null +++ b/src/core/handshake.ts @@ -0,0 +1,348 @@ +/** + * Protocol handshake — version negotiation for the mesh wire format, fixing #31: a mixed fleet of old and new peers negotiates down to what both actually support (or refuses loudly) instead of one side silently misinterpreting the other's state sync. + * + * The frame is wire-mesh's handshake-frame (spec/handshake.cddl in ExaDev/wire-mesh), CBOR-encoded, negotiated by wire-mesh-core's `negotiate()` — the same mechanism every wire-mesh consumer speaks. The `version` field carries agent-comms' own wire-format version (not wire-mesh's protocol version): version 1 is the current format, the one with entity revision fields (#29) and deliveryQueues (#30). A peer that never sends a handshake frame is a legacy peer (version 0, unversioned) and the connection proceeds exactly as before — mixed-fleet tolerance during rollout, the scenario #31 describes. + * + * Wire order: a client sends its handshake frame as the very first bytes on a connection and does not wait — the rest of its traffic is ordinary newline-delimited JSON. A server sends its frame only in reply to a received one, so legacy clients never see binary bytes at all. The one unavoidable cross-build artefact is a legacy server receiving a new client's CBOR frame into its line buffer, where it lands without a newline and is flushed as a single malformed (skipped) line when the first JSON message arrives — the pre-existing malformed-line behaviour. + */ + +import { + decodeSequence, + encode as cborEncode, + cdeDecodeOptions, + cdeEncodeOptions, +} from "cbor2"; +import { + negotiate, + type NegotiationResult, +} from "@exadev/wire-mesh-core/domain/handshake"; + +/** agent-comms' own wire-format version. 1 = the current format (entity revision fields, deliveryQueues). */ +export const MESH_PROTOCOL_VERSION = 1; + +/** + * The capability domain this mesh negotiates under — a wire-mesh namespaced-domain-id (registrant-owned, no allocator): ExaDev's agent-comms mesh semantics. Peers that do not share it are not this protocol. + */ +export const AGENT_COMMS_DOMAIN = "dev.exadev.agent-comms/mesh"; + +/** Handshake frames are tiny; anything larger than this is not a frame, it is garbage. */ +const MAX_HANDSHAKE_BYTES = 1024; + +/** CBOR map head byte range (0xa0–0xbf); JSON always starts with '{' (0x7b). */ +function isCborMapHead(byte: number): boolean { + return byte >= 0xa0 && byte <= 0xbf; +} + +const JSON_OBJECT_START = 0x7b; // '{' + +interface HandshakeShape { + type: "handshake"; + version: number; + domains: string[]; +} + +/** + * Narrow structural check for a received handshake frame. + * + * Deliberately NOT the generated handshakeFrameSchema: its domain union's regexp branches are emitted double-escaped by cddl.js (ExaDev/cddl.js#10), so schema-validating any namespaced domain — including ours — rejects valid frames until that fix lands. This guard checks exactly the shape `negotiate()` consumes. + */ +function isHandshakeShape(value: unknown): value is HandshakeShape { + if (typeof value !== "object" || value === null) return false; + if (!("type" in value) || value.type !== "handshake") return false; + if (!("version" in value) || typeof value.version !== "number") return false; + if (!("domains" in value) || !Array.isArray(value.domains)) return false; + return value.domains.every((d) => typeof d === "string"); +} + +function localFrame(): HandshakeShape { + return { + type: "handshake", + version: MESH_PROTOCOL_VERSION, + domains: [AGENT_COMMS_DOMAIN], + }; +} + +/** The handshake frame this build sends, CBOR-encoded (canonical), ready to write as a connection's first bytes. */ +export function encodeHandshakeFrame(): Uint8Array { + return cborEncode(localFrame(), cdeEncodeOptions); +} + +/** Negotiates this build's protocol against a received handshake frame — core's negotiation over agent-comms' versions. */ +export function negotiateMeshProtocol( + remote: HandshakeShape, +): NegotiationResult { + return negotiate(localFrame(), remote); +} + +export type HandshakeOutcome = + | { kind: "pending" } + | { kind: "legacy"; rest: Buffer; reason: "json-first-byte" } + | { kind: "negotiated"; rest: Buffer; result: NegotiationResult } + | { kind: "refused"; reason: string }; + +/** + * Per-connection gate fed the incoming byte stream. Consumes the (optional) leading handshake frame and classifies the connection: negotiated (a version was agreed — for a server, the caller replies with `encodeHandshakeFrame()`), legacy (first byte was '{' — a pre-handshake peer, proceed exactly as before), or refused (a handshake we cannot speak: destroy the connection loudly rather than desync — the #31 enforcement point). After the first classification every subsequent feed passes the bytes through unchanged. + */ +export class ConnectionHandshake { + private decided: "legacy" | "negotiated" | null = null; + private pending: Buffer[] = []; + private pendingLength = 0; + + constructor(private readonly role: "client" | "server") {} + + feed(data: Buffer): HandshakeOutcome { + if (this.decided !== null) { + if (this.decided === "negotiated") { + return { + kind: "negotiated", + rest: data, + result: this.negotiatedResult(), + }; + } + return { kind: "legacy", rest: data, reason: "json-first-byte" }; + } + // The connection's very first byte only classifies legacy vs. CBOR. A later chunk (the second half of a split frame) starts mid-item, and its own leading byte is not a fresh frame head -- re-checking it against isCborMapHead on every chunk was the bug a reassembly test caught. + if (this.pending.length === 0) { + const first = data[0]; + if (first === undefined) { + return { kind: "pending" }; + } + if (first === JSON_OBJECT_START) { + this.decided = "legacy"; + return { kind: "legacy", rest: data, reason: "json-first-byte" }; + } + if (!isCborMapHead(first)) { + return { + kind: "refused", + reason: `unexpected first byte 0x${first.toString(16)} — not a handshake frame or JSON message`, + }; + } + } + // A CBOR item: accumulate until it decodes (frames are tiny; TCP may split them). + this.pending.push(data); + this.pendingLength += data.length; + if (this.pendingLength > MAX_HANDSHAKE_BYTES) { + return { + kind: "refused", + reason: `handshake frame exceeds ${String(MAX_HANDSHAKE_BYTES)} bytes`, + }; + } + const joined = Buffer.concat(this.pending); + let value: unknown; + try { + // decodeSequence yields lazily: its first item resolves as soon as enough bytes exist for it, tolerating (rather than choking on) non-CBOR bytes that follow in the same buffer -- the ordinary case once a client's JSON traffic lands in the same TCP read as the frame. Plain decode() throws "Extra data in input" the instant anything trails the item, which would misclassify every such read as still-pending forever. + const item = decodeSequence(joined, cdeDecodeOptions).next(); + if (item.done !== false) { + return { kind: "pending" }; + } + value = item.value; + } catch { + return { kind: "pending" }; + } + if (!isHandshakeShape(value)) { + return { + kind: "refused", + reason: "CBOR item on a new connection is not a handshake frame", + }; + } + // The frame consumed only its own bytes; anything after it is the stream's JSON traffic. + const encoded = cborEncode(value, cdeEncodeOptions); + const rest = joined.subarray(encoded.length); + const result = negotiateMeshProtocol(value); + if (!result.ok) { + return { + kind: "refused", + reason: `handshake refused (${this.role}): peer protocol version ${String(value.version)}, no shared domain`, + }; + } + this.decided = "negotiated"; + this.lastResult = result; + return { kind: "negotiated", rest, result }; + } + + private lastResult: NegotiationResult | undefined; + + private negotiatedResult(): NegotiationResult { + if (this.lastResult === undefined) { + throw new Error("negotiated connection has no negotiation result"); + } + return this.lastResult; + } + + /** True once this connection was classified (legacy or negotiated) — further feed() calls pass through. */ + get settled(): boolean { + return this.decided !== null; + } +} + +// --------------------------------------------------------------------------- +// Socket attachment (TCP/TLS) — one helper, every connection site +// --------------------------------------------------------------------------- + +/** The slice of the Node socket surface the handshake needs — satisfied by net.Socket and tls.TLSSocket alike. */ +export interface HandshakeSocket { + write(data: Uint8Array | string): unknown; + destroy(): void; + on(event: "data", listener: (data: Buffer) => void): unknown; +} + +/** + * Wires a connection's handshake: a client sends its frame immediately (and + * never waits — the rest of its traffic is JSON either way); a server sends + * its frame only in reply to a received one, so legacy clients never see + * binary bytes. Payload bytes after classification (and everything on a + * legacy connection) flow to `onPayload` unchanged. A refused handshake + * destroys the connection and reports the reason — the loud #31 refusal + * replacing silent desync. + */ +export function attachSocketHandshake( + socket: HandshakeSocket, + role: "client" | "server", + onPayload: (data: Buffer) => void, + onError?: (error: Error) => void, +): void { + const gate = new ConnectionHandshake(role); + if (role === "client") { + socket.write(encodeHandshakeFrame()); + } + // A settled connection reports "negotiated" (or "legacy") on every subsequent feed, not just the classifying one -- feed() has no separate signal for "just decided" versus "already decided, passing through". Without this guard the server branch below wrote a fresh reply frame on every single data event for the rest of the connection's life, corrupting the peer's JSON-line buffer with stray CBOR bytes mid-stream. + let serverReplySent = false; + socket.on("data", (data: Buffer) => { + const outcome = gate.feed(data); + switch (outcome.kind) { + case "pending": + return; + case "legacy": + onPayload(outcome.rest); + return; + case "negotiated": + if (role === "server" && !serverReplySent) { + // Reply only once, on evidence the peer speaks the handshake — a legacy client must never receive binary bytes. + serverReplySent = true; + socket.write(encodeHandshakeFrame()); + } + onPayload(outcome.rest); + return; + case "refused": + onError?.(new Error(outcome.reason)); + socket.destroy(); + return; + } + }); +} + +// --------------------------------------------------------------------------- +// WebSocket attachment — binary first message is the handshake +// --------------------------------------------------------------------------- + +/** + * Gate for a WebSocket connection, where every message is already framed: a + * binary first message is the peer's handshake frame (reply in kind via + * `sendBinary` when serving), a text first message is a legacy peer's JSON. + * Binary messages after the first, or a non-handshake binary first message, + * are refused. + */ +export class WsHandshakeGate { + private settled = false; + + constructor( + private readonly role: "client" | "server", + private readonly sendBinary: (data: Uint8Array) => void, + ) {} + + /** + * Classifies one incoming message. `isBinary` is the WS library's own frame-type flag (`ws`'s `message` event passes `(data, isBinary)`), not `typeof raw === "string"`: in Node, `ws` always delivers `data` as a Buffer regardless of whether the frame was sent as text or binary, so a `typeof` check can never see a text frame as a string here and would misclassify every legacy JSON message as an unexpected second handshake. `"payload"` means deliver it to the existing JSON message path (text only); `"consumed"` means it was the handshake and nothing downstream should see it; a throw is the refused case — the caller closes the socket. + */ + feed(raw: unknown, isBinary: boolean): "payload" | "consumed" { + if (this.settled) { + if (!isBinary) return "payload"; + throw new Error( + "binary message after connection start on a WebSocket mesh connection", + ); + } + if (!isBinary) { + this.settled = true; + return "payload"; + } + const bytes = + raw instanceof Buffer + ? raw + : raw instanceof ArrayBuffer + ? new Uint8Array(raw) + : ArrayBuffer.isView(raw) + ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) + : undefined; + if (bytes === undefined) { + throw new Error("unsupported WebSocket message type"); + } + const gate = new ConnectionHandshake(this.role); + const outcome = gate.feed(Buffer.from(bytes)); + if (outcome.kind === "pending") { + throw new Error( + "handshake frame did not arrive as one WebSocket message", + ); + } + if (outcome.kind === "refused") { + throw new Error(outcome.reason); + } + if (outcome.kind === "legacy") { + // A text-shaped payload cannot reach here (handled above); binary that + // is not a handshake frame is a refusal in ConnectionHandshake. + throw new Error( + "unexpected legacy classification for a binary WebSocket message", + ); + } + if (this.role === "server") { + this.sendBinary(encodeHandshakeFrame()); + } + if (outcome.rest.length > 0) { + throw new Error( + "handshake frame carried trailing bytes in a WebSocket message", + ); + } + this.settled = true; + return "consumed"; + } +} + +/** The slice of the WebSocket surface the handshake needs. */ +export interface HandshakeWs { + send(data: string | Uint8Array): unknown; + terminate(): void; + // isBinary is the `ws` library's own frame-type flag (its `message` event always passes it as the second argument) -- the only reliable way to tell a text frame from a binary one, since `data` itself arrives as a Buffer in Node either way. + on( + event: "message", + listener: (raw: unknown, isBinary: boolean) => void, + ): unknown; +} + +/** + * Wires a WebSocket connection's handshake: a client sends its frame as a + * binary message immediately (before any JSON); a server replies in kind only + * on receiving one, so legacy clients never see a binary message. Text + * messages flow to `onText` unchanged; a refused handshake terminates the + * connection and reports the reason. + */ +export function attachWsHandshake( + ws: HandshakeWs, + role: "client" | "server", + onText: (raw: unknown) => void, + onError?: (error: Error) => void, +): void { + const gate = new WsHandshakeGate(role, (data) => { + ws.send(data); + }); + if (role === "client") { + ws.send(encodeHandshakeFrame()); + } + ws.on("message", (raw: unknown, isBinary: boolean) => { + try { + if (gate.feed(raw, isBinary) === "payload") { + onText(raw); + } + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + ws.terminate(); + } + }); +} diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 268a60f..24af96f 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -15,7 +15,7 @@ import * as os from "node:os"; import { nanoid } from "./nanoid.js"; import { CommsError } from "./store.js"; import { TcpTransport } from "./tcp-transport.js"; -import { dmKey } from "./wire-protocol.js"; +import { dmKey, normaliseWireState } from "./wire-protocol.js"; import type { SerialisedState } from "./wire-protocol.js"; import { DiscoveryManager } from "./discovery.js"; import { MdnsDiscoveryBackend } from "./discovery-mdns.js"; @@ -394,7 +394,7 @@ export class MeshStore implements CommsStore { msg: MeshMessage, ): Promise { if (msg.method === "state_sync") { - this.applyStateSync(msg.state); + this.applyStateSync(normaliseWireState(msg.state)); } else if (msg.method === "state_update") { await this.applyPatch(msg.patch); } diff --git a/src/core/tcp-transport.ts b/src/core/tcp-transport.ts index d833d3e..bcadee6 100644 --- a/src/core/tcp-transport.ts +++ b/src/core/tcp-transport.ts @@ -17,6 +17,7 @@ import * as net from "node:net"; import { encode, isMeshMessage, MessageBuffer } from "./wire-protocol.js"; import type { MeshMessage, PeerInfo } from "./wire-protocol.js"; +import { attachSocketHandshake } from "./handshake.js"; import type { ConnectionHandle, ListenerInfo, @@ -186,6 +187,22 @@ export class TcpTransport implements MeshTransport { const socket = net.createConnection({ port, host }, () => { this.coordinatorSocket = socket; + // Wire up the protocol handshake first (client role: sends its frame immediately as the connection's very first bytes) so the introduction below lands after it on the wire, not before. + const buffer = new MessageBuffer(); + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + this.dispatchCoordinatorClientMessage(item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Send introduction const intro: MeshMessage = { method: "introduce", @@ -193,17 +210,6 @@ export class TcpTransport implements MeshTransport { dataPort: localDataPort, }; socket.write(encode(intro)); - - // Wire up coordinator message handling - const buffer = new MessageBuffer(); - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - this.dispatchCoordinatorClientMessage(item); - } - } - }); socket.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -251,6 +257,34 @@ export class TcpTransport implements MeshTransport { const socket = net.createConnection({ port, host }, () => { this.coordinatorSocket = socket; + // Wire up the protocol handshake first (client role) so connect_request below lands after it on the wire, pending: wait for connect_accepted/rejected, then normal dispatch + const buffer = new MessageBuffer(); + let approved = false; + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (!isMeshMessage(item)) continue; + + if (!approved) { + if (item.method === "connect_accepted") { + approved = true; + resolve(); + } else if (item.method === "connect_rejected") { + socket.destroy(); + reject(new Error(`Connection rejected: ${item.reason}`)); + return; + } + } else { + this.dispatchCoordinatorClientMessage(item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Send connect_request instead of introduce const req: MeshMessage = { method: "connect_request", @@ -260,30 +294,6 @@ export class TcpTransport implements MeshTransport { fingerprint, }; socket.write(encode(req)); - - // Wire up coordinator message handling - // Pending: wait for connect_accepted/rejected, then normal dispatch - const buffer = new MessageBuffer(); - let approved = false; - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (!isMeshMessage(item)) continue; - - if (!approved) { - if (item.method === "connect_accepted") { - approved = true; - resolve(); - } else if (item.method === "connect_rejected") { - socket.destroy(); - reject(new Error(`Connection rejected: ${item.reason}`)); - return; - } - } else { - this.dispatchCoordinatorClientMessage(item); - } - } - }); socket.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -423,23 +433,28 @@ export class TcpTransport implements MeshTransport { const buffer = new MessageBuffer(); this.peerConnections.set(peer.id, { socket, buffer }); + // Wire up the protocol handshake first (client role) so everything below lands after it on the wire, not before. + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + const handle: ConnectionHandle = { id: peer.id }; + this.dispatchDataMessage(handle, item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Identify ourselves const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; socket.write(encode(pong)); void this.flushPending(peer.id, socket); - // Wire up ongoing message handling - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - const handle: ConnectionHandle = { id: peer.id }; - this.dispatchDataMessage(handle, item); - } - } - }); - resolve(); }, ); @@ -723,37 +738,42 @@ export class TcpTransport implements MeshTransport { socket.on("error", () => this.coordinatorServerSockets.delete(socket)); const buffer = new MessageBuffer(); - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (!isMeshMessage(item)) continue; - - if (item.method === "introduce") { - const handle: ConnectionHandle = { id: item.peerId, policy }; - this.introConnections.set(handle.id, socket); - this.events.onIntroduction(handle, { - peerId: item.peerId, - dataPort: item.dataPort, - }); - } else if (item.method === "connect_request") { - const handle: ConnectionHandle = { id: item.peerId, policy }; - this.pendingConnections.set(handle.id, { - socket, - peerId: item.peerId, - dataPort: item.dataPort, - name: item.name, - fingerprint: item.fingerprint, - policy, - }); - this.events.onConnectionRequest(handle, { - peerId: item.peerId, - dataPort: item.dataPort, - name: item.name, - fingerprint: item.fingerprint, - }); + attachSocketHandshake( + socket, + "server", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (!isMeshMessage(item)) continue; + + if (item.method === "introduce") { + const handle: ConnectionHandle = { id: item.peerId, policy }; + this.introConnections.set(handle.id, socket); + this.events.onIntroduction(handle, { + peerId: item.peerId, + dataPort: item.dataPort, + }); + } else if (item.method === "connect_request") { + const handle: ConnectionHandle = { id: item.peerId, policy }; + this.pendingConnections.set(handle.id, { + socket, + peerId: item.peerId, + dataPort: item.dataPort, + name: item.name, + fingerprint: item.fingerprint, + policy, + }); + this.events.onConnectionRequest(handle, { + peerId: item.peerId, + dataPort: item.dataPort, + name: item.name, + fingerprint: item.fingerprint, + }); + } } - } - }); + }, + (error) => this.events.onError?.(error), + ); } // ----------------------------------------------------------------------- @@ -778,31 +798,36 @@ export class TcpTransport implements MeshTransport { let remotePeerId: string | undefined; let disconnected = false; - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - if (item.method === "pong") { - const peerId = item.peerId; - remotePeerId = peerId; - if (!this.peerConnections.has(peerId)) { - this.peerConnections.set(peerId, { socket, buffer }); + attachSocketHandshake( + socket, + "server", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + if (item.method === "pong") { + const peerId = item.peerId; + remotePeerId = peerId; + if (!this.peerConnections.has(peerId)) { + this.peerConnections.set(peerId, { socket, buffer }); + } + void this.flushPending(peerId, socket); + const handle: ConnectionHandle = { id: peerId }; + const info: PeerInfo = { + id: peerId, + port: 0, + startedAt: new Date().toISOString(), + }; + this.events.onPeerConnected(handle, info); + } else if (remotePeerId !== undefined) { + const handle: ConnectionHandle = { id: remotePeerId }; + this.dispatchDataMessage(handle, item); } - void this.flushPending(peerId, socket); - const handle: ConnectionHandle = { id: peerId }; - const info: PeerInfo = { - id: peerId, - port: 0, - startedAt: new Date().toISOString(), - }; - this.events.onPeerConnected(handle, info); - } else if (remotePeerId !== undefined) { - const handle: ConnectionHandle = { id: remotePeerId }; - this.dispatchDataMessage(handle, item); } } - } - }); + }, + (error) => this.events.onError?.(error), + ); const onDisconnect = (): void => { if (disconnected) return; diff --git a/src/core/tls-transport.ts b/src/core/tls-transport.ts index 42388c8..6a05615 100644 --- a/src/core/tls-transport.ts +++ b/src/core/tls-transport.ts @@ -20,6 +20,7 @@ import * as net from "node:net"; import * as tls from "node:tls"; import { encode, isMeshMessage, MessageBuffer } from "./wire-protocol.js"; +import { attachSocketHandshake } from "./handshake.js"; import type { MeshMessage, PeerInfo } from "./wire-protocol.js"; import type { ConnectionHandle, @@ -276,6 +277,22 @@ export class TlsTransport { const socket = tls.connect({ ...this.connectOptions, host, port }, () => { this.coordinatorSocket = socket; + // Wire up the protocol handshake first (client role) so the introduction below lands after it on the wire, not before. + const buffer = new MessageBuffer(); + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + this.dispatchCoordinatorClientMessage(item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Send introduction const intro: MeshMessage = { method: "introduce", @@ -284,16 +301,6 @@ export class TlsTransport { }; socket.write(encode(intro)); - // Wire up coordinator message handling - const buffer = new MessageBuffer(); - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - this.dispatchCoordinatorClientMessage(item); - } - } - }); socket.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -341,6 +348,36 @@ export class TlsTransport { const socket = tls.connect({ ...this.connectOptions, host, port }, () => { this.coordinatorSocket = socket; + clearTimeout(timer); + + // Wire up the protocol handshake first (client role) so connect_request below lands after it on the wire, not before. + const buffer = new MessageBuffer(); + let approved = false; + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (!isMeshMessage(item)) continue; + + if (!approved) { + if (item.method === "connect_accepted") { + approved = true; + resolve(); + } else if (item.method === "connect_rejected") { + socket.destroy(); + reject(new Error(`Connection rejected: ${item.reason}`)); + return; + } + } else { + this.dispatchCoordinatorClientMessage(item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Send connect_request instead of introduce const req: MeshMessage = { method: "connect_request", @@ -350,31 +387,6 @@ export class TlsTransport { fingerprint, }; socket.write(encode(req)); - - clearTimeout(timer); - - // Wire up coordinator message handling - const buffer = new MessageBuffer(); - let approved = false; - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (!isMeshMessage(item)) continue; - - if (!approved) { - if (item.method === "connect_accepted") { - approved = true; - resolve(); - } else if (item.method === "connect_rejected") { - socket.destroy(); - reject(new Error(`Connection rejected: ${item.reason}`)); - return; - } - } else { - this.dispatchCoordinatorClientMessage(item); - } - } - }); socket.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -520,23 +532,28 @@ export class TlsTransport { const buffer = new MessageBuffer(); this.peerConnections.set(peer.id, { socket, buffer }); + // Wire up the protocol handshake first (client role) so everything below lands after it on the wire, not before. + attachSocketHandshake( + socket, + "client", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + const handle: ConnectionHandle = { id: peer.id }; + this.dispatchDataMessage(handle, item); + } + } + }, + (error) => this.events.onError?.(error), + ); + // Identify ourselves const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; socket.write(encode(pong)); void this.flushPending(peer.id, socket); - // Wire up ongoing message handling - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - const handle: ConnectionHandle = { id: peer.id }; - this.dispatchDataMessage(handle, item); - } - } - }); - resolve(); }, ); @@ -810,38 +827,43 @@ export class TlsTransport { socket.on("error", () => this.coordinatorServerSockets.delete(socket)); const buffer = new MessageBuffer(); - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (!isMeshMessage(item)) continue; - - if (item.method === "introduce") { - if (!this.verifyClaimedPeerId(socket, item.peerId)) continue; - const handle: ConnectionHandle = { id: item.peerId, policy }; - this.introConnections.set(handle.id, socket); - this.events.onIntroduction(handle, { - peerId: item.peerId, - dataPort: item.dataPort, - }); - } else if (item.method === "connect_request") { - const handle: ConnectionHandle = { id: item.peerId, policy }; - this.pendingConnections.set(handle.id, { - socket, - peerId: item.peerId, - dataPort: item.dataPort, - name: item.name, - fingerprint: item.fingerprint, - policy, - }); - this.events.onConnectionRequest(handle, { - peerId: item.peerId, - dataPort: item.dataPort, - name: item.name, - fingerprint: item.fingerprint, - }); + attachSocketHandshake( + socket, + "server", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (!isMeshMessage(item)) continue; + + if (item.method === "introduce") { + if (!this.verifyClaimedPeerId(socket, item.peerId)) continue; + const handle: ConnectionHandle = { id: item.peerId, policy }; + this.introConnections.set(handle.id, socket); + this.events.onIntroduction(handle, { + peerId: item.peerId, + dataPort: item.dataPort, + }); + } else if (item.method === "connect_request") { + const handle: ConnectionHandle = { id: item.peerId, policy }; + this.pendingConnections.set(handle.id, { + socket, + peerId: item.peerId, + dataPort: item.dataPort, + name: item.name, + fingerprint: item.fingerprint, + policy, + }); + this.events.onConnectionRequest(handle, { + peerId: item.peerId, + dataPort: item.dataPort, + name: item.name, + fingerprint: item.fingerprint, + }); + } } - } - }); + }, + (error) => this.events.onError?.(error), + ); } // ----------------------------------------------------------------------- @@ -861,32 +883,37 @@ export class TlsTransport { let remotePeerId: string | undefined; let disconnected = false; - socket.on("data", (data) => { - const items = buffer.append(data.toString()); - for (const item of items) { - if (isMeshMessage(item)) { - if (item.method === "pong") { - const peerId = item.peerId; - if (!this.verifyClaimedPeerId(socket, peerId)) continue; - remotePeerId = peerId; - if (!this.peerConnections.has(peerId)) { - this.peerConnections.set(peerId, { socket, buffer }); + attachSocketHandshake( + socket, + "server", + (data) => { + const items = buffer.append(data.toString()); + for (const item of items) { + if (isMeshMessage(item)) { + if (item.method === "pong") { + const peerId = item.peerId; + if (!this.verifyClaimedPeerId(socket, peerId)) continue; + remotePeerId = peerId; + if (!this.peerConnections.has(peerId)) { + this.peerConnections.set(peerId, { socket, buffer }); + } + void this.flushPending(peerId, socket); + const handle: ConnectionHandle = { id: peerId }; + const info: PeerInfo = { + id: peerId, + port: 0, + startedAt: new Date().toISOString(), + }; + this.events.onPeerConnected(handle, info); + } else if (remotePeerId !== undefined) { + const handle: ConnectionHandle = { id: remotePeerId }; + this.dispatchDataMessage(handle, item); } - void this.flushPending(peerId, socket); - const handle: ConnectionHandle = { id: peerId }; - const info: PeerInfo = { - id: peerId, - port: 0, - startedAt: new Date().toISOString(), - }; - this.events.onPeerConnected(handle, info); - } else if (remotePeerId !== undefined) { - const handle: ConnectionHandle = { id: remotePeerId }; - this.dispatchDataMessage(handle, item); } } - } - }); + }, + (error) => this.events.onError?.(error), + ); const onDisconnect = (): void => { if (disconnected) return; diff --git a/src/core/wire-protocol.ts b/src/core/wire-protocol.ts index d869b3b..c2bfa08 100644 --- a/src/core/wire-protocol.ts +++ b/src/core/wire-protocol.ts @@ -144,3 +144,42 @@ export function dmKey(a: string, b: string): string { const sorted = [a, b].sort(); return `${sorted[0] ?? a}--${sorted[1] ?? b}`; } + +// --------------------------------------------------------------------------- +// Wire evolution tolerance (#31 direction 2) +// --------------------------------------------------------------------------- + +/** + * The shape a state_sync payload actually has on the wire: `SerialisedState` with every field that predates #29/#30 -- and each entity's `version` within those fields -- optional, since nothing validates this JSON.parse'd message beyond `isMeshMessage`'s bare `method` check. `SerialisedState` itself stays the fully-populated type domain logic works with elsewhere; this is deliberately narrower than `Partial` so it models only the specific absences an older build can actually produce. + */ +export interface WireStateInput { + agents?: Record< + string, + Omit & { version?: number } + >; + rooms?: Record & { version?: number }>; + messages?: SerialisedState["messages"]; + dms?: SerialisedState["dms"]; + deliveryQueues?: SerialisedState["deliveryQueues"]; +} + +/** + * Normalises a state_sync payload at the wire boundary so a snapshot from an older build — one predating the entity `version` fields (#29) or `deliveryQueues` (#30) — parses to a complete state instead of throwing in `applyStateSync`. Missing collections default to empty; missing entity versions default to 1 (a pre-versioning sender's records are treated as fresh, which is what they were when written — there was no older format). The handshake (handshake.ts) is the loud, forward-looking half of the #31 fix; this is the tolerant half for peers that never negotiate. + */ +export function normaliseWireState(state: WireStateInput): SerialisedState { + const agents: Record = {}; + for (const [id, agent] of Object.entries(state.agents ?? {})) { + agents[id] = { ...agent, version: agent.version ?? 1 }; + } + const rooms: Record = {}; + for (const [id, room] of Object.entries(state.rooms ?? {})) { + rooms[id] = { ...room, version: room.version ?? 1 }; + } + return { + agents, + rooms, + messages: state.messages ?? {}, + dms: state.dms ?? {}, + deliveryQueues: state.deliveryQueues ?? {}, + }; +} diff --git a/src/core/ws-transport.ts b/src/core/ws-transport.ts index c28bff9..d0817f7 100644 --- a/src/core/ws-transport.ts +++ b/src/core/ws-transport.ts @@ -13,6 +13,7 @@ import { WebSocket, WebSocketServer } from "ws"; import { isMeshMessage } from "./wire-protocol.js"; +import { attachWsHandshake } from "./handshake.js"; import type { MeshMessage, PeerInfo } from "./wire-protocol.js"; import type { ConnectionHandle, @@ -181,6 +182,19 @@ export class WebSocketTransport implements MeshTransport { ws.on("open", () => { this.coordinatorWs = ws; + // Wire up the protocol handshake first (client role: attachWsHandshake sends its own binary frame immediately) so the introduction below lands after it on the wire, not before, and not duplicated. + attachWsHandshake( + ws, + "client", + (raw) => { + const msg = parseMessage(raw); + if (msg !== undefined) { + this.dispatchCoordinatorClientMessage(msg); + } + }, + (error) => this.events.onError?.(error), + ); + // Send introduction const intro: MeshMessage = { method: "introduce", @@ -188,14 +202,6 @@ export class WebSocketTransport implements MeshTransport { dataPort: localDataPort, }; ws.send(JSON.stringify(intro)); - - // Wire up coordinator message handling - ws.on("message", (raw) => { - const msg = parseMessage(raw); - if (msg !== undefined) { - this.dispatchCoordinatorClientMessage(msg); - } - }); ws.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -252,6 +258,33 @@ export class WebSocketTransport implements MeshTransport { ws.on("open", () => { this.coordinatorWs = ws; + // Wire up the protocol handshake first (client role) so connect_request below lands after it on the wire, not duplicated. Wait for connect_accepted or connect_rejected. + let approved = false; + attachWsHandshake( + ws, + "client", + (raw) => { + const msg = parseMessage(raw); + if (msg === undefined) return; + + if (!approved) { + if (msg.method === "connect_accepted") { + approved = true; + clearTimeout(timer); + resolve(); + } else if (msg.method === "connect_rejected") { + clearTimeout(timer); + ws.terminate(); + reject(new Error(`Connection rejected: ${msg.reason}`)); + return; + } + } else { + this.dispatchCoordinatorClientMessage(msg); + } + }, + (error) => this.events.onError?.(error), + ); + // Send connect_request instead of introduce const req: MeshMessage = { method: "connect_request", @@ -261,28 +294,6 @@ export class WebSocketTransport implements MeshTransport { fingerprint, }; ws.send(JSON.stringify(req)); - - // Wait for connect_accepted or connect_rejected - let approved = false; - ws.on("message", (raw) => { - const msg = parseMessage(raw); - if (msg === undefined) return; - - if (!approved) { - if (msg.method === "connect_accepted") { - approved = true; - clearTimeout(timer); - resolve(); - } else if (msg.method === "connect_rejected") { - clearTimeout(timer); - ws.terminate(); - reject(new Error(`Connection rejected: ${msg.reason}`)); - return; - } - } else { - this.dispatchCoordinatorClientMessage(msg); - } - }); ws.on("error", () => { /* ignore late errors on coordinator connection */ }); @@ -337,21 +348,26 @@ export class WebSocketTransport implements MeshTransport { ws.on("open", () => { this.peerConnections.set(peer.id, ws); + // Wire up the protocol handshake first (client role) so everything below lands after it on the wire, not duplicated. + attachWsHandshake( + ws, + "client", + (raw) => { + const msg = parseMessage(raw); + if (msg !== undefined) { + const handle: ConnectionHandle = { id: peer.id }; + this.dispatchDataMessage(handle, msg); + } + }, + (error) => this.events.onError?.(error), + ); + // Identify ourselves const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; ws.send(JSON.stringify(pong)); void this.flushPending(peer.id, ws); - // Wire up ongoing message handling - ws.on("message", (raw) => { - const msg = parseMessage(raw); - if (msg !== undefined) { - const handle: ConnectionHandle = { id: peer.id }; - this.dispatchDataMessage(handle, msg); - } - }); - resolve(); }); @@ -630,34 +646,39 @@ export class WebSocketTransport implements MeshTransport { ws.on("close", () => this.coordinatorServerSockets.delete(ws)); ws.on("error", () => this.coordinatorServerSockets.delete(ws)); - ws.on("message", (raw) => { - const msg = parseMessage(raw); - if (msg === undefined) return; - - if (msg.method === "introduce") { - const handle: ConnectionHandle = { id: msg.peerId }; - this.introConnections.set(handle.id, ws); - this.events.onIntroduction(handle, { - peerId: msg.peerId, - dataPort: msg.dataPort, - }); - } else if (msg.method === "connect_request") { - const handle: ConnectionHandle = { id: msg.peerId }; - this.pendingConnections.set(handle.id, { - ws, - peerId: msg.peerId, - dataPort: msg.dataPort, - name: msg.name, - fingerprint: msg.fingerprint, - }); - this.events.onConnectionRequest(handle, { - peerId: msg.peerId, - dataPort: msg.dataPort, - name: msg.name, - fingerprint: msg.fingerprint, - }); - } - }); + attachWsHandshake( + ws, + "server", + (raw) => { + const msg = parseMessage(raw); + if (msg === undefined) return; + + if (msg.method === "introduce") { + const handle: ConnectionHandle = { id: msg.peerId }; + this.introConnections.set(handle.id, ws); + this.events.onIntroduction(handle, { + peerId: msg.peerId, + dataPort: msg.dataPort, + }); + } else if (msg.method === "connect_request") { + const handle: ConnectionHandle = { id: msg.peerId }; + this.pendingConnections.set(handle.id, { + ws, + peerId: msg.peerId, + dataPort: msg.dataPort, + name: msg.name, + fingerprint: msg.fingerprint, + }); + this.events.onConnectionRequest(handle, { + peerId: msg.peerId, + dataPort: msg.dataPort, + name: msg.name, + fingerprint: msg.fingerprint, + }); + } + }, + (error) => this.events.onError?.(error), + ); } // ----------------------------------------------------------------------- @@ -681,29 +702,34 @@ export class WebSocketTransport implements MeshTransport { let remotePeerId: string | undefined; let disconnected = false; - ws.on("message", (raw) => { - const msg = parseMessage(raw); - if (msg === undefined) return; - - if (msg.method === "pong") { - const peerId = msg.peerId; - remotePeerId = peerId; - if (!this.peerConnections.has(peerId)) { - this.peerConnections.set(peerId, ws); + attachWsHandshake( + ws, + "server", + (raw) => { + const msg = parseMessage(raw); + if (msg === undefined) return; + + if (msg.method === "pong") { + const peerId = msg.peerId; + remotePeerId = peerId; + if (!this.peerConnections.has(peerId)) { + this.peerConnections.set(peerId, ws); + } + void this.flushPending(peerId, ws); + const handle: ConnectionHandle = { id: peerId }; + const info: PeerInfo = { + id: peerId, + port: 0, + startedAt: new Date().toISOString(), + }; + this.events.onPeerConnected(handle, info); + } else if (remotePeerId !== undefined) { + const handle: ConnectionHandle = { id: remotePeerId }; + this.dispatchDataMessage(handle, msg); } - void this.flushPending(peerId, ws); - const handle: ConnectionHandle = { id: peerId }; - const info: PeerInfo = { - id: peerId, - port: 0, - startedAt: new Date().toISOString(), - }; - this.events.onPeerConnected(handle, info); - } else if (remotePeerId !== undefined) { - const handle: ConnectionHandle = { id: remotePeerId }; - this.dispatchDataMessage(handle, msg); - } - }); + }, + (error) => this.events.onError?.(error), + ); const onDisconnect = (): void => { if (disconnected) return; diff --git a/src/test/handshake.test.ts b/src/test/handshake.test.ts new file mode 100644 index 0000000..b1660cf --- /dev/null +++ b/src/test/handshake.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for the protocol handshake (#31): the gate state machine, negotiation over wire-mesh-core, and the state-sync wire tolerance. + */ + +import * as assert from "node:assert/strict"; +import * as net from "node:net"; +import { describe, it } from "node:test"; +import { encode as cborEncode, cdeEncodeOptions } from "cbor2"; +import { + AGENT_COMMS_DOMAIN, + ConnectionHandshake, + MESH_PROTOCOL_VERSION, + attachSocketHandshake, + encodeHandshakeFrame, + negotiateMeshProtocol, +} from "../core/handshake.js"; +import { + normaliseWireState, + type SerialisedState, +} from "../core/wire-protocol.js"; + +describe("negotiateMeshProtocol", () => { + it("negotiates down to the lower version with the shared domain", () => { + const result = negotiateMeshProtocol({ + type: "handshake", + version: MESH_PROTOCOL_VERSION + 1, + domains: [AGENT_COMMS_DOMAIN], + }); + assert.equal(result.ok, true); + assert.equal(result.version, MESH_PROTOCOL_VERSION); + assert.deepEqual(result.sharedDomains, [AGENT_COMMS_DOMAIN]); + }); + + it("refuses a peer with no shared domain", () => { + const result = negotiateMeshProtocol({ + type: "handshake", + version: MESH_PROTOCOL_VERSION, + domains: ["example.com/something-else"], + }); + assert.equal(result.ok, false); + }); +}); + +describe("ConnectionHandshake", () => { + it("classifies a peer frame as negotiated and returns trailing JSON bytes", () => { + const gate = new ConnectionHandshake("server"); + const frame = encodeHandshakeFrame(); + const jsonTail = Buffer.from('{"method":"introduce"}\n', "utf8"); + const outcome = gate.feed(Buffer.concat([frame, jsonTail])); + assert.equal(outcome.kind, "negotiated"); + if (outcome.kind !== "negotiated") return; + assert.equal(outcome.result.version, MESH_PROTOCOL_VERSION); + assert.equal(outcome.rest.toString(), jsonTail.toString()); + // Subsequent feeds pass through unchanged. + const more = gate.feed(Buffer.from("{}", "utf8")); + assert.equal(more.kind, "negotiated"); + if (more.kind !== "negotiated") return; + assert.equal(more.rest.toString(), "{}"); + }); + + it("reassembles a frame split across TCP-sized chunks", () => { + const gate = new ConnectionHandshake("server"); + const frame = Buffer.from(encodeHandshakeFrame()); + const split = Math.floor(frame.length / 2); + assert.equal(gate.feed(frame.subarray(0, split)).kind, "pending"); + const outcome = gate.feed(frame.subarray(split)); + assert.equal(outcome.kind, "negotiated"); + }); + + it("classifies a JSON-first-byte connection as legacy and passes bytes through", () => { + const gate = new ConnectionHandshake("server"); + const outcome = gate.feed(Buffer.from('{"method":"introduce"}\n', "utf8")); + assert.equal(outcome.kind, "legacy"); + if (outcome.kind !== "legacy") return; + assert.match(outcome.rest.toString(), /introduce/); + }); + + it("refuses a handshake whose negotiation fails (no shared domain)", () => { + const gate = new ConnectionHandshake("server"); + const hostile = cborEncode( + { type: "handshake", version: 1, domains: ["example.com/other"] }, + cdeEncodeOptions, + ); + const outcome = gate.feed(Buffer.from(hostile)); + assert.equal(outcome.kind, "refused"); + if (outcome.kind !== "refused") return; + assert.match(outcome.reason, /no shared domain/); + }); + + it("refuses a CBOR item that is not a handshake frame", () => { + const gate = new ConnectionHandshake("server"); + const notAHandshake = cborEncode({ hello: "world" }, cdeEncodeOptions); + const outcome = gate.feed(Buffer.from(notAHandshake)); + assert.equal(outcome.kind, "refused"); + }); + + it("refuses undecodable leading bytes that are neither CBOR maps nor JSON", () => { + const gate = new ConnectionHandshake("server"); + const outcome = gate.feed(Buffer.from([0xff, 0x00, 0x01])); + assert.equal(outcome.kind, "refused"); + }); + + it("refuses a binary blob far beyond any handshake frame size", () => { + const gate = new ConnectionHandshake("server"); + const big = Buffer.alloc(2048, 0x61); // 'a' — neither CBOR map head nor '{' + assert.equal(gate.feed(big).kind, "refused"); + }); +}); + +describe("normaliseWireState (#31 direction 2)", () => { + it("defaults missing deliveryQueues and entity versions for old-build snapshots", () => { + const legacy = { + agents: { + a1: { id: "a1", name: "a", version: undefined }, + }, + rooms: {}, + messages: {}, + dms: {}, + } as unknown as Parameters[0]; + const normalised = normaliseWireState(legacy); + assert.deepEqual(normalised.deliveryQueues, {}); + assert.equal((normalised.agents.a1 as { version: number }).version, 1); + }); + + it("passes a complete modern state through unchanged", () => { + // Deliberately loose (matching the "legacy" fixture above): this test asserts only the four fields normaliseWireState touches, not a fully valid AgentIdentity/Room for every other field those domain types carry. + const modern = { + agents: { a1: { id: "a1", version: 3 } }, + rooms: { r1: { id: "r1", version: 2 } }, + messages: {}, + dms: {}, + deliveryQueues: { a1: [] }, + } as unknown as SerialisedState; + assert.deepEqual(normaliseWireState(modern), modern); + }); +}); + +// A live gate over a real socket pair: the client frame goes out first; the server classifies on receipt and replies; the client then sees the reply. +describe("ConnectionHandshake over a real socket pair", () => { + it("client sends frame first; both sides reach negotiated", async () => { + const server = net.createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as net.AddressInfo).port; + + const serverOutcome = new Promise<"negotiated" | "legacy" | "refused">( + (resolve) => { + server.on("connection", (socket) => { + const gate = new ConnectionHandshake("server"); + socket.on("data", (data: Buffer) => { + const outcome = gate.feed(data); + if (outcome.kind !== "pending") { + if (outcome.kind === "negotiated") { + socket.write(encodeHandshakeFrame()); + } + resolve(outcome.kind); + } + }); + }); + }, + ); + + const client = net.createConnection({ port, host: "127.0.0.1" }); + const clientGate = new ConnectionHandshake("client"); + const clientOutcome = new Promise<"negotiated" | "legacy" | "refused">( + (resolve) => { + client.on("data", (data: Buffer) => { + const outcome = clientGate.feed(data); + if (outcome.kind !== "pending") resolve(outcome.kind); + }); + }, + ); + client.write(encodeHandshakeFrame()); + + assert.equal(await serverOutcome, "negotiated"); + assert.equal(await clientOutcome, "negotiated"); + + client.destroy(); + server.close(); + }); + + it("a legacy peer (no handshake frame) is tolerated: JSON bytes pass straight through both sides", async () => { + const server = net.createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as net.AddressInfo).port; + + const serverReceived = new Promise((resolve, reject) => { + server.on("connection", (socket) => { + attachSocketHandshake( + socket, + "server", + (data) => resolve(data.toString()), + reject, + ); + }); + }); + + // A pre-#31 peer: writes its JSON line directly, sends no handshake frame at all. + const client = net.createConnection({ port, host: "127.0.0.1" }); + await new Promise((resolve) => client.on("connect", resolve)); + client.write('{"method":"introduce"}\n'); + + assert.match(await serverReceived, /introduce/); + + client.destroy(); + server.close(); + }); + + it("an incompatible handshake (no shared domain) is refused loudly: the connection is destroyed rather than desynced", async () => { + const server = net.createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as net.AddressInfo).port; + + const serverError = new Promise((resolve) => { + server.on("connection", (socket) => { + attachSocketHandshake( + socket, + "server", + () => { + throw new Error( + "payload should never be reached on a refused handshake", + ); + }, + resolve, + ); + }); + }); + + const client = net.createConnection({ port, host: "127.0.0.1" }); + await new Promise((resolve) => client.on("connect", resolve)); + const foreignFrame = cborEncode( + { + type: "handshake", + version: MESH_PROTOCOL_VERSION, + domains: ["example.com/unrelated-mesh"], + }, + cdeEncodeOptions, + ); + client.write(Buffer.from(foreignFrame)); + + const error = await serverError; + assert.match(error.message, /no shared domain/); + + await new Promise((resolve) => { + client.on("close", resolve); + client.on("error", () => resolve()); + }); + server.close(); + }); +});