echoclaw-relay-agent 0.14.0 → 0.15.2

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.
@@ -23,6 +23,7 @@ import { SessionStore } from './relay/SessionStore.js';
23
23
  import { IdentityStore } from './relay/IdentityStore.js';
24
24
  import { FrameCrypto } from './crypto/FrameCrypto.js';
25
25
  import { GatewayManager } from './gateway/GatewayManager.js';
26
+ import { IdentityExchangeError } from './types.js';
26
27
  export class RelayAgent extends EventEmitter {
27
28
  constructor(config) {
28
29
  super();
@@ -537,7 +538,7 @@ export class RelayAgent extends EventEmitter {
537
538
  await this._handleIdentityExchange(sessionData, freshPairing);
538
539
  }
539
540
  catch (err) {
540
- this.emit('error', new Error(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
541
+ this.emit('error', new IdentityExchangeError(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
541
542
  }
542
543
  }
543
544
  // Persist session (with identity fields if exchange succeeded)
@@ -564,35 +565,57 @@ export class RelayAgent extends EventEmitter {
564
565
  * Agent waits for desktop's identity_exchange, then responds with its own.
565
566
  */
566
567
  async _handleIdentityExchange(sessionData, forceExchange = false) {
567
- const identity = await this.identityStore.loadOrCreate();
568
- const ed = this.identityStore.getEd25519Identity();
569
- // Populate session with own identity
570
- sessionData.identityDeviceId = identity.deviceId;
571
- // If we already have peer's key from previous pairing, skip exchange
572
- // UNLESS forceExchange is true (fresh pairing → may be a new desktop machine).
573
- if (identity.peerPublicKeyRaw && !forceExchange) {
574
- sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
575
- sessionData.bindingSecret = identity.bindingSecret;
568
+ // Start listening for desktop's identity_exchange IMMEDIATELY — before any
569
+ // async work — to prevent a race condition where the desktop sends its
570
+ // identity_exchange before our loadOrCreate() completes.
571
+ // The desktop (warm restart, identity cached) may complete onPaired() and
572
+ // send the message before our cold-start I/O finishes.
573
+ let earlyIdentity = null;
574
+ const earlyHandler = (payload) => {
575
+ if (payload?.type === 'identity_exchange' && payload.ed25519_pubkey) {
576
+ earlyIdentity = payload;
577
+ this.removeListener('message', earlyHandler);
578
+ }
579
+ };
580
+ this.on('message', earlyHandler);
581
+ try {
582
+ const identity = await this.identityStore.loadOrCreate();
583
+ const ed = this.identityStore.getEd25519Identity();
584
+ // Populate session with own identity
585
+ sessionData.identityDeviceId = identity.deviceId;
586
+ // If we already have peer's key from previous pairing, skip exchange
587
+ // UNLESS forceExchange is true (fresh pairing → may be a new desktop machine).
588
+ if (identity.peerPublicKeyRaw && !forceExchange) {
589
+ sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
590
+ sessionData.bindingSecret = identity.bindingSecret;
591
+ this._identityExchanged = true;
592
+ return;
593
+ }
594
+ // Remove early handler before entering wait — prevents both handlers from
595
+ // processing the same message simultaneously (Reviewer #2, #3 finding).
596
+ this.removeListener('message', earlyHandler);
597
+ // Use early-captured message if available, otherwise wait with timeout
598
+ const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
599
+ // Respond with our own identity
600
+ const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
601
+ await this.send({
602
+ type: 'identity_exchange',
603
+ ed25519_pubkey: myPubKeyBase64,
604
+ device_id: identity.deviceId,
605
+ });
606
+ // Persist peer's public key
607
+ await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
608
+ sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
576
609
  this._identityExchanged = true;
577
- return;
610
+ this.emit('identity_exchanged', {
611
+ ownDeviceId: identity.deviceId,
612
+ peerDeviceId: peerIdentity.device_id,
613
+ });
614
+ }
615
+ finally {
616
+ // Clean up early handler if still registered
617
+ this.removeListener('message', earlyHandler);
578
618
  }
579
- // Wait for desktop's identity_exchange (10s timeout)
580
- const peerIdentity = await this._waitForIdentityExchange(10000);
581
- // Respond with our own identity
582
- const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
583
- await this.send({
584
- type: 'identity_exchange',
585
- ed25519_pubkey: myPubKeyBase64,
586
- device_id: identity.deviceId,
587
- });
588
- // Persist peer's public key
589
- await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
590
- sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
591
- this._identityExchanged = true;
592
- this.emit('identity_exchanged', {
593
- ownDeviceId: identity.deviceId,
594
- peerDeviceId: peerIdentity.device_id,
595
- });
596
619
  }
597
620
  /**
598
621
  * Wait for an identity_exchange message from the desktop.
@@ -36,6 +36,7 @@ export declare class RelayClient extends EventEmitter {
36
36
  private _stopped;
37
37
  private _paired;
38
38
  private _needsRekey;
39
+ private _rekeyInFlight;
39
40
  private _rekeyRetryCount;
40
41
  private _activePairing;
41
42
  private readonly config;
@@ -30,6 +30,7 @@ import { PairingProtocol } from './relay/PairingProtocol.js';
30
30
  import { SessionStore } from './relay/SessionStore.js';
31
31
  import { IdentityStore } from './relay/IdentityStore.js';
32
32
  import { FrameCrypto } from './crypto/FrameCrypto.js';
33
+ import { IdentityExchangeError } from './types.js';
33
34
  export class RelayClient extends EventEmitter {
34
35
  constructor(config) {
35
36
  super();
@@ -93,6 +94,12 @@ export class RelayClient extends EventEmitter {
93
94
  writable: true,
94
95
  value: false
95
96
  });
97
+ Object.defineProperty(this, "_rekeyInFlight", {
98
+ enumerable: true,
99
+ configurable: true,
100
+ writable: true,
101
+ value: false
102
+ });
96
103
  Object.defineProperty(this, "_rekeyRetryCount", {
97
104
  enumerable: true,
98
105
  configurable: true,
@@ -242,6 +249,7 @@ export class RelayClient extends EventEmitter {
242
249
  this._stopped = true;
243
250
  this._paired = false;
244
251
  this._needsRekey = false;
252
+ this._rekeyInFlight = false;
245
253
  this._activePairing?.abort();
246
254
  this._activePairing = null;
247
255
  this.transport?.disconnect();
@@ -510,7 +518,7 @@ export class RelayClient extends EventEmitter {
510
518
  catch (err) {
511
519
  // Identity exchange failure is non-fatal — connection works without it,
512
520
  // but identity reconnect won't be available.
513
- this.emit('error', new Error(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
521
+ this.emit('error', new IdentityExchangeError(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
514
522
  }
515
523
  }
516
524
  // Persist session (with identity fields if exchange succeeded)
@@ -527,36 +535,56 @@ export class RelayClient extends EventEmitter {
527
535
  * Desktop initiates by sending its identity first, then waits for agent's response.
528
536
  */
529
537
  async _performIdentityExchange(sessionData, forceExchange = false) {
530
- const identity = await this.identityStore.loadOrCreate();
531
- const ed = this.identityStore.getEd25519Identity();
532
- // Populate session with own identity
533
- sessionData.identityDeviceId = identity.deviceId;
534
- // If we already have peer's key (from previous pairing stored in identity file),
535
- // skip the exchange just carry it forward.
536
- // UNLESS forceExchange is true (fresh pairing with new code → may be a new agent machine).
537
- if (identity.peerPublicKeyRaw && !forceExchange) {
538
- sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
539
- sessionData.bindingSecret = identity.bindingSecret;
538
+ // Register listener IMMEDIATELY — before any async work — to prevent
539
+ // a race condition where the agent responds before we start waiting.
540
+ // Agent (warm restart, identity cached) may send identity_exchange
541
+ // before our loadOrCreate() or send() completes.
542
+ let earlyIdentity = null;
543
+ const earlyHandler = (payload) => {
544
+ if (payload?.type === 'identity_exchange' && payload.ed25519_pubkey) {
545
+ earlyIdentity = payload;
546
+ this.removeListener('message', earlyHandler);
547
+ }
548
+ };
549
+ this.on('message', earlyHandler);
550
+ try {
551
+ const identity = await this.identityStore.loadOrCreate();
552
+ const ed = this.identityStore.getEd25519Identity();
553
+ // Populate session with own identity
554
+ sessionData.identityDeviceId = identity.deviceId;
555
+ // If we already have peer's key (from previous pairing stored in identity file),
556
+ // skip the exchange — just carry it forward.
557
+ // UNLESS forceExchange is true (fresh pairing with new code → may be a new agent machine).
558
+ if (identity.peerPublicKeyRaw && !forceExchange) {
559
+ sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
560
+ sessionData.bindingSecret = identity.bindingSecret;
561
+ this._identityExchanged = true;
562
+ return; // finally will clean up earlyHandler
563
+ }
564
+ // Send our Ed25519 public key to the agent (through E2E encrypted DATA channel)
565
+ const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
566
+ await this.send({
567
+ type: 'identity_exchange',
568
+ ed25519_pubkey: myPubKeyBase64,
569
+ device_id: identity.deviceId,
570
+ });
571
+ // Remove early handler before entering wait — prevents dual listeners
572
+ this.removeListener('message', earlyHandler);
573
+ // Use early-captured message if available, otherwise wait with timeout
574
+ const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
575
+ // Persist peer's public key
576
+ await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
577
+ sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
540
578
  this._identityExchanged = true;
541
- return;
579
+ this.emit('identity_exchanged', {
580
+ ownDeviceId: identity.deviceId,
581
+ peerDeviceId: peerIdentity.device_id,
582
+ });
583
+ }
584
+ finally {
585
+ // Clean up early handler if still registered (e.g. early return path)
586
+ this.removeListener('message', earlyHandler);
542
587
  }
543
- // Send our Ed25519 public key to the agent (through E2E encrypted DATA channel)
544
- const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
545
- await this.send({
546
- type: 'identity_exchange',
547
- ed25519_pubkey: myPubKeyBase64,
548
- device_id: identity.deviceId,
549
- });
550
- // Wait for agent's identity_exchange response (10s timeout)
551
- const peerIdentity = await this._waitForIdentityExchange(10000);
552
- // Persist peer's public key
553
- await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
554
- sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
555
- this._identityExchanged = true;
556
- this.emit('identity_exchanged', {
557
- ownDeviceId: identity.deviceId,
558
- peerDeviceId: peerIdentity.device_id,
559
- });
560
588
  }
561
589
  /**
562
590
  * Wait for an identity_exchange message from the agent.
@@ -650,34 +678,82 @@ export class RelayClient extends EventEmitter {
650
678
  });
651
679
  this.transport.on('open', () => {
652
680
  if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
681
+ // If a re-key is already in flight on a previous (now-dead) connection,
682
+ // abort it and start fresh on this new connection. Just returning would
683
+ // leave the new WebSocket idle while the old pairing hangs on a dead socket.
684
+ // (Reviewer finding: Gemini + Claude both flagged simple `return` as a bug.)
685
+ if (this._rekeyInFlight) {
686
+ this._activePairing?.abort();
687
+ this._rekeyInFlight = false;
688
+ // Fall through to start fresh re-key on the new connection.
689
+ }
653
690
  // Server requested re-ECDH (e.g. AGENT_RESTARTED) —
654
- // cancel any in-flight pairing and start fresh handshake.
691
+ // start fresh handshake on the (possibly new) transport.
655
692
  // Invalidate old crypto state: agent has new keys, old ones are useless.
656
- this._needsRekey = false;
657
- this._activePairing?.abort();
693
+ // Note: _needsRekey stays true until handshake succeeds.
694
+ this._rekeyInFlight = true;
658
695
  this.sessionKey = null;
659
696
  this.frameCrypto = null;
660
697
  this.setStatus('connecting');
661
- const pairing = new PairingProtocol(this.transport);
662
- this._activePairing = pairing;
663
- pairing.pairAsClient(this.pairingCode)
698
+ let pairing;
699
+ let pairingPromise;
700
+ try {
701
+ pairing = new PairingProtocol(this.transport);
702
+ this._activePairing = pairing;
703
+ pairingPromise = pairing.pairAsClient(this.pairingCode);
704
+ }
705
+ catch (syncErr) {
706
+ // Guard against synchronous throw from constructor or pairAsClient
707
+ this._rekeyInFlight = false;
708
+ this._activePairing = null;
709
+ this.emit('error', syncErr instanceof Error ? syncErr : new Error(String(syncErr)));
710
+ return;
711
+ }
712
+ pairingPromise
664
713
  .then(result => {
665
- this._rekeyRetryCount = 0; // Reset on success
714
+ // Identity guard: if _activePairing changed, this is a stale
715
+ // (aborted) promise settling as microtask — ignore it.
716
+ if (this._activePairing !== pairing)
717
+ return;
718
+ this._rekeyInFlight = false;
719
+ this._activePairing = null;
720
+ this._needsRekey = false; // Clear only on success
721
+ this._rekeyRetryCount = 0;
666
722
  this.onPaired(result, 'resumed');
667
723
  })
668
724
  .catch(err => {
725
+ // Identity guard: old aborted pairing's .catch() fires as microtask
726
+ // AFTER new pairing is set up — must not clobber new pairing's state.
727
+ if (this._activePairing !== pairing)
728
+ return;
729
+ this._rekeyInFlight = false;
669
730
  this._activePairing = null;
670
731
  this._rekeyRetryCount++;
671
- // Circuit breaker: stop after 5 consecutive re-key failures
732
+ // Circuit breaker: stop after 5 consecutive re-key failures.
733
+ // Clear all rekey state so next connect() can start fresh.
734
+ // IMPORTANT: reset ALL state before emitting error — emit() is
735
+ // synchronous, and a listener may call connect() immediately.
672
736
  if (this._rekeyRetryCount >= 5) {
737
+ const retries = this._rekeyRetryCount;
738
+ // 1. Reset all in-memory state
673
739
  this._needsRekey = false;
674
- this.emit('error', new Error(`Re-key failed after ${this._rekeyRetryCount} retries, giving up`));
675
- // Force transport to close and reconnect from scratch
740
+ this._paired = false;
741
+ this._identityExchanged = false;
742
+ this.sessionKey = null;
743
+ this.frameCrypto = null;
744
+ this._rekeyRetryCount = 0;
745
+ // 2. Clear disk state + teardown transport
746
+ this.sessionStore.clear().catch((clearErr) => {
747
+ this.emit('error', new Error(`Failed to clear stale session: ${clearErr instanceof Error ? clearErr.message : clearErr}`));
748
+ });
676
749
  this.transport?.disconnect();
750
+ this.setStatus('disconnected');
751
+ // 3. Notify LAST — state is fully clean if listener calls connect()
752
+ this.emit('error', new Error(`Re-key failed after ${retries} retries, giving up`));
677
753
  return;
678
754
  }
679
- // Re-key failed — set _needsRekey back to true for retry.
680
- this._needsRekey = true;
755
+ // Re-key failed — _needsRekey stays true (never cleared), so next
756
+ // on('open') will retry automatically.
681
757
  // If WS is still OPEN (pairing failed at protocol level, not transport level),
682
758
  // _needsRekey won't be consumed until next on('open').
683
759
  // Force a reconnect to trigger a fresh on('open').
package/dist/cli.js CHANGED
@@ -283,6 +283,13 @@ async function runSetup(code, relay, bridgePort) {
283
283
  resolve(info);
284
284
  });
285
285
  agent.on('error', (err) => {
286
+ // Identity exchange failure is non-fatal — ECDH pairing still works,
287
+ // only identity reconnect won't be available. Don't reject setup.
288
+ if (err.constructor?.name === 'IdentityExchangeError') {
289
+ console.log(` ${YELLOW}Warning: ${err.message}${RESET}`);
290
+ console.log(` ${DIM}Connection will work, but identity reconnect won't be available.${RESET}`);
291
+ return;
292
+ }
286
293
  clearTimeout(timeout);
287
294
  reject(err);
288
295
  });
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * connect(pairingCode) → send(payload) / subscribe(event, cb) → disconnect()
9
9
  */
10
10
  export type { EncryptedFrame, RelayMessage, SessionData, ReconnectConfig, RelayClientConfig, RelayAgentConfig, ClientStatus, ConnectedEvent, DisconnectedEvent, ReconnectingEvent, PairedEvent, ErrorEvent, } from './types.js';
11
- export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT } from './types.js';
11
+ export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT, IdentityExchangeError } from './types.js';
12
12
  export { FrameCrypto } from './crypto/FrameCrypto.js';
13
13
  export { ReconnectPolicy } from './relay/ReconnectPolicy.js';
14
14
  export { SessionStore } from './relay/SessionStore.js';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * External callers only need:
8
8
  * connect(pairingCode) → send(payload) / subscribe(event, cb) → disconnect()
9
9
  */
10
- export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT } from './types.js';
10
+ export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT, IdentityExchangeError } from './types.js';
11
11
  // ── Crypto ───────────────────────────────────────────────────────
12
12
  export { FrameCrypto } from './crypto/FrameCrypto.js';
13
13
  // ── Relay ────────────────────────────────────────────────────────
package/dist/types.d.ts CHANGED
@@ -73,5 +73,13 @@ export interface PeerStatusEvent {
73
73
  status: 'warning' | 'online';
74
74
  timestamp?: number;
75
75
  }
76
+ /**
77
+ * Non-fatal error emitted when Ed25519 identity exchange fails.
78
+ * Connection still works — only identity-based reconnect is unavailable.
79
+ * Consumers (e.g. setup CLI) can check `instanceof` to avoid treating this as fatal.
80
+ */
81
+ export declare class IdentityExchangeError extends Error {
82
+ constructor(message: string);
83
+ }
76
84
  export type { InstallRequest, InstallAbort, InstallAck, InstallStatus, InstallResult, InstallPreviewData, InstallPreviewResult, InstallPhase, InstallResultStatus, InstallIncoming, InstallOutgoing, } from './install/types.js';
77
85
  export { INSTALL_ERROR_CODES } from './install/types.js';
package/dist/types.js CHANGED
@@ -9,4 +9,15 @@ export const DEFAULT_RECONNECT = {
9
9
  maxDelayMs: 30000,
10
10
  jitterFactor: 0.3,
11
11
  };
12
+ /**
13
+ * Non-fatal error emitted when Ed25519 identity exchange fails.
14
+ * Connection still works — only identity-based reconnect is unavailable.
15
+ * Consumers (e.g. setup CLI) can check `instanceof` to avoid treating this as fatal.
16
+ */
17
+ export class IdentityExchangeError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'IdentityExchangeError';
21
+ }
22
+ }
12
23
  export { INSTALL_ERROR_CODES } from './install/types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.14.0",
3
+ "version": "0.15.2",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",