echoclaw-relay-agent 0.19.4 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ export declare class RelayAgent extends EventEmitter {
27
27
  private pairingCode;
28
28
  private _status;
29
29
  private _dataHandler;
30
+ private _dataHandlerInner;
30
31
  private _stopped;
31
32
  private _starting;
32
33
  private _paired;
@@ -81,6 +81,12 @@ export class RelayAgent extends EventEmitter {
81
81
  writable: true,
82
82
  value: null
83
83
  });
84
+ Object.defineProperty(this, "_dataHandlerInner", {
85
+ enumerable: true,
86
+ configurable: true,
87
+ writable: true,
88
+ value: null
89
+ });
84
90
  Object.defineProperty(this, "_stopped", {
85
91
  enumerable: true,
86
92
  configurable: true,
@@ -286,9 +292,11 @@ export class RelayAgent extends EventEmitter {
286
292
  this.sessionKey = null;
287
293
  this.frameCrypto = null;
288
294
  this._dataHandler = null;
295
+ this._dataHandlerInner = null;
289
296
  this._rekeyListener = null;
290
297
  this._proactiveKeyPair = null;
291
298
  this._rekeyInFlight = false;
299
+ this._identityExchanged = false;
292
300
  this.setStatus('disconnected');
293
301
  }
294
302
  // ── Private ────────────────────────────────────────────────
@@ -552,7 +560,16 @@ export class RelayAgent extends EventEmitter {
552
560
  this.emit('error', ixErr);
553
561
  }
554
562
  }
555
- // Persist session (with identity fields if exchange succeeded)
563
+ // Persist session — merge with existing to preserve identity data on re-key.
564
+ // Fresh pairing writes clean; re-key only updates sessionKey/sessionId.
565
+ if (!freshPairing) {
566
+ const existing = await this.sessionStore.load();
567
+ if (existing) {
568
+ sessionData.identityDeviceId = sessionData.identityDeviceId || existing.identityDeviceId;
569
+ sessionData.peerPublicKeyRaw = sessionData.peerPublicKeyRaw || existing.peerPublicKeyRaw;
570
+ sessionData.bindingSecret = sessionData.bindingSecret || existing.bindingSecret;
571
+ }
572
+ }
556
573
  await this.sessionStore.save(sessionData);
557
574
  // Re-install re-key listener so future desktop HELLOs are handled.
558
575
  // Without this, after a re-key, the listener references the old transport
@@ -651,11 +668,16 @@ export class RelayAgent extends EventEmitter {
651
668
  setupDataHandler() {
652
669
  if (!this.transport)
653
670
  return;
654
- // Remove previous data handler to prevent listener accumulation on reconnect
655
- if (this._dataHandler) {
656
- this.transport.removeListener('message', this._dataHandler);
671
+ // Single permanent listener pattern: install once, swap inner handler on re-key.
672
+ // Prevents listener accumulation from multiple onPaired() calls.
673
+ if (!this._dataHandler) {
674
+ this._dataHandler = (msg) => {
675
+ if (this._dataHandlerInner)
676
+ this._dataHandlerInner(msg);
677
+ };
678
+ this.transport.on('message', this._dataHandler);
657
679
  }
658
- this._dataHandler = async (msg) => {
680
+ this._dataHandlerInner = async (msg) => {
659
681
  if (msg.type !== 'DATA' || !msg.iv || !msg.payload || !this.frameCrypto)
660
682
  return;
661
683
  try {
@@ -694,7 +716,6 @@ export class RelayAgent extends EventEmitter {
694
716
  this.emit('error', err);
695
717
  }
696
718
  };
697
- this.transport.on('message', this._dataHandler);
698
719
  }
699
720
  setupTransportEvents() {
700
721
  if (!this.transport)
@@ -703,6 +724,11 @@ export class RelayAgent extends EventEmitter {
703
724
  if (!this._stopped) {
704
725
  this.setStatus('reconnecting');
705
726
  this.emit('disconnected', { code: 0, reason: 'transport closed' });
727
+ // Reset re-key state on transport close — new connection = fresh re-key.
728
+ // Prevents deadlock where old _rekeyInFlight blocks new connection's re-key.
729
+ this._rekeyInFlight = false;
730
+ this._activePairing?.abort();
731
+ this._activePairing = null;
706
732
  // V2: Notify gateway of disconnect (starts grace period)
707
733
  this.gatewayManager?.onDisconnected();
708
734
  }
@@ -685,21 +685,39 @@ export class RelayClient extends EventEmitter {
685
685
  this.transport.on('connect_timeout', (timeoutMs) => {
686
686
  this.emit('error', Object.assign(new Error(`Connection timed out after ${timeoutMs / 1000}s`), { code: 'CONNECT_TIMEOUT' }));
687
687
  });
688
- // Listen for server CLOSE messages to handle re-ECDH and peer status notifications.
688
+ // Listen for server CLOSE/STATUS messages whitelist dispatch.
689
+ // Only specific payloads trigger re-key. Others dispatch to handlers.
690
+ const REKEY_TRIGGERS = new Set(['AGENT_RESTARTED', 'FORCE_REKEY']);
691
+ const PEER_STATUS = new Set(['AGENT_WARNING', 'AGENT_ONLINE', 'DESKTOP_DISCONNECTED']);
692
+ const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION']);
689
693
  this.transport.on('message', (msg) => {
690
- if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
691
- const payload = msg.payload;
692
- if (payload === 'AGENT_WARNING' || payload === 'AGENT_ONLINE') {
693
- // Peer status notification — emit event, don't trigger re-key
694
+ if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
695
+ const payload = typeof msg.payload === 'string' ? msg.payload : '';
696
+ if (REKEY_TRIGGERS.has(payload)) {
697
+ this._needsRekey = true;
698
+ return;
699
+ }
700
+ if (PEER_STATUS.has(payload)) {
694
701
  this.emit('peer_status', {
695
- status: payload === 'AGENT_WARNING' ? 'warning' : 'online',
702
+ status: payload === 'AGENT_ONLINE' ? 'online' : 'warning',
696
703
  timestamp: msg.timestamp,
697
704
  });
705
+ return;
698
706
  }
699
- else {
700
- // AGENT_RESTARTED etc.trigger re-ECDH on next open
701
- this._needsRekey = true;
707
+ if (payload === 'UNPAIRED') {
708
+ // Permanent disconnectionclear session, stop reconnecting
709
+ this.sessionStore.clear().catch(() => { });
710
+ this.emit('unpaired');
711
+ this.transport?.disconnect();
712
+ return;
713
+ }
714
+ if (SESSION_FATAL.has(payload)) {
715
+ // Session gone — clear and let reconnect do fresh pairing
716
+ this.sessionStore.clear().catch(() => { });
717
+ return;
702
718
  }
719
+ // Unknown payload — log but don't trigger re-key
720
+ // (prevents infinite re-key loops from future server messages)
703
721
  }
704
722
  });
705
723
  this.transport.on('open', () => {
@@ -706,6 +706,8 @@ export class ChatHandler {
706
706
  }
707
707
  }
708
708
  }, 2000);
709
+ if (timer.unref)
710
+ timer.unref(); // Don't block process exit
709
711
  this._lifecycleTimers.set(runId, timer);
710
712
  }
711
713
  }
@@ -101,10 +101,15 @@ export class PairingProtocol extends EventEmitter {
101
101
  reject(err);
102
102
  }
103
103
  }
104
- // Error from server
105
- if (msg.type === 'CLOSE') {
106
- cleanup();
107
- reject(new Error(`Relay server closed: ${msg.payload || 'unknown'}`));
104
+ // Server messages — only fatal errors reject pairing
105
+ if (msg.type === 'CLOSE' || msg.type === 'STATUS') {
106
+ const payload = typeof msg.payload === 'string' ? msg.payload : '';
107
+ const FATAL = ['SESSION_NOT_FOUND', 'INVALID_CODE', 'MISSING_CODE', 'NO_PAIRING', 'UNPAIRED'];
108
+ if (FATAL.includes(payload)) {
109
+ cleanup();
110
+ reject(new Error(`Relay server closed: ${payload}`));
111
+ }
112
+ // Non-fatal (AGENT_WARNING, AGENT_ONLINE, DESKTOP_DISCONNECTED, STATUS) — ignore
108
113
  }
109
114
  };
110
115
  this.transport.on('message', onMessage);
@@ -202,13 +207,18 @@ export class PairingProtocol extends EventEmitter {
202
207
  }
203
208
  // PAIRED notification from server — agent has joined (initiator mode)
204
209
  // No action needed, just wait for agent's HELLO with pubkey
205
- // Error from server
206
- if (msg.type === 'CLOSE') {
207
- cleanup();
208
- const reason = msg.payload === 'INVALID_CODE'
209
- ? `Invalid pairing code: ****`
210
- : `Relay server closed: ${msg.payload || 'unknown'}`;
211
- reject(new Error(reason));
210
+ // Server messages — only fatal errors reject pairing
211
+ if (msg.type === 'CLOSE' || msg.type === 'STATUS') {
212
+ const payload = typeof msg.payload === 'string' ? msg.payload : '';
213
+ const FATAL = ['SESSION_NOT_FOUND', 'INVALID_CODE', 'MISSING_CODE', 'NO_PAIRING', 'UNPAIRED'];
214
+ if (FATAL.includes(payload)) {
215
+ cleanup();
216
+ const reason = payload === 'INVALID_CODE'
217
+ ? `Invalid pairing code: ****`
218
+ : `Relay server closed: ${payload}`;
219
+ reject(new Error(reason));
220
+ }
221
+ // Non-fatal — ignore
212
222
  }
213
223
  };
214
224
  this.transport.on('message', onMessage);
package/dist/types.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface RelayMessage {
12
12
  version: typeof RELAY_PROTOCOL_VERSION;
13
13
  session_id: string;
14
14
  msg_id: string;
15
- type: 'HELLO' | 'DATA' | 'PING' | 'CLOSE';
15
+ type: 'HELLO' | 'DATA' | 'PING' | 'CLOSE' | 'STATUS' | 'PAIRED';
16
16
  pubkey?: string;
17
17
  iv?: string;
18
18
  payload?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",