echoclaw-relay-agent 0.19.3 → 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
@@ -602,18 +619,19 @@ export class RelayAgent extends EventEmitter {
602
619
  this._identityExchanged = true;
603
620
  return;
604
621
  }
605
- // Remove early handler before entering wait prevents both handlers from
606
- // processing the same message simultaneously (Reviewer #2, #3 finding).
607
- this.removeListener('message', earlyHandler);
608
- // Use early-captured message if available, otherwise wait with timeout
609
- const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
610
- // Respond with our own identity
622
+ // SYMMETRIC EXCHANGE: Send our identity FIRST, then wait for peer's.
623
+ // Both sides send immediately upon pairing eliminates "who sends first" race.
611
624
  const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
612
625
  await this.send({
613
626
  type: 'identity_exchange',
614
627
  ed25519_pubkey: myPubKeyBase64,
615
628
  device_id: identity.deviceId,
616
629
  });
630
+ // Remove early handler before entering wait — prevents both handlers from
631
+ // processing the same message simultaneously.
632
+ this.removeListener('message', earlyHandler);
633
+ // Use early-captured message if available, otherwise wait with timeout
634
+ const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(15000);
617
635
  // Persist peer's public key
618
636
  await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
619
637
  sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
@@ -650,11 +668,16 @@ export class RelayAgent extends EventEmitter {
650
668
  setupDataHandler() {
651
669
  if (!this.transport)
652
670
  return;
653
- // Remove previous data handler to prevent listener accumulation on reconnect
654
- if (this._dataHandler) {
655
- 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);
656
679
  }
657
- this._dataHandler = async (msg) => {
680
+ this._dataHandlerInner = async (msg) => {
658
681
  if (msg.type !== 'DATA' || !msg.iv || !msg.payload || !this.frameCrypto)
659
682
  return;
660
683
  try {
@@ -693,7 +716,6 @@ export class RelayAgent extends EventEmitter {
693
716
  this.emit('error', err);
694
717
  }
695
718
  };
696
- this.transport.on('message', this._dataHandler);
697
719
  }
698
720
  setupTransportEvents() {
699
721
  if (!this.transport)
@@ -702,6 +724,11 @@ export class RelayAgent extends EventEmitter {
702
724
  if (!this._stopped) {
703
725
  this.setStatus('reconnecting');
704
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;
705
732
  // V2: Notify gateway of disconnect (starts grace period)
706
733
  this.gatewayManager?.onDisconnected();
707
734
  }
@@ -286,11 +286,20 @@ export class RelayClient extends EventEmitter {
286
286
  await this.onPaired(result, 'paired');
287
287
  }
288
288
  catch (err) {
289
- // Identity exchange failed on fresh pairing tear down transport to
290
- // prevent leaking an authenticated-but-unusable WebSocket, then re-throw.
291
- // Do NOT emit('error') here the throw propagates to connect() caller.
292
- this.transport?.disconnect();
293
- throw err;
289
+ // Identity exchange failed — non-fatal for fresh pairing.
290
+ // ECDH succeeded, encrypted channel works. Identity exchange only enables
291
+ // V2 identity-based reconnect. Keep connection alive, emit warning.
292
+ const isIdentityErr = err?.message?.includes('Identity exchange');
293
+ if (isIdentityErr) {
294
+ console.warn('[RelayClient] Identity exchange failed (non-fatal):', err?.message);
295
+ this.emit('error', Object.assign(new Error(`Identity exchange failed: ${err?.message}`), { name: 'IdentityExchangeError' }));
296
+ // Connection is usable — don't tear down
297
+ }
298
+ else {
299
+ // Non-identity errors are still fatal
300
+ this.transport?.disconnect();
301
+ throw err;
302
+ }
294
303
  }
295
304
  }
296
305
  async resumeSession(session) {
@@ -577,7 +586,8 @@ export class RelayClient extends EventEmitter {
577
586
  this._identityExchanged = true;
578
587
  return; // finally will clean up earlyHandler
579
588
  }
580
- // Send our Ed25519 public key to the agent (through E2E encrypted DATA channel)
589
+ // SYMMETRIC EXCHANGE: Send our identity FIRST, then wait for peer's.
590
+ // Both sides send immediately upon pairing — eliminates "who sends first" race.
581
591
  const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
582
592
  await this.send({
583
593
  type: 'identity_exchange',
@@ -586,8 +596,8 @@ export class RelayClient extends EventEmitter {
586
596
  });
587
597
  // Remove early handler before entering wait — prevents dual listeners
588
598
  this.removeListener('message', earlyHandler);
589
- // Use early-captured message if available, otherwise wait with timeout
590
- const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
599
+ // Use early-captured message if available, otherwise wait with timeout (15s for setup)
600
+ const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(15000);
591
601
  // Persist peer's public key
592
602
  await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
593
603
  sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
@@ -675,21 +685,39 @@ export class RelayClient extends EventEmitter {
675
685
  this.transport.on('connect_timeout', (timeoutMs) => {
676
686
  this.emit('error', Object.assign(new Error(`Connection timed out after ${timeoutMs / 1000}s`), { code: 'CONNECT_TIMEOUT' }));
677
687
  });
678
- // 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']);
679
693
  this.transport.on('message', (msg) => {
680
- if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
681
- const payload = msg.payload;
682
- if (payload === 'AGENT_WARNING' || payload === 'AGENT_ONLINE') {
683
- // 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)) {
684
701
  this.emit('peer_status', {
685
- status: payload === 'AGENT_WARNING' ? 'warning' : 'online',
702
+ status: payload === 'AGENT_ONLINE' ? 'online' : 'warning',
686
703
  timestamp: msg.timestamp,
687
704
  });
705
+ return;
688
706
  }
689
- else {
690
- // AGENT_RESTARTED etc.trigger re-ECDH on next open
691
- 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;
692
718
  }
719
+ // Unknown payload — log but don't trigger re-key
720
+ // (prevents infinite re-key loops from future server messages)
693
721
  }
694
722
  });
695
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.3",
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",