From ddeed9a7b735d41ced855cc3928de60b90f7fa04 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 23:03:54 +0000 Subject: [PATCH] Harden client reconnect and protocol guards --- plugin/core/runtime.ts | 73 ++++++++++++++++++++++++++++++++++++++-- plugin/core/transport.ts | 17 ++++++---- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 5e7edbc..e75476b 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -4,11 +4,14 @@ import { buildHeartbeat, buildHello, buildPairConfirm, + CodecError, createAuthRequestSigningInput, decodeBuiltin, encodeBuiltin, + encodeRuleMessage, isBuiltinMessage, type AuthFailedPayload, + type BuiltinPayloadMap, type HelloAckPayload, type PairFailedPayload, type PairRequestPayload, @@ -118,7 +121,15 @@ export class YonexusClientRuntime { return; } - const envelope = decodeBuiltin(raw); + let envelope: TypedBuiltinEnvelope; + try { + envelope = decodeBuiltin(raw) as TypedBuiltinEnvelope; + } catch (error) { + if (error instanceof CodecError) { + this.lastPairingFailure = error.message; + } + return; + } if (envelope.type === "hello_ack") { this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); return; @@ -160,6 +171,8 @@ export class YonexusClientRuntime { await this.handleRePairRequired(); return; } + + this.lastPairingFailure = `unsupported_builtin:${String(envelope.type)}`; } handleTransportStateChange(state: ClientConnectionState): void { @@ -169,6 +182,7 @@ export class YonexusClientRuntime { if (state === "disconnected") { this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle"; + this.pendingPairing = undefined; } } @@ -291,7 +305,11 @@ export class YonexusClientRuntime { return; } - if (payload.reason === "re_pair_required") { + if ( + payload.reason === "re_pair_required" || + payload.reason === "nonce_collision" || + payload.reason === "rate_limited" + ) { this.lastPairingFailure = payload.reason; await this.resetTrustState(); return; @@ -375,6 +393,57 @@ export class YonexusClientRuntime { await this.options.stateStore.save(this.clientState); this.phase = "pair_required"; } + + /** + * Send a rule message to the server. + * Message must already conform to `${rule_identifier}::${message_content}`. + * + * @param message - The complete rule message with identifier and content + * @returns True if message was sent, false if not connected or not authenticated + */ + sendMessageToServer(message: string): boolean { + if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) { + return false; + } + + // Validate the message is a properly formatted rule message + try { + if (message.startsWith("builtin::")) { + return false; + } + const delimiterIndex = message.indexOf("::"); + if (delimiterIndex === -1) { + return false; + } + const ruleIdentifier = message.slice(0, delimiterIndex); + const content = message.slice(delimiterIndex + 2); + encodeRuleMessage(ruleIdentifier, content); + } catch { + return false; + } + + return this.options.transport.send(message); + } + + /** + * Send a rule message to the server using separate rule identifier and content. + * + * @param ruleIdentifier - The rule identifier (alphanumeric with underscores/hyphens) + * @param content - The message content + * @returns True if message was sent, false if not connected/authenticated or invalid format + */ + sendRuleMessage(ruleIdentifier: string, content: string): boolean { + if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) { + return false; + } + + try { + const encoded = encodeRuleMessage(ruleIdentifier, content); + return this.options.transport.send(encoded); + } catch { + return false; + } + } } export function createYonexusClientRuntime( diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index fac8eba..795bdbf 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -40,6 +40,7 @@ export class YonexusClientTransport implements ClientTransport { private reconnectAttempts = 0; private reconnectTimer: NodeJS.Timeout | null = null; private heartbeatTimer: NodeJS.Timeout | null = null; + private shouldReconnect = false; // Reconnect configuration private readonly maxReconnectAttempts = 10; @@ -67,6 +68,8 @@ export class YonexusClientTransport implements ClientTransport { return; } + this.shouldReconnect = true; + this.clearReconnectTimer(); this.setState("connecting"); const { mainHost } = this.options.config; @@ -75,12 +78,13 @@ export class YonexusClientTransport implements ClientTransport { this.ws = new WebSocket(mainHost); const onOpen = () => { + this.ws?.off("error", onInitialError); this.setState("connected"); this.reconnectAttempts = 0; // Reset on successful connection resolve(); }; - const onError = (error: Error) => { + const onInitialError = (error: Error) => { this.setState("error"); if (this.options.onError) { this.options.onError(error); @@ -89,7 +93,7 @@ export class YonexusClientTransport implements ClientTransport { }; this.ws.once("open", onOpen); - this.ws.once("error", onError); + this.ws.once("error", onInitialError); this.ws.on("message", (data) => { const message = data.toString("utf8"); @@ -114,6 +118,7 @@ export class YonexusClientTransport implements ClientTransport { } disconnect(): void { + this.shouldReconnect = false; this.clearReconnectTimer(); this.stopHeartbeat(); @@ -153,18 +158,16 @@ export class YonexusClientTransport implements ClientTransport { } private handleDisconnect(code: number, reason: string): void { - const wasAuthenticated = this._state === "authenticated"; this.ws = null; this.stopHeartbeat(); this.setState("disconnected"); - // Don't reconnect if it was a normal close - if (code === 1000) { + // Don't reconnect if it was a normal close or caller explicitly stopped reconnects. + if (code === 1000 || !this.shouldReconnect) { return; } - // Attempt reconnect if we were previously authenticated - if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) { + if (this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(); } }